textmachine/backend/internal/checks/checkers_zh_ru_test.go

208 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package checks
import (
"testing"
"textmachine/backend/internal/lang"
)
// testLangPack loads the real in-repo zh→ru language pack (configs/langpacks/) so the fixtures run the
// checker ALGORITHM over the pack's OWN patterns and tables — no pair literal is duplicated in a test.
func testLangPack(t *testing.T) *lang.Pack {
t.Helper()
p, err := lang.Load("../../configs/langpacks", "zh", "ru")
if err != nil {
t.Fatalf("load langpack: %v", err)
}
return p
}
// testCheckers builds the compiled checker spec from the REAL zh-ru pack + ru target data (pair-14 data-out):
// the fixtures exercise the checker ALGORITHM over the pack's own DETECTION patterns / tables / wordlists, so
// no pair literal is duplicated in the test — a pattern edit in the langpack flows straight into these cases.
func testCheckers(t *testing.T) *Checkers {
t.Helper()
return CompileCheckers(testLangPack(t).DCCheckers, lang.TargetChecksFor("ru"))
}
// checkers_zh_ru_test.go: WS5 (г) — the DC1/DC2/DC6 checkers must CATCH the empirical trap positives
// (ws5_checkers_verify.py §a) and stay SILENT on clean/in-register text (precision over recall). These
// are the regression fixtures the plan requires (positives from exp15 §7.9 / exp14b).
func TestDC1TimeUnits(t *testing.T) {
cases := []struct {
name, src, tgt string
wantFlag bool
}{
{"count-as-hours", "他闭关了三个时辰。", "Он затворился на три часа.", true}, // 三时辰=6h rendered «три часа»
{"correct-conversion", "他闭关了三个时辰。", "Он затворился на шесть часов.", false}, // 6h — correct, no flag
{"paraphrase-no-hours", "他闭关了三个时辰。", "Он затворился надолго.", false}, // no hours count → valid
{"no-shichen", "他走了三里。", "Он прошёл три ли.", false},
{"arabic-count", "过了2个时辰。", "Прошло 2 часа.", true}, // 2时辰=4h rendered «2 часа»
}
dcc := testCheckers(t)
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
n, _ := dcc.lintTimeUnits(c.src, c.tgt)
if (n > 0) != c.wantFlag {
t.Fatalf("lintTimeUnits(%q,%q) fired=%v, want %v", c.src, c.tgt, n > 0, c.wantFlag)
}
})
}
}
func TestDC2MagnitudeScale(t *testing.T) {
cases := []struct {
name, src, tgt string
wantFlag bool
}{
{"qianwan-as-thousands", "有千万条蛊虫。", "Там были тысячи гу-червей.", true}, // 千万=10M as «тысячи»
{"qianwan-correct", "有千万条蛊虫。", "Там были десятки миллионов гу-червей.", false}, // «миллион» present → ok
{"shushiwan-as-tens", "聚集了数十万人。", "Собрались десятки тысяч человек.", true}, // 数十万 as «десятки тысяч»
{"shushiwan-correct", "聚集了数十万人。", "Собрались сотни тысяч человек.", false}, // «сотни тысяч» → ok
// ws5 parity (R4-review MAJOR): a CORRECT rendering «сотни тысяч» SUPPRESSES the flag even when
// «десятки тысяч» appears elsewhere as an unrelated quantity (the ok_re guard).
{"shushiwan-okre-suppress", "聚集了数十万人。", "Здесь сотни тысяч воинов, а не десятки тысяч слуг.", false},
// ws5 parity (R4-review MINOR): the inner fire check is CASE-SENSITIVE (reference no re.I), so a
// sentence-initial capitalized «Десятки тысяч» does NOT fire.
{"case-sensitive-capitalized", "聚集了数十万人。", "Десятки тысяч человек собрались.", false},
{"no-magnitude", "他有三个朋友。", "У него три друга.", false},
}
dcc := testCheckers(t)
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
n, _ := dcc.lintMagnitudeScale(c.src, c.tgt)
if (n > 0) != c.wantFlag {
t.Fatalf("lintMagnitudeScale(%q,%q) fired=%v, want %v", c.src, c.tgt, n > 0, c.wantFlag)
}
})
}
}
func TestDC6RegisterLexicon(t *testing.T) {
// The register blocklist now comes from the BOOK config (book.yaml register_blocklist → CheapGateConfig,
// D39.79 Q4), not the pair pack. These fixtures exercise the checker ALGORITHM (whole-word, target
// word-boundary) over a book-supplied blocklist — the terem paradigm that used to live in dc-checkers.txt.
blocklist := []string{"терем", "терема", "тереме", "теремом", "терему", "теремах"}
cases := []struct {
name, tgt string
wantN int
}{
{"terem", "Он вошёл в высокий терем.", 1},
{"terem-inflected", "В тереме было тихо, у терема стоял страж.", 2}, // тереме + терема → 2 distinct register forms
{"clean", "Он вошёл в высокий зал павильона.", 0},
{"substring-not-word", "Термин был странным.", 0}, // «терм» inside «термин» must NOT fire (whole-word)
}
dcc := testCheckers(t)
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
n, det := dcc.lintRegisterLexicon(c.tgt, blocklist)
if n != c.wantN {
t.Fatalf("lintRegisterLexicon(%q) = %d (%v), want %d", c.tgt, n, det, c.wantN)
}
})
}
// The D39.79 second-book generality invariant: the real zh-ru pack ships NO register_neg, so a book that
// supplies no register_blocklist runs DC6 inert — a zh→ru book of another genre gets ZERO false DC6 flags
// and needs NO Go/pair edit.
if n, _ := dcc.lintRegisterLexicon("Он вошёл в высокий терем.", nil); n != 0 {
t.Fatalf("with an empty pair register_neg and no book register_blocklist DC6 must be inert, got %d", n)
}
}
// The DC checkers must stay SILENT on a non-zh / clean chunk (they self-gate on content), so a
// non-Chinese book never accrues style flags from them.
func TestDCCheckersSilentOnCleanChunk(t *testing.T) {
r := RunCheapGates("静かな図書館の朝。", "", "Тихое утро в библиотеке.", CheapGateConfig{})
if r.DC1TimeUnits != 0 || r.DC2Magnitude != 0 || r.DC6Register != 0 {
t.Fatalf("DC checkers must be silent on a clean ja→ru chunk, got %+v", r)
}
}
// TestDC1HoursWordBoundary pins the RIGHT boundary of ru_hours_re (pack-16 pair-data fix). The stem «час» is a
// prefix of ordinary Russian words («части», «часовых»), and Go RE2 has neither lookahead nor a Unicode \b, so
// without an explicit boundary the class fired on clean prose — harmless as observability, a wrong REWRITE the
// moment a repair loop acts on the class. The paradigm forms must still fire (no recall traded for precision).
func TestDC1HoursWordBoundary(t *testing.T) {
dcc := testCheckers(t)
const src = "他闭关了三个时辰。" // 三个时辰 = 6h; a «три час…» rendering is the defect
cases := []struct {
name, tgt string
wantFlag bool
}{
{"false-positive-parts", "Он разделил это на три части.", false}, // «три час|ти» — must NOT fire
{"false-positive-adjective", "Он ждал три часовых смены.", false}, // «часовых» is not an hour count
{"true-positive-nominative", "Он затворился на три часа.", true}, // the canonical defect
{"true-positive-prepositional", "Он провёл в этом три часах.", true}, // inflected form still fires
{"correct-conversion-silent", "Он затворился на шесть часов.", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
n, det := dcc.lintTimeUnits(src, c.tgt)
if (n > 0) != c.wantFlag {
t.Fatalf("lintTimeUnits(%q) fired=%v (%v), want %v", c.tgt, n > 0, det, c.wantFlag)
}
})
}
}
// TestDC1FractionalUnit pins the 半个时辰 probe (pack-16): the counted branch cannot see a fractional unit word,
// so the most frequent 时辰 form in the corpus was invisible to the class. Both data keys are required — a pack
// shipping neither leaves the probe inert.
func TestDC1FractionalUnit(t *testing.T) {
dcc := testCheckers(t)
cases := []struct {
name, src, tgt string
wantFlag bool
}{
{"half-as-half-hour", "他等了半个时辰。", "Он прождал полчаса.", true}, // 半个时辰 ≈ 1 h, not 30 min
{"half-correct", "他等了半个时辰。", "Он прождал час.", false},
{"half-paraphrase", "他等了半个时辰。", "Он прождал недолго.", false},
{"no-half-in-source", "他等了很久。", "Он прождал полчаса.", false}, // no source probe → silent
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
n, det := dcc.lintTimeUnits(c.src, c.tgt)
if (n > 0) != c.wantFlag {
t.Fatalf("lintTimeUnits(%q,%q) fired=%v (%v), want %v", c.src, c.tgt, n > 0, det, c.wantFlag)
}
})
}
}
// TestDC1FractionalProbeInertWithoutData pins the empty-probe guard: a pack with no fractional keys must leave
// the sub-check inert rather than fire on every «полчаса» (the pack-15 lintMagnitudeScale lesson).
func TestDC1FractionalProbeInertWithoutData(t *testing.T) {
dcc := testCheckers(t)
dcc.halfShichenRE, dcc.halfShichenFireWord = nil, ""
if n, _ := dcc.lintTimeUnits("他等了半个时辰。", "Он прождал полчаса."); n != 0 {
t.Fatalf("fractional probe must be inert without pair data, fired %d", n)
}
}
// TestChevronMixingWholeWordInnerMarker pins the row-93 whole-word fix on isSpokenChevronLine's inner-marker
// veto. «в уме» is a substring of «в умении»; a substring veto would MUFFLE a real style clash — a chevron
// reply whose text merely CONTAINS «в умении», mixed with an em-dash line in the same chunk. The veto must
// match whole-word, so the clash fires; a genuine whole-word marker («про себя») must still veto. The
// labelled corpus carries 0 instances of the «в умении» shape, so this moves no label cell — a boundary fix.
func TestChevronMixingWholeWordInnerMarker(t *testing.T) {
dcc := testCheckers(t)
const emDashLine = "— Здравствуй, брат.\n" // an em-dash speech line → the mixing pre-condition
cases := []struct {
name, chevron string
wantMixing bool
}{
// «в умении» contains the substring «в уме» but is not the marker — the style clash must fire.
{"substring-not-marker-fires", "«Замолчи!» — воскликнул он, упражняясь в умении.", true},
// «про себя» is a whole-word inner marker — the reply is a thought, so the veto still muffles.
{"whole-word-marker-vetoes", "«Замолчи!» — пробормотал он про себя.", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
n, det := lintDialogueDash(emDashLine+c.chevron, dcc)
if (n > 0) != c.wantMixing {
t.Fatalf("lintDialogueDash mixing fired=%v (%v), want %v", n > 0, det, c.wantMixing)
}
})
}
}