Compare commits

...
14 Commits
Author SHA1 Message Date
mathiasandClaude Sonnet 5.5 f25acebe4c 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>
2026-10-01 23:20:12 +02:00
mathiasandClaude Sonnet 5.5 7f9e967ad0 test(reconcile): a verdict returned with an error is not used
Mutation-testing showed the forced-unsure line was untested because my fake
always returned unsure with its errors.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 23:20:12 +02:00
mathiasandClaude Sonnet 5.5 0fead2e102 feat(domain): Reconciliation -- how a merged transcript came about (ADR-034)
Status (reconciled / single_engine / skipped), exact counts, and a bounded list of
contested spots (changed or unsure) with a position and both readings.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 23:19:54 +02:00
mathiasandClaude Sonnet 5.5 a1e16fa658 test(reconcile): fixtures need two shared words between disputes
Disputes separated by a single shared word are merged into one decision by design,
so several of my fixtures were secretly one dispute. Corrected before any
implementation existed; the rule is unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 23:19:22 +02:00
mathiasandClaude Sonnet 5.5 b20558ffa6 test(reconcile): merging two transcripts, and failing soft (red)
Identical texts and trivial disputes cost no model call; an alt verdict takes the
alternative's own words, base/unsure keep the base and only unsure is flagged;
insertions and deletions land in the right place; the reading order alternates; the
model gets the base's context. Three consecutive model errors stop the whole
reconciliation (base text, none of the partial changes, reason recorded) while a
success in between resets the breaker; a cancelled job is an error, not 'skipped'.
Spots carry the second they start at; the stored list is capped, the counts are not.

Red: Reconcile, Options, MaxContested and domain.Reconcile* are undefined.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 23:19:00 +02:00
mathiasandClaude Sonnet 5.5 5e54c9beff feat(reconcile): LLM adjudicator with the measured prompt, plus an order-checked wrapper
Sends the exact wording that scored 92% (gemma4-26b) and 97% (gemma4-31b); maps
1/2 back to base/alt under both orders; an unreadable reply is unsure, a transport
error is an error. OrderChecked asks twice with the order swapped and keeps only
agreement. Mutation-checked: answer mapping, unparseable-as-guess, the word
boundary and the order check each turn a test red.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 23:18:11 +02:00
mathiasandClaude Sonnet 5.5 2bfe84ee64 test(reconcile): a number or word that merely starts with 1 or 2 is not an answer
Mutation-testing the adjudicator showed dropping the word boundary went unnoticed:
'ANSWER: 12' would be read as 1.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 23:18:11 +02:00
mathiasandClaude Sonnet 5.5 481321a75c test(reconcile): the adjudicator sends the measured prompt and maps answers back (red)
Readings in the requested order; 1/2 mapped to base/alt under both orders;
UNSURE, an unparseable or out-of-range reply is unsure rather than a guess; an
empty reading is described, not blank; a model error is an error, distinguishable
from 'unsure'. The optional order-checked wrapper asks twice with the order swapped
and keeps only agreement.

Red: Request, Verdict, NewLLMAdjudicator and NewOrderChecked are undefined.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 23:17:42 +02:00
mathiasandClaude Sonnet 5.5 2600e6c45a feat(reconcile): find disputes, resolve trivial ones without a model, give context
Disputes carry both readings with their own spelling; differences separated by
one shared word merge into one decision. Compound splits/joins and one or two
filler particles are resolved without a model; 'nej', numbers and content words
never are. Mutation-checked (merge distance, filler limit, 'nej', compound rule).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 23:17:21 +02:00
mathiasandClaude Sonnet 5.5 4e43327792 test(reconcile): finding disputes, resolving the trivial ones, and their context (red)
A dispute carries both readings with their own spelling; differences separated by
a single shared word are one decision, two shared words keep them apart; compound
splits/joins and a short run of filler words are resolved without a model, while
numbers, content words, and 'nej' (an answer) are not.

