textmachine/backend/internal/text/norm.go

263 lines
13 KiB
Go
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 text is the pipeline's text substrate: the canonical forms a source or target string is
// reduced to before anything compares, matches or hashes it.
//
// It exists because three subsystems must agree on those forms EXACTLY — the memory bank matches and
// post-checks with them, the miner normalizes its candidate space with them, the cheap checkers tokenize
// with them — and a private copy in any of the three would be a silent match failure, not a compile
// error. Everything here is pure, deterministic and free of language policy: no pair data, no book
// canon, no configuration. The normalization ARTIFACT is versioned as a whole (NormVersion) and folded
// into the job snapshot, so changing a rule or the embedded trad→simp table is a loud --resnapshot
// rather than a quiet re-verdict on already-paid chunks.
package text
import (
"crypto/sha256"
_ "embed"
"encoding/hex"
"fmt"
"strings"
"unicode"
"golang.org/x/text/unicode/norm"
)
// norm.go: the NORMALIZATION artifact behind every source/target match in the pipeline
// (registry A4 — the most likely "silently empty" bug: a glossary key is present in the
// chunk but in a different orthographic form, so the record silently fails to match).
// It is shared substrate: the memory bank matches and post-checks with it, the miner
// normalizes its candidate space with it, and the seed loader keys with it. Two pure,
// deterministic, VERSIONED normalizers, applied SYMMETRICALLY to both sides of every
// comparison:
//
// NormalizeSourceKey — zh/ja source surfaces (glossary keys/aliases AND chunk text):
// NFKC (fold half/full-width, e.g. テ→テ, →Q) → trad→simp (per-char, the
// embedded table) → katakana→hiragana fold → Unicode lower. So a key stored in
// Simplified still matches a Traditional source surface (魯鎮 ↔ 鲁镇), a full-width
// matches a half-width Q, and スズキ matches すずき.
// NormalizeTargetForm — Russian dst forms (stored decl forms AND model output) for
// the post-check substring test: NFC → Unicode lower → ё→е fold.
//
// The whole artifact is versioned by NormVersion(), which is FOLDED INTO the
// snapshot via memoryVersion() (pipeline/snapshot.go): a change to the algorithm OR to the
// embedded trad→simp table is a loud --resnapshot, never a silent match-behaviour
// change on already-checkpointed chunks. Determinism is load-bearing — the injected
// glossary block is a pure function of (frozen glossary, normalized chunk), and it
// enters request_hash via the rendered messages.
//go:embed data/trad2simp.txt
var trad2simpRaw string
// memoryNormAlgoVersion tags the normalization ALGORITHM (the pipeline of steps and
// their order). Bump it on an algorithm change. The trad→simp TABLE content is
// versioned SEPARATELY by hashing trad2simpRaw into memoryNormVersion below, so
// editing/completing the data file also invalidates — drift-proof (D8): you cannot
// forget to bump a version when you change the table, because the table's bytes ARE
// the version.
// Ш-2 (D39.16, folded here in R1): unicode.Version is woven into the algorithm tag so a Unicode-table
// bump (a go toolchain upgrade — NFKC/NFC via x/text, unicode.Is(Cf), the kana range, SignificantLen's
// unicode.Han/Cyrillic predicates) changes this version → the snapshot moves → a LOUD --resnapshot,
// never a silent match-behaviour change on already-checkpointed chunks. TRIPWIRE: do NOT bump the
// toolchain past the pinned Unicode edition without a --resnapshot (we are on go1.26.4 / Unicode 15.0.0).
//
// R1-FL-C: golang.org/x/text/norm carries its OWN Unicode edition INDEPENDENT of stdlib unicode.Version
// (its NFKC/NFC tables ship in the module, not the toolchain), so a standalone `go get -u golang.org/x/text`
// could silently shift NormalizeSourceKey's NFKC output → a re-verdict on the post-check WITHOUT moving the
// snapshot. Weaving xTextVersion in makes an x/text bump a loud --resnapshot too. It is a PINNED constant
// (debug.ReadBuildInfo returns an empty x/text version under `go test`, where the golden is captured, so a
// build-info fold would be non-deterministic) DRIFT-CHECKED against go.mod by TestMemnormXTextVersionPin:
// a go.mod bump that forgets this constant fails that test loudly — the tripwire enforced by mechanism.
const xTextVersion = "v0.40.0" // MUST equal go.mod's golang.org/x/text (asserted by TestMemnormXTextVersionPin); a bump = --resnapshot
const memoryNormAlgoVersion = "memnorm-v3-nfkc+apos+stripignorable+trad+kana+lower/nfc+dash+apos+stripignorable+lower+yofold+u" + unicode.Version + "+xtext" + xTextVersion
var (
// trad2simp is the parsed Traditional→Simplified single-char map (data/trad2simp.txt).
trad2simp map[rune]rune
// memoryNormVersion is the snapshot-load-bearing version of the entire
// normalization artifact: the algorithm tag + a content hash of the embedded
// trad→simp table. memoryVersion() (pipeline/snapshot.go) folds it into the job snapshot.
memoryNormVersion string
)
func init() {
trad2simp = parseTradTable(trad2simpRaw)
h := sha256.Sum256([]byte(memoryNormAlgoVersion + "\x00" + trad2simpRaw))
memoryNormVersion = memoryNormAlgoVersion + "-" + hex.EncodeToString(h[:])[:12]
}
// NormVersion returns the version of the whole normalization artifact (algorithm tag +
// content hash of the embedded trad→simp table). It is a function, not an exported var,
// so no caller can reassign the value the job snapshot folds.
func NormVersion() string { return memoryNormVersion }
// parseTradTable parses the embedded "<trad> <simp>" table. Comment ('#') and blank
// lines are ignored; a non-empty content line that is NOT exactly two
// single-rune whitespace-separated fields is a corrupt table → panic at init (a
// loud build/test failure, caught the moment any test in the package runs, rather
// than a silently truncated map that would re-open the A4 hole). The table is
// authored in-repo and asserted by memnorm_test.go, so a panic here means the data
// file was corrupted, not bad user input.
func parseTradTable(raw string) map[rune]rune {
m := make(map[rune]rune, 1024)
for i, line := range strings.Split(raw, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
fields := strings.Fields(line)
if len(fields) != 2 {
panic(fmt.Sprintf("text: trad2simp table line %d: want 2 fields, got %d (%q)", i+1, len(fields), line))
}
tr := []rune(fields[0])
si := []rune(fields[1])
if len(tr) != 1 || len(si) != 1 {
panic(fmt.Sprintf("text: trad2simp table line %d: each side must be a single rune (%q → %q)", i+1, fields[0], fields[1]))
}
// A later duplicate wins deterministically (the file may list a char under
// two families); assert the mapping is consistent to catch a typo where the
// same trad char is mapped to two different simp chars.
if prev, ok := m[tr[0]]; ok && prev != si[0] {
panic(fmt.Sprintf("text: trad2simp table line %d: %q maps to both %q and %q", i+1, fields[0], string(prev), fields[1]))
}
m[tr[0]] = si[0]
}
return m
}
// NormalizeSourceKey normalizes a zh/ja SOURCE surface (a glossary key/alias OR the
// chunk text) for exact matching. Pure and deterministic; the pipeline is
// NFKC → drop default-ignorables → trad→simp (per rune) → katakana→hiragana → Unicode
// lower. Applied to BOTH keys and text so orthographic variants match (A4). Dropping
// default-ignorable/format code points (zero-width space/joiner, BOM, variation
// selectors) closes the re-opened A4 hole where a key 渡邊 silently misses a chunk
// surface 渡<ZWSP>邊 (common in scraped/EPUB CJK). Kana folding mirrors the eval spec
// (memory_hotpath.py): full-width katakana U+30A1..U+30F6 shift down 0x60 to hiragana;
// the prolonged-sound mark ー (U+30FC) and half-width forms are already handled (the
// latter by NFKC).
func NormalizeSourceKey(s string) string {
s = norm.NFKC.String(s)
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
if isApostropheVariant(r) {
r = '\'' // typographic apostrophe (OBrien) folds to ASCII so a key matches either form
}
if isIgnorableForMatch(r) {
continue // zero-width / format / variation selector: invisible, never part of the key
}
if m, ok := trad2simp[r]; ok {
r = m
}
if r >= 0x30A1 && r <= 0x30F6 { // katakana → hiragana
r -= 0x60
}
b.WriteRune(unicode.ToLower(r))
}
return b.String()
}
// NormalizeTargetForm normalizes a Russian TARGET form (a stored decl form OR the
// model output) for the post-check whole-word test. Applied SYMMETRICALLY to both
// sides so a correctly-rendered term is NEVER a false MISS (over-flagging is the
// failure mode research/14 §2 warns about). Steps, each closing a self-review finding:
// - NFC;
// - dash/hyphen variants (en-dash, NB-hyphen, minus, soft hyphen, …) → ASCII '-', so
// «А–кью»/«А‑кью» match a stored «А-кью» (self-review #8);
// - drop default-ignorable/format code points (zero-width space/joiner, word joiner,
// BOM, variation selectors) that survive NFC, so a stored «вана» is not a false MISS
// against model output «ва<WJ>на» (over-flag; the soft hyphen is folded above, kept);
// - runs of ANY Unicode whitespace (newline, NBSP U+00A0, ideographic space, …) →
// one ASCII space, then trim, so a multi-word name wrapped across a line break
// («Бородатого\nВана») matches a stored «Бородатого Вана» (self-review #2);
// - Unicode lower + ё→е fold (the ё-ification е/ё variation).
//
// Pure and deterministic.
func NormalizeTargetForm(s string) string {
s = norm.NFC.String(s)
var b strings.Builder
b.Grow(len(s))
prevSpace := false
for _, r := range s {
if isDashVariant(r) {
r = '-'
}
if isApostropheVariant(r) {
r = '\'' // symmetric with the source side (д’Артаньян) — no false post-check MISS on a typographic apostrophe
}
if isIgnorableForMatch(r) {
continue // drop zero-width/format/VS (soft hyphen already folded to '-' above)
}
if unicode.IsSpace(r) {
if !prevSpace {
b.WriteByte(' ')
prevSpace = true
}
continue
}
prevSpace = false
r = unicode.ToLower(r)
if r == 'ё' {
r = 'е'
}
b.WriteRune(r)
}
return strings.TrimSpace(b.String())
}
// isDashVariant reports whether r is a hyphen/dash/minus variant that should fold to
// ASCII '-' for the post-check (Unicode dash punctuation + the math minus + soft hyphen).
func isDashVariant(r rune) bool {
switch r {
case '', '', '', '', '—', '―', '', '­':
return true
}
return false
}
// isApostropheVariant reports whether r is a typographic apostrophe/single-quote variant that
// should fold to ASCII '\” (Task-6 re-audit): U+2018/U+2019 curly single quotes, U+02BC
// modifier-letter apostrophe, U+FF07 fullwidth apostrophe. Smart-quote editors turn a source
// key O'Brien's apostrophe into U+2019, which NFKC/NFC do NOT fold to U+0027 — leaving the key
// silently un-matchable (silently empty) on en names. Folded symmetrically on both sides.
func isApostropheVariant(r rune) bool {
switch r {
case '', '', 'ʼ', '':
return true
}
return false
}
// isIgnorableForMatch reports whether r is a default-ignorable / format code point that
// must be DROPPED before matching. These survive NFKC/NFC unchanged yet are invisible or
// carry no character identity, so leaving them in silently breaks a match — on the source
// side the A4 "silently empty" hole (a key 渡邊 misses 渡<ZWSP>邊), on the target side a false
// post-check MISS (a stored «вана» misses «ва<WJ>на»). Covers unicode.Cf (U+200B ZWSP,
// U+200C/D ZWNJ/ZWJ, U+2060 WJ, U+FEFF BOM, bidi marks, …) plus the variation selectors
// (VS116 U+FE00..FE0F and the ideographic supplement U+E0100..E01EF), which decorate a
// base glyph without changing it. The soft hyphen U+00AD is also unicode.Cf, but the
// target normalizer folds it to '-' via isDashVariant BEFORE consulting this predicate
// (self-review #8); on the source side (no dash semantics) dropping it is correct.
func isIgnorableForMatch(r rune) bool {
if unicode.Is(unicode.Cf, r) {
return true
}
return (r >= 0xFE00 && r <= 0xFE0F) || (r >= 0xE0100 && r <= 0xE01EF)
}
// SignificantLen counts the "significant" characters of a normalized key — CJK
// ideographs/kana plus letters (Latin/Cyrillic) — for the single-key ban (registry
// A3): a key whose significant length is below minKeyLen may not fire on its own
// (a lone Han/kana/letter like 炎/气/钱 matches unrelated text on the wrong sense).
// Digits and punctuation do not count (so "阿Q" = 2, but a bare "Q" = 1). Mirrors the
// eval spec's cjk_len.
func SignificantLen(normalized string) int {
n := 0
for _, r := range normalized {
switch {
case DenseScript(r):
n++
case unicode.IsLetter(r):
n++
}
}
return n
}