Minor updates to overlap detection and segment coalescing logic
This commit is contained in:
@@ -18,7 +18,7 @@ type ResolutionSummary struct {
|
||||
|
||||
// Resolve replaces detected overlap-group segments with word-run segments when
|
||||
// word-level timing is available.
|
||||
func Resolve(in model.MergedTranscript, wordRunGap float64, wordRunReorderWindow float64) (model.MergedTranscript, ResolutionSummary, error) {
|
||||
func Resolve(in model.MergedTranscript, wordRunGap float64, wordRunReorderWindow float64, contextWindow float64) (model.MergedTranscript, ResolutionSummary, error) {
|
||||
summary := ResolutionSummary{
|
||||
GroupsProcessed: len(in.OverlapGroups),
|
||||
}
|
||||
@@ -30,6 +30,12 @@ func Resolve(in model.MergedTranscript, wordRunGap float64, wordRunReorderWindow
|
||||
for index, segment := range in.Segments {
|
||||
refToIndex[SegmentRef(segment)] = index
|
||||
}
|
||||
overlapRefs := make(map[string]struct{})
|
||||
for _, group := range in.OverlapGroups {
|
||||
for _, ref := range group.Segments {
|
||||
overlapRefs[ref] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
removeRefs := make(map[string]struct{})
|
||||
clearAnnotationRefs := make(map[string]struct{})
|
||||
@@ -38,7 +44,7 @@ func Resolve(in model.MergedTranscript, wordRunGap float64, wordRunReorderWindow
|
||||
replacementOrder := make(map[string]replacementOrder)
|
||||
|
||||
for _, group := range in.OverlapGroups {
|
||||
resolved, err := resolveGroup(in, group, refToIndex, wordRunGap, wordRunReorderWindow)
|
||||
resolved, err := resolveGroup(in, group, refToIndex, overlapRefs, wordRunGap, wordRunReorderWindow, contextWindow)
|
||||
if err != nil {
|
||||
return model.MergedTranscript{}, ResolutionSummary{}, err
|
||||
}
|
||||
@@ -125,15 +131,39 @@ type wordRun struct {
|
||||
end float64
|
||||
}
|
||||
|
||||
func resolveGroup(in model.MergedTranscript, group model.OverlapGroup, refToIndex map[string]int, wordRunGap float64, wordRunReorderWindow float64) (resolvedGroup, error) {
|
||||
func resolveGroup(in model.MergedTranscript, group model.OverlapGroup, refToIndex map[string]int, overlapRefs map[string]struct{}, wordRunGap float64, wordRunReorderWindow float64, contextWindow float64) (resolvedGroup, error) {
|
||||
segmentsBySpeaker := make(map[string][]model.Segment)
|
||||
refsBySpeaker := make(map[string][]string)
|
||||
groupRefs := make(map[string]struct{}, len(group.Segments))
|
||||
groupSpeakers := make(map[string]struct{})
|
||||
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)
|
||||
}
|
||||
groupRefs[ref] = struct{}{}
|
||||
segment := in.Segments[index]
|
||||
groupSpeakers[segment.Speaker] = struct{}{}
|
||||
}
|
||||
|
||||
expandedStart := group.Start - contextWindow
|
||||
expandedEnd := group.End + contextWindow
|
||||
for _, segment := range in.Segments {
|
||||
ref := SegmentRef(segment)
|
||||
if _, exists := groupRefs[ref]; !exists {
|
||||
if _, exists := overlapRefs[ref]; exists {
|
||||
continue
|
||||
}
|
||||
if _, exists := groupSpeakers[segment.Speaker]; !exists {
|
||||
continue
|
||||
}
|
||||
if !intervalIntersects(segment.Start, segment.End, expandedStart, expandedEnd) {
|
||||
continue
|
||||
}
|
||||
if !segmentNearGroupBoundary(segment, group, contextWindow) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
segmentsBySpeaker[segment.Speaker] = append(segmentsBySpeaker[segment.Speaker], segment)
|
||||
refsBySpeaker[segment.Speaker] = append(refsBySpeaker[segment.Speaker], ref)
|
||||
}
|
||||
@@ -141,7 +171,7 @@ func resolveGroup(in model.MergedTranscript, group model.OverlapGroup, refToInde
|
||||
speakers := groupSpeakerOrder(group, segmentsBySpeaker)
|
||||
resolved := resolvedGroup{}
|
||||
for speakerIndex, speaker := range speakers {
|
||||
timedWords, untimedWords := gatherResolutionWords(segmentsBySpeaker[speaker], group.Start, group.End)
|
||||
timedWords, untimedWords := gatherResolutionWords(segmentsBySpeaker[speaker], expandedStart, expandedEnd)
|
||||
if len(timedWords) == 0 {
|
||||
continue
|
||||
}
|
||||
@@ -162,6 +192,25 @@ func resolveGroup(in model.MergedTranscript, group model.OverlapGroup, refToInde
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func intervalIntersects(start float64, end float64, windowStart float64, windowEnd float64) bool {
|
||||
return end > windowStart && start < windowEnd
|
||||
}
|
||||
|
||||
func segmentNearGroupBoundary(segment model.Segment, group model.OverlapGroup, window float64) bool {
|
||||
return withinWindow(segment.Start, group.Start, window) ||
|
||||
withinWindow(segment.End, group.Start, window) ||
|
||||
withinWindow(segment.Start, group.End, window) ||
|
||||
withinWindow(segment.End, group.End, window)
|
||||
}
|
||||
|
||||
func withinWindow(value float64, boundary float64, window float64) bool {
|
||||
diff := value - boundary
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
return diff <= window
|
||||
}
|
||||
|
||||
func reorderReplacementSegments(groupID int, replacements []model.Segment, wordRunReorderWindow float64) ([]model.Segment, map[string]replacementOrder) {
|
||||
if len(replacements) == 0 {
|
||||
return replacements, nil
|
||||
|
||||
@@ -15,7 +15,7 @@ func TestResolveNoOverlapGroupsIsNoOp(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
got, summary, err := Resolve(merged, 0.75, 0.4)
|
||||
got, summary, err := Resolve(merged, 0.75, 0.4, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve failed: %v", err)
|
||||
}
|
||||
@@ -40,7 +40,7 @@ func TestResolveCreatesChronologicalWordRunSegments(t *testing.T) {
|
||||
merged.Segments[0].OverlapGroupID = 1
|
||||
merged.Segments[1].OverlapGroupID = 1
|
||||
|
||||
got, summary, err := Resolve(merged, 0.75, 0.4)
|
||||
got, summary, err := Resolve(merged, 0.75, 0.4, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve failed: %v", err)
|
||||
}
|
||||
@@ -94,7 +94,7 @@ func TestResolveIncludesWordsByIntervalIntersection(t *testing.T) {
|
||||
}
|
||||
merged.Segments[0].OverlapGroupID = 1
|
||||
|
||||
got, _, err := Resolve(merged, 10, 0.4)
|
||||
got, _, err := Resolve(merged, 10, 0.4, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve failed: %v", err)
|
||||
}
|
||||
@@ -106,6 +106,158 @@ func TestResolveIncludesWordsByIntervalIntersection(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIncludesContextWordsAroundOverlapWindow(t *testing.T) {
|
||||
merged := model.MergedTranscript{
|
||||
Segments: []model.Segment{
|
||||
segmentWithWords("a.json", 0, "Alice", 7.5, 9.5, word("before", 8.5, 8.7)),
|
||||
segmentWithWords("a.json", 1, "Alice", 10, 12, word("inside", 10.5, 10.7)),
|
||||
segmentWithWords("a.json", 2, "Alice", 12.5, 13.5, word("after", 13, 13.2)),
|
||||
segmentWithWords("b.json", 0, "Bob", 10.2, 11.2, word("bob", 10.4, 10.6)),
|
||||
},
|
||||
OverlapGroups: []model.OverlapGroup{
|
||||
group(1, 10, 12, []string{"a.json#1", "b.json#0"}, []string{"Alice", "Bob"}),
|
||||
},
|
||||
}
|
||||
merged.Segments[1].OverlapGroupID = 1
|
||||
merged.Segments[3].OverlapGroupID = 1
|
||||
|
||||
got, summary, err := Resolve(merged, 10, 0.4, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve failed: %v", err)
|
||||
}
|
||||
if summary.GroupsChanged != 1 || summary.OriginalsRemoved != 4 || summary.ReplacementsCreated != 2 {
|
||||
t.Fatalf("unexpected summary: %#v", summary)
|
||||
}
|
||||
if gotTexts(got.Segments) != "before inside after,bob" {
|
||||
t.Fatalf("segment texts = %s", gotTexts(got.Segments))
|
||||
}
|
||||
alice := got.Segments[0]
|
||||
if alice.Start != 8.5 || alice.End != 13.2 {
|
||||
t.Fatalf("context bounds = %f-%f, want 8.5-13.2", alice.Start, alice.End)
|
||||
}
|
||||
if !reflect.DeepEqual(alice.DerivedFrom, []string{"a.json#0", "a.json#1", "a.json#2"}) {
|
||||
t.Fatalf("derived_from = %v", alice.DerivedFrom)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDoesNotIncludeContextOutsideWindow(t *testing.T) {
|
||||
merged := model.MergedTranscript{
|
||||
Segments: []model.Segment{
|
||||
segmentWithWords("a.json", 0, "Alice", 5, 6.9, word("outside", 6, 6.2)),
|
||||
segmentWithWords("a.json", 1, "Alice", 10, 12, word("inside", 10.5, 10.7)),
|
||||
segmentWithWords("b.json", 0, "Bob", 10.2, 11.2, word("bob", 10.4, 10.6)),
|
||||
},
|
||||
OverlapGroups: []model.OverlapGroup{
|
||||
group(1, 10, 12, []string{"a.json#1", "b.json#0"}, []string{"Alice", "Bob"}),
|
||||
},
|
||||
}
|
||||
|
||||
got, _, err := Resolve(merged, 10, 0.4, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve failed: %v", err)
|
||||
}
|
||||
if gotTexts(got.Segments) != "Alice,bob,inside" {
|
||||
t.Fatalf("segment texts = %s", gotTexts(got.Segments))
|
||||
}
|
||||
if got.Segments[0].SourceSegmentIndex == nil || *got.Segments[0].SourceSegmentIndex != 0 {
|
||||
t.Fatalf("outside context segment was not preserved: %#v", got.Segments[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDoesNotIncludeNearbyNonGroupSpeakerContext(t *testing.T) {
|
||||
merged := model.MergedTranscript{
|
||||
Segments: []model.Segment{
|
||||
segmentWithWords("a.json", 0, "Alice", 10, 12, word("alice", 10.5, 10.7)),
|
||||
segmentWithWords("b.json", 0, "Bob", 10.2, 11.2, word("bob", 10.4, 10.6)),
|
||||
segmentWithWords("c.json", 0, "Carol", 12.5, 13.5, word("carol", 13, 13.2)),
|
||||
},
|
||||
OverlapGroups: []model.OverlapGroup{
|
||||
group(1, 10, 12, []string{"a.json#0", "b.json#0"}, []string{"Alice", "Bob"}),
|
||||
},
|
||||
}
|
||||
|
||||
got, _, err := Resolve(merged, 10, 0.4, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve failed: %v", err)
|
||||
}
|
||||
if gotTexts(got.Segments) != "bob,alice,Carol" {
|
||||
t.Fatalf("segment texts = %s", gotTexts(got.Segments))
|
||||
}
|
||||
if got.Segments[2].SourceSegmentIndex == nil || *got.Segments[2].SourceSegmentIndex != 0 {
|
||||
t.Fatalf("non-group speaker context segment was not preserved: %#v", got.Segments[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRemovesIncludedContextSegmentsForReplacedSpeaker(t *testing.T) {
|
||||
merged := model.MergedTranscript{
|
||||
Segments: []model.Segment{
|
||||
segmentWithWords("a.json", 0, "Alice", 8, 9, word("before", 8.5, 8.7)),
|
||||
segmentWithWords("a.json", 1, "Alice", 10, 12, word("inside", 10.5, 10.7)),
|
||||
segmentWithWords("b.json", 0, "Bob", 10.2, 11.2),
|
||||
},
|
||||
OverlapGroups: []model.OverlapGroup{
|
||||
group(1, 10, 12, []string{"a.json#1", "b.json#0"}, []string{"Alice", "Bob"}),
|
||||
},
|
||||
}
|
||||
merged.Segments[1].OverlapGroupID = 1
|
||||
merged.Segments[2].OverlapGroupID = 1
|
||||
|
||||
got, summary, err := Resolve(merged, 10, 0.4, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve failed: %v", err)
|
||||
}
|
||||
if summary.OriginalsRemoved != 2 || summary.ReplacementsCreated != 1 {
|
||||
t.Fatalf("unexpected summary: %#v", summary)
|
||||
}
|
||||
if gotTexts(got.Segments) != "before inside,Bob" {
|
||||
t.Fatalf("segment texts = %s", gotTexts(got.Segments))
|
||||
}
|
||||
if got.Segments[1].OverlapGroupID != 0 {
|
||||
t.Fatalf("kept original group annotation = %d, want 0", got.Segments[1].OverlapGroupID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSkipsContextSegmentReferencedByAnotherOverlapGroup(t *testing.T) {
|
||||
merged := model.MergedTranscript{
|
||||
Segments: []model.Segment{
|
||||
segmentWithWords("a.json", 0, "Alice", 8, 9, word("other-group", 8.5, 8.7)),
|
||||
segmentWithWords("a.json", 1, "Alice", 10, 12, word("inside", 10.5, 10.7)),
|
||||
segmentWithWords("b.json", 0, "Bob", 10.2, 11.2, word("bob", 10.4, 10.6)),
|
||||
segmentWithWords("c.json", 0, "Carol", 8.5, 9.5),
|
||||
},
|
||||
OverlapGroups: []model.OverlapGroup{
|
||||
group(1, 10, 12, []string{"a.json#1", "b.json#0"}, []string{"Alice", "Bob"}),
|
||||
group(2, 8, 9.5, []string{"a.json#0", "c.json#0"}, []string{"Alice", "Carol"}),
|
||||
},
|
||||
}
|
||||
merged.Segments[0].OverlapGroupID = 2
|
||||
merged.Segments[1].OverlapGroupID = 1
|
||||
merged.Segments[2].OverlapGroupID = 1
|
||||
merged.Segments[3].OverlapGroupID = 2
|
||||
|
||||
refToIndex := map[string]int{}
|
||||
for index, segment := range merged.Segments {
|
||||
refToIndex[SegmentRef(segment)] = index
|
||||
}
|
||||
overlapRefs := map[string]struct{}{
|
||||
"a.json#0": {},
|
||||
"a.json#1": {},
|
||||
"b.json#0": {},
|
||||
"c.json#0": {},
|
||||
}
|
||||
|
||||
resolved, err := resolveGroup(merged, merged.OverlapGroups[0], refToIndex, overlapRefs, 10, 0.4, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve failed: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(resolved.removeRefs, []string{"a.json#1", "b.json#0"}) {
|
||||
t.Fatalf("remove refs = %v", resolved.removeRefs)
|
||||
}
|
||||
if gotTexts(resolved.replacements) != "bob,inside" {
|
||||
t.Fatalf("replacement texts = %s", gotTexts(resolved.replacements))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWordRunGapThreshold(t *testing.T) {
|
||||
merged := model.MergedTranscript{
|
||||
Segments: []model.Segment{
|
||||
@@ -117,7 +269,7 @@ func TestResolveWordRunGapThreshold(t *testing.T) {
|
||||
}
|
||||
merged.Segments[0].OverlapGroupID = 1
|
||||
|
||||
got, _, err := Resolve(merged, 0.75, 0.4)
|
||||
got, _, err := Resolve(merged, 0.75, 0.4, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve failed: %v", err)
|
||||
}
|
||||
@@ -142,7 +294,7 @@ func TestResolvePartialResolutionKeepsNoWordSpeakerOriginals(t *testing.T) {
|
||||
merged.Segments[0].OverlapGroupID = 1
|
||||
merged.Segments[1].OverlapGroupID = 1
|
||||
|
||||
got, summary, err := Resolve(merged, 0.75, 0.4)
|
||||
got, summary, err := Resolve(merged, 0.75, 0.4, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve failed: %v", err)
|
||||
}
|
||||
@@ -179,7 +331,7 @@ func TestResolveGroupWithNoUsableWordsRemainsUnchanged(t *testing.T) {
|
||||
merged.Segments[0].OverlapGroupID = 1
|
||||
merged.Segments[1].OverlapGroupID = 1
|
||||
|
||||
got, summary, err := Resolve(merged, 0.75, 0.4)
|
||||
got, summary, err := Resolve(merged, 0.75, 0.4, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve failed: %v", err)
|
||||
}
|
||||
@@ -202,7 +354,7 @@ func TestResolveReplacementProvenanceIsDeterministic(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
got, _, err := Resolve(merged, 0.75, 0.4)
|
||||
got, _, err := Resolve(merged, 0.75, 0.4, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve failed: %v", err)
|
||||
}
|
||||
@@ -239,7 +391,7 @@ func TestResolveIncludesUntimedWordsInTextWithoutChangingBounds(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
got, _, err := Resolve(merged, 0.75, 0.4)
|
||||
got, _, err := Resolve(merged, 0.75, 0.4, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve failed: %v", err)
|
||||
}
|
||||
@@ -274,7 +426,7 @@ func TestResolveUntimedWordsDoNotBridgeWordRunGap(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
got, _, err := Resolve(merged, 0.75, 0.4)
|
||||
got, _, err := Resolve(merged, 0.75, 0.4, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve failed: %v", err)
|
||||
}
|
||||
@@ -300,7 +452,7 @@ func TestResolveSpeakerWithOnlyUntimedWordsIsNotReplaced(t *testing.T) {
|
||||
}
|
||||
merged.Segments[0].OverlapGroupID = 1
|
||||
|
||||
got, summary, err := Resolve(merged, 0.75, 0.4)
|
||||
got, summary, err := Resolve(merged, 0.75, 0.4, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve failed: %v", err)
|
||||
}
|
||||
@@ -323,7 +475,7 @@ func TestResolveReordersNearStartWordRunsByDuration(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
got, _, err := Resolve(merged, 0.75, 0.4)
|
||||
got, _, err := Resolve(merged, 0.75, 0.4, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve failed: %v", err)
|
||||
}
|
||||
@@ -349,7 +501,7 @@ func TestResolveDoesNotReorderWordRunsOutsideWindow(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
got, _, err := Resolve(merged, 0.75, 0.4)
|
||||
got, _, err := Resolve(merged, 0.75, 0.4, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve failed: %v", err)
|
||||
}
|
||||
@@ -370,7 +522,7 @@ func TestResolveReordersTransitiveNearStartClustersByDuration(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
got, _, err := Resolve(merged, 0.75, 0.4)
|
||||
got, _, err := Resolve(merged, 0.75, 0.4, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve failed: %v", err)
|
||||
}
|
||||
@@ -390,7 +542,7 @@ func TestResolveReorderFallsBackToDeterministicOrderForEqualDurations(t *testing
|
||||
},
|
||||
}
|
||||
|
||||
got, _, err := Resolve(merged, 0.75, 0.4)
|
||||
got, _, err := Resolve(merged, 0.75, 0.4, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve failed: %v", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user