Red: FindDisputes, AutoResolve and ContextAround are undefined.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 23:16:55 +02:00
mathiasandClaude Sonnet 5.5 92a39da45c feat(reconcile): minimal word-level diff (Myers) with a bounded edit distance
Prefix/suffix trimmed first; adjacent deletes and inserts read as one Replace;
beyond 3,000 edits (two texts sharing almost nothing) the middle is one Replace
instead of gigabytes of trace. Mutation-checked: a wrong tie-break, missing
replace coalescing, wrong suffix accounting and a punctuation-bearing key each
turn tests red.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 23:16:31 +02:00
mathiasandClaude Sonnet 5.5 aa5607b002 feat(reconcile): tokenise for word-level comparison
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 23:16:31 +02:00
mathiasandClaude Sonnet 5.5 984d92e3a9 test(reconcile): word-level diff is minimal, tiles both inputs and is fast (red)
A random-input property test compares the equal runs against a dynamic-programming
LCS: a diff that is valid but not minimal would turn real agreement into disputes
the model is then asked about. A 6,500-word, 380-edit case mirrors a real hour.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 23:15:44 +02:00
mathiasandClaude Sonnet 5.5 00fd884c6c test(reconcile): tokenising for word-level comparison (red)
Keeps the word as written, keys it without case/punctuation/hyphens/plus, joins a
lone dash or full stop to its neighbour so it is not a word the engines can
disagree about. Red: package reconcile does not exist.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 23:15:44 +02:00
11 changed files with 1435 additions and 0 deletions
+44
View File
@@ -129,6 +129,50 @@ type Transcription struct {
Segments []Segment Segments []Segment
Endpoint string Endpoint string
AudioSeconds float64 AudioSeconds float64
// Reconciliation is set when two engines transcribed the same audio and a local
// model chose between their disagreements (ADR-034). Nil for a single engine.
Reconciliation *Reconciliation
}
// ReconcileStatus says what became of an attempt to merge two transcripts.
type ReconcileStatus string
const (
// ReconcileDone: both engines produced text and the disagreements were resolved.
ReconcileDone ReconcileStatus = "reconciled"
// ReconcileSingle: only one engine produced text, so there was nothing to merge.
ReconcileSingle ReconcileStatus = "single_engine"
// ReconcileSkipped: both engines produced text but the model could not be used;
// the base text stands, unreconciled.
ReconcileSkipped ReconcileStatus = "skipped"
)
// ContestedSpot is a place the engines disagreed that a reader may want to check
// against the audio: one the model changed, or one it was unsure about.
type ContestedSpot struct {
Seconds float64 `json:"seconds"` // roughly where in the recording
Base string `json:"base"` // what the base engine heard
Alt string `json:"alt"` // what the other engine heard
Used string `json:"used"` // "base" or "alt": the reading now in the text
Unsure bool `json:"unsure"` // the model could not decide; the base was kept
}
// Reconciliation records how a merged transcript came about, so the page can say so
// plainly and the user can check the uncertain spots. The counts are exact; Contested
// is bounded.
type Reconciliation struct {
Status ReconcileStatus `json:"status"`
Reason string `json:"reason,omitempty"` // why single_engine or skipped
Base string `json:"base"` // engine whose text is the base
Alt string `json:"alt"`
Reconciler string `json:"reconciler"`
Disputes int `json:"disputes"` // places the engines disagreed
AutoResolved int `json:"auto_resolved"` // trivial: compounds, filler words
Adjudicated int `json:"adjudicated"` // decided by the model
Changed int `json:"changed"` // the alternative's reading was used
Unsure int `json:"unsure"` // base kept, flagged
Errors int `json:"errors"` // model calls that failed
Contested []ContestedSpot `json:"contested,omitempty"`
} }
// UploadStatus is the state of an uploaded-audio transcription job. // UploadStatus is the state of an uploaded-audio transcription job.
+119
View File
@@ -0,0 +1,119 @@
package reconcile
import (
"context"
"fmt"
"regexp"
"strings"
)
// Verdict is an adjudicator's decision about one dispute. The zero value is
// VerdictUnsure: with no decision the base reading stands and the spot is flagged.
type Verdict int
const (
VerdictUnsure Verdict = iota
VerdictBase
VerdictAlt
)
// Request is one dispute as the adjudicator sees it: the surrounding words, the two
// readings, and which of them is shown as "Reading 1". Alternating AltFirst between
// disputes averages out position bias.
type Request struct {
Before, After string
Base, Alt string
AltFirst bool
}
// Adjudicator decides which reading of a dispute the speaker most likely said.
type Adjudicator interface {
Adjudicate(ctx context.Context, r Request) (Verdict, error)
}
// Completer is the chat surface the LLM adjudicator needs. *llm.Client satisfies it.
type Completer interface {
Complete(ctx context.Context, system, user string) (string, error)
}
// systemPrompt is the wording that was MEASURED (docs/research/
// local-model-adjudicator-sweep-2026-10-01.md): gemma4-26b 92% and gemma4-31b 97% against
// the speaker's blind answers. Changing it invalidates those figures.
const systemPrompt = "You compare two automatic transcriptions of the same Swedish speech. A single person is dictating project notes " +
"(finance and payments jargon, with English terms mixed into Swedish). The two transcriptions differ at ONE marked spot. " +
"Decide which reading is what the speaker most likely actually said, using the surrounding text and ordinary Swedish. " +
"Reply with exactly one line, either 'ANSWER: 1', 'ANSWER: 2' or 'ANSWER: UNSURE', then one short sentence of reasoning."
var answerRe = regexp.MustCompile(`(?i)ANSWER:\s*(1|2|UNSURE)\b`)
// LLMAdjudicator asks a chat model. It never invents an answer: a reply it cannot read
// is VerdictUnsure, and a transport error is returned as an error so the caller can tell
// "the model is down" from "the model is unsure".
type LLMAdjudicator struct{ c Completer }
// NewLLMAdjudicator wraps a Completer.
func NewLLMAdjudicator(c Completer) *LLMAdjudicator { return &LLMAdjudicator{c: c} }
// Adjudicate implements Adjudicator.
func (a *LLMAdjudicator) Adjudicate(ctx context.Context, r Request) (Verdict, error) {
first, second := r.Base, r.Alt
if r.AltFirst {
first, second = r.Alt, r.Base
}
user := fmt.Sprintf("Text before the spot:\n%s\n\n[[ THE SPOT ]]\n\nText after the spot:\n%s\n\nReading 1: %s\nReading 2: %s\n",
r.Before, r.After, describe(first), describe(second))
reply, err := a.c.Complete(ctx, systemPrompt, user)
if err != nil {
return VerdictUnsure, fmt.Errorf("reconcile: adjudicate: %w", err)
}
m := answerRe.FindStringSubmatch(reply)
if m == nil {
return VerdictUnsure, nil
}
switch strings.ToUpper(m[1]) {
case "1":
if r.AltFirst {
return VerdictAlt, nil
}
return VerdictBase, nil
case "2":
if r.AltFirst {
return VerdictBase, nil
}
return VerdictAlt, nil
}
return VerdictUnsure, nil
}
func describe(reading string) string {
if strings.TrimSpace(reading) == "" {
return "(nothing: the words are simply absent)"
}
return reading
}
// OrderChecked asks the inner adjudicator twice with the reading order swapped and
// keeps a verdict only when both agree. A model that flips with the order is showing
// position bias, not judgement, so disagreement is VerdictUnsure. It doubles the time.
type OrderChecked struct{ inner Adjudicator }
// NewOrderChecked wraps an adjudicator.
func NewOrderChecked(inner Adjudicator) *OrderChecked { return &OrderChecked{inner: inner} }
// Adjudicate implements Adjudicator.
func (o *OrderChecked) Adjudicate(ctx context.Context, r Request) (Verdict, error) {
r.AltFirst = false
v1, err := o.inner.Adjudicate(ctx, r)
if err != nil {
return VerdictUnsure, err
}
r.AltFirst = true
v2, err := o.inner.Adjudicate(ctx, r)
if err != nil {
return VerdictUnsure, err
}
if v1 == v2 {
return v1, nil
}
return VerdictUnsure, nil
}
+147
View File
@@ -0,0 +1,147 @@
package reconcile_test
import (
"context"
"errors"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/reconcile"
)
type fakeCompleter struct {
replies []string
err error
systems []string
users []string
}
func (f *fakeCompleter) Complete(_ context.Context, system, user string) (string, error) {
f.systems = append(f.systems, system)
f.users = append(f.users, user)
if f.err != nil {
return "", f.err
}
i := len(f.users) - 1
if i >= len(f.replies) {
i = len(f.replies) - 1
}
return f.replies[i], nil
}
var req = reconcile.Request{Before: "kunderna ska då", After: "De ska kunna betala", Base: "onboardade", Alt: "omvårdade"}
func TestLLMAdjudicator_SendsTheMeasuredPromptWithTheReadingsInTheRequestedOrder(t *testing.T) {
fc := &fakeCompleter{replies: []string{"ANSWER: 1"}}
a := reconcile.NewLLMAdjudicator(fc)
_, err := a.Adjudicate(context.Background(), req)
require.NoError(t, err)
require.Len(t, fc.users, 1)
assert.Contains(t, fc.systems[0], "two automatic transcriptions of the same Swedish speech")
assert.Contains(t, fc.systems[0], "ANSWER: 1")
u := fc.users[0]
assert.Contains(t, u, "Text before the spot:\nkunderna ska då")
assert.Contains(t, u, "[[ THE SPOT ]]")
assert.Contains(t, u, "Text after the spot:\nDe ska kunna betala")
assert.Contains(t, u, "Reading 1: onboardade\nReading 2: omvårdade", "base first when AltFirst is false")
}
func TestLLMAdjudicator_AltFirstSwapsWhichReadingIsNumberOne(t *testing.T) {
fc := &fakeCompleter{replies: []string{"ANSWER: 1"}}
r := req
r.AltFirst = true
_, err := reconcile.NewLLMAdjudicator(fc).Adjudicate(context.Background(), r)
require.NoError(t, err)
assert.Contains(t, fc.users[0], "Reading 1: omvårdade\nReading 2: onboardade")
}
func TestLLMAdjudicator_MapsTheAnswerBackToBaseOrAltWhicheverOrderWasShown(t *testing.T) {
tests := []struct {
name string
altFirst bool
reply string
want reconcile.Verdict
}{
{"1 with base first is base", false, "ANSWER: 1", reconcile.VerdictBase},
{"2 with base first is alt", false, "ANSWER: 2", reconcile.VerdictAlt},
{"1 with alt first is alt", true, "ANSWER: 1", reconcile.VerdictAlt},
{"2 with alt first is base", true, "ANSWER: 2", reconcile.VerdictBase},
{"unsure is unsure", false, "ANSWER: UNSURE", reconcile.VerdictUnsure},
{"case and trailing reasoning are tolerated", false, "answer: 2\nThe second reading fits the context.", reconcile.VerdictAlt},
{"reasoning before the answer line is tolerated", true, "Hmm.\nANSWER: 2", reconcile.VerdictBase},
{"no answer line is unsure, not a guess", false, "I think the first one.", reconcile.VerdictUnsure},
{"an empty reply is unsure", false, "", reconcile.VerdictUnsure},
{"an out of range number is unsure", false, "ANSWER: 3", reconcile.VerdictUnsure},
{"a number that merely starts with 1 is not 1", false, "ANSWER: 12", reconcile.VerdictUnsure},
{"a word that merely starts with 2 is not 2", false, "ANSWER: 2nd option", reconcile.VerdictUnsure},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := req
r.AltFirst = tt.altFirst
got, err := reconcile.NewLLMAdjudicator(&fakeCompleter{replies: []string{tt.reply}}).Adjudicate(context.Background(), r)
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
func TestLLMAdjudicator_AnEmptyReadingIsDescribedNotLeftBlank(t *testing.T) {
fc := &fakeCompleter{replies: []string{"ANSWER: 1"}}
r := req
r.Base = ""
_, err := reconcile.NewLLMAdjudicator(fc).Adjudicate(context.Background(), r)
require.NoError(t, err)
assert.Contains(t, fc.users[0], "Reading 1: (nothing: the words are simply absent)")
}
func TestLLMAdjudicator_ACompleterErrorIsReturnedNotTurnedIntoAVerdict(t *testing.T) {
boom := errors.New("gateway down")
_, err := reconcile.NewLLMAdjudicator(&fakeCompleter{err: boom}).Adjudicate(context.Background(), req)
assert.ErrorIs(t, err, boom, "the caller must be able to tell 'the model is down' from 'the model is unsure'")
}
func TestOrderChecked_AsksTwiceWithTheOrderSwappedAndKeepsOnlyAgreement(t *testing.T) {
tests := []struct {
name string
replies []string // for [base-first call, alt-first call]
want reconcile.Verdict
}{
{"both orders pick the base", []string{"ANSWER: 1", "ANSWER: 2"}, reconcile.VerdictBase},
{"both orders pick the alternative", []string{"ANSWER: 2", "ANSWER: 1"}, reconcile.VerdictAlt},
{"it flips with the order, so it is position bias, not a judgement", []string{"ANSWER: 1", "ANSWER: 1"}, reconcile.VerdictUnsure},
{"one order unsure is unsure", []string{"ANSWER: 1", "ANSWER: UNSURE"}, reconcile.VerdictUnsure},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fc := &fakeCompleter{replies: tt.replies}
got, err := reconcile.NewOrderChecked(reconcile.NewLLMAdjudicator(fc)).Adjudicate(context.Background(), req)
require.NoError(t, err)
assert.Equal(t, tt.want, got)
require.Len(t, fc.users, 2)
assert.True(t, strings.Contains(fc.users[0], "Reading 1: onboardade"), "first call shows the base first")
assert.True(t, strings.Contains(fc.users[1], "Reading 1: omvårdade"), "second call shows the alternative first")
})
}
}
func TestOrderChecked_AnErrorOnEitherCallIsAnError(t *testing.T) {
_, err := reconcile.NewOrderChecked(reconcile.NewLLMAdjudicator(&fakeCompleter{err: errors.New("down")})).Adjudicate(context.Background(), req)
assert.Error(t, err)
}
+187
View File
@@ -0,0 +1,187 @@
package reconcile
// OpKind is the kind of one run in an edit script.
type OpKind int
const (
// Equal: A[AStart:AEnd] and B[BStart:BEnd] are the same words.
Equal OpKind = iota
// Replace: different words on both sides.
Replace
// Delete: words only in A.
Delete
// Insert: words only in B.
Insert
)
// Op is one run of the edit script. The ops of a Diff tile both inputs: each op starts
// where the previous one ended, in A and in B.
type Op struct {
Kind OpKind
AStart, AEnd int
BStart, BEnd int
}
// maxEdits bounds the Myers search. Memory grows with the square of the edit
// distance, so two texts that share almost nothing (one engine produced garbage)
// are reported as one Replace instead of spending gigabytes proving it. A real
// hour disagrees in roughly 400 places, i.e. about 800 edits, far below this.
const maxEdits = 3000
// Diff returns a minimal edit script from a to b (Myers, O(ND)). Adjacent deletions
// and insertions are reported as one Replace.
func Diff(a, b []string) []Op {
pre := 0
for pre < len(a) && pre < len(b) && a[pre] == b[pre] {
pre++
}
suf := 0
for suf < len(a)-pre && suf < len(b)-pre && a[len(a)-1-suf] == b[len(b)-1-suf] {
suf++
}
midA, midB := a[pre:len(a)-suf], b[pre:len(b)-suf]
steps, ok := myers(midA, midB)
if !ok {
steps = steps[:0]
for range midA {
steps = append(steps, stepDel)
}
for range midB {
steps = append(steps, stepIns)
}
}
var ops []Op
ai, bi := 0, 0
emit := func(k OpKind, da, db int) {
if da == 0 && db == 0 {
return
}
if k == Equal && len(ops) > 0 && ops[len(ops)-1].Kind == Equal {
ops[len(ops)-1].AEnd += da
ops[len(ops)-1].BEnd += db
} else {
ops = append(ops, Op{Kind: k, AStart: ai, AEnd: ai + da, BStart: bi, BEnd: bi + db})
}
ai += da
bi += db
}
emit(Equal, pre, pre)
for i := 0; i < len(steps); {
if steps[i] == stepEq {
j := i
for j < len(steps) && steps[j] == stepEq {
j++
}
emit(Equal, j-i, j-i)
i = j
continue
}
dels, inss := 0, 0
for i < len(steps) && steps[i] != stepEq {
if steps[i] == stepDel {
dels++
} else {
inss++
}
i++
}
switch {
case dels > 0 && inss > 0:
emit(Replace, dels, inss)
case dels > 0:
emit(Delete, dels, 0)
default:
emit(Insert, 0, inss)
}
}
emit(Equal, suf, suf)
return ops
}
type step int8
const (
stepEq step = iota
stepDel
stepIns
)
// myers returns one step per element of the edit script, in order. ok is false when
// the edit distance exceeds maxEdits.
func myers(a, b []string) ([]step, bool) {
n, m := len(a), len(b)
if n == 0 && m == 0 {
return nil, true
}
limit := min(n+m, maxEdits)
off := limit + 1
v := make([]int32, 2*limit+3)
var trace [][]int32 // trace[d] is v as it stood at the start of round d, for k in [-d, d]
for d := 0; d <= limit; d++ {
snap := make([]int32, 2*d+1)
for k := -d; k <= d; k++ {
snap[k+d] = v[off+k]
}
trace = append(trace, snap)
for k := -d; k <= d; k += 2 {
var x int
if k == -d || (k != d && v[off+k-1] < v[off+k+1]) {
x = int(v[off+k+1])
} else {
x = int(v[off+k-1]) + 1
}
y := x - k
for x < n && y < m && a[x] == b[y] {
x++
y++
}
v[off+k] = int32(x)
if x >= n && y >= m {
return backtrack(trace, n, m, d), true
}
}
}
return nil, false
}
func backtrack(trace [][]int32, n, m, d int) []step {
var rev []step
x, y := n, m
for ; d >= 0; d-- {
snap := trace[d]
at := func(k int) int { return int(snap[k+d]) }
k := x - y
var prevK int
if k == -d || (k != d && at(k-1) < at(k+1)) {
prevK = k + 1
} else {
prevK = k - 1
}
prevX := 0
if d > 0 {
prevX = at(prevK)
}
prevY := prevX - prevK
for x > prevX && y > prevY {
rev = append(rev, stepEq)
x--
y--
}
if d > 0 {
if x == prevX {
rev = append(rev, stepIns)
} else {
rev = append(rev, stepDel)
}
}
x, y = prevX, prevY
}
out := make([]step, len(rev))
for i, s := range rev {
out[len(rev)-1-i] = s
}
return out
}
+139
View File
@@ -0,0 +1,139 @@
package reconcile_test
import (
"math/rand"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/reconcile"
)
func words(s string) []string { return strings.Fields(s) }
// apply rebuilds b from a using the ops, copying equal runs from a and the
// replacement/insertion runs from b. If Diff is right this must equal b.
func apply(a, b []string, ops []reconcile.Op) []string {
var out []string
for _, o := range ops {
switch o.Kind {
case reconcile.Equal:
out = append(out, a[o.AStart:o.AEnd]...)
default:
out = append(out, b[o.BStart:o.BEnd]...)
}
}
return out
}
func TestDiff_Cases(t *testing.T) {
tests := []struct {
name string
a, b string
want []reconcile.Op
}{
{"identical", "a b c", "a b c", []reconcile.Op{{Kind: reconcile.Equal, AStart: 0, AEnd: 3, BStart: 0, BEnd: 3}}},
{"both empty", "", "", nil},
{"one word replaced", "a b c", "a x c", []reconcile.Op{
{Kind: reconcile.Equal, AStart: 0, AEnd: 1, BStart: 0, BEnd: 1},
{Kind: reconcile.Replace, AStart: 1, AEnd: 2, BStart: 1, BEnd: 2},
{Kind: reconcile.Equal, AStart: 2, AEnd: 3, BStart: 2, BEnd: 3},
}},
{"word only in b", "a c", "a b c", []reconcile.Op{
{Kind: reconcile.Equal, AStart: 0, AEnd: 1, BStart: 0, BEnd: 1},
{Kind: reconcile.Insert, AStart: 1, AEnd: 1, BStart: 1, BEnd: 2},
{Kind: reconcile.Equal, AStart: 1, AEnd: 2, BStart: 2, BEnd: 3},
}},
{"word only in a", "a b c", "a c", []reconcile.Op{
{Kind: reconcile.Equal, AStart: 0, AEnd: 1, BStart: 0, BEnd: 1},
{Kind: reconcile.Delete, AStart: 1, AEnd: 2, BStart: 1, BEnd: 1},
{Kind: reconcile.Equal, AStart: 2, AEnd: 3, BStart: 1, BEnd: 2},
}},
{"nothing in common", "a b", "x y z", []reconcile.Op{
{Kind: reconcile.Replace, AStart: 0, AEnd: 2, BStart: 0, BEnd: 3},
}},
{"a is empty", "", "x y", []reconcile.Op{{Kind: reconcile.Insert, AStart: 0, AEnd: 0, BStart: 0, BEnd: 2}}},
{"b is empty", "x y", "", []reconcile.Op{{Kind: reconcile.Delete, AStart: 0, AEnd: 2, BStart: 0, BEnd: 0}}},
{"adjacent delete and insert read as one replace", "a b c d", "a x y d", []reconcile.Op{
{Kind: reconcile.Equal, AStart: 0, AEnd: 1, BStart: 0, BEnd: 1},
{Kind: reconcile.Replace, AStart: 1, AEnd: 3, BStart: 1, BEnd: 3},
{Kind: reconcile.Equal, AStart: 3, AEnd: 4, BStart: 3, BEnd: 4},
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, reconcile.Diff(words(tt.a), words(tt.b)))
})
}
}
// lcsLen is a plain dynamic-programming reference. Myers must find an edit script
// whose equal runs add up to exactly this: a diff that is merely valid but not
// minimal would turn real agreement into disputes the adjudicator is then asked about.
func lcsLen(a, b []string) int {
prev := make([]int, len(b)+1)
for i := 1; i <= len(a); i++ {
cur := make([]int, len(b)+1)
for j := 1; j <= len(b); j++ {
if a[i-1] == b[j-1] {
cur[j] = prev[j-1] + 1
} else {
cur[j] = max(prev[j], cur[j-1])
}
}
prev = cur
}
return prev[len(b)]
}
func TestDiff_RebuildsBAndKeepsEveryCommonWordOnRandomInputs(t *testing.T) {
rng := rand.New(rand.NewSource(7))
alphabet := []string{"a", "b", "c", "d", "e", "f"}
gen := func(n int) []string {
out := make([]string, n)
for i := range out {
out[i] = alphabet[rng.Intn(len(alphabet))]
}
return out
}
for i := 0; i < 400; i++ {
a, b := gen(rng.Intn(25)), gen(rng.Intn(25))
ops := reconcile.Diff(a, b)
require.Equal(t, append([]string{}, b...), append([]string{}, apply(a, b, ops)...), "a=%v b=%v ops=%v", a, b, ops)
common := 0
prevA, prevB := 0, 0
for _, o := range ops {
require.Equal(t, prevA, o.AStart, "ops must tile a without gaps: %v", ops)
require.Equal(t, prevB, o.BStart, "ops must tile b without gaps: %v", ops)
prevA, prevB = o.AEnd, o.BEnd
if o.Kind == reconcile.Equal {
common += o.AEnd - o.AStart
require.Equal(t, a[o.AStart:o.AEnd], b[o.BStart:o.BEnd], "an Equal op must really be equal")
}
}
require.Equal(t, len(a), prevA)
require.Equal(t, len(b), prevB)
require.Equal(t, lcsLen(a, b), common, "the diff must be minimal: a=%v b=%v", a, b)
}
}
func TestDiff_HandlesAnHoursWorthOfWordsFast(t *testing.T) {
rng := rand.New(rand.NewSource(3))
a := make([]string, 6500)
for i := range a {
a[i] = string(rune('a'+rng.Intn(26))) + string(rune('a'+rng.Intn(26)))
}
b := append([]string{}, a...)
for i := 0; i < 380; i++ { // ~380 disputes, as measured on a real hour
b[rng.Intn(len(b))] = "zz" + string(rune('a'+rng.Intn(26)))
}
ops := reconcile.Diff(a, b)
assert.NotEmpty(t, ops)
assert.Equal(t, append([]string{}, b...), append([]string{}, apply(a, b, ops)...))
}
+100
View File
@@ -0,0 +1,100 @@
package reconcile
import "strings"
// Dispute is one place where the two transcripts disagree. BaseStart:BaseEnd and
// AltStart:AltEnd are token ranges into the two Tokenize results; an empty range
// means that engine heard nothing there. Base and Alt are the two readings as each
// engine wrote them, so a replaced span takes the alternative's own spelling.
type Dispute struct {
BaseStart, BaseEnd int
AltStart, AltEnd int
Base, Alt string
}
// FindDisputes aligns base against alt word by word and returns the disagreements.
// Differences separated by a single shared word are merged into one dispute: for a
// reader that is one decision, and asking about "b" and "d" separately in "b c d"
// hides that "c" sits between them. Two shared words keep disputes apart.
func FindDisputes(base, alt []Token) []Dispute {
a, b := keysOf(base), keysOf(alt)
ops := Diff(a, b)
var out []Dispute
for i := 0; i < len(ops); i++ {
if ops[i].Kind == Equal {
continue
}
cur := ops[i]
for i+2 < len(ops) && ops[i+1].Kind == Equal && ops[i+1].AEnd-ops[i+1].AStart == 1 && ops[i+2].Kind != Equal {
cur.AEnd, cur.BEnd = ops[i+2].AEnd, ops[i+2].BEnd
i += 2
}
out = append(out, Dispute{
BaseStart: cur.AStart, BaseEnd: cur.AEnd,
AltStart: cur.BStart, AltEnd: cur.BEnd,
Base: join(base[cur.AStart:cur.AEnd]),
Alt: join(alt[cur.BStart:cur.BEnd]),
})
}
return out
}
// fillers are Swedish discourse particles. A dispute that is only one or two of them
// on one side changes no meaning, so it is resolved without asking a model. The list
// is deliberately short and leaves out words that can be an answer ("nej") or that
// carry content.
var fillers = map[string]bool{
"ju": true, "då": true, "liksom": true, "alltså": true, "så": true, "ja": true,
"eh": true, "ehm": true, "öh": true, "mm": true, "hm": true, "typ": true, "va": true, "asså": true,
}
// maxFillerRun is how many filler words may be waved through together.
const maxFillerRun = 2
// AutoResolve reports whether a dispute needs no model, in which case the base
// reading is kept: either the two sides are the same letters once spaces are removed
// (a compound written as one word or two), or one side is empty and the other is a
// short run of filler words.
func AutoResolve(d Dispute) bool {
baseKeys, altKeys := keysOfText(d.Base), keysOfText(d.Alt)
if strings.Join(baseKeys, "") == strings.Join(altKeys, "") {
return true
}
return (len(baseKeys) == 0 && onlyFillers(altKeys)) || (len(altKeys) == 0 && onlyFillers(baseKeys))
}
func onlyFillers(keys []string) bool {
if len(keys) == 0 || len(keys) > maxFillerRun {
return false
}
for _, k := range keys {
if !fillers[k] {
return false
}
}
return true
}
// ContextAround returns up to window words of the base before and after the dispute,
// as the base wrote them.
func ContextAround(base []Token, d Dispute, window int) (before, after string) {
return join(base[max(0, d.BaseStart-window):d.BaseStart]), join(base[d.BaseEnd:min(len(base), d.BaseEnd+window)])
}
func keysOf(ts []Token) []string {
out := make([]string, len(ts))
for i, t := range ts {
out[i] = t.Key
}
return out
}
func keysOfText(s string) []string { return keysOf(Tokenize(s)) }
func join(ts []Token) string {
parts := make([]string, len(ts))
for i, t := range ts {
parts[i] = t.Text
}
return strings.Join(parts, " ")
}
+131
View File
@@ -0,0 +1,131 @@
package reconcile_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/reconcile"
)
func disputes(base, alt string) []reconcile.Dispute {
return reconcile.FindDisputes(reconcile.Tokenize(base), reconcile.Tokenize(alt))
}
func TestFindDisputes_NoneWhenTheTextsAgreeWordForWord(t *testing.T) {
assert.Empty(t, disputes("Vi går igenom läget.", "vi går igenom läget"),
"casing and punctuation are not disagreements")
}
func TestFindDisputes_OneReplacedWordIsOneDisputeCarryingBothReadings(t *testing.T) {
got := disputes("Vi ska onboardade kunderna.", "Vi ska omvårdade kunderna.")
require.Len(t, got, 1)
d := got[0]
assert.Equal(t, "onboardade", d.Base)
assert.Equal(t, "omvårdade", d.Alt)
assert.Equal(t, 2, d.BaseStart)
assert.Equal(t, 3, d.BaseEnd)
assert.Equal(t, 2, d.AltStart)
assert.Equal(t, 3, d.AltEnd)
}
func TestFindDisputes_ReadingsKeepTheirOwnSpellingAndPunctuation(t *testing.T) {
got := disputes("Det blir T+1, alltså.", "Det blir T 1, alltså.")
require.Len(t, got, 1)
assert.Equal(t, "T+1,", got[0].Base)
assert.Equal(t, "T 1,", got[0].Alt, "the alternative's words replace the span as the engine wrote them")
}
func TestFindDisputes_AWordOnlyOneEngineHeardHasAnEmptyOtherSide(t *testing.T) {
got := disputes("Man får tillbaka ett förslag.", "Man får tillbaka ett coolt förslag.")
require.Len(t, got, 1)
assert.Equal(t, "", got[0].Base)
assert.Equal(t, "coolt", got[0].Alt)
assert.Equal(t, got[0].BaseStart, got[0].BaseEnd, "an insertion has an empty base range at its position")
assert.Equal(t, 4, got[0].BaseStart)
}
func TestFindDisputes_TwoDifferencesSeparatedByOneSharedWordAreOneDispute(t *testing.T) {
// "x ... y" around a single agreed word is one decision for the reader, not two.
got := disputes("a b c d e", "a x c y e")
require.Len(t, got, 1)
assert.Equal(t, "b c d", got[0].Base)
assert.Equal(t, "x c y", got[0].Alt)
}
func TestFindDisputes_TwoSharedWordsKeepTheDisputesApart(t *testing.T) {
got := disputes("a b c d e f", "a x c d y f")
require.Len(t, got, 2)
assert.Equal(t, "b", got[0].Base)
assert.Equal(t, "e", got[1].Base)
}
func TestFindDisputes_ChainsOfCloseDifferencesMergeIntoOne(t *testing.T) {
got := disputes("a b c d e f g", "a x c y e z g")
require.Len(t, got, 1)
assert.Equal(t, "b c d e f", got[0].Base)
}
func TestFindDisputes_LeavesIndicesThatAddressBothTokenSlices(t *testing.T) {
base := reconcile.Tokenize("Hej Anders och Maria")
alt := reconcile.Tokenize("Hej Sanders och Maria")
got := reconcile.FindDisputes(base, alt)
require.Len(t, got, 1)
assert.Equal(t, "Anders", base[got[0].BaseStart].Text)
assert.Equal(t, "Sanders", alt[got[0].AltStart].Text)
}
func TestAutoResolve(t *testing.T) {
tests := []struct {
name string
base, alt string
resolved bool
}{
{"a compound written as two words or one", "i crossborder delen", "i crossborderdelen", true},
{"a compound with a hyphen", "crossborder-delen", "crossborder delen", true},
{"a filler only the alternative heard", "vi ska ju betala", "vi ska betala", true},
{"a filler only the base heard", "vi ska betala", "vi ska ju betala", true},
{"two fillers in a row", "vi ska ja ju betala", "vi ska betala", true},
{"a different word is for the model", "vi ska onboardade", "vi ska omvårdade", false},
{"a different number is for the model", "totalt 1050 kronor", "totalt 2050 kronor", false},
{"a content word only one side heard is for the model", "ett förslag", "ett coolt förslag", false},
{"three filler words are too many to wave through", "vi ska ju då liksom betala", "vi ska betala", false},
{"a filler swapped for a content word is for the model", "vi ska ju betala", "vi ska nu betala", false},
{"nej is an answer, not a filler", "men nej det går inte", "men det går inte", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := disputes(tt.base, tt.alt)
require.Len(t, got, 1, "the fixture must be exactly one dispute")
assert.Equal(t, tt.resolved, reconcile.AutoResolve(got[0]))
})
}
}
func TestContextAround_TakesWordsFromTheBaseOnBothSidesAndStopsAtTheEdges(t *testing.T) {
base := reconcile.Tokenize("ett två tre fyra fem sex sju åtta nio tio")
d := reconcile.Dispute{BaseStart: 4, BaseEnd: 6} // "fem sex"
before, after := reconcile.ContextAround(base, d, 3)
assert.Equal(t, "två tre fyra", before)
assert.Equal(t, "sju åtta nio", after)
b2, a2 := reconcile.ContextAround(base, reconcile.Dispute{BaseStart: 0, BaseEnd: 1}, 3)
assert.Equal(t, "", b2)
assert.Equal(t, "två tre fyra", a2)
b3, a3 := reconcile.ContextAround(base, reconcile.Dispute{BaseStart: 9, BaseEnd: 10}, 3)
assert.Equal(t, "sju åtta nio", b3)
assert.Equal(t, "", a3)
}
+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)
}
}
+293
View File
@@ -0,0 +1,293 @@
package reconcile_test
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/reconcile"
)
// scripted is an Adjudicator that answers from a function and records every request.
type scripted struct {
fn func(i int, r reconcile.Request) (reconcile.Verdict, error)
calls []reconcile.Request
}
func (s *scripted) Adjudicate(_ context.Context, r reconcile.Request) (reconcile.Verdict, error) {
i := len(s.calls)
s.calls = append(s.calls, r)
return s.fn(i, r)
}
func always(v reconcile.Verdict) *scripted {
return &scripted{fn: func(int, reconcile.Request) (reconcile.Verdict, error) { return v, nil }}
}
func tr(text string) domain.Transcription {
return domain.Transcription{Text: text, AudioSeconds: 100, Endpoint: "e"}
}
var opts = reconcile.Options{Window: 100, BreakAfter: 3, Reconciler: "iguana/gemma4-26b", BaseEngine: "iguana/pianissimo", AltEngine: "iguana/kb-whisper"}
func merge(t *testing.T, base, alt domain.Transcription, adj reconcile.Adjudicator) domain.Transcription {
t.Helper()
out, err := reconcile.Reconcile(context.Background(), base, alt, adj, opts)
require.NoError(t, err)
return out
}
func TestReconcile_IdenticalTextsNeedNoModelAndComeBackUnchanged(t *testing.T) {
adj := always(reconcile.VerdictAlt)
out := merge(t, tr("Vi går igenom läget."), tr("vi går igenom läget"), adj)
assert.Equal(t, "Vi går igenom läget.", out.Text, "the base's own spelling and punctuation stand")
assert.Empty(t, adj.calls)
require.NotNil(t, out.Reconciliation)
assert.Equal(t, domain.ReconcileDone, out.Reconciliation.Status)
assert.Zero(t, out.Reconciliation.Disputes)
}
func TestReconcile_TrivialDisputesAreResolvedWithoutAskingTheModel(t *testing.T) {
adj := always(reconcile.VerdictAlt)
out := merge(t, tr("i crossborder delen ska vi ju betala"), tr("i crossborderdelen ska vi betala"), adj)
assert.Empty(t, adj.calls, "a compound split and a filler particle are not worth a model call")
assert.Equal(t, "i crossborder delen ska vi ju betala", out.Text, "the base reading is kept")
assert.Equal(t, 2, out.Reconciliation.Disputes)
assert.Equal(t, 2, out.Reconciliation.AutoResolved)
assert.Zero(t, out.Reconciliation.Adjudicated)
}
func TestReconcile_AltVerdictReplacesTheSpanWithTheAlternativesOwnWords(t *testing.T) {
out := merge(t, tr("Totalt 1015 kronor. Det ska betalas."), tr("Totalt 1050 kronor. Det ska betalas."), always(reconcile.VerdictAlt))
assert.Equal(t, "Totalt 1050 kronor. Det ska betalas.", out.Text)
assert.Equal(t, 1, out.Reconciliation.Changed)
assert.Equal(t, 1, out.Reconciliation.Adjudicated)
}
func TestReconcile_BaseAndUnsureVerdictsKeepTheBaseButOnlyUnsureIsFlagged(t *testing.T) {
base := tr("Totalt 1015 kronor till SEB.")
alt := tr("Totalt 1050 kronor till SCB.")
keptBase := merge(t, base, alt, always(reconcile.VerdictBase))
unsure := merge(t, base, alt, always(reconcile.VerdictUnsure))
assert.Equal(t, "Totalt 1015 kronor till SEB.", keptBase.Text)
assert.Zero(t, keptBase.Reconciliation.Unsure)
assert.Empty(t, keptBase.Reconciliation.Contested, "a confident 'the base is right' is not a spot worth the reader's time")
assert.Equal(t, "Totalt 1015 kronor till SEB.", unsure.Text, "unsure keeps the base")
assert.Equal(t, 2, unsure.Reconciliation.Unsure)
require.Len(t, unsure.Reconciliation.Contested, 2)
assert.True(t, unsure.Reconciliation.Contested[0].Unsure)
assert.Equal(t, "base", unsure.Reconciliation.Contested[0].Used)
}
func TestReconcile_ChangedSpotsAreRecordedWithBothReadings(t *testing.T) {
out := merge(t, tr("det blir T+1 imorgon"), tr("det blir en T imorgon"), always(reconcile.VerdictAlt))
require.Len(t, out.Reconciliation.Contested, 1)
c := out.Reconciliation.Contested[0]
assert.Equal(t, "T+1", c.Base)
assert.Equal(t, "en T", c.Alt)
assert.Equal(t, "alt", c.Used)
assert.False(t, c.Unsure)
}
func TestReconcile_AWordOnlyTheAlternativeHeardIsInsertedWhereItBelongs(t *testing.T) {
out := merge(t, tr("Man får tillbaka ett förslag."), tr("Man får tillbaka ett coolt förslag."), always(reconcile.VerdictAlt))
assert.Equal(t, "Man får tillbaka ett coolt förslag.", out.Text)
}
func TestReconcile_AWordOnlyTheBaseHeardCanBeRemoved(t *testing.T) {
out := merge(t, tr("Man får tillbaka ett coolt förslag."), tr("Man får tillbaka ett förslag."), always(reconcile.VerdictAlt))
assert.Equal(t, "Man får tillbaka ett förslag.", out.Text)
}
func TestReconcile_SeveralDisputesAreAppliedIndependentlyAndInOrder(t *testing.T) {
adj := &scripted{fn: func(i int, r reconcile.Request) (reconcile.Verdict, error) {
if r.Alt == "omvårdade" || r.Alt == "SCB" {
return reconcile.VerdictAlt, nil
}
return reconcile.VerdictBase, nil
}}
out := merge(t, tr("kunderna onboardade nu och sen betalas till SEB via ombud med 1050 kronor"),
tr("kunderna omvårdade nu och sen betalas till SCB via ombud med 2050 kronor"), adj)
assert.Equal(t, "kunderna omvårdade nu och sen betalas till SCB via ombud med 1050 kronor", out.Text)
assert.Equal(t, 3, out.Reconciliation.Disputes)
assert.Equal(t, 2, out.Reconciliation.Changed)
}
func TestReconcile_TheReadingOrderAlternatesFromOneDisputeToTheNext(t *testing.T) {
adj := always(reconcile.VerdictBase)
merge(t, tr("a x s1 s2 y t1 t2 z d"), tr("a p s1 s2 q t1 t2 r d"), adj)
require.Len(t, adj.calls, 3)
assert.False(t, adj.calls[0].AltFirst)
assert.True(t, adj.calls[1].AltFirst)
assert.False(t, adj.calls[2].AltFirst, "alternating averages out position bias across a transcript")
}
func TestReconcile_TheModelGetsTheContextWindowFromTheBase(t *testing.T) {
adj := always(reconcile.VerdictBase)
o := opts
o.Window = 2
_, err := reconcile.Reconcile(context.Background(), tr("ett två tre fyra fem sex sju åtta nio"), tr("ett två tre fyra FEMM sex sju åtta nio"), adj, o)
require.NoError(t, err)
require.Len(t, adj.calls, 1)
assert.Equal(t, "tre fyra", adj.calls[0].Before)
assert.Equal(t, "sex sju", adj.calls[0].After)
assert.Equal(t, "fem", adj.calls[0].Base)
assert.Equal(t, "FEMM", adj.calls[0].Alt)
}
func TestReconcile_OneModelErrorLeavesThatSpotUnsureAndCarriesOn(t *testing.T) {
boom := errors.New("timeout")
adj := &scripted{fn: func(i int, r reconcile.Request) (reconcile.Verdict, error) {
if i == 0 {
return reconcile.VerdictUnsure, boom
}
return reconcile.VerdictAlt, nil
}}
out := merge(t, tr("a x s1 s2 y c"), tr("a p s1 s2 q c"), adj)
assert.Equal(t, "a x s1 s2 q c", out.Text, "the first spot stayed as the base, the second was decided")
assert.Equal(t, 1, out.Reconciliation.Errors)
assert.Equal(t, 1, out.Reconciliation.Unsure)
assert.Equal(t, domain.ReconcileDone, out.Reconciliation.Status)
}
func TestReconcile_ConsecutiveModelErrorsStopTheWholeReconciliationInsteadOfWaitingOutEveryTimeout(t *testing.T) {
boom := errors.New("gateway down")
adj := &scripted{fn: func(int, reconcile.Request) (reconcile.Verdict, error) { return reconcile.VerdictUnsure, boom }}
a, b := make([]string, 0, 20), make([]string, 0, 20)
for i := 0; i < 10; i++ {
a = append(a, fmt.Sprintf("w%d", i), "s", "t")
b = append(b, fmt.Sprintf("v%d", i), "s", "t")
}
out := merge(t, tr(strings.Join(a, " ")), tr(strings.Join(b, " ")), adj)
assert.Len(t, adj.calls, 3, "three consecutive failures and it stops asking")
assert.Equal(t, strings.Join(a, " "), out.Text, "the base text, with none of the partial changes")
assert.Equal(t, domain.ReconcileSkipped, out.Reconciliation.Status)
assert.Contains(t, out.Reconciliation.Reason, "gateway down")
}
func TestReconcile_ASuccessBetweenFailuresResetsTheBreaker(t *testing.T) {
boom := errors.New("blip")
adj := &scripted{fn: func(i int, r reconcile.Request) (reconcile.Verdict, error) {
if i%3 == 2 {
return reconcile.VerdictBase, nil
}
return reconcile.VerdictUnsure, boom
}}
a, b := make([]string, 0, 20), make([]string, 0, 20)
for i := 0; i < 9; i++ {
a = append(a, fmt.Sprintf("w%d", i), "s", "t")
b = append(b, fmt.Sprintf("v%d", i), "s", "t")
}
out := merge(t, tr(strings.Join(a, " ")), tr(strings.Join(b, " ")), adj)
assert.Len(t, adj.calls, 9, "two failures then a success never reach three in a row")
assert.Equal(t, domain.ReconcileDone, out.Reconciliation.Status)
assert.Equal(t, 6, out.Reconciliation.Errors)
}
func TestReconcile_ACancelledJobIsAnErrorNotASkippedReconciliation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
adj := &scripted{fn: func(int, reconcile.Request) (reconcile.Verdict, error) {
cancel()
return reconcile.VerdictUnsure, ctx.Err()
}}
_, err := reconcile.Reconcile(ctx, tr("a x b"), tr("a y b"), adj, opts)
assert.ErrorIs(t, err, context.Canceled, "a shutdown must not be recorded as 'the model was unavailable'")
}
func TestReconcile_KeepsTheBaseSegmentsDurationAndNamesTheEngines(t *testing.T) {
base := tr("a x b")
base.Segments = []domain.Segment{{Start: 0, End: 4, Text: "a x b"}}
base.AudioSeconds = 3821.5
out := merge(t, base, tr("a y b"), always(reconcile.VerdictAlt))
assert.Equal(t, base.Segments, out.Segments, "the timed segments are the base engine's, untouched")
assert.InDelta(t, 3821.5, out.AudioSeconds, 1e-9)
assert.Equal(t, "iguana/pianissimo", out.Reconciliation.Base)
assert.Equal(t, "iguana/kb-whisper", out.Reconciliation.Alt)
assert.Equal(t, "iguana/gemma4-26b", out.Reconciliation.Reconciler)
}
func TestReconcile_ASpotCarriesTheSecondItStartsAt(t *testing.T) {
base := tr("ett två tre. fyra fem sex. sju åtta nio.")
base.Segments = []domain.Segment{
{Start: 0, End: 10, Text: "ett två tre."},
{Start: 10, End: 20, Text: "fyra fem sex."},
{Start: 20, End: 30, Text: "sju åtta nio."},
}
alt := tr("ett två tre. fyra femm sex. sju åtta nio.")
out := merge(t, base, alt, always(reconcile.VerdictUnsure))
require.Len(t, out.Reconciliation.Contested, 1)
assert.InDelta(t, 10.0, out.Reconciliation.Contested[0].Seconds, 1e-9, "'fem' is in the second segment, which starts at 10 s")
}
func TestReconcile_WithNoSegmentsASpotIsPlacedProportionally(t *testing.T) {
base := tr("a b c d e f g h i j")
base.AudioSeconds = 100
alt := tr("a b c d e f g h i J2")
out := merge(t, base, alt, always(reconcile.VerdictUnsure))
require.Len(t, out.Reconciliation.Contested, 1)
assert.InDelta(t, 90.0, out.Reconciliation.Contested[0].Seconds, 1e-9, "token 9 of 10 in 100 s")
}
func TestReconcile_ContestedSpotsAreCappedButTheCountsAreExact(t *testing.T) {
a, b := make([]string, 0, 600), make([]string, 0, 600)
for i := 0; i < 300; i++ {
a = append(a, fmt.Sprintf("w%d", i), "same", "same2")
b = append(b, fmt.Sprintf("v%d", i), "same", "same2")
}
out := merge(t, tr(strings.Join(a, " ")), tr(strings.Join(b, " ")), always(reconcile.VerdictUnsure))
assert.Equal(t, 300, out.Reconciliation.Unsure)
assert.Equal(t, 300, out.Reconciliation.Disputes)
assert.Len(t, out.Reconciliation.Contested, reconcile.MaxContested, "the stored list is bounded; the counts are not")
}
func TestReconcile_AVerdictReturnedAlongsideAnErrorIsIgnored(t *testing.T) {
// An adjudicator that returns a verdict AND an error must not have the verdict used:
// the contract is that an error means no decision.
adj := &scripted{fn: func(int, reconcile.Request) (reconcile.Verdict, error) {
return reconcile.VerdictAlt, errors.New("half a reply")
}}
out := merge(t, tr("a x b"), tr("a y b"), adj)
assert.Equal(t, "a x b", out.Text, "no decision means the base stands")
assert.Zero(t, out.Reconciliation.Changed)
assert.Equal(t, 1, out.Reconciliation.Unsure)
}
+57
View File
@@ -0,0 +1,57 @@
// Package reconcile merges two transcripts of the same speech: it finds where they
// disagree word by word, resolves the trivial disagreements itself, and asks a LOCAL
// model to choose between the two readings of the rest (ADR-034). It depends on
// nothing outside the standard library and the domain; the model, the engines and
// the store are wired in from outside.
package reconcile
import (
"strings"
"unicode"
)
// Token is one word of a transcript. Text is the word as written, with its casing
// and punctuation, so the merged transcript reads naturally; Key is what two engines
// are compared on: lower case, letters and digits only.
type Token struct {
Text string
Key string
}
// Tokenize splits s on whitespace. A "word" that has no letter or digit (a dash, a
// lone full stop) is not something two engines can disagree about, so it is joined
// to the neighbouring word instead of becoming a token of its own.
func Tokenize(s string) []Token {
var out []Token
pending := ""
for _, w := range strings.Fields(s) {
k := key(w)
if k == "" {
if len(out) > 0 {
out[len(out)-1].Text += " " + w
} else if pending == "" {
pending = w
} else {
pending += " " + w
}
continue
}
text := w
if pending != "" {
text = pending + " " + w
pending = ""
}
out = append(out, Token{Text: text, Key: k})
}
return out
}
func key(w string) string {
var b strings.Builder
for _, r := range w {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
b.WriteRune(unicode.ToLower(r))
}
}
return b.String()
}
+66
View File
@@ -0,0 +1,66 @@
package reconcile_test
import (
"testing"
"github.com/stretchr/testify/assert"
"git.d-ma.be/mathias/tapir/internal/reconcile"
)
func texts(ts []reconcile.Token) []string {
out := make([]string, len(ts))
for i, t := range ts {
out[i] = t.Text
}
return out
}
func keys(ts []reconcile.Token) []string {
out := make([]string, len(ts))
for i, t := range ts {
out[i] = t.Key
}
return out
}
func TestTokenize_KeepsTheWordAsWrittenAndKeysItWithoutCaseOrPunctuation(t *testing.T) {
got := reconcile.Tokenize("Hej, allihopa! Vi går igenom ÅÄÖ.")
assert.Equal(t, []string{"Hej,", "allihopa!", "Vi", "går", "igenom", "ÅÄÖ."}, texts(got),
"the original spelling and punctuation are kept for the merged text")
assert.Equal(t, []string{"hej", "allihopa", "vi", "går", "igenom", "åäö"}, keys(got),
"the key is lower case with punctuation removed, Swedish letters lowered correctly")
}
func TestTokenize_KeysIgnoreHyphensAndPlusSoOnlyWordsDecideEquality(t *testing.T) {
got := reconcile.Tokenize("end-to-end T+1 5 000 kr")
assert.Equal(t, []string{"endtoend", "t1", "5", "000", "kr"}, keys(got))
}
func TestTokenize_APunctuationOnlyWordJoinsThePreviousWordInsteadOfBecomingOne(t *testing.T) {
got := reconcile.Tokenize("Vi går – sen tillbaka . Slut")
assert.Equal(t, []string{"Vi", "går –", "sen", "tillbaka .", "Slut"}, texts(got),
"a dash or a lone full stop is not a word the engines can disagree about")
assert.Equal(t, []string{"vi", "går", "sen", "tillbaka", "slut"}, keys(got))
}
func TestTokenize_LeadingPunctuationOnlyWordJoinsTheNextWord(t *testing.T) {
got := reconcile.Tokenize("— Hej då")
assert.Equal(t, []string{"— Hej", "då"}, texts(got))
}
func TestTokenize_NewlinesAndRunsOfSpaceAreJustSeparators(t *testing.T) {
got := reconcile.Tokenize(" Hej\n allihopa \t Vi ")
assert.Equal(t, []string{"Hej", "allihopa", "Vi"}, texts(got))
}
func TestTokenize_EmptyAndPunctuationOnlyInputGiveNoTokens(t *testing.T) {
assert.Empty(t, reconcile.Tokenize(""))
assert.Empty(t, reconcile.Tokenize(" \n "))
assert.Empty(t, reconcile.Tokenize("— . !"))
}