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 1–15, §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]...) }