Skip to content

feat(speakers): reversible speaker merges (provenance + un-merge) - #1330

Merged
r3dbars merged 4 commits into
mainfrom
feat/reversible-speaker-merges
Jul 1, 2026
Merged

r3dbars merged 4 commits into
mainfrom
feat/reversible-speaker-merges

Conversation

@r3dbars

@r3dbars r3dbars commented Jun 25, 2026

Copy link
Copy Markdown
Owner

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; mergeProfilesByName fuses 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 SpeakerDatabase so the SpeakerStore protocol — 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 in addOrUpdateSpeakerImpl. 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.
  • Un-merge API — unmerge(mergeId:) / unmergeMostRecent(forTargetId:), LIFO-safe via rowid so restoring an older snapshot can't silently drop a newer fuse layered on the same keeper. Plus reassignContribution(id:toProfileId:) (per-utterance reassignment, re-derives profile means from stored contribution embeddings).
  • UI — "Undo Last Merge…" row action in SpeakerPeopleSettingsSection.swift and honest merge copy (replaces "can't be undone").
  • Idempotent CREATE TABLE IF NOT EXISTS migration.

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 retroactivelyMergeSpeaker already 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

r3dbars and others added 4 commits June 25, 2026 06:09
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
@r3dbars
r3dbars marked this pull request as ready for review July 1, 2026 20:02
@r3dbars
r3dbars merged commit 424f702 into main Jul 1, 2026
3 checks passed
@r3dbars
r3dbars deleted the feat/reversible-speaker-merges branch August 11, 2026 21:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant