Added initial segment overlap resolution logic

This commit is contained in:
2026-04-27 15:52:53 -05:00
parent e42a2326e8
commit 1b9f4bd922
16 changed files with 1357 additions and 59 deletions

View File

@@ -77,7 +77,7 @@ func finalizeCandidate(in *model.MergedTranscript, candidate overlapCandidate, c
refs := make([]string, 0, len(candidate.indices))
for _, index := range candidate.indices {
in.Segments[index].OverlapGroupID = groupID
refs = append(refs, segmentRef(in.Segments[index]))
refs = append(refs, SegmentRef(in.Segments[index]))
}
in.OverlapGroups = append(in.OverlapGroups, model.OverlapGroup{
@@ -106,8 +106,15 @@ func distinctSpeakers(segments []model.Segment, indices []int) []string {
return speakers
}
func segmentRef(segment model.Segment) string {
return fmt.Sprintf("%s#%d", segment.Source, segment.SourceSegmentIndex)
// SegmentRef returns the stable overlap reference for a segment.
func SegmentRef(segment model.Segment) string {
if segment.SourceSegmentIndex != nil {
return fmt.Sprintf("%s#%d", segment.Source, *segment.SourceSegmentIndex)
}
if segment.SourceRef != "" {
return segment.SourceRef
}
return segment.Source
}
func clearExisting(in *model.MergedTranscript) {

View File

@@ -132,7 +132,7 @@ func TestDetectIsIdempotent(t *testing.T) {
func segment(source string, sourceIndex int, speaker string, start float64, end float64) model.Segment {
return model.Segment{
Source: source,
SourceSegmentIndex: sourceIndex,
SourceSegmentIndex: intPtr(sourceIndex),
Speaker: speaker,
Start: start,
End: end,
@@ -140,6 +140,10 @@ func segment(source string, sourceIndex int, speaker string, start float64, end
}
}
func intPtr(value int) *int {
return &value
}
func assertGroup(t *testing.T, merged model.MergedTranscript, groupIndex int, id int, start float64, end float64, refs []string, speakers []string) {
t.Helper()
if len(merged.OverlapGroups) <= groupIndex {

344
internal/overlap/resolve.go Normal file
View File

@@ -0,0 +1,344 @@
package overlap
import (
"fmt"
"sort"
"strings"
"gitea.maximumdirect.net/eric/seriatim/internal/model"
)
// ResolutionSummary records deterministic counters for a resolve-overlaps pass.
type ResolutionSummary struct {
GroupsProcessed int
GroupsChanged int
OriginalsRemoved int
ReplacementsCreated int
}
// Resolve replaces detected overlap-group segments with word-run segments when
// word-level timing is available.
func Resolve(in model.MergedTranscript, wordRunGap float64) (model.MergedTranscript, ResolutionSummary, error) {
summary := ResolutionSummary{
GroupsProcessed: len(in.OverlapGroups),
}
if len(in.OverlapGroups) == 0 {
return in, summary, nil
}
refToIndex := make(map[string]int, len(in.Segments))
for index, segment := range in.Segments {
refToIndex[SegmentRef(segment)] = index
}
removeRefs := make(map[string]struct{})
clearAnnotationRefs := make(map[string]struct{})
removeGroupIDs := make(map[int]struct{})
replacements := make([]model.Segment, 0)
for _, group := range in.OverlapGroups {
resolved, err := resolveGroup(in, group, refToIndex, wordRunGap)
if err != nil {
return model.MergedTranscript{}, ResolutionSummary{}, err
}
if len(resolved.replacements) == 0 {
continue
}
summary.GroupsChanged++
removeGroupIDs[group.ID] = struct{}{}
replacements = append(replacements, resolved.replacements...)
for _, ref := range group.Segments {
clearAnnotationRefs[ref] = struct{}{}
}
for _, ref := range resolved.removeRefs {
if _, exists := removeRefs[ref]; !exists {
summary.OriginalsRemoved++
}
removeRefs[ref] = struct{}{}
}
summary.ReplacementsCreated += len(resolved.replacements)
}
if summary.GroupsChanged == 0 {
return in, summary, nil
}
segments := make([]model.Segment, 0, len(in.Segments)-len(removeRefs)+len(replacements))
for _, segment := range in.Segments {
ref := SegmentRef(segment)
if _, remove := removeRefs[ref]; remove {
continue
}
if _, clear := clearAnnotationRefs[ref]; clear {
segment.OverlapGroupID = 0
}
segments = append(segments, segment)
}
segments = append(segments, replacements...)
sort.SliceStable(segments, func(i, j int) bool {
return model.SegmentLess(segments[i], segments[j])
})
overlapGroups := make([]model.OverlapGroup, 0, len(in.OverlapGroups)-len(removeGroupIDs))
for _, group := range in.OverlapGroups {
if _, remove := removeGroupIDs[group.ID]; remove {
continue
}
overlapGroups = append(overlapGroups, group)
}
return model.MergedTranscript{
Segments: segments,
OverlapGroups: overlapGroups,
}, summary, nil
}
type resolvedGroup struct {
removeRefs []string
replacements []model.Segment
}
type resolutionWord struct {
word model.Word
source string
ref string
sequence int
}
type wordRun struct {
timedWords []resolutionWord
untimedWords []resolutionWord
start float64
end float64
}
func resolveGroup(in model.MergedTranscript, group model.OverlapGroup, refToIndex map[string]int, wordRunGap float64) (resolvedGroup, error) {
segmentsBySpeaker := make(map[string][]model.Segment)
refsBySpeaker := make(map[string][]string)
for _, ref := range group.Segments {
index, exists := refToIndex[ref]
if !exists {
return resolvedGroup{}, fmt.Errorf("overlap group %d references missing segment %q", group.ID, ref)
}
segment := in.Segments[index]
segmentsBySpeaker[segment.Speaker] = append(segmentsBySpeaker[segment.Speaker], segment)
refsBySpeaker[segment.Speaker] = append(refsBySpeaker[segment.Speaker], ref)
}
speakers := groupSpeakerOrder(group, segmentsBySpeaker)
resolved := resolvedGroup{}
for speakerIndex, speaker := range speakers {
timedWords, untimedWords := gatherResolutionWords(segmentsBySpeaker[speaker], group.Start, group.End)
if len(timedWords) == 0 {
continue
}
runs := buildWordRuns(timedWords, wordRunGap)
if len(runs) == 0 {
continue
}
attachUntimedWords(runs, untimedWords)
resolved.removeRefs = append(resolved.removeRefs, refsBySpeaker[speaker]...)
for runIndex, run := range runs {
resolved.replacements = append(resolved.replacements, replacementSegment(group.ID, speakerIndex+1, runIndex+1, speaker, run))
}
}
return resolved, nil
}
func groupSpeakerOrder(group model.OverlapGroup, segmentsBySpeaker map[string][]model.Segment) []string {
seen := make(map[string]struct{}, len(group.Speakers))
speakers := make([]string, 0, len(group.Speakers))
for _, speaker := range group.Speakers {
if _, exists := segmentsBySpeaker[speaker]; !exists {
continue
}
if _, exists := seen[speaker]; exists {
continue
}
seen[speaker] = struct{}{}
speakers = append(speakers, speaker)
}
extra := make([]string, 0)
for speaker := range segmentsBySpeaker {
if _, exists := seen[speaker]; exists {
continue
}
extra = append(extra, speaker)
}
sort.Strings(extra)
speakers = append(speakers, extra...)
return speakers
}
func gatherResolutionWords(segments []model.Segment, groupStart float64, groupEnd float64) ([]resolutionWord, []resolutionWord) {
timedWords := make([]resolutionWord, 0)
untimedWords := make([]resolutionWord, 0)
sequence := 0
for _, segment := range segments {
ref := SegmentRef(segment)
for _, word := range segment.Words {
candidate := resolutionWord{
word: word,
source: segment.Source,
ref: ref,
sequence: sequence,
}
sequence++
if !word.Timed {
untimedWords = append(untimedWords, candidate)
continue
}
if word.End <= groupStart || word.Start >= groupEnd {
continue
}
timedWords = append(timedWords, candidate)
}
}
sort.SliceStable(timedWords, func(i, j int) bool {
left := timedWords[i].word
right := timedWords[j].word
if left.Start != right.Start {
return left.Start < right.Start
}
if left.End != right.End {
return left.End < right.End
}
return left.Text < right.Text
})
return timedWords, untimedWords
}
func buildWordRuns(words []resolutionWord, wordRunGap float64) []wordRun {
if len(words) == 0 {
return nil
}
runs := make([]wordRun, 0)
current := newWordRun(words[0])
previousEnd := words[0].word.End
for _, word := range words[1:] {
if word.word.Start-previousEnd <= wordRunGap {
current.add(word)
} else {
runs = append(runs, current.finish())
current = newWordRun(word)
}
previousEnd = word.word.End
}
runs = append(runs, current.finish())
return runs
}
func newWordRun(word resolutionWord) wordRun {
return wordRun{
timedWords: []resolutionWord{word},
start: word.word.Start,
end: word.word.End,
}
}
func (r *wordRun) add(word resolutionWord) {
r.timedWords = append(r.timedWords, word)
if word.word.Start < r.start {
r.start = word.word.Start
}
if word.word.End > r.end {
r.end = word.word.End
}
}
func (r wordRun) finish() wordRun {
return r
}
func attachUntimedWords(runs []wordRun, untimedWords []resolutionWord) {
if len(runs) == 0 || len(untimedWords) == 0 {
return
}
for _, word := range untimedWords {
target := 0
for index, run := range runs {
if word.sequence < run.firstSequence() {
if index == 0 {
target = 0
} else {
target = index - 1
}
break
}
target = index
}
runs[target].untimedWords = append(runs[target].untimedWords, word)
}
}
func (r wordRun) firstSequence() int {
first := r.timedWords[0].sequence
for _, word := range r.timedWords[1:] {
if word.sequence < first {
first = word.sequence
}
}
return first
}
func (r wordRun) allWordsInTextOrder() []resolutionWord {
words := make([]resolutionWord, 0, len(r.timedWords)+len(r.untimedWords))
words = append(words, r.timedWords...)
words = append(words, r.untimedWords...)
sort.SliceStable(words, func(i, j int) bool {
return words[i].sequence < words[j].sequence
})
return words
}
func replacementSegment(groupID int, speakerIndex int, runIndex int, speaker string, run wordRun) model.Segment {
orderedWords := run.allWordsInTextOrder()
words := make([]model.Word, 0, len(orderedWords))
text := make([]string, 0, len(orderedWords))
refs := make([]string, 0, len(orderedWords))
source := ""
for _, word := range orderedWords {
words = append(words, word.word)
text = append(text, word.word.Text)
refs = append(refs, word.ref)
if source == "" {
source = word.source
} else if source != word.source {
source = "derived"
}
}
return model.Segment{
Source: source,
SourceRef: fmt.Sprintf("word-run:%d:%d:%d", groupID, speakerIndex, runIndex),
DerivedFrom: uniqueSortedStrings(refs),
Speaker: speaker,
Start: run.start,
End: run.end,
Text: strings.Join(text, " "),
Words: words,
}
}
func uniqueSortedStrings(values []string) []string {
seen := make(map[string]struct{}, len(values))
unique := make([]string, 0, len(values))
for _, value := range values {
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
unique = append(unique, value)
}
sort.Strings(unique)
return unique
}

View File

@@ -0,0 +1,345 @@
package overlap
import (
"reflect"
"testing"
"gitea.maximumdirect.net/eric/seriatim/internal/model"
)
func TestResolveNoOverlapGroupsIsNoOp(t *testing.T) {
merged := model.MergedTranscript{
Segments: []model.Segment{
segmentWithWords("a.json", 0, "Alice", 1, 2, word("hello", 1.1, 1.2)),
},
}
got, summary, err := Resolve(merged, 0.75)
if err != nil {
t.Fatalf("resolve failed: %v", err)
}
if !reflect.DeepEqual(got, merged) {
t.Fatalf("expected no-op result:\ngot %#v\nwant %#v", got, merged)
}
if summary.GroupsProcessed != 0 || summary.GroupsChanged != 0 {
t.Fatalf("unexpected summary: %#v", summary)
}
}
func TestResolveCreatesChronologicalWordRunSegments(t *testing.T) {
merged := model.MergedTranscript{
Segments: []model.Segment{
segmentWithWords("a.json", 0, "Alice", 1, 5, word("A1", 1.1, 1.2), word("A2", 1.8, 2.0)),
segmentWithWords("b.json", 0, "Bob", 1.5, 4, word("B1", 1.55, 1.7), word("B2", 2.6, 2.8)),
},
OverlapGroups: []model.OverlapGroup{
group(1, 1, 5, []string{"a.json#0", "b.json#0"}, []string{"Alice", "Bob"}),
},
}
merged.Segments[0].OverlapGroupID = 1
merged.Segments[1].OverlapGroupID = 1
got, summary, err := Resolve(merged, 0.75)
if err != nil {
t.Fatalf("resolve failed: %v", err)
}
if summary.GroupsProcessed != 1 || summary.GroupsChanged != 1 || summary.OriginalsRemoved != 2 || summary.ReplacementsCreated != 3 {
t.Fatalf("unexpected summary: %#v", summary)
}
if len(got.OverlapGroups) != 0 {
t.Fatalf("expected resolved group to be removed, got %#v", got.OverlapGroups)
}
gotTexts := []string{got.Segments[0].Text, got.Segments[1].Text, got.Segments[2].Text}
wantTexts := []string{"A1 A2", "B1", "B2"}
if !reflect.DeepEqual(gotTexts, wantTexts) {
t.Fatalf("texts = %v, want %v", gotTexts, wantTexts)
}
for _, segment := range got.Segments {
if segment.ID != 0 {
t.Fatalf("replacement segment has ID %d, want 0", segment.ID)
}
if segment.SourceSegmentIndex != nil {
t.Fatalf("replacement segment source index = %d, want nil", *segment.SourceSegmentIndex)
}
if segment.OverlapGroupID != 0 {
t.Fatalf("replacement segment overlap group ID = %d, want 0", segment.OverlapGroupID)
}
if segment.SourceRef == "" {
t.Fatal("replacement segment missing source_ref")
}
}
}
func TestResolveIncludesWordsByIntervalIntersection(t *testing.T) {
merged := model.MergedTranscript{
Segments: []model.Segment{
segmentWithWords(
"a.json",
0,
"Alice",
9,
21,
word("before", 9.5, 10),
word("left-edge", 9.9, 10.1),
word("inside", 11, 11.2),
word("right-edge", 19.9, 20.1),
word("after", 20, 20.2),
),
},
OverlapGroups: []model.OverlapGroup{
group(1, 10, 20, []string{"a.json#0"}, []string{"Alice"}),
},
}
merged.Segments[0].OverlapGroupID = 1
got, _, err := Resolve(merged, 10)
if err != nil {
t.Fatalf("resolve failed: %v", err)
}
if len(got.Segments) != 1 {
t.Fatalf("segment count = %d, want 1", len(got.Segments))
}
if got.Segments[0].Text != "left-edge inside right-edge" {
t.Fatalf("text = %q", got.Segments[0].Text)
}
}
func TestResolveWordRunGapThreshold(t *testing.T) {
merged := model.MergedTranscript{
Segments: []model.Segment{
segmentWithWords("a.json", 0, "Alice", 1, 4, word("one", 1, 1.1), word("two", 1.85, 2), word("three", 2.8, 3)),
},
OverlapGroups: []model.OverlapGroup{
group(1, 1, 4, []string{"a.json#0"}, []string{"Alice"}),
},
}
merged.Segments[0].OverlapGroupID = 1
got, _, err := Resolve(merged, 0.75)
if err != nil {
t.Fatalf("resolve failed: %v", err)
}
if len(got.Segments) != 2 {
t.Fatalf("segment count = %d, want 2", len(got.Segments))
}
if got.Segments[0].Text != "one two" || got.Segments[1].Text != "three" {
t.Fatalf("unexpected replacement texts: %#v", got.Segments)
}
}
func TestResolvePartialResolutionKeepsNoWordSpeakerOriginals(t *testing.T) {
merged := model.MergedTranscript{
Segments: []model.Segment{
segmentWithWords("a.json", 0, "Alice", 1, 5, word("hello", 1.2, 1.4)),
segmentWithWords("b.json", 0, "Bob", 2, 4),
},
OverlapGroups: []model.OverlapGroup{
group(1, 1, 5, []string{"a.json#0", "b.json#0"}, []string{"Alice", "Bob"}),
},
}
merged.Segments[0].OverlapGroupID = 1
merged.Segments[1].OverlapGroupID = 1
got, summary, err := Resolve(merged, 0.75)
if err != nil {
t.Fatalf("resolve failed: %v", err)
}
if summary.OriginalsRemoved != 1 || summary.ReplacementsCreated != 1 {
t.Fatalf("unexpected summary: %#v", summary)
}
if len(got.OverlapGroups) != 0 {
t.Fatalf("expected changed group to be removed, got %#v", got.OverlapGroups)
}
if len(got.Segments) != 2 {
t.Fatalf("segment count = %d, want 2", len(got.Segments))
}
if got.Segments[0].Text != "hello" || got.Segments[1].Text != "Bob" {
t.Fatalf("unexpected segment texts: %#v", got.Segments)
}
if got.Segments[1].SourceSegmentIndex == nil {
t.Fatal("kept original should retain source_segment_index")
}
if got.Segments[1].OverlapGroupID != 0 {
t.Fatalf("kept original overlap group ID = %d, want 0", got.Segments[1].OverlapGroupID)
}
}
func TestResolveGroupWithNoUsableWordsRemainsUnchanged(t *testing.T) {
merged := model.MergedTranscript{
Segments: []model.Segment{
segmentWithWords("a.json", 0, "Alice", 1, 5),
segmentWithWords("b.json", 0, "Bob", 2, 4),
},
OverlapGroups: []model.OverlapGroup{
group(1, 1, 5, []string{"a.json#0", "b.json#0"}, []string{"Alice", "Bob"}),
},
}
merged.Segments[0].OverlapGroupID = 1
merged.Segments[1].OverlapGroupID = 1
got, summary, err := Resolve(merged, 0.75)
if err != nil {
t.Fatalf("resolve failed: %v", err)
}
if summary.GroupsChanged != 0 || summary.OriginalsRemoved != 0 || summary.ReplacementsCreated != 0 {
t.Fatalf("unexpected summary: %#v", summary)
}
if !reflect.DeepEqual(got, merged) {
t.Fatalf("expected unchanged transcript:\ngot %#v\nwant %#v", got, merged)
}
}
func TestResolveReplacementProvenanceIsDeterministic(t *testing.T) {
merged := model.MergedTranscript{
Segments: []model.Segment{
segmentWithWords("a.json", 1, "Alice", 1, 3, word("second", 1.5, 1.6)),
segmentWithWords("a.json", 0, "Alice", 1, 3, word("first", 1.1, 1.2)),
},
OverlapGroups: []model.OverlapGroup{
group(1, 1, 3, []string{"a.json#1", "a.json#0"}, []string{"Alice"}),
},
}
got, _, err := Resolve(merged, 0.75)
if err != nil {
t.Fatalf("resolve failed: %v", err)
}
if len(got.Segments) != 1 {
t.Fatalf("segment count = %d, want 1", len(got.Segments))
}
segment := got.Segments[0]
if segment.SourceRef != "word-run:1:1:1" {
t.Fatalf("source_ref = %q", segment.SourceRef)
}
if !reflect.DeepEqual(segment.DerivedFrom, []string{"a.json#0", "a.json#1"}) {
t.Fatalf("derived_from = %v", segment.DerivedFrom)
}
}
func TestResolveIncludesUntimedWordsInTextWithoutChangingBounds(t *testing.T) {
merged := model.MergedTranscript{
Segments: []model.Segment{
segmentWithWords(
"a.json",
0,
"Alice",
1,
3,
untimedWord("pre"),
word("one", 1.1, 1.2),
untimedWord("middle"),
word("two", 1.4, 1.5),
untimedWord("post"),
),
},
OverlapGroups: []model.OverlapGroup{
group(1, 1, 3, []string{"a.json#0"}, []string{"Alice"}),
},
}
got, _, err := Resolve(merged, 0.75)
if err != nil {
t.Fatalf("resolve failed: %v", err)
}
if len(got.Segments) != 1 {
t.Fatalf("segment count = %d, want 1", len(got.Segments))
}
segment := got.Segments[0]
if segment.Text != "pre one middle two post" {
t.Fatalf("text = %q", segment.Text)
}
if segment.Start != 1.1 || segment.End != 1.5 {
t.Fatalf("bounds = %f-%f, want 1.1-1.5", segment.Start, segment.End)
}
}
func TestResolveUntimedWordsDoNotBridgeWordRunGap(t *testing.T) {
merged := model.MergedTranscript{
Segments: []model.Segment{
segmentWithWords(
"a.json",
0,
"Alice",
1,
4,
word("one", 1, 1.1),
untimedWord("middle"),
word("two", 2, 2.1),
),
},
OverlapGroups: []model.OverlapGroup{
group(1, 1, 4, []string{"a.json#0"}, []string{"Alice"}),
},
}
got, _, err := Resolve(merged, 0.75)
if err != nil {
t.Fatalf("resolve failed: %v", err)
}
if len(got.Segments) != 2 {
t.Fatalf("segment count = %d, want 2", len(got.Segments))
}
if got.Segments[0].Text != "one middle" || got.Segments[1].Text != "two" {
t.Fatalf("unexpected texts: %#v", got.Segments)
}
if got.Segments[0].End != 1.1 || got.Segments[1].Start != 2 {
t.Fatalf("untimed word changed bounds: %#v", got.Segments)
}
}
func TestResolveSpeakerWithOnlyUntimedWordsIsNotReplaced(t *testing.T) {
merged := model.MergedTranscript{
Segments: []model.Segment{
segmentWithWords("a.json", 0, "Alice", 1, 3, untimedWord("hello")),
},
OverlapGroups: []model.OverlapGroup{
group(1, 1, 3, []string{"a.json#0"}, []string{"Alice"}),
},
}
merged.Segments[0].OverlapGroupID = 1
got, summary, err := Resolve(merged, 0.75)
if err != nil {
t.Fatalf("resolve failed: %v", err)
}
if summary.GroupsChanged != 0 {
t.Fatalf("unexpected summary: %#v", summary)
}
if !reflect.DeepEqual(got, merged) {
t.Fatalf("expected unchanged transcript:\ngot %#v\nwant %#v", got, merged)
}
}
func segmentWithWords(source string, sourceIndex int, speaker string, start float64, end float64, words ...model.Word) model.Segment {
segment := segment(source, sourceIndex, speaker, start, end)
segment.Words = words
return segment
}
func word(text string, start float64, end float64) model.Word {
return model.Word{
Text: text,
Start: start,
End: end,
Timed: true,
}
}
func untimedWord(text string) model.Word {
return model.Word{
Text: text,
}
}
func group(id int, start float64, end float64, refs []string, speakers []string) model.OverlapGroup {
return model.OverlapGroup{
ID: id,
Start: start,
End: end,
Segments: refs,
Speakers: speakers,
Class: defaultClass,
Resolution: defaultResolution,
}
}