textmachine/backend/internal/pipeline/miner.go

152 lines
5.6 KiB
Go
Raw 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 pipeline
import "sort"
// miner.go: the WS3 bank-mining detector orchestration (a Go port of the frozen exp16 arm A3 = V-C,
// LEMMATIZER-DEFAULT-B). Default B drops the pymorphy3-backed sub-channels the ratified §10-14 default
// removes: the spread multiplier (λ=0 already zeroed it), the dst-variants co-occurrence, and the
// Palladius ru-side name confirmation (+56 bonus). The dst (WHAT) is delivered by the banknote (WS4),
// never by co-occurrence, so exact rank is not a product value — the invariant guarantees are the
// candidate SET (membership), recall@proposed, and the catastrophe screen (∈top-50); dropping the
// Palladius sub-channel moves ONLY 古月's exact rank (21→22 vs the pymorphy3 reference), screen still
// PASS. Pure and deterministic ($0, no LLM, no network, no time/rand).
// MinerConfig is the frozen miner-v1 config (thresholds tuned on chapters 115, §D1). Values mirror
// arms.FROZEN; the pymorphy3-dependent knobs (lam, spread_freq_min) are absent by default-B design.
type MinerConfig struct {
FreqFloor int
SubsumeAlpha float64
FormantMinPartners int
FormantMinOverRep float64
Bonus map[string]float64 // typ → base bonus (name/place/title/term)
PatternWeight map[string]float64 // evidence-source → weight (max over a candidate's evidence)
SubfloorFreqScale float64
TopK int
PackVersion string
}
// frozenMinerConfig is the ratified default-B config (versionable — a threshold edit is a mining-config
// bump the caller folds; a ±50% sweep is invariant, A3@f≥3=1.0 holds by floor/over_rep/subsume/partners).
func frozenMinerConfig() MinerConfig {
return MinerConfig{
FreqFloor: minerFreqFloor,
SubsumeAlpha: minerSubsumeAlpha,
FormantMinPartners: 3,
FormantMinOverRep: 15.0,
Bonus: map[string]float64{"name": 140.0, "place": 140.0, "title": 120.0, "term": 80.0},
PatternWeight: map[string]float64{
"surname": 1.0, "ordinal_title": 1.0, "title_suffix": 0.9, "topo_suffix": 0.9,
"rank_grade": 1.0, "formant_suffix": 0.6, "formant_prefix": 0.5, "title_bare": 0.4,
"palladius": 0.8, // retained for parity of the weight table; the sub-channel is off in default B
},
SubfloorFreqScale: 40.0,
TopK: 90,
PackVersion: minerPackVersion,
}
}
// ScoredCand is one candidate after the V-C arm assembly (arms.ScoredCand, minus the default-B-dropped
// dst_variants/spread). types/evidence are in deterministic first-seen order; fromPattern marks a
// pattern-channel contribution.
type ScoredCand struct {
Src string
Score float64
Freq int
Types []string
Evidence []string
FromPattern bool
}
// mineResult carries the detector outputs the alias/emit layers consume.
type mineResult struct {
ranked []ScoredCand // A3 ranking, sorted (-score,-freq,src)
subsumed map[string]bool // V-A ranking-dropped boundary fragments
formants map[rune]formantInfo
cfg MinerConfig
}
// mineDetect runs V-A → V-C (arm A3, default B) over the normalized chunks and returns the ranked
// candidate list. contrast is the general-zh reference (loaded once). Deterministic.
func mineDetect(chunks []MinerChunk, contrast *Contrast, cfg MinerConfig) mineResult {
va := runVA(chunks, contrast, cfg.FreqFloor, cfg.SubsumeAlpha)
pats, formants := patternCandidates(chunks, va.candFreq, contrast, cfg.FormantMinPartners, cfg.FormantMinOverRep)
merged := make(map[string]*ScoredCand, len(va.ranked)+len(pats))
for _, c := range va.ranked {
merged[c.src] = &ScoredCand{Src: c.src, Score: c.score, Freq: c.freq} // score·(1+λ·sp) with λ=0
}
for cand, info := range pats {
pw := 0.3
for _, ev := range info.evidence {
if w, ok := cfg.PatternWeight[evSource(ev)]; ok && w > pw {
pw = w
}
}
typ := "term"
if len(info.types) > 0 {
typ = info.types[0]
}
base, ok := cfg.Bonus[typ]
if !ok {
base = cfg.Bonus["term"]
}
bonus := base * pw
if sc, ok := merged[cand]; ok {
sc.Score += bonus
for _, t := range info.types {
if !containsStr(sc.Types, t) {
sc.Types = append(sc.Types, t)
}
}
sc.Evidence = append(sc.Evidence, firstN(info.evidence, 3)...)
sc.FromPattern = true
} else {
f := countOccurrences(cand, chunks)
merged[cand] = &ScoredCand{
Src: cand, Score: bonus + cfg.SubfloorFreqScale*float64(f)*pw, Freq: f,
Types: append([]string(nil), info.types...), Evidence: firstN(info.evidence, 3),
FromPattern: true,
}
}
}
// Palladius ru-side confirmation loop is INTENTIONALLY OMITTED (default B — it needs dst_variants +
// is_name_lemma, both pymorphy3; it only shifts 古月's exact rank, which is not a product value).
ranked := make([]ScoredCand, 0, len(merged))
for _, sc := range merged {
ranked = append(ranked, *sc)
}
sortScored(ranked)
return mineResult{ranked: ranked, subsumed: va.subsumed, formants: formants, cfg: cfg}
}
// sortScored orders the arm ranking by (-score, -freq, src) — the frozen key.
func sortScored(cs []ScoredCand) {
sort.Slice(cs, func(i, j int) bool {
if cs[i].Score != cs[j].Score {
return cs[i].Score > cs[j].Score
}
if cs[i].Freq != cs[j].Freq {
return cs[i].Freq > cs[j].Freq
}
return cs[i].Src < cs[j].Src
})
}
// evSource is the pattern-weight key: the evidence tag before its ':' (arms' ev.split(":")[0]).
func evSource(ev string) string {
for i := 0; i < len(ev); i++ {
if ev[i] == ':' {
return ev[:i]
}
}
return ev
}
// firstN returns up to n elements of ss (a fresh slice, never aliasing the input).
func firstN(ss []string, n int) []string {
if len(ss) < n {
n = len(ss)
}
return append([]string(nil), ss[:n]...)
}