package reconcile import ( "context" "fmt" "strings" "git.d-ma.be/mathias/tapir/internal/domain" ) // MaxContested bounds the stored list of contested spots. The counts on the // Reconciliation are always exact; only this list is capped. const MaxContested = 200 // Options configure Reconcile. Zero Window and BreakAfter take their defaults. type Options struct { // Window is how many words of the base to show on each side of a dispute. 100 is // what was measured (docs/research/local-model-adjudicator-sweep-2026-10-01.md). Window int // BreakAfter is how many consecutive model errors stop the reconciliation, so a // model that is down costs three timeouts, not one per dispute. BreakAfter int // BaseEngine, AltEngine and Reconciler are recorded on the result. BaseEngine, AltEngine, Reconciler string } // Reconcile merges two transcripts of the same speech. The base text stands except // where the model chooses the alternative's reading of a dispute; trivial disputes // (a compound split, a filler word) are resolved without the model. It never fails a // transcription: if the model cannot be used the base text comes back unreconciled // and the result says why. The only error it returns is a cancelled context, which // is the job stopping, not a verdict on the model. func Reconcile(ctx context.Context, base, alt domain.Transcription, adj Adjudicator, o Options) (domain.Transcription, error) { window, breakAfter := o.Window, o.BreakAfter if window <= 0 { window = 100 } if breakAfter <= 0 { breakAfter = 3 } bt, at := Tokenize(base.Text), Tokenize(alt.Text) ds := FindDisputes(bt, at) rec := &domain.Reconciliation{ Status: domain.ReconcileDone, Base: o.BaseEngine, Alt: o.AltEngine, Reconciler: o.Reconciler, Disputes: len(ds), } seconds := positioner(base, len(bt)) verdicts := make([]Verdict, len(ds)) consecutive := 0 for i, d := range ds { if AutoResolve(d) { rec.AutoResolved++ verdicts[i] = VerdictBase continue } if err := ctx.Err(); err != nil { return domain.Transcription{}, err } before, after := ContextAround(bt, d, window) v, err := adj.Adjudicate(ctx, Request{Before: before, After: after, Base: d.Base, Alt: d.Alt, AltFirst: i%2 == 1}) if err != nil { if ctxErr := ctx.Err(); ctxErr != nil { return domain.Transcription{}, ctxErr } rec.Errors++ consecutive++ if consecutive >= breakAfter { return skipped(base, rec, fmt.Sprintf("the model failed %d times in a row: %v", consecutive, err)), nil } v = VerdictUnsure } else { consecutive = 0 rec.Adjudicated++ } verdicts[i] = v switch v { case VerdictAlt: rec.Changed++ addContested(rec, domain.ContestedSpot{Seconds: seconds(d.BaseStart), Base: d.Base, Alt: d.Alt, Used: "alt"}) case VerdictUnsure: rec.Unsure++ addContested(rec, domain.ContestedSpot{Seconds: seconds(d.BaseStart), Base: d.Base, Alt: d.Alt, Used: "base", Unsure: true}) } } var parts []string pos := 0 for i, d := range ds { parts = appendTexts(parts, bt[pos:d.BaseStart]) if verdicts[i] == VerdictAlt { parts = appendTexts(parts, at[d.AltStart:d.AltEnd]) } else { parts = appendTexts(parts, bt[d.BaseStart:d.BaseEnd]) } pos = d.BaseEnd } parts = appendTexts(parts, bt[pos:]) out := base out.Text = strings.Join(parts, " ") out.Reconciliation = rec return out, nil } // skipped is the base text, none of the partial changes, and the reason. func skipped(base domain.Transcription, rec *domain.Reconciliation, reason string) domain.Transcription { out := base out.Reconciliation = &domain.Reconciliation{ Status: domain.ReconcileSkipped, Reason: reason, Base: rec.Base, Alt: rec.Alt, Reconciler: rec.Reconciler, Disputes: rec.Disputes, AutoResolved: rec.AutoResolved, Errors: rec.Errors, } return out } func appendTexts(dst []string, ts []Token) []string { for _, t := range ts { dst = append(dst, t.Text) } return dst } // positioner maps a token index of the base to the second it starts at: through the // segments when there are any, proportionally otherwise. func positioner(base domain.Transcription, tokens int) func(int) float64 { if len(base.Segments) == 0 { return func(i int) float64 { if tokens == 0 { return 0 } return float64(i) / float64(tokens) * base.AudioSeconds } } starts := make([]int, len(base.Segments)) n := 0 for i, s := range base.Segments { starts[i] = n n += len(Tokenize(s.Text)) } return func(i int) float64 { k := 0 for k+1 < len(starts) && starts[k+1] <= i { k++ } return base.Segments[k].Start } } func addContested(rec *domain.Reconciliation, c domain.ContestedSpot) { if len(rec.Contested) < MaxContested { rec.Contested = append(rec.Contested, c) } }