textmachine/eval/softsign-decl/main.go

337 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.

// 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 <commit>), чтобы незакоммиченные правки параллельных сессий не меняли результат.
//
// Что мерит: для каждого 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 | A3A0 | 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)
}