textmachine/backend/internal/pipeline/waveselection.go

106 lines
4.1 KiB
Go

package pipeline
import (
"encoding/json"
"sort"
"textmachine/backend/internal/membank"
"textmachine/backend/internal/store"
)
// waveselection.go: the record of WHAT EACH WAVE WAS SHOWN (backlog row 417, schema v17).
//
// It exists because the two waves see different banks and the old table cannot hold both. The draft wave
// selects over the BASE bank (mined rows excluded, so the first run of a book drafts with no mined
// terminology at all); the editor selects over the ENRICHED one. retrieval_state has one row per chunk, so
// the unit merge writes the editor's post-check onto the leader's DRAFT row and the editor's own injection
// counts never land anywhere — and its EVICTIONS landed nowhere at all, which chunkrun.go says in as many
// words. The consequence, measured on the paid run of 11.09: four terms shipped in a rendering the bank did
// not hold, and whether the bank had even reached the editor's prompt was not answerable from the database.
//
// Recomputed every run and idempotent per (position, wave), so a resume converges instead of accumulating.
// Observability only: nothing here is read on the money path and nothing here changes a request.
// recordWaveSelection persists one wave's selection at one position. The counts are derived from the SAME
// Selection the wave actually injected — not re-selected here — so the record cannot describe a different
// selection from the one that went to the model.
func (r *Runner) recordWaveSelection(snapID, wave string, chapter, chunkIdx int, sel membank.Selection) error {
ws := store.WaveSelection{
BookID: r.Book.BookID, Chapter: chapter, ChunkIdx: chunkIdx, Wave: wave, SnapshotID: snapID,
NSpoilerBlocked: len(sel.Rejected),
NEvicted: len(sel.Evicted),
InjectedSrcs: srcsJSON(sel.Injected),
EvictedSrcs: srcsJSON(sel.Evicted),
}
for _, p := range sel.Injected {
if p.Sticky {
ws.NSticky++
} else {
ws.NExactHits++
}
if p.Disp == membank.Ambiguous {
ws.NAmbiguousFlagged++
}
}
return r.Store.UpsertWaveSelection(ws)
}
// srcsJSON renders the source surfaces of a set of picked records, sorted and deduped, as a JSON array.
// Sorted because the column is compared across runs and across waves, and selection order is a priority
// order that a budget change would reshuffle without any row having moved; "[]" rather than "null" for the
// empty case, so a position with nothing shown reads as a list nobody filled rather than a missing field.
func srcsJSON(ps []membank.PickedEntry) string {
seen := map[string]bool{}
out := []string{}
for _, p := range ps {
if src := p.Src(); src != "" && !seen[src] {
seen[src] = true
out = append(out, src)
}
}
sort.Strings(out)
b, err := json.Marshal(out)
if err != nil {
return "[]"
}
return string(b)
}
// WaveInjection is one wave's book-wide injection totals — the summary that answers "did the bank reach
// this wave at all", which is the question the paid run of 11.09 could not put to the database.
type WaveInjection struct {
Wave string `json:"wave"`
Positions int `json:"positions"`
ExactHits int `json:"exact_hits"`
Sticky int `json:"sticky"`
Ambiguous int `json:"ambiguous"`
Blocked int `json:"spoiler_blocked"`
Evicted int `json:"evicted"`
}
// waveInjections folds the per-position records into one row per wave, in a fixed wave order so the report
// is stable. A wave with no rows is omitted rather than printed as zeroes: "this wave injected nothing" and
// "this run predates the table" are different facts, and only the first one deserves a line.
func waveInjections(rows []store.WaveSelection) []WaveInjection {
byWave := map[string]*WaveInjection{}
for _, w := range rows {
a := byWave[w.Wave]
if a == nil {
a = &WaveInjection{Wave: w.Wave}
byWave[w.Wave] = a
}
a.Positions++
a.ExactHits += w.NExactHits
a.Sticky += w.NSticky
a.Ambiguous += w.NAmbiguousFlagged
a.Blocked += w.NSpoilerBlocked
a.Evicted += w.NEvicted
}
var out []WaveInjection
for _, name := range []string{store.WaveDraft, store.WaveEdit} {
if a := byWave[name]; a != nil {
out = append(out, *a)
}
}
return out
}