feat(reconcile): merge two transcripts with a model choosing between disagreements
CI / Lint / Test / Vet (pull_request) Successful in 20s
CI / Build & Import (pull_request) Skipped
CI / Deploy via GitOps (pull_request) Skipped

Base text stands except where the model picks the alternative's reading; trivial
disputes skip the model; the reading order alternates; the base's context goes to
the model. Fails soft: three consecutive model errors return the base text
unreconciled (none of the partial changes) with the reason; a verdict returned with
an error is ignored; a cancelled job is an error, not 'skipped'. Counts are exact,
the stored list of contested spots is capped at 200 and carries the second each
starts at. Mutation-checked on nine behaviours.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 23:20:12 +02:00
co-authored by Claude Sonnet 5.5
parent 7f9e967ad0
commit f25acebe4c
+152
View File
@@ -0,0 +1,152 @@
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)
}
}