// Command softsign-decl измеряет разрыв мягкого знака в декл-пост-чеке банка (fix-pack §а finding-1) на // БОЕВОМ стеммере, НЕ ТРОГАЯ репозиторий. Прецедент — pkg6 `labels/` и pkg7 `minerharness`: отдельный // Go-модуль ВНЕ репо с `module textmachine/backend/<имя>` и `replace textmachine/backend => <пин-копия>`; // имя обязано начинаться с `textmachine/backend/`, иначе Go запретит импорт `internal/`. Сборка/пин — // `build.sh` (git archive ), чтобы незакоммиченные правки параллельных сессий не меняли результат. // // Что мерит: для каждого dst банка — приходит ли ПРИНЯТАЯ форма в боевой 25-глав-выход, под четырьмя // правилами матчинга декл-окна: // A0 — БОЕВОЕ поведение: declinedFormPresent (mempostcheck.go:169-191) — «ь» НЕ стрипается. // A1 — наивное: «ь» добавлен в реестр стрипа, применяется к КАЖДОМУ компоненту, БЕЗ якоря (отвергнутый). // A2 — anchor-gated: мягкий стрип на компоненте разрешён, если ДРУГОЙ компонент совпал ТОЧНО. // A3 — anchor-gated: мягкий стрип разрешён, если ДРУГОЙ компонент совпал по НОРМАЛЬНОМУ стему (⊇ A2). // A0 — построчный порт боевого кода на экспортных символах lang; паритет с реальным (unexported) // dstFormPresent сверен отдельно в membank-пробе (0/29 расхождений) до порта. Ноль сети, ноль моделей, $0. package main import ( "flag" "fmt" "os" "sort" "strings" "unicode" "textmachine/backend/internal/lang" "textmachine/backend/internal/text" ) type mode int const ( modeA0 mode = iota modeA1 modeA2 modeA3 ) // stemAug моделирует «добавить голый «ь» в реестр decl_suffix»: стрипнуть длиннейший реестровый суффикс; // если ничего не стрипнуто, а слово кончается на «ь» и стем остаётся ≥ minStemRunes(3), стрипнуть «ь». // Зеркалит longest-first (реальный суффикс всегда бьёт 1-руновый «ь»). func stemAug(s lang.TargetStemmer, w string) string { w = strings.ToLower(w) if st := s.Stem(w); st != w { return st } r := []rune(w) if len(r) >= 4 && r[len(r)-1] == 'ь' { return string(r[:len(r)-1]) } return w } func softSameStem(s lang.TargetStemmer, a, b string) bool { la, lb := strings.ToLower(a), strings.ToLower(b) if la == lb { return true } return stemAug(s, la) == stemAug(s, lb) } // containsWholeWord — порт mempostcheck.go:197-211 (буквенная граница на обоих концах, по рунам). func containsWholeWord(hay, form []rune) bool { n := len(form) if n == 0 || n > len(hay) { return false } for i := 0; i+n <= len(hay); i++ { eq := true for k := 0; k < n; k++ { if hay[i+k] != form[k] { eq = false break } } if !eq { continue } if (i == 0 || !unicode.IsLetter(hay[i-1])) && (i+n == len(hay) || !unicode.IsLetter(hay[i+n])) { return true } } return false } // windowMatch — совпадает ли окно outWords[i:i+n] с базовыми словами bw под правилом m. func windowMatch(s lang.TargetStemmer, outWords, bw []string, i int, m mode) bool { n := len(bw) anyExact, anyNorm := false, false norm := make([]bool, n) soft := make([]bool, n) for k := 0; k < n; k++ { ow, bwk := outWords[i+k], strings.ToLower(bw[k]) if ow == bwk { anyExact = true } norm[k] = s.SameStem(ow, bw[k]) soft[k] = softSameStem(s, ow, bw[k]) if norm[k] { anyNorm = true } } for k := 0; k < n; k++ { switch m { case modeA0: if !norm[k] { return false } case modeA1: if !soft[k] { return false } case modeA2: if !(norm[k] || (soft[k] && anyExact)) { return false } case modeA3: if !(norm[k] || (soft[k] && anyNorm)) { return false } } } return true } func countWindows(s lang.TargetStemmer, outWords, bw []string, m mode) int { n := len(bw) if n == 0 || n > len(outWords) { return 0 } c := 0 for i := 0; i+n <= len(outWords); i++ { if windowMatch(s, outWords, bw, i, m) { c++ } } return c } func firstWindowText(s lang.TargetStemmer, outWords, bw []string, m mode) string { n := len(bw) for i := 0; i+n <= len(outWords); i++ { if windowMatch(s, outWords, bw, i, m) { return strings.Join(outWords[i:i+n], " ") } } return "" } // present — порт dstFormPresent: базовая цельнословная форма ИЛИ декл-окно под правилом m. func present(s lang.TargetStemmer, noutRunes, baseRunes []rune, outWords, bw []string, m mode) bool { if containsWholeWord(noutRunes, baseRunes) { return true } return countWindows(s, outWords, bw, m) > 0 } func loadBankDst(path string) ([]string, error) { raw, err := os.ReadFile(path) if err != nil { return nil, err } lines := strings.Split(string(raw), "\n") seen := map[string]bool{} var out []string for i, ln := range lines { if i == 0 || strings.TrimSpace(ln) == "" { continue } f := strings.Split(ln, "\t") if len(f) < 2 { continue } dst := strings.TrimSpace(f[1]) if dst == "" || seen[dst] { continue } seen[dst] = true out = append(out, dst) } return out, nil } func main() { var ( bankP = flag.String("bank", "", "BANK-FULL.tsv (col2 = dst)") outP = flag.String("output", "", "боевой target-выход (txt)") repP = flag.String("report", "", "куда писать отчёт (durable ~/books/...)") tgt = flag.String("tgt", "ru", "целевой язык") ) flag.Parse() if *bankP == "" || *outP == "" || *repP == "" { fmt.Fprintln(os.Stderr, "usage: softsign-decl -bank B -output O -report R [-tgt ru]") os.Exit(2) } dsts, err := loadBankDst(*bankP) if err != nil { fmt.Fprintln(os.Stderr, "bank:", err) os.Exit(1) } rawOut, err := os.ReadFile(*outP) if err != nil { fmt.Fprintln(os.Stderr, "output:", err) os.Exit(1) } s := lang.NewTargetStemmer(lang.TargetChecksFor(*tgt)) if !s.Enabled() { fmt.Fprintln(os.Stderr, "stemmer inert for", *tgt) os.Exit(1) } nout := text.NormalizeTargetForm(string(rawOut)) noutRunes := []rune(nout) outWords := lang.TokenizeWords(nout) var b strings.Builder pf := func(f string, a ...any) { fmt.Fprintf(&b, f, a...) } pf("SOFT-SIGN DECL PROBE — bank=%d dst, output=%d words, tgt=%s\n", len(dsts), len(outWords), *tgt) pf("A0=live(no «ь») · A1=naive global «ь» · A2=anchor:exact · A3=anchor:norm\n") pf("========================================================\n\n") type row struct { dst string nWords int softWord, softStem string a0Win, a1Win, a2Win, a3Win int a0Base bool a2New, a3New string } var soft []row for _, dst := range dsts { base := text.NormalizeTargetForm(dst) bw := lang.TokenizeWords(base) var sw, ss string for _, w := range bw { r := []rune(w) if len(r) >= 4 && r[len(r)-1] == 'ь' { sw, ss = w, string(r[:len(r)-1]) break } } if sw == "" { continue } soft = append(soft, row{ dst: dst, nWords: len(bw), softWord: sw, softStem: ss, a0Win: countWindows(s, outWords, bw, modeA0), a1Win: countWindows(s, outWords, bw, modeA1), a2Win: countWindows(s, outWords, bw, modeA2), a3Win: countWindows(s, outWords, bw, modeA3), a0Base: containsWholeWord(noutRunes, []rune(base)), a2New: firstWindowText(s, outWords, bw, modeA2), a3New: firstWindowText(s, outWords, bw, modeA3), }) } sort.Slice(soft, func(i, j int) bool { return soft[i].a3Win-soft[i].a0Win > soft[j].a3Win-soft[j].a0Win }) pf("M1/M2. SOFT-SIGN TERMS (%d of %d dst) — occurrence-level windows captured\n", len(soft), len(dsts)) pf("dst | words | soft→stem | nomBase? | A0 A1 A2 A3 | A3−A0 | A3 first-new\n----\n") var gA3multi, gA3single, gA2 int for _, r := range soft { g3 := r.a3Win - r.a0Win gA2 += r.a2Win - r.a0Win if r.nWords > 1 { gA3multi += g3 } else { gA3single += g3 } nt := "" if g3 > 0 { nt = r.a3New } pf("%-30s | %d | %s→%s | nom=%v | A0=%d A1=%d A2=%d A3=%d | +%d | %s\n", r.dst, r.nWords, r.softWord, r.softStem, r.a0Base, r.a0Win, r.a1Win, r.a2Win, r.a3Win, g3, nt) } pf("\nGAP (occurrence windows over A0): A3 multi=%d single=%d | A2 total=%d\n", gA3multi, gA3single, gA2) // M3: presence flips over ALL dst (worst case: every term vs whole book) pf("\nM3. PRESENCE FLIPS over ALL %d dst (A0-miss → variant-present)\n", len(dsts)) type flip struct{ dst, win string } var f1, f2, f3 []flip for _, dst := range dsts { base := text.NormalizeTargetForm(dst) br := []rune(base) bw := lang.TokenizeWords(base) if present(s, noutRunes, br, outWords, bw, modeA0) { continue } if present(s, noutRunes, br, outWords, bw, modeA1) { f1 = append(f1, flip{dst, firstWindowText(s, outWords, bw, modeA1)}) } if present(s, noutRunes, br, outWords, bw, modeA2) { f2 = append(f2, flip{dst, firstWindowText(s, outWords, bw, modeA2)}) } if present(s, noutRunes, br, outWords, bw, modeA3) { f3 = append(f3, flip{dst, firstWindowText(s, outWords, bw, modeA3)}) } } dump := func(name string, fs []flip) { pf("%s new presences: %d\n", name, len(fs)) for _, x := range fs { pf(" %s ⟶ [%s]\n", x.dst, x.win) } } dump("A1 (naive «ь»)", f1) dump("A2 (anchor=exact)", f2) dump("A3 (anchor=norm)", f3) in := func(fs []flip, d string) bool { for _, x := range fs { if x.dst == d { return true } } return false } pf("\nA1-ONLY presences (removed by anchor gate A3) — collision-suspect set:\n") nOnly := 0 for _, x := range f1 { if !in(f3, x.dst) { pf(" %s ⟶ [%s]\n", x.dst, x.win) nOnly++ } } pf(" (count=%d)\n", nOnly) // M4: confirmed collision class (book-independent) under global soft SameStem vs anchor gate pf("\nM4. CONFIRMED COLLISION CLASS (fix-pack §а) — global soft (A1) vs single-word anchor gate\n") for _, p := range [][2]string{{"Синь", "синим"}, {"Линь", "линий"}, {"Тень", "теней"}} { pf(" A1 softSameStem(%q,%q)=%v (stems %q/%q) | anchor gate: lone word, no anchor ⟶ never soft\n", p[0], p[1], softSameStem(s, p[0], p[1]), stemAug(s, p[0]), stemAug(s, p[1])) } if err := os.WriteFile(*repP, []byte(b.String()), 0o644); err != nil { fmt.Fprintln(os.Stderr, "write report:", err) os.Exit(1) } fmt.Print(b.String()) fmt.Fprintf(os.Stderr, "\nreport written: %s\n", *repP) }