337 lines
11 KiB
Go
337 lines
11 KiB
Go
// 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 | 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)
|
||
}
|