Repository navigation
feat(speakers): reversible speaker merges (provenance + un-merge) - #1330
Merged
Merged
Conversation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A wrong speaker merge used to be permanently lossy: mergeProfilesImpl L2-blends the source embedding into the target then DELETEs the source row, so original embeddings were unrecoverable and there was no record of which clips built a profile or what a merge fused. Auto-merge fires after every recording, so a wrong fuse silently corrupts identity and compounds. This adds the safety net under that flow, kept entirely on the concrete SpeakerDatabase so the SpeakerStore protocol (the matching/merge surface the speaker-accuracy + ERes2Net threads edit) stays untouched: - speaker_provenance: one row per contribution (recording mean embedding) that built a profile, plus a fuse marker per merge. Recorded in addOrUpdateSpeakerImpl; idempotent CREATE TABLE migration. - speaker_merge_events: full pre-merge snapshots of BOTH source and target for every merge (explicit / duplicate / by_name), written inside the existing merge transaction. Since the blend is non-invertible, un-merge reconstructs two distinct profiles from the retained snapshots rather than trying to arithmetically undo it. - Un-merge API (unmerge / unmergeMostRecent), LIFO-safe via rowid so restoring an older snapshot can't silently drop a newer fuse; plus a per-contribution reassignment path that re-derives profile means. - SpeakerPeopleSettingsSection: "Undo Last Merge" row action + honest copy (replaces "can't be undone"). Tests: SpeakerProvenanceTests covers un-merge restoring two distinct profiles (exact pre-merge embeddings), auto-duplicate-merge reversal, LIFO refusal, fuse-marker lifecycle, contribution audit, reassignment. Verified: build.sh --no-open, run-tests.sh (10152), swift test (503), run-integration-smoke.sh, all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…lookup Addresses two P2 findings from independent review: - Un-merge restored the keeper from its stale pre-merge snapshot, silently discarding any recordings the keeper gained after the merge. Now un-merge re-derives both profiles from their (disjoint) contribution embeddings after restoring snapshots + moving provenance back, so post-merge learning survives. Falls back to the exact snapshot for legacy profiles with no stored contribution rows. - The Settings "Undo Last Merge" map was built from recentUndoableMerges() at its default 25-row cap, so a speaker whose latest merge fell outside the newest 25 events lost its undo action. Replaced with a targeted, indexed per-target query (undoableMerge(forTargetId:)) run per visible speaker — uncapped. unmergeMostRecent now uses the same targeted lookup. New test: testUnmergePreservesPostMergeTargetLearning. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…O build
CI's clean release whole-module build failed to resolve the shared
l2Normalize helper from rederiveProfileFromContributionsImpl ("cannot find
'l2Normalize' in scope", cascading into a Data(buffer:) SourceType inference
error) — a release-only type-inference derail that the local debug/cached
builds didn't surface. Inline the L2 normalization and use an explicit
Data(bytes:count:) so the method carries no cross-file inference dependency.
No behavior change; SpeakerProvenanceTests still green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
r3dbars
pushed a commit
that referenced
this pull request
Jul 1, 2026
Adds a sequencing section: land reversible speaker merges first, then a rebased ERes2Net voiceprint, then the FluidAudio bump — they collide in DiarizationService. Notes the 0.15.x diarizer backends as a re-check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VhxUpFUKLD3BayqmgJBC5r
9 of 15 tasks
r3dbars
marked this pull request as ready for review
July 1, 2026 20:02
This was referenced Jul 1, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
A wrong speaker merge is permanently lossy today.
mergeProfilesImpl(SpeakerProfileMerger.swift) L2-blends the source embedding into the target then deletes the source row — original embeddings are unrecoverable and there's no per-utterance provenance. Auto-merge fires after every recording (mergeDuplicates, threshold 0.6;mergeProfilesByNamefuses any two profiles sharing a display name), so a wrong fuse silently corrupts speaker identity and compounds over time. The UI even told users it "can't be undone."This is the safety net that should land before the more-aggressive merge work (write-time contamination gate / link-merge floor decouple) and ERes2Net (#1175) go GA.
What
Scoped to provenance + un-merge, kept entirely on the concrete
SpeakerDatabaseso theSpeakerStoreprotocol — the matching/merge surface the in-flight threads edit — is untouched (lowest merge-conflict footprint).speaker_provenance— one row per contribution (a recording's mean embedding) that built a profile, plus a fuse marker per merge. Recorded inaddOrUpdateSpeakerImpl. Answers "which clips built this profile / what fused in."speaker_merge_events— full pre-merge snapshots of both source and target for every merge (explicit/duplicate/by_name), written inside the existing merge transaction. The blend is non-invertible, so un-merge reconstructs two distinct profiles from the retained snapshots rather than trying to arithmetically undo the blend.unmerge(mergeId:)/unmergeMostRecent(forTargetId:), LIFO-safe viarowidso restoring an older snapshot can't silently drop a newer fuse layered on the same keeper. PlusreassignContribution(id:toProfileId:)(per-utterance reassignment, re-derives profile means from stored contribution embeddings).CREATE TABLE IF NOT EXISTSmigration.Known limitation
Un-merge restores the two voice profiles (so future recordings match the right person again). It does not rewrite past transcript text that the merge's
retroactivelyMergeSpeakeralready renamed — the copy reflects this.Tests
SpeakerProvenanceTests: un-merge restores two distinct profiles with exact pre-merge embeddings; auto-duplicate-merge reversal; LIFO refusal of an older merge under a newer one; fuse-marker lifecycle; per-profile contribution audit; reassignment.Verification
bash build-deps.sh --force+bash build.sh --no-open✅bash run-tests.sh→ 10152 passed ✅swift test→ 503 passed ✅bash run-integration-smoke.sh✅Sequencing
Land this before the write-time contamination gate / link-merge floor decouple and ERes2Net (#1175) merge-surface changes. Diff is deliberately additive and protocol-free; rebase-friendly.
🤖 Generated with Claude Code