textmachine/backend/internal/miner/miner_emit.go

377 lines
16 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 miner
import (
"fmt"
"sort"
"strings"
"gopkg.in/yaml.v3"
"textmachine/backend/internal/lang"
"textmachine/backend/internal/seed"
"textmachine/backend/internal/store"
"textmachine/backend/internal/text"
)
// miner_emit.go: the WS3 seed-delta emission (§C2-7) — the miner's owner-facing output. It runs the
// default-B detector, applies the subsumption + fragment + type + freq FILTERS (never a raw 13618-dump,
// which carries §B2 fragment noise), clusters aliases (tier-1), and emits one Term per NEW entity.
//
// Discipline: the miner NEVER writes `approved` — every emitted term is a `draft`/`auto` PROPOSAL for the
// owner to sign (§C2). In default B the WHAT (dst) is delivered by the banknote (WS4) at the bank-mining sign
// boundary, so the miner emits WHICH-only candidates (status:auto, no dst — inert until a dst + owner
// promotion). A candidate that clusters with an existing seed surface is an alias-of-existing and is left
// for the owner to attach (not emitted as a new entity), so the delta is genuinely new terms.
// emitMinFreq is the emission frequency floor (emit_owner_sheets.build_precision30: freq ≥ 5).
const emitMinFreq = 5
// emitRankCap is the top-N cap applied to the ranked candidate list BEFORE the emission filters (FL-3),
// mirroring the reference's a3[:200] slice (emit_owner_sheets / alias.py:165). It bounds the owner-signed
// signature-map volume to the reference; it does NOT touch the miner's WHICH-invariant SET/recall/screen
// (those are properties of mr.ranked, computed before emission).
const emitRankCap = 200
// EmitRankCap exposes that bound to the other producer of signature-map rows — the bank stop's reverse
// section (banknote-only surfaces the miner structurally cannot see). One definition, so the volume the
// owner is asked to sign cannot be capped at 200 through one door and uncapped through the other.
func EmitRankCap() int { return emitRankCap }
// Term is one WHICH candidate the miner proposes (a seed-delta entry). The miner never writes a dst
// (default B) or `approved`; the caller persists it via the mined-write path (Source:"mined", status
// auto) and the owner signs it at the bank-mining stop.
type Term struct {
Src string
Type string // name|place|title|term (the candidate's first pattern type; "" → term)
SinceCh int // first chapter of appearance across the term + its aliases (auto)
Freq int
Aliases []string // identity-cluster co-surfaces (normalized), sorted; the entity's other surfaces
Evidence []string // up to 3 pattern-evidence tags (for the owner sidecar)
// Dst is the CONSOLIDATED rendering the terminologist produced over the whole book's contexts (pack-20,
// D39.42 п.1). The MINER never fills it — it is attached by the caller after the terminologist runs, and
// it is what selects the emission MODE in DeltaYAML (§C2-7). Empty when the role is off or declined the
// term: the term then emits status:auto, inert until someone signs it.
Dst string
}
// MineBank runs the full default-B miner over the normalized chunks and emits the alias-clustered,
// filtered mined delta (WHICH candidates for owner sign). seed is the current glossary (its surfaces
// scope the non-seed filter, the fragment guard, and the alias entity/metadata). rejects is the owner's
// declined-term set (normalized src, R1-FL-B): a candidate whose entity touches a rejected surface is
// dropped from the delta so a declined term never re-fires the stop (a nil/empty rejects is a no-op — the
// emission is byte-identical to before, preserving the frozen parity). Deterministic and $0.
func MineBank(chunks []Chunk, contrast *Contrast, seed []store.GlossaryEntry, rejects map[string]bool, cfg Config, pack *lang.Pack) []Term {
out, _ := MineBankStats(chunks, contrast, seed, rejects, cfg, pack)
return out
}
// EmissionStats is the funnel BEHIND the emitted delta — how many candidates the detector's alphabet held
// and where they were lost. It exists because an empty delta is otherwise unreadable (G10, polygon package
// seven): `emitted=0` looks exactly the same whether the book has no new terms or whether a thousand
// candidates were ranked and every one of them was cut by the top-N slice, the type filter or the seed. A
// stop that cannot tell those apart tells the owner "your book is clean" when it means "I looked through a
// keyhole". Pure counters, no behaviour: every number here is read off the same pass that already runs.
type EmissionStats struct {
Ranked int // the detector's whole ranked candidate set — the "alphabet"
AfterCap int // after the top-emitRankCap slice
Eligible int // after the emission eligibility filters (type, length, subsumption)
SeedSkipped int // clusters dropped as aliases-of-existing (a seed surface is in the cluster)
Rejected int // clusters dropped by the owner's reject list
Emitted int // what the delta actually carries
}
// MineBankStats is MineBank plus the funnel counters. Same emission, byte for byte — the counters are
// read-only.
func MineBankStats(chunks []Chunk, contrast *Contrast, seed []store.GlossaryEntry, rejects map[string]bool, cfg Config, pack *lang.Pack) ([]Term, EmissionStats) {
var stats EmissionStats
mr := mineDetect(chunks, contrast, cfg, pack)
stats.Ranked = len(mr.ranked)
// Seed surfaces (normalized) + their metadata for the alias rules and the non-seed / fragment guards.
seedSurfaces := map[string]bool{}
seedMeta := map[string]aliasSurface{}
for _, e := range seed {
for _, surf := range append([]string{e.Src}, aliasStrings(e.Aliases)...) {
nk := text.NormalizeSourceKey(surf)
if nk == "" {
continue
}
seedSurfaces[nk] = true
// The primary src carries the dst/gender/type; an alias inherits them (same entity).
if _, ok := seedMeta[nk]; !ok {
meta := aliasSurface{src: nk, typ: e.Type}
if e.Status == "approved" {
meta.approvedDst = e.Dst
}
meta.gender = e.Gender
seedMeta[nk] = meta
}
}
}
// Qualifying candidate pool (build_precision30 filters), in ranked order (the ranked order is the
// representative-selection order below — the top-ranked cluster member owns the aliases). The ranked
// list is capped to the top emitRankCap BEFORE the eligibility filters (FL-3), mirroring the
// reference's a3[:200] slice (emit_owner_sheets.build_precision30 / alias.py:165): the cap bounds the
// owner-signed signature map to the reference's volume, so an eligible candidate below the cap is not
// surfaced. WHICH-invariants (the 13618-member SET, recall, the catastrophe screen) are unaffected —
// they are properties of mr.ranked itself, not of the capped emission slice.
ranked := mr.ranked
if len(ranked) > emitRankCap {
ranked = ranked[:emitRankCap]
}
stats.AfterCap = len(ranked)
var pool []scoredCand
for _, c := range ranked {
if !emissionEligible(c, mr.subsumed, seedSurfaces, pack) {
continue
}
pool = append(pool, c)
}
stats.Eligible = len(pool)
// Alias universe = qualifying candidate surfaces seed surfaces; run tier-1 clustering.
surfaces := map[string]aliasSurface{}
for _, c := range pool {
typ := ""
if len(c.Types) > 0 {
typ = c.Types[0]
}
surfaces[c.Src] = aliasSurface{src: c.Src, typ: typ}
}
for nk, meta := range seedMeta {
surfaces[nk] = meta // seed metadata wins (gender/approved_dst)
}
universe := make([]string, 0, len(surfaces))
for s := range surfaces {
universe = append(universe, s)
}
sort.Strings(universe)
ident, _, _ := proposeAliasEdges(surfaces, chunks, seedSurfaces, pack)
clusters := clusterAlias(ident, universe)
// clusterOf maps a surface to its identity cluster (members); a surface not in a multi-cluster maps
// to a singleton.
clusterOf := map[string][]string{}
for _, cl := range clusters {
for _, s := range cl {
clusterOf[s] = cl
}
}
var out []Term
emitted := map[string]bool{}
for _, c := range pool { // ranked order → the top-ranked cluster member is the representative
if seedSurfaces[c.Src] || emitted[c.Src] {
continue
}
cl := clusterOf[c.Src]
// A cluster touching a seed surface is an alias-of-existing entity → the owner attaches it; skip.
if clusterTouches(cl, seedSurfaces) {
markEmitted(cl, emitted)
stats.SeedSkipped++
continue
}
// A declined entity (R1-FL-B) is dropped WITH its whole cluster, so it never re-fires the stop:
// marking the cluster emitted stops a lower-ranked member from re-emitting the same entity next run
// (which would keep the delta non-empty forever). rejects[c.Src] covers a SINGLETON representative
// (cl is nil for a candidate in no multi-cluster); clusterTouches covers a rejected alias of a
// multi-member cluster. markEmitted(nil,...) is a harmless no-op for the singleton case.
if rejects[c.Src] || clusterTouches(cl, rejects) {
markEmitted(cl, emitted)
stats.Rejected++
continue
}
var aliases []string
if len(cl) > 1 {
for _, m := range cl {
if m != c.Src && !seedSurfaces[m] {
aliases = append(aliases, m)
}
}
sort.Strings(aliases)
markEmitted(cl, emitted) // the whole cluster is represented by this one term
} else {
emitted[c.Src] = true
}
typ := "term"
if len(c.Types) > 0 {
typ = c.Types[0]
}
out = append(out, Term{
Src: c.Src, Type: typ, Freq: c.Freq, Aliases: aliases, Evidence: c.Evidence,
SinceCh: entitySinceCh(append([]string{c.Src}, aliases...), chunks),
})
}
// Deterministic output order: by src (the caller may re-sort, but pin a stable delta).
sort.Slice(out, func(i, j int) bool { return out[i].Src < out[j].Src })
stats.Emitted = len(out)
return out, stats
}
// DeltaYAML serializes the mined delta into the seed.Term YAML schema (§C2-7) — the owner-sign artifact
// and the `tmctl seed-lint` input. The miner supplies the WHICH; `proposals` (keyed by normalized src)
// supplies the WHAT the banknote channel collected during the draft wave, so the owner signs a term that
// already carries the translator's proposed rendering instead of a bare source surface.
//
// Every term stays status:auto — that is the load-bearing line. Attaching a dst delivers EVIDENCE, not
// trust: an auto term is outside the CONFIRMED-only injection (D39.2 trust gate), so nothing the model
// proposed reaches the book until the owner signs it. Before this join the parsed dst was dropped into
// `_` (D39.36) and the owner met the stop holding bare terms, having to invent the Russian for a word
// the model had already rendered.
//
// nil/empty proposals reproduce the previous output BYTE FOR BYTE (WHICH-only, no dst), so a book
// running with the channel off is unaffected. Alternative renderings are listed in the note with their
// chunk counts — the disagreement IS the reason to sign (one term came back three ways in the 25.07
// mini-run). It reuses the EXISTING seed.Term schema (no new fields — §3(б)); evidence / zones live in
// the sidecar sign-map. The output is guaranteed loadable by loadGlossarySeed (status:auto may lack a
// dst); seed-lint proves it against the real loader.
func DeltaYAML(mined []Term, proposals map[string][]DstProposal) (string, error) {
sf := seed.File{}
for _, m := range mined {
st := seed.Term{Src: m.Src, Type: m.Type, Status: "auto", SinceCh: m.SinceCh}
for _, a := range m.Aliases {
st.Aliases = append(st.Aliases, seed.Alias{Alias: a, Type: "mined"})
}
note := ""
if len(m.Evidence) > 0 {
note = "mined WHICH candidate; evidence: " + fmt.Sprint(m.Evidence)
}
// The join key is the miner's own normalized surface, so a proposal written with different
// orthography still lands on its term (the same normalization the seed-surface exclusion uses).
if props := proposals[text.NormalizeSourceKey(m.Src)]; len(props) > 0 {
st.Dst = props[0].Dst
note = appendProposalNote(note, props)
}
// TWO-MODE EMISSION (§C2-7, ratified and until pack-20 unbuilt): a term that arrives with a
// CONSOLIDATED canon proposal emits `draft` — the mode whose injection carries ⟨проверить⟩ — while a
// term with no dst stays `auto`, inert. The mode turns on the CONSOLIDATED dst only, never on a raw
// per-chunk banknote guess: a proposal one chunk's translator improvised is evidence for the owner,
// and lifting it to draft would let the model's first thought become the book's working canon. Both
// modes are still PROPOSALS — neither is `approved`, and the miner has no path to write that word.
if m.Dst != "" {
st.Dst, st.Status = m.Dst, "draft"
note = appendConsolidatedNote(note)
}
st.Note = note
sf.Terms = append(sf.Terms, st)
}
b, err := yaml.Marshal(sf)
if err != nil {
return "", fmt.Errorf("miner: marshal mined delta: %w", err)
}
return string(b), nil
}
// appendProposalNote documents WHERE the attached dst came from and what else was proposed. The
// provenance line is not decoration: a dst in a sign map that does not say "a model proposed this,
// nobody approved it" invites the owner to read it as already-canon.
func appendProposalNote(note string, props []DstProposal) string {
var b strings.Builder
if note != "" {
b.WriteString(note)
b.WriteString("; ")
}
fmt.Fprintf(&b, "dst PROPOSED by the translator (banknote, %d chunk(s)) — NOT approved, sign or replace it", props[0].Chunks)
if len(props) > 1 {
b.WriteString("; other proposals: ")
for i, p := range props[1:] {
if i > 0 {
b.WriteString(", ")
}
fmt.Fprintf(&b, "%q ×%d", p.Dst, p.Chunks)
}
}
return b.String()
}
// appendConsolidatedNote records that the dst on this row is the TERMINOLOGIST's consolidation, not a
// per-chunk guess and not a signature. The distinction is the whole trust story of the auto mode: a
// `draft` row reaches the wire with an unverified marker, so the sign map must say, in the artifact
// itself, who produced the rendering and that nobody has approved it.
func appendConsolidatedNote(note string) string {
const s = "dst CONSOLIDATED by the terminologist over the whole book's contexts — a PROPOSAL (status:draft), not approved"
if note == "" {
return s
}
return note + "; " + s
}
// emissionEligible applies the build_precision30 filters: type ∈ {name,place,title}, freq ≥ 5, src not
// subsumed, len ≥ 2 rune, not a boundary fragment.
func emissionEligible(c scoredCand, subsumed, seedSurfaces map[string]bool, pack *lang.Pack) bool {
if !hasAnyType(c.Types, "name", "place", "title") {
return false
}
if c.Freq < emitMinFreq || subsumed[c.Src] || runeLen(c.Src) < 2 {
return false
}
if isFragment(c.Src, seedSurfaces, pack) {
return false
}
return true
}
func hasAnyType(types []string, want ...string) bool {
for _, t := range types {
for _, w := range want {
if t == w {
return true
}
}
}
return false
}
// clusterTouches reports whether any surface of the identity cluster is in `set` — used both for the seed
// surfaces (an alias-of-existing entity) and the reject surfaces (a declined entity), which are treated the
// same way at emission: the whole cluster is suppressed.
func clusterTouches(cl []string, set map[string]bool) bool {
for _, s := range cl {
if set[s] {
return true
}
}
return false
}
func markEmitted(cl []string, emitted map[string]bool) {
for _, s := range cl {
emitted[s] = true
}
}
// entitySinceCh is the earliest chapter any of the entity's surfaces appears in (canon.since_ch = first
// chapter of appearance).
func entitySinceCh(surfaces []string, chunks []Chunk) int {
min := 0
for _, s := range surfaces {
for ch := range candidateChapters(s, chunks) {
if min == 0 || ch < min {
min = ch
}
}
}
return min
}
func aliasStrings(as []store.GlossaryAlias) []string {
out := make([]string, len(as))
for i, a := range as {
out[i] = a.Alias
}
return out
}
// DstProposal is one PROPOSED rendering of a mined term, delivered by the banknote channel (WS4) and
// attached to the signature map at sign time. It is evidence for the owner, never a canon: the emitted
// term keeps status:auto, so a proposal enters the bank only through a signature.
//
// Chunks is how many chunks proposed this exact rendering — the disagreement signal. One term coming
// back with three renderings is the drift a canon closes, and hiding that behind a single "winner"
// would throw away the reason to sign it at all.
type DstProposal struct {
Dst string
Type string
Chunks int
}