Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ extension SpeakerDatabase {
SpeakerNegativeExemplarPolicy.shouldVeto(
candidate: embedding,
positiveSimilarity: similarity,
negativeExemplars: negatives
negativeExemplars: negatives,
profileAverage: speaker.embedding,
positiveExemplars: speaker.exemplars
) {
AppLogger.speakers.info("Match vetoed: negative exemplar", [
"name": speaker.displayName ?? speaker.id.uuidString,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,9 @@ extension Transcription {
SpeakerNegativeExemplarPolicy.shouldVeto(
candidate: embedding,
positiveSimilarity: similarity,
negativeExemplars: negatives
negativeExemplars: negatives,
profileAverage: profile.embedding,
positiveExemplars: profile.exemplars
) {
AppLogger.transcription.info("Match vetoed: negative exemplar", [
"profile": profile.displayName ?? profile.id.uuidString.prefix(8).description,
Expand Down
109 changes: 109 additions & 0 deletions Sources/TranscriptedCore/Speaker/SpeakerNegativeExemplarPolicy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ public enum SpeakerNegativeExemplarPolicy {
/// resemblance to an *explicitly rejected* sample can veto. Coincidental similarity never fires.
public static let vetoFloor: Double = 0.80

/// How far to transport a rejected sample along one of a profile's observed condition shifts when
/// deriving a cross-condition negative representation (see the transported `maxNegativeSimilarity`
/// overload). `1.0` moves the rejected sample by one full observed shift (e.g. clean → the telephone
/// offset the owner's own exemplars exhibit); `0.75` is a mild transport that lifts cross-condition
/// veto coverage on real degraded audio while keeping owner-collateral flat (tuned on
/// `SpeakerExemplarDeltaEvalTests`).
public static let crossConditionTransportScale: Double = 0.75

/// Highest cosine similarity between `embedding` and any of a profile's negative exemplars.
/// Dimension-mismatched exemplars are skipped (same guard the positive matchers use), and an
/// empty list yields `-1` (no signal), so a profile without negative exemplars never vetoes.
Expand All @@ -45,6 +53,83 @@ public enum SpeakerNegativeExemplarPolicy {
return best
}

/// Cross-condition negative similarity: the max cosine of `embedding` against each stored negative
/// exemplar **and** against each negative *transported* along the profile's own observed condition
/// shifts.
///
/// The positive side already fixed the condition gap with multi-exemplar voiceprints — a returning
/// owner is scored against its best-fitting capture condition, not one blended centroid. The
/// negative side had no analog: a single rejected in-room sample and a later telephone/VoIP sample
/// of the *same* rejected impostor sit too far apart in embedding space for the raw cosine to clear
/// the veto floor, so a correction made in one condition failed to protect the owner in another
/// (see `docs/speaker-eval-exemplar-delta-2026-07.md`, "regime-limited"). This gives the negative
/// side the same multi-condition treatment.
///
/// Channel/condition shifts (clean → compressed remote → telephone) are largely speaker-independent,
/// and the profile has already *observed* several of them as the offset between each of its positive
/// exemplars and its blended average (`positiveExemplar − average`). Transporting a rejected sample
/// by `crossConditionTransportScale ×` one of those offsets synthesizes "the same rejected voice, in
/// a condition this profile has actually seen", so a cross-condition return of that impostor still
/// resembles one derived negative ≥ the floor.
///
/// It is deliberately conservative and owner-safe:
/// - **Only real, forward shifts.** Transport is along `+(exemplar − average)` — conditions the
/// profile genuinely exhibits — never arbitrary directions. A profile with **no** positive
/// exemplars (single-condition, e.g. a one-utterance profile) derives nothing and this reduces
/// *exactly* to `maxNegativeSimilarity`, so those profiles are byte-for-byte unchanged.
/// - **The owner gate is untouched.** Callers still require the resulting similarity to clear the
/// 0.80 floor *and* beat the candidate's own positive similarity (`shouldVeto`), so a genuine
/// owner — closer to its own fingerprint than to a transported *impostor* sample — is never
/// vetoed. Transport widens which *impostor* returns are caught; it does not relax the floor.
public static func maxNegativeSimilarity(
_ embedding: [Float],
negativeExemplars: [[Float]],
profileAverage: [Float],
positiveExemplars: [[Float]]
) -> Double {
guard !embedding.isEmpty else { return -1 }

// Baseline: raw resemblance to any stored negative (identical to the two-arg overload).
var best = maxNegativeSimilarity(embedding, negativeExemplars: negativeExemplars)

// Observed condition shifts: unit(exemplar) − average, for each same-dimension positive
// exemplar. Empty ⇒ no transport ⇒ `best` is exactly the raw negative similarity above.
let shifts = conditionShifts(average: profileAverage, positiveExemplars: positiveExemplars)
guard !shifts.isEmpty else { return best }

for exemplar in negativeExemplars where exemplar.count == embedding.count {
let base = SpeakerVectorMath.l2Normalize(exemplar)
for shift in shifts where shift.count == base.count {
var transported = base
for i in 0..<transported.count {
transported[i] += Float(crossConditionTransportScale) * shift[i]
}
best = max(best, SpeakerVectorMath.cosineSimilarity(embedding, transported))
}
}
return best
}

/// The profile's observed condition-shift directions: `unit(exemplar) − average` for each
/// same-dimension positive exemplar. `average` is already the L2-normalized blended fingerprint;
/// exemplars are normalized here so a shift is a pure direction between two unit representatives.
private static func conditionShifts(
average: [Float],
positiveExemplars: [[Float]]
) -> [[Float]] {
guard !average.isEmpty else { return [] }
var shifts: [[Float]] = []
for exemplar in positiveExemplars where exemplar.count == average.count {
let unit = SpeakerVectorMath.l2Normalize(exemplar)
var shift = unit
for i in 0..<shift.count {
shift[i] -= average[i]
}
shifts.append(shift)
}
return shifts
}

/// Whether a candidate embedding should be excluded from matching a profile because it too
/// closely resembles a sample the user already rejected for that profile.
///
Expand Down Expand Up @@ -82,4 +167,28 @@ public enum SpeakerNegativeExemplarPolicy {
let negativeSimilarity = maxNegativeSimilarity(embedding, negativeExemplars: negativeExemplars)
return shouldVeto(positiveSimilarity: positiveSimilarity, negativeSimilarity: negativeSimilarity)
}

/// Cross-condition-aware veto: same rule as above, but the negative similarity is computed with
/// condition transport (see `maxNegativeSimilarity(_:negativeExemplars:profileAverage:positiveExemplars:)`),
/// so a rejected impostor returning in a different audio condition the profile has seen is still
/// caught. The owner gate is unchanged — the floor and the `≥ positiveSimilarity` comparison still
/// protect the genuine returning owner — so a profile with no positive exemplars, or a candidate
/// that resembles its own fingerprint more than any (transported) rejected sample, behaves exactly
/// as the raw overload. Used by the matchers, which have the profile's average and exemplars in hand.
public static func shouldVeto(
candidate embedding: [Float],
positiveSimilarity: Double,
negativeExemplars: [[Float]],
profileAverage: [Float],
positiveExemplars: [[Float]]
) -> Bool {
guard !negativeExemplars.isEmpty else { return false }
let negativeSimilarity = maxNegativeSimilarity(
embedding,
negativeExemplars: negativeExemplars,
profileAverage: profileAverage,
positiveExemplars: positiveExemplars
)
return shouldVeto(positiveSimilarity: positiveSimilarity, negativeSimilarity: negativeSimilarity)
}
}
80 changes: 57 additions & 23 deletions Tests/TranscriptedCoreTests/SpeakerExemplarDeltaEvalTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -295,9 +295,20 @@ final class SpeakerExemplarDeltaEvalTests: XCTestCase {
/// - ownerCollateralRate — A's own voice wrongly vetoed by B's negative (must stay ~0).
private func vetoExperiment(profiles: [Profile], testBySpeaker: [String: [(quality: String, emb: [Float])]], db: SpeakerDatabase) -> [String: Any] {
var confusablePairs = 0
var replays = 0, reMatch = 0, vetoed = 0, vetoedAndReMatch = 0, vetoable = 0
var sameCondReplays = 0, sameCondReMatch = 0, sameCondVetoed = 0
var ownerCollateral = 0, ownerChecks = 0
var replays = 0, reMatch = 0, vetoable = 0

// Two veto arms, each split by whether the returning wrong voice arrives in the SAME audio
// condition as the rejected sample or a DIFFERENT (cross) condition:
// - RAW: the single stored rejected embedding only (pre-fix behavior, the eval's original
// `vetoedAmongReMatch`). Raw cosine collapses cross-condition, so it under-fires there.
// - TX: condition-transported negatives (#1493 follow-up) — the rejected sample plus its
// transport along the profile's own observed condition shifts. Same 0.80/≥pos owner
// gate; only the negative representation is richer.
// reMatch (pos ≥ floor) is arm-independent; the vetoed-among-re-match counters are per arm.
var reMatchCross = 0, reMatchSame = 0
var rawVetoedCross = 0, rawVetoedSame = 0
var txVetoedCross = 0, txVetoedSame = 0
var rawOwnerCollateral = 0, txOwnerCollateral = 0, ownerChecks = 0

// DB round-trip sanity: the store persists + reads a negative exemplar (real write path).
if let a = profiles.first, let bEmb = testBySpeaker[profiles.dropFirst().first?.gt ?? ""]?.first?.emb {
Expand All @@ -319,44 +330,67 @@ final class SpeakerExemplarDeltaEvalTests: XCTestCase {
replays += 1
let pos2 = SpeakerVectorMath.bestSimilarity(candidate: u2.emb, average: a.average, exemplars: a.exemplars)
let didReMatch = pos2 >= matchFloor
let didVeto = SpeakerNegativeExemplarPolicy.shouldVeto(
let sameCond = u2.quality == u1.quality
// RAW arm: single stored negative, no transport.
let rawVeto = SpeakerNegativeExemplarPolicy.shouldVeto(
candidate: u2.emb, positiveSimilarity: pos2, negativeExemplars: negatives)
// TX arm: condition-transported negatives (the fix).
let txVeto = SpeakerNegativeExemplarPolicy.shouldVeto(
candidate: u2.emb, positiveSimilarity: pos2, negativeExemplars: negatives,
profileAverage: a.average, positiveExemplars: a.exemplars)
let negSim = SpeakerVectorMath.cosineSimilarity(u2.emb, negatives[0])
if didReMatch { reMatch += 1 }
if didVeto { vetoed += 1 }
if didReMatch && didVeto { vetoedAndReMatch += 1 }
if negSim >= SpeakerNegativeExemplarPolicy.vetoFloor { vetoable += 1 }
if u2.quality == u1.quality {
sameCondReplays += 1
if didReMatch { sameCondReMatch += 1 }
if didVeto { sameCondVetoed += 1 }
if didReMatch {
reMatch += 1
if sameCond { reMatchSame += 1 } else { reMatchCross += 1 }
if rawVeto { if sameCond { rawVetoedSame += 1 } else { rawVetoedCross += 1 } }
if txVeto { if sameCond { txVetoedSame += 1 } else { txVetoedCross += 1 } }
}
}
// Collateral: A's own held-out utterances must not be vetoed by B's negative.
// Collateral: A's own held-out utterances must not be vetoed by B's negative — under
// EITHER arm. The transport must not start vetoing the genuine owner.
for own in ownUtterances {
ownerChecks += 1
let posOwn = SpeakerVectorMath.bestSimilarity(candidate: own.emb, average: a.average, exemplars: a.exemplars)
if SpeakerNegativeExemplarPolicy.shouldVeto(candidate: own.emb, positiveSimilarity: posOwn, negativeExemplars: negatives) {
ownerCollateral += 1
if SpeakerNegativeExemplarPolicy.shouldVeto(
candidate: own.emb, positiveSimilarity: posOwn, negativeExemplars: negatives) {
rawOwnerCollateral += 1
}
if SpeakerNegativeExemplarPolicy.shouldVeto(
candidate: own.emb, positiveSimilarity: posOwn, negativeExemplars: negatives,
profileAverage: a.average, positiveExemplars: a.exemplars) {
txOwnerCollateral += 1
}
}
}
}
func rate(_ x: Int, _ y: Int) -> Double { y > 0 ? Double(x) / Double(y) : 0 }
let rawVetoed = rawVetoedCross + rawVetoedSame
let txVetoed = txVetoedCross + txVetoedSame
return [
"confusablePairs": confusablePairs,
"replays": replays,
"reMatchRateLegacy": rate(reMatch, replays),
"vetoedRate": rate(vetoed, replays),
"vetoedAmongReMatch": rate(vetoedAndReMatch, reMatch), // fraction of wrong re-matches the veto removes
"vetoableShare": rate(vetoable, replays), // u2 resembles rejected sample >= 0.80
"reMatchCount": reMatch, "vetoedCount": vetoed, "vetoedAndReMatchCount": vetoedAndReMatch,
"sameCondition": [
"replays": sameCondReplays,
"reMatchRateLegacy": rate(sameCondReMatch, sameCondReplays),
"vetoedRate": rate(sameCondVetoed, sameCondReplays),
"vetoableShare": rate(vetoable, replays), // u2 resembles rejected sample >= 0.80 (raw)
"reMatchCount": reMatch,
"reMatchCrossCount": reMatchCross, "reMatchSameCount": reMatchSame,
// RAW arm (pre-fix): fraction of wrong re-matches the single stored negative removes.
"raw": [
"vetoedAmongReMatch": rate(rawVetoed, reMatch),
"vetoedAmongReMatchCross": rate(rawVetoedCross, reMatchCross),
"vetoedAmongReMatchSame": rate(rawVetoedSame, reMatchSame),
"ownerCollateralRate": rate(rawOwnerCollateral, ownerChecks),
"ownerCollateralCount": rawOwnerCollateral,
],
// TX arm (the fix): same metrics with condition-transported negatives.
"transported": [
"vetoedAmongReMatch": rate(txVetoed, reMatch),
"vetoedAmongReMatchCross": rate(txVetoedCross, reMatchCross),
"vetoedAmongReMatchSame": rate(txVetoedSame, reMatchSame),
"ownerCollateralRate": rate(txOwnerCollateral, ownerChecks),
"ownerCollateralCount": txOwnerCollateral,
"transportScale": SpeakerNegativeExemplarPolicy.crossConditionTransportScale,
],
"ownerCollateralRate": rate(ownerCollateral, ownerChecks),
"ownerChecks": ownerChecks,
]
}
Expand Down
Loading
Loading