Skip to content

Commit 69e42da

Browse files
authored
fix(cli/scoring): sort speaker ids so diarization speaker mapping is deterministic (#923)
## Summary Fixes #922 — same nondeterminism class as #911/#921, in the diarization scorer. `computeSpeakerMapping` built its speaker index orders with `Array(predicted.keys)` / `Array(groundTruth.keys)`. Swift dictionary iteration order is per-instance random (the hash seed incorporates the storage allocation — re-confirmed empirically on Swift 6.2.3), and both assignment paths (the subset DP and the greedy fallback) tie-break by first-encountered index via strict `>` comparisons. The confusion matrix rounds overlaps to integer milliseconds, so exact ties are realistic — and whenever two pairings tie, the winning speaker mapping (and JER) depended on random key enumeration order. ## Evidence Standalone harness on the scenario in the new test (two predicted speakers each overlapping one ground-truth speaker by exactly 4.75 s after the 0.25 s collar): the pre-fix code produced **2 distinct speaker mappings within a single 200-call process** (3/3 runs), and fails the new test's predicate in **20/20 processes**. With the fix: 1 mapping, always (`right → spk_a`, pinned in the test). ## Fixes (commit 2 adds the full sweep from adversarial review) 1. `computeSpeakerMapping`: sort both key arrays — ties resolve by the solver's fixed index-order rule over lexicographically sorted speaker ids; unambiguous assignments unchanged. 2. `offlineMetrics`: the `correctlyAssigned`, `jaccardScores`, and unmapped-pred loops accumulated Doubles in dictionary order. Addition is not associative, and both DER and JER pass through near-zero cancellation (`max(0, overlapSpeech - correctlyAssigned)`, `1.0 - averageJaccard`) where a ulp-level reorder survives the Float cast — with 3+ speakers, DER/JER were still not bit-deterministic. All three loops now iterate in sorted key order. 3. Streaming path (`DiarizationBenchmark.calculateStreamingMetrics`): `overlapsByGtSpeaker.max(by: { $0.value < $1.value })` kept the first-encountered element among tied overlaps in random dictionary order — same class. Ties now break by smaller speaker id. ## Tests `DiarizationSpeakerMappingTests`: tied-overlap stability across 50 calls (each call regroups segments into fresh dictionaries, each with an independent per-allocation key order — the unfixed code fails this 20/20 processes), the exact tied winner pinned so silent tie-break changes fail loudly, plus an unambiguous-mapping correctness check.
1 parent 74ad822 commit 69e42da

3 files changed

Lines changed: 138 additions & 7 deletions

File tree

Sources/FluidAudioCLI/Commands/DiarizationBenchmark.swift

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -939,6 +939,18 @@ enum StreamDiarizationBenchmark {
939939
}
940940
}
941941

942+
/// Deterministic winner among per-speaker overlap totals: largest
943+
/// overlap, ties broken by smaller speaker id (dictionary iteration
944+
/// order is per-instance random; issue #922).
945+
static func bestOverlapMatch(
946+
_ overlapsBySpeaker: [String: Float]
947+
) -> (speakerId: String, overlap: Float)? {
948+
overlapsBySpeaker.max(by: {
949+
if $0.value != $1.value { return $0.value < $1.value }
950+
return $0.key > $1.key
951+
}).map { ($0.key, $0.value) }
952+
}
953+
942954
/// Calculate DER metrics with first-occurrence mapping for streaming evaluation
943955
private static func calculateStreamingMetrics(
944956
predicted: [TimedSpeakerSegment],
@@ -985,7 +997,7 @@ enum StreamDiarizationBenchmark {
985997
}
986998

987999
// Find the GT speaker with most overlap
988-
if let (bestMatch, bestOverlap) = overlapsByGtSpeaker.max(by: { $0.value < $1.value }),
1000+
if let (bestMatch, bestOverlap) = bestOverlapMatch(overlapsByGtSpeaker),
9891001
bestOverlap > 0.5
9901002
{ // Require at least 0.5s total overlap
9911003
firstOccurrenceMap[predSegment.speakerId] = bestMatch

Sources/FluidAudioCLI/Utils/DiarizationMetrics.swift

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -186,8 +186,11 @@ enum DiarizationMetricsCalculator {
186186
groundTruth: groundTruthBySpeaker
187187
)
188188

189+
// Dictionary iteration order is per-instance random, so accumulate in
190+
// sorted order — Double addition is not associative, and near-zero
191+
// confusion/JER residues are sensitive to the summation order.
189192
var correctlyAssigned = 0.0
190-
for (predId, truthId) in speakerMapping {
193+
for (predId, truthId) in speakerMapping.sorted(by: { $0.key < $1.key }) {
191194
if let predSegments = predictedBySpeaker[predId],
192195
let truthSegments = groundTruthBySpeaker[truthId]
193196
{
@@ -205,7 +208,7 @@ enum DiarizationMetricsCalculator {
205208
var jaccardScores: [Double] = []
206209
let inverseMapping = Dictionary(uniqueKeysWithValues: speakerMapping.map { ($0.value, $0.key) })
207210

208-
for (truthId, truthSegments) in groundTruthBySpeaker {
211+
for (truthId, truthSegments) in groundTruthBySpeaker.sorted(by: { $0.key < $1.key }) {
209212
let matchedPred = inverseMapping[truthId]
210213
let predictedSegmentsForSpeaker = matchedPred.flatMap { predictedBySpeaker[$0] } ?? []
211214
let intersection = overlapDuration(predictedSegmentsForSpeaker, truthSegments)
@@ -215,7 +218,8 @@ enum DiarizationMetricsCalculator {
215218
}
216219
}
217220

218-
for (predId, predSegments) in predictedBySpeaker where speakerMapping[predId] == nil {
221+
for (predId, predSegments) in predictedBySpeaker.sorted(by: { $0.key < $1.key })
222+
where speakerMapping[predId] == nil {
219223
if unionDuration(predSegments) > 0 {
220224
jaccardScores.append(0.0)
221225
}
@@ -230,7 +234,7 @@ enum DiarizationMetricsCalculator {
230234
}
231235

232236
if let logger = logger {
233-
logger.debug("🎯 Offline mapping: \(speakerMapping)")
237+
logger.debug("🎯 Offline mapping: \(speakerMapping.sorted(by: { $0.key < $1.key }))")
234238
let formattedDer = String(format: "%.1f", der)
235239
let formattedMiss = String(format: "%.1f", missRate)
236240
let formattedFalseAlarm = String(format: "%.1f", falseAlarmRate)
@@ -593,8 +597,11 @@ enum DiarizationMetricsCalculator {
593597
) -> [String: String] {
594598
guard !predicted.isEmpty, !groundTruth.isEmpty else { return [:] }
595599

596-
let predictedIds = Array(predicted.keys)
597-
let groundTruthIds = Array(groundTruth.keys)
600+
// Sorted so assignment tie-breaks are deterministic: dictionary key
601+
// order is per-instance random, and the solvers keep the
602+
// first-encountered winner among tied overlaps (issue #922).
603+
let predictedIds = predicted.keys.sorted()
604+
let groundTruthIds = groundTruth.keys.sorted()
598605

599606
var confusionMatrix = Array(
600607
repeating: Array(repeating: 0, count: predictedIds.count),
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
#if os(macOS)
2+
import XCTest
3+
4+
@testable import FluidAudio
5+
@testable import FluidAudioCLI
6+
7+
/// Tests for the diarization scorer's speaker mapping (issue #922).
8+
///
9+
/// `computeSpeakerMapping` builds its speaker index orders from dictionary
10+
/// keys, and the assignment solvers keep the first-encountered winner among
11+
/// tied overlaps. Before the key arrays were sorted, per-instance random
12+
/// dictionary order made the winning mapping — and with it JER — drift
13+
/// between runs on tied confusion-matrix entries.
14+
final class DiarizationSpeakerMappingTests: XCTestCase {
15+
16+
private func segment(_ speaker: String, _ start: Float, _ end: Float) -> TimedSpeakerSegment {
17+
TimedSpeakerSegment(
18+
speakerId: speaker, embedding: [], startTimeSeconds: start, endTimeSeconds: end,
19+
qualityScore: 1.0)
20+
}
21+
22+
/// Two predicted speakers tie exactly on overlap with one ground-truth
23+
/// speaker (4.75 s each after the 0.25 s collar). Only one can win the
24+
/// assignment; the winner must not depend on dictionary key order.
25+
func testTiedOverlapsMapDeterministically() {
26+
let groundTruth = [segment("spk_a", 0, 10)]
27+
let predicted = [
28+
segment("left", 0, 5),
29+
segment("right", 5, 10),
30+
]
31+
32+
let first = DiarizationMetricsCalculator.offlineMetrics(
33+
predicted: predicted, groundTruth: groundTruth)
34+
// Pinned winner: the assignment DP keeps the skip-branch result on
35+
// ties, so with sorted ids "right" wins. A silent tie-break change
36+
// would shift recorded benchmark numbers; this catches it.
37+
XCTAssertEqual(first.speakerMapping, ["right": "spk_a"])
38+
39+
// Every call regroups segments into fresh dictionaries, so each
40+
// iteration samples a new per-instance key order.
41+
for _ in 0..<50 {
42+
let metrics = DiarizationMetricsCalculator.offlineMetrics(
43+
predicted: predicted, groundTruth: groundTruth)
44+
XCTAssertEqual(metrics.speakerMapping, first.speakerMapping)
45+
XCTAssertEqual(metrics.jer, first.jer)
46+
XCTAssertEqual(metrics.der, first.der)
47+
}
48+
}
49+
50+
/// With 3+ speakers, der/jer accumulate Doubles across several speakers;
51+
/// the sums must be byte-stable call to call now that the accumulation
52+
/// loops iterate in sorted key order.
53+
func testMultiSpeakerMetricsAreStableAcrossCalls() {
54+
let groundTruth = [
55+
segment("gt_a", 0, 10),
56+
segment("gt_b", 11, 20),
57+
segment("gt_c", 21, 30),
58+
]
59+
let predicted = [
60+
segment("p1", 0, 9),
61+
segment("p2", 11.5, 19),
62+
segment("p3", 21.5, 28),
63+
]
64+
65+
let first = DiarizationMetricsCalculator.offlineMetrics(
66+
predicted: predicted, groundTruth: groundTruth)
67+
XCTAssertEqual(first.speakerMapping, ["p1": "gt_a", "p2": "gt_b", "p3": "gt_c"])
68+
69+
for _ in 0..<50 {
70+
let metrics = DiarizationMetricsCalculator.offlineMetrics(
71+
predicted: predicted, groundTruth: groundTruth)
72+
XCTAssertEqual(metrics.speakerMapping, first.speakerMapping)
73+
XCTAssertEqual(metrics.der, first.der)
74+
XCTAssertEqual(metrics.jer, first.jer)
75+
XCTAssertEqual(metrics.speakerErrorRate, first.speakerErrorRate)
76+
}
77+
}
78+
79+
/// The streaming scorer's overlap winner must break ties by smaller
80+
/// speaker id, not by whichever tied entry a fresh dictionary happens to
81+
/// enumerate first.
82+
func testStreamingTieBreakPicksSmallestSpeakerId() {
83+
for _ in 0..<100 {
84+
let overlaps: [String: Float] = ["spk_b": 2.0, "spk_a": 2.0, "spk_c": 1.5]
85+
let best = StreamDiarizationBenchmark.bestOverlapMatch(overlaps)
86+
XCTAssertEqual(best?.speakerId, "spk_a")
87+
XCTAssertEqual(best?.overlap, 2.0)
88+
}
89+
90+
let unambiguous = StreamDiarizationBenchmark.bestOverlapMatch(["spk_a": 1.0, "spk_b": 3.0])
91+
XCTAssertEqual(unambiguous?.speakerId, "spk_b")
92+
XCTAssertNil(StreamDiarizationBenchmark.bestOverlapMatch([:]))
93+
}
94+
95+
/// Unambiguous overlaps must still produce the correct mapping after the
96+
/// key arrays are sorted.
97+
func testUnambiguousMappingIsCorrect() {
98+
let groundTruth = [
99+
segment("alice", 0, 10),
100+
segment("bob", 11, 20),
101+
]
102+
let predicted = [
103+
segment("s1", 0, 9),
104+
segment("s2", 11.5, 20),
105+
]
106+
107+
let metrics = DiarizationMetricsCalculator.offlineMetrics(
108+
predicted: predicted, groundTruth: groundTruth)
109+
XCTAssertEqual(metrics.speakerMapping, ["s1": "alice", "s2": "bob"])
110+
}
111+
}
112+
#endif

0 commit comments

Comments
 (0)