@gotcos/glasses-server 6.27.11 → 6.27.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,69 @@
1
1
  ## Unreleased
2
2
 
3
+ ## 6.27.13
4
+ - **`POST /api/meeting/:sessionId/backfill-enrolment`** — train a profile from a voice
5
+ that was named BEFORE enrolment shipped. Those meetings have a correct transcript and
6
+ no profile, and re-running the rename cannot help: the voice is already a real name
7
+ there, so the placeholder guard correctly declines. The live case is Kirstyn Blum —
8
+ 60 and 109 chunks across two meetings, 182 sidecar mentions, absent from a 77-profile
9
+ store.
10
+ - The correction LEDGER already holds what enrolment needs: the original `from`, the
11
+ `to`, and the exact chunk indices written at apply time. This replays those rows
12
+ through the SAME `enrolNamedVoice` — raw-index mapping, refusal when unmappable,
13
+ coherence, the diversity cap and the `correction:<sessionId>` tag all apply
14
+ identically. No second implementation to drift.
15
+ - **Named-source corrections are excluded, by the existing rule rather than a new one.**
16
+ `Ext -> Kirstyn` is training data; `Allison Wheeler -> Kirstyn` is a mis-attribution
17
+ fix, and training on it would put Allison's voice into Kirstyn's profile. Both rows
18
+ are reported; only the placeholder one attempts anything. Mutation-verified: dropping
19
+ the placeholder guard fails that test.
20
+ - **Runs in-process, which is the point.** The voice store is owned by the running
21
+ server and rewritten wholesale, so an external process that enrols directly has its
22
+ work silently clobbered. Not hypothetical — an attempt on 2026-08-13 validated
23
+ cleanly, selected 20 samples, and left the store untouched at its Aug 7 mtime.
24
+ - **Fails closed.** Without `confirm: true` it reports what it would enrol and writes
25
+ nothing. The preview runs every gate — a preview that skipped them would be a guess
26
+ about what the real call does — so `enrolNamedVoice` gained a `dryRun` flag. The
27
+ projection can land HIGHER than reality, because only `enrollEmbedding` can judge
28
+ near-duplicates against the live profile; that is documented on the flag.
29
+
30
+ ## 6.27.12
31
+ - **Naming an unidentified voice creates a real speaker profile — correctly this time.**
32
+ Re-enables what 6.27.10 shipped broken and 6.27.11 disabled.
33
+ - **The index join is fixed.** 6.27.10 fed COMPACTED sidecar positions to a store keyed
34
+ on RAW capture indices; on a live session that enrolled 73 of 103 rows belonging to
35
+ other people, 22 of them the owner. Enrolment now runs through
36
+ `attachRawChunkIndices` and **refuses outright** when the mapping is not established —
37
+ detected by reference identity, which is how that function signals "cannot map" —
38
+ reporting `skipped: 'no_index_mapping'`. There is no fallback to positions anywhere.
39
+ - **`Ext` is a bucket, not a person, so coherence is checked.** Only the dominant
40
+ MUTUALLY coherent cluster is enrolled and `clusterSkipped` reports the rest. Mutual
41
+ rather than medoid-star on purpose: a seed sitting between two voices merges them
42
+ otherwise. The floor is aliased from the exported `MERGE_SIMILARITY_FLOOR` so it
43
+ cannot drift from the identifier's own accept threshold.
44
+ - **Samples are stamped `correction:<sessionId>`.** A bare tag matched none of the three
45
+ prefixes `isSampleFromSession` accepts, so "Not in this meeting" — the app's only undo
46
+ — would have retracted nothing, the samples would have counted as untraceable, and
47
+ they would have landed in the weakest eviction tier with no correction quota.
48
+ - **Bounded.** `greedyDiversitySelect` caps at 20 before enrolling. Unbounded, ~41ms per
49
+ cycle against a 7.9 MB store x 109 chunks blocked the event loop past the helper's 30s
50
+ timeout, showing "Server stopped" for a correction that applied.
51
+ - **Reports honestly.** `enrolment: { enrolled, attempted, created, clusterSkipped,
52
+ skipped }`. A zero is now legible instead of indistinguishable from success.
53
+ `created: false` when appending to an existing profile, so nothing claims a profile
54
+ was made when samples were added. `enrolledEmbeddings` retained for existing clients.
55
+ - **Tests, 28 -> 42, plus 14 on the selection lib.** The chunk-embedding mock is GONE:
56
+ fixtures write real JSONL through the real encoder and the route reads it through the
57
+ real decoder and index filter. That double mock is why 6.27.10 shipped — the join was
58
+ never exercised, and a gapless 3-chunk fixture pinned the bug as the contract.
59
+ - Nine mutations, all landed in-target and confirmed present before each run. M1
60
+ (bypass the mapping) fails four tests including "enrols the RAW-index embeddings,
61
+ never the sidecar positions". M2 initially SURVIVED — the branch was unreached — so
62
+ the test was fixed rather than the code.
63
+ - Known: `skipDedupCheck` stays at its default `false`, so near-duplicate samples are
64
+ rejected and `enrolled` can fall below the 20 selected. Conservative and honestly
65
+ reported; `/api/voice/enroll-ext` passes `true`.
66
+
3
67
  ## 6.27.11
4
68
  - **SAFETY REVERT: the 6.27.10 relabel enrolment is disabled.** It joined two
5
69
  different index spaces. `plan.value.changed` are positions in the COMPACTED sidecar
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.27.11",
3
+ "version": "6.27.13",
4
4
  "description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,263 @@
1
+ // Turning "that unidentified voice was Kirstyn Blum" into a real voice profile.
2
+ //
3
+ // WHY THIS EXISTS. Relabelling was TEXT ONLY: it rewrote `speaker` strings in the
4
+ // meeting sidecar and never touched the voice store. Naming a voice over 109
5
+ // segments labelled that one meeting and taught the system nothing — she never
6
+ // appeared in /api/voice/profiles, the review panel still offered her as `new
7
+ // name` inside the SAME meeting, no later meeting could match her, and there was
8
+ // no profile to accumulate further chunks against. Verified live 2026-08-13:
9
+ // `Kirstyn Blum` appears 70x in one sidecar and 112x in another while
10
+ // voice-profiles.json held 77 profiles and no Kirstyn.
11
+ //
12
+ // ─────────────────────────────────────────────────────────────────────────────
13
+ // WHY THIS IS ITS OWN MODULE AND WHY IT IS THIS PARANOID
14
+ //
15
+ // 6.27.10 shipped this inline in the route and was reverted the same night.
16
+ // It joined TWO DIFFERENT INDEX SPACES:
17
+ //
18
+ // plan.value.changed positions in the COMPACTED sidecar array
19
+ // (meeting-relabel.ts, iterating rows already filtered
20
+ // to those carrying text)
21
+ // chunk-embedding store keyed on the RAW CAPTURE INDEX
22
+ // (transcribe-stream.ts writes `i: chunkIndex`)
23
+ //
24
+ // `getSessionChunks` is `session.chunks.filter(c => c && c.text)` while an
25
+ // embedding is written for every chunk with 2s+ of audio, BEFORE ASR is known.
26
+ // The two diverge at the first text-less chunk and the gap grows from there.
27
+ // 73 of 74 live sessions with embeddings have gaps, so divergence was the NORMAL
28
+ // case, not an edge case.
29
+ //
30
+ // Consequence, measured on `meeting_1786628481833_eagkaz`: naming one voice
31
+ // enrolled 73 of 103 rows belonging to OTHER PEOPLE — 22 chunks of MU (the device
32
+ // owner), 16 Vikas, 10 Vishnu, 8 Niranjan, 7 Anil, 6 Chris, 3 Manoj, 1 Navaz, and
33
+ // only 30 that were actually the named voice. It reported SUCCESS, because rows do
34
+ // come back from the store; they were simply the wrong rows.
35
+ //
36
+ // This writes to a SHARED, LONG-LIVED store that drives speaker attribution across
37
+ // every future meeting. A wrong write is permanent and silent. So every step below
38
+ // fails CLOSED: no mapping means no enrolment, never a fall back to positions.
39
+
40
+ import {
41
+ chunkEmbeddingTtlMs,
42
+ chunkEmbeddingsEnabled,
43
+ readChunkEmbeddings,
44
+ } from './chunk-embedding-store.js'
45
+ import {
46
+ enrollEmbedding,
47
+ isEmbeddingAvailable,
48
+ readVoiceProfiles,
49
+ } from './speaker-embeddings.js'
50
+ import { attachRawChunkIndices, type ReviewChunk } from './meeting-speaker-review.js'
51
+ import {
52
+ MAX_ENROL_PER_CORRECTION,
53
+ dominantCoherentCluster,
54
+ greedyDiversitySelect,
55
+ } from './voice-enrolment-selection.js'
56
+
57
+ /** Labels the diariser invents when it does not know who is speaking. Naming one
58
+ * of these is a FIRST TRAINING RUN for a new person, not a correction. */
59
+ const PLACEHOLDER_LABEL = /^(ext|unknown|unidentified(\s+\d+)?|speaker\s*\d+)$/i
60
+
61
+ export const isPlaceholderLabel = (label: string): boolean => PLACEHOLDER_LABEL.test(label.trim())
62
+
63
+ /**
64
+ * Why nothing was enrolled, when the answer is not "it worked".
65
+ *
66
+ * Reported rather than inferred, because there are four ways this silently
67
+ * enrols zero and they are indistinguishable from the outside:
68
+ * 1. the ~26 MB 3dspeaker model is absent (it is .npmignore'd, and a managed
69
+ * cutover has stranded it before) -> store_unavailable
70
+ * 2. COS_CHUNK_EMBEDDINGS=0 -> disabled
71
+ * 3. the 14-day embedding TTL has passed -> expired
72
+ * 4. the meeting predates the store -> no_embeddings
73
+ *
74
+ * `null` means nothing BLOCKED enrolment. It does not mean samples were written —
75
+ * read `enrolled` for that, and `clusterSkipped` for the coherence verdict.
76
+ */
77
+ export type EnrolmentSkipReason =
78
+ | null
79
+ | 'no_index_mapping'
80
+ | 'no_embeddings'
81
+ | 'disabled'
82
+ | 'expired'
83
+ | 'store_unavailable'
84
+
85
+ export interface EnrolmentReport {
86
+ /** Samples the voice store actually accepted. */
87
+ enrolled: number
88
+ /** Candidate embeddings found for the relabelled chunks, before any gating.
89
+ * Zero here means this correction was never an enrolment (a real name being
90
+ * corrected to another real name), not that enrolment failed. */
91
+ attempted: number
92
+ /** True ONLY when a profile did not exist for this name and now does. An
93
+ * existing name is APPENDED to, and must never be reported as created. */
94
+ created: boolean
95
+ /** Candidates rejected as a different voice. `clusterSkipped === attempted`
96
+ * means the bucket had no dominant voice at all and nothing was written. */
97
+ clusterSkipped: number
98
+ skipped: EnrolmentSkipReason
99
+ }
100
+
101
+ const IDLE: EnrolmentReport = {
102
+ enrolled: 0, attempted: 0, created: false, clusterSkipped: 0, skipped: null,
103
+ }
104
+
105
+ /**
106
+ * Compacted sidecar positions -> RAW capture indices, or null.
107
+ *
108
+ * `attachRawChunkIndices` signals "I cannot map this" by returning the SAME ARRAY
109
+ * it was given — when `chunkEntries` is absent, or when the text-bearing count
110
+ * disagrees with the compacted count. That unchanged case is exactly the
111
+ * poisoning case, so it is detected by REFERENCE IDENTITY and refused. There is
112
+ * deliberately no fall back to positions: a shifted mapping is worse than none,
113
+ * because it looks like it worked.
114
+ *
115
+ * A single unmappable position refuses the WHOLE set, for the same reason a
116
+ * partial mapping is refused: enrolling the subset that happened to map still
117
+ * writes whoever the unmapped ones turned out to be, and there is no way to tell
118
+ * from the result which happened.
119
+ */
120
+ export function resolveRawChunkIndices(
121
+ sidecar: Record<string, unknown>,
122
+ positions: number[],
123
+ ): number[] | null {
124
+ const chunks = sidecar.chunks
125
+ if (!Array.isArray(chunks)) return null
126
+ const withRaw = attachRawChunkIndices(chunks as ReviewChunk[], sidecar.chunkEntries)
127
+ if (withRaw === chunks) return null
128
+
129
+ const raw: number[] = []
130
+ for (const position of positions) {
131
+ const index = withRaw[position]?.chunkIndex
132
+ if (typeof index !== 'number' || !Number.isInteger(index) || index < 0) return null
133
+ raw.push(index)
134
+ }
135
+ return raw
136
+ }
137
+
138
+ /**
139
+ * A missing embedding file is either aged out or never written, and the two are
140
+ * worth telling apart: 'expired' means the correction arrived too late and the
141
+ * loop would have worked, 'no_embeddings' means this meeting could never train
142
+ * anything. The store cannot distinguish them — the file is simply gone — so the
143
+ * meeting's own start time against the live TTL is the evidence.
144
+ */
145
+ function missingReason(sidecar: Record<string, unknown>): EnrolmentSkipReason {
146
+ const startTime = sidecar.startTime
147
+ if (typeof startTime !== 'number' || !Number.isFinite(startTime) || startTime <= 0) return 'no_embeddings'
148
+ return Date.now() - startTime > chunkEmbeddingTtlMs() ? 'expired' : 'no_embeddings'
149
+ }
150
+
151
+ /** Does a profile already exist under this name? Read from the PERSISTED store
152
+ * rather than `isEnrolled`, which asks the sherpa manager and therefore answers
153
+ * "no" for every name on an install with no model — which would report every
154
+ * append as a creation. */
155
+ function profileExists(name: string): boolean {
156
+ try {
157
+ return readVoiceProfiles().profiles.some(p => p.name === name)
158
+ } catch {
159
+ // Unknowable is not "new". Claiming creation is the lie that matters here.
160
+ return true
161
+ }
162
+ }
163
+
164
+ export interface EnrolNamedVoiceInput {
165
+ sessionId: string
166
+ from: string
167
+ to: string
168
+ /** `plan.value.changed` — COMPACTED sidecar positions. Never raw indices. */
169
+ changed: number[]
170
+ /** The parsed sidecar, for `chunks` + `chunkEntries` + `startTime`. */
171
+ sidecar: Record<string, unknown>
172
+ /** Run every gate but write nothing. `enrolled` then counts what WOULD be enrolled;
173
+ * the real call can land LOWER, because `enrollEmbedding`'s dedup gate rejects
174
+ * near-duplicates and only it can judge that against the live profile. A preview
175
+ * that skipped the gates would be a guess about what the real call does. */
176
+ dryRun?: boolean
177
+ }
178
+
179
+ /**
180
+ * Enrol the voice a human just named.
181
+ *
182
+ * SCOPED to placeholder -> real name. Correcting one real name to another is left
183
+ * alone deliberately: moving a voice between existing people is `merge-profiles`,
184
+ * which is explicit and confirmation-gated, and a sweep of this store put two
185
+ * DISTINCT people at 0.85 similarity, so doing it implicitly would poison both.
186
+ *
187
+ * An EXISTING name is appended to without a prompt, and reports `created: false`.
188
+ *
189
+ * Samples are stamped `correction:<sessionId>`, not a bare source string. The
190
+ * prefix is load-bearing in four places: `isSampleFromSession` accepts only
191
+ * `auto:` / `correction:` / `g2-training:`, so a bare tag makes the app's only
192
+ * undo ("Not in this meeting") retract NOTHING; `untraceableSampleCount` counts
193
+ * it as untraceable; `provenanceTier` falls to 'unknown', ranking a human-typed
194
+ * name BELOW Fireflies attendee metadata for eviction; and `isCorrection()` is a
195
+ * `startsWith('correction')` test, so the correction quota never protects it.
196
+ *
197
+ * NEVER THROWS. Enrolment is a bonus on top of a rename that is already durable
198
+ * on disk; a voice store that refuses must not undo what the user asked for.
199
+ */
200
+ export function enrolNamedVoice(input: EnrolNamedVoiceInput): EnrolmentReport {
201
+ const { sessionId, from, to, changed, sidecar } = input
202
+
203
+ // Not an enrolment at all. `attempted: 0` with `skipped: null` is how the
204
+ // caller tells this apart from an enrolment that found no candidates.
205
+ if (!isPlaceholderLabel(from) || isPlaceholderLabel(to) || changed.length === 0) return IDLE
206
+
207
+ if (!chunkEmbeddingsEnabled()) return { ...IDLE, skipped: 'disabled' }
208
+ // No extractor/manager means every enrollEmbedding would return success:false.
209
+ // Naming that beats looping twenty times and reporting a bare zero.
210
+ if (!isEmbeddingAvailable()) return { ...IDLE, skipped: 'store_unavailable' }
211
+
212
+ const rawIndices = resolveRawChunkIndices(sidecar, changed)
213
+ if (!rawIndices) return { ...IDLE, skipped: 'no_index_mapping' }
214
+
215
+ let read: ReturnType<typeof readChunkEmbeddings>
216
+ try {
217
+ read = readChunkEmbeddings(sessionId)
218
+ } catch {
219
+ return { ...IDLE, skipped: 'store_unavailable' }
220
+ }
221
+ if (read.missing) return { ...IDLE, skipped: missingReason(sidecar) }
222
+
223
+ const wanted = new Set(rawIndices)
224
+ const candidates = read.rows.filter(r => wanted.has(r.i) && r.embedding && r.embedding.length > 0)
225
+ if (candidates.length === 0) return { ...IDLE, skipped: 'no_embeddings' }
226
+
227
+ const attempted = candidates.length
228
+ const cluster = dominantCoherentCluster(candidates.map(r => r.embedding))
229
+ const clusterSkipped = attempted - cluster.members.length
230
+ // Every candidate disagreed with every other one. There is no voice here to
231
+ // learn, only a bucket of strangers sharing one placeholder label.
232
+ if (cluster.members.length === 0) {
233
+ return { enrolled: 0, attempted, created: false, clusterSkipped, skipped: null }
234
+ }
235
+
236
+ // Diversity trims the coherent group to a bounded, spread-out sample. Those
237
+ // trimmed are NOT counted in clusterSkipped — they were the right voice, just
238
+ // redundant, and reporting them as rejected would read as a coherence problem.
239
+ const coherent = cluster.members.map(m => candidates[m].embedding)
240
+ const selected = greedyDiversitySelect(coherent, MAX_ENROL_PER_CORRECTION)
241
+
242
+ const existedBefore = profileExists(to)
243
+ const source = `correction:${sessionId}`
244
+ let enrolled = 0
245
+ try {
246
+ if (input.dryRun) {
247
+ // Projection, not a promise — see the dryRun docblock. Every gate above has
248
+ // already run, so this is what the real call will attempt.
249
+ enrolled = selected.length
250
+ } else for (const embedding of selected) {
251
+ if (enrollEmbedding(to, embedding, source).success) enrolled += 1
252
+ }
253
+ } catch {
254
+ return {
255
+ enrolled, attempted, created: !existedBefore && enrolled > 0, clusterSkipped,
256
+ skipped: 'store_unavailable',
257
+ }
258
+ }
259
+
260
+ return {
261
+ enrolled, attempted, created: !existedBefore && enrolled > 0, clusterSkipped, skipped: null,
262
+ }
263
+ }
@@ -0,0 +1,202 @@
1
+ // WHICH embeddings from a meeting may become training data for one person.
2
+ //
3
+ // Two independent filters, both pure over Float32Array[] so they are testable by
4
+ // execution rather than by reading them:
5
+ //
6
+ // dominantCoherentCluster — does this bag of chunks sound like ONE person?
7
+ // greedyDiversitySelect — of the ones that do, which 20 span the most range?
8
+ //
9
+ // THE PROBLEM THE FIRST ONE SOLVES. `Ext` is not a person. `identifySpeaker`
10
+ // returns 'Ext' for every voice that falls below its accept threshold, so a
11
+ // meeting with five unrecognised people produces ONE 'Ext' label covering all
12
+ // five. voice-directory.ts says it outright: "Ext/Unidentified clusters are
13
+ // meeting-local, not people."
14
+ //
15
+ // Measured on session `meeting_1786628481833_eagkaz` (2026-08-13): 115 'Ext'
16
+ // embeddings, 1,770 pairwise cosines, MEDIAN 0.170, and 98% of them below 0.55 —
17
+ // the identifier's own accept threshold. Enrolling that bucket wholesale under a
18
+ // name a human typed writes several strangers into one profile, permanently and
19
+ // silently, and the profile then matches all of them forever.
20
+ //
21
+ // So the rule is: enrol the DOMINANT COHERENT GROUP and report what was left
22
+ // behind, rather than enrolling everything and reporting success.
23
+
24
+ import { MERGE_SIMILARITY_FLOOR, rawCosineSimilarity } from './speaker-embeddings.js'
25
+
26
+ /**
27
+ * Two chunks are the same voice only above this cosine.
28
+ *
29
+ * Aliased from the already-exported `MERGE_SIMILARITY_FLOOR` rather than written
30
+ * as a literal, because that constant IS `SEARCH_THRESHOLD` (speaker-embeddings.ts)
31
+ * — the threshold `identifySpeaker` itself uses to accept a match. Deriving it
32
+ * means the gate cannot drift away from the identifier it is second-guessing; a
33
+ * hardcoded 0.55 here would silently disagree the day that value is tuned.
34
+ */
35
+ export const VOICE_COHERENCE_FLOOR = MERGE_SIMILARITY_FLOOR
36
+
37
+ /** How many samples one correction may add to a profile. */
38
+ export const MAX_ENROL_PER_CORRECTION = 20
39
+
40
+ export interface CoherentCluster {
41
+ /** Indices into the input array, ascending. EMPTY when nothing coheres. */
42
+ members: number[]
43
+ /** Index the cluster formed around, or -1 when there is no cluster. */
44
+ seed: number
45
+ }
46
+
47
+ /**
48
+ * The largest group of embeddings that plausibly share one voice.
49
+ *
50
+ * MUTUALLY COHERENT, not merely star-shaped, and not connected components.
51
+ * The distinction is the whole safety property:
52
+ *
53
+ * components A~B and B~C admits C alongside A even when A and C are nothing
54
+ * alike. Two people merge through anyone who sounds like both.
55
+ * star every member within `floor` of one seed. Better, but a seed
56
+ * sitting between two voices still admits both — measured: with
57
+ * a=[1,0], b=[1,1], c=[0,1], b agrees with a AND c while a and c
58
+ * are orthogonal, so the star around b is the whole crowd.
59
+ * mutual every PAIR within `floor`. Cannot admit two voices at once.
60
+ *
61
+ * Built as a seed star and then refined: repeatedly drop the member with the
62
+ * most internal disagreements (ties to the one with the lowest mean similarity,
63
+ * then the highest index) until every pair clears the floor. Greedy rather than
64
+ * a true maximum clique, which is NP-hard — greedy errs toward a SMALLER group,
65
+ * and under-enrolling is the safe direction when the alternative is writing a
66
+ * stranger into someone's profile permanently.
67
+ *
68
+ * A LONE candidate is returned as its own cluster: one chunk cannot contradict
69
+ * itself, and refusing it would mean a short correction never trains anything.
70
+ * But a bag of MANY mutually dissimilar embeddings returns EMPTY — the seed with
71
+ * the most agreement still has none, so picking one of them would be choosing an
72
+ * arbitrary stranger out of a crowd, which is precisely the failure this exists
73
+ * to stop. The caller reports that as `clusterSkipped === attempted`.
74
+ *
75
+ * Ties on seed choice break by mean similarity to the agreeing set, then by
76
+ * lowest index, so the result is deterministic for a given input.
77
+ */
78
+ export function dominantCoherentCluster(
79
+ embeddings: Float32Array[],
80
+ floor: number = VOICE_COHERENCE_FLOOR,
81
+ ): CoherentCluster {
82
+ const n = embeddings.length
83
+ if (n === 0) return { members: [], seed: -1 }
84
+ if (n === 1) return { members: [0], seed: 0 }
85
+
86
+ // Full pairwise matrix once: the refinement below reads it repeatedly, and
87
+ // recomputing a 192-dim cosine inside that loop is the difference between
88
+ // microseconds and seconds on a long meeting.
89
+ const sim: number[][] = Array.from({ length: n }, () => new Array<number>(n).fill(0))
90
+ for (let i = 0; i < n; i++) {
91
+ sim[i][i] = 1
92
+ for (let j = i + 1; j < n; j++) {
93
+ const s = rawCosineSimilarity(embeddings[i], embeddings[j])
94
+ sim[i][j] = s
95
+ sim[j][i] = s
96
+ }
97
+ }
98
+
99
+ let bestSeed = -1
100
+ let bestAgree: number[] = []
101
+ let bestMean = -Infinity
102
+ for (let i = 0; i < n; i++) {
103
+ const agree: number[] = []
104
+ let sum = 0
105
+ for (let j = 0; j < n; j++) {
106
+ if (i !== j && sim[i][j] >= floor) { agree.push(j); sum += sim[i][j] }
107
+ }
108
+ const meanSim = agree.length > 0 ? sum / agree.length : -Infinity
109
+ if (agree.length > bestAgree.length || (agree.length === bestAgree.length && meanSim > bestMean)) {
110
+ bestSeed = i
111
+ bestAgree = agree
112
+ bestMean = meanSim
113
+ }
114
+ }
115
+
116
+ // Nothing agreed with anything. There is no dominant voice here, only a crowd.
117
+ if (bestSeed === -1 || bestAgree.length === 0) return { members: [], seed: -1 }
118
+
119
+ let members = [bestSeed, ...bestAgree].sort((a, b) => a - b)
120
+ for (;;) {
121
+ let worst = -1, worstConflicts = 0, worstMean = Infinity
122
+ for (const m of members) {
123
+ let conflicts = 0, sum = 0
124
+ for (const other of members) {
125
+ if (m === other) continue
126
+ if (sim[m][other] < floor) conflicts++
127
+ sum += sim[m][other]
128
+ }
129
+ const meanSim = members.length > 1 ? sum / (members.length - 1) : 1
130
+ if (conflicts > worstConflicts || (conflicts === worstConflicts && conflicts > 0 && meanSim <= worstMean)) {
131
+ worst = m; worstConflicts = conflicts; worstMean = meanSim
132
+ }
133
+ }
134
+ if (worstConflicts === 0) break
135
+ members = members.filter(m => m !== worst)
136
+ // The seed itself can be the outlier — a voice sitting between two others is
137
+ // exactly the member that has to go for the rest to be mutually coherent.
138
+ if (members.length <= 1) break
139
+ }
140
+
141
+ // A refinement that ate everything but one member left no evidence of a shared
142
+ // voice, only the last survivor of a crowd. Same verdict as no cluster at all.
143
+ if (members.length < 2) return { members: [], seed: -1 }
144
+
145
+ // The seed may have been pruned; report the member that best represents what
146
+ // survived, so `seed` always names a row that is actually in the cluster.
147
+ let seed = members[0], seedMean = -Infinity
148
+ for (const m of members) {
149
+ let sum = 0
150
+ for (const other of members) if (m !== other) sum += sim[m][other]
151
+ const meanSim = sum / (members.length - 1)
152
+ if (meanSim > seedMean) { seed = m; seedMean = meanSim }
153
+ }
154
+ return { members, seed }
155
+ }
156
+
157
+ /**
158
+ * Pick the N most acoustically diverse embeddings — greedy max-min-distance.
159
+ *
160
+ * Moved here VERBATIM from routes/voice.ts, where it was a private function used
161
+ * by POST /api/voice/enroll-ext. It was copied rather than shared once already
162
+ * (voice-profile-store.ts `selectDiverseIndices` is the same algorithm over
163
+ * number[][], with a different return ORDER); this module is now the home for the
164
+ * Float32Array form so the relabel path and the ext-audio path cannot drift.
165
+ *
166
+ * Bounding the count is not cosmetic. Every `enrollEmbedding` does
167
+ * loadProfileStore -> persistProfile -> invalidateProfileCache, so the next
168
+ * iteration re-reads and re-parses the whole store — ~41 ms per cycle against a
169
+ * live 7.9 MB / 77-profile file, before the fsync'd write. At 109 chunks that
170
+ * blocks the event loop past COS Control's 30 s helper timeout, and the user is
171
+ * told "Server stopped" for a correction that actually applied.
172
+ */
173
+ export function greedyDiversitySelect(embeddings: Float32Array[], maxN: number): Float32Array[] {
174
+ if (embeddings.length <= maxN) return embeddings
175
+
176
+ // Find the most dissimilar pair as seeds
177
+ let maxDist = -1, seedA = 0, seedB = 1
178
+ for (let i = 0; i < embeddings.length; i++) {
179
+ for (let j = i + 1; j < embeddings.length; j++) {
180
+ const dist = 1 - rawCosineSimilarity(embeddings[i], embeddings[j])
181
+ if (dist > maxDist) { maxDist = dist; seedA = i; seedB = j }
182
+ }
183
+ }
184
+
185
+ const selected = new Set([seedA, seedB])
186
+ while (selected.size < maxN) {
187
+ let bestIdx = -1, bestMinDist = -1
188
+ for (let i = 0; i < embeddings.length; i++) {
189
+ if (selected.has(i)) continue
190
+ let minDist = Infinity
191
+ for (const s of selected) {
192
+ const dist = 1 - rawCosineSimilarity(embeddings[i], embeddings[s])
193
+ if (dist < minDist) minDist = dist
194
+ }
195
+ if (minDist > bestMinDist) { bestMinDist = minDist; bestIdx = i }
196
+ }
197
+ if (bestIdx === -1) break
198
+ selected.add(bestIdx)
199
+ }
200
+
201
+ return [...selected].map(i => embeddings[i])
202
+ }
@@ -23,8 +23,8 @@ import {
23
23
  meetingAudioChunkPath,
24
24
  meetingAudioRetentionDays,
25
25
  } from '../lib/meeting-audio-archive.js'
26
- import { enrollEmbedding, readVoiceProfiles, retractEmbeddingsBySource } from '../lib/speaker-embeddings.js'
27
- import { chunkEmbeddingsForIndices } from '../lib/chunk-embedding-store.js'
26
+ import { readVoiceProfiles, retractEmbeddingsBySource } from '../lib/speaker-embeddings.js'
27
+ import { enrolNamedVoice } from '../lib/meeting-relabel-enrolment.js'
28
28
 
29
29
  /**
30
30
  * The label a de-attributed voice takes, numbered within its meeting.
@@ -975,45 +975,95 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
975
975
  })
976
976
  })
977
977
 
978
- /** Labels the diariser invents when it does not know who is speaking. Naming one
979
- * of these is a FIRST TRAINING RUN for a new person, not a correction. */
980
- const PLACEHOLDER_LABEL = /^(ext|unknown|unidentified(\s+\d+)?|speaker\s*\d+)$/i
981
- const isPlaceholderLabel = (label: string): boolean => PLACEHOLDER_LABEL.test(label.trim())
982
-
983
978
  /**
984
- * Turn a named placeholder into a real voice profile.
979
+ * Replay a recorded correction's enrolment the retroactive path.
980
+ *
981
+ * WHY. Enrolment fires inside `POST /relabel`, so a voice named BEFORE that shipped
982
+ * has a correct transcript and no profile. Kirstyn Blum is the live case: named
983
+ * across two meetings (60 and 109 chunks), 182 mentions in the sidecars, absent from
984
+ * a 77-profile store. Re-running the rename cannot help — she is already a real
985
+ * name there, so the placeholder guard correctly declines.
986
+ *
987
+ * The correction LEDGER already holds exactly what enrolment needs: the original
988
+ * `from`, the `to`, and the precise chunk indices, written at apply time.
985
989
  *
986
- * WHY THIS EXISTS. Relabelling was TEXT ONLY: it rewrote `speaker` strings in the
987
- * meeting sidecar and never touched the voice store. So naming an unidentified
988
- * voice "Kirstyn Blum" labelled that one meeting and taught the system nothing
989
- * she never appeared in `/api/voice/profiles`, the review panel still offered her
990
- * as `new name` inside the SAME meeting, no later meeting could match her, and
991
- * there was no profile to accumulate further chunks against. The server already
992
- * had `/api/voice/enroll-ext` for exactly this and the naming flow never called it.
990
+ * This runs IN-PROCESS on purpose. The voice store is owned by the running server,
991
+ * which holds it in memory and rewrites it wholesale; an external process that
992
+ * enrols directly has its work silently clobbered on the next persist. That is not
993
+ * hypothetical an attempt on 2026-08-13 validated cleanly, selected 20 samples,
994
+ * and left the store untouched at its Aug 7 mtime.
993
995
  *
994
- * Enrolment is ADDITIVE and scoped: it runs only when a placeholder becomes a real
995
- * name. Correcting one real name to another is left alone deliberately — moving a
996
- * voice between existing people is `merge-profiles`, which is explicit and
997
- * confirmation-gated, and doing it implicitly here would poison profiles.
996
+ * SAME GATES, no exceptions. It calls `enrolNamedVoice`, so raw-index mapping,
997
+ * refusal when unmappable, voice coherence, the diversity cap and the
998
+ * `correction:<sessionId>` tag all apply identically. Rows whose `from` is a real
999
+ * person are skipped by that function's own placeholder rule, which is what keeps a
1000
+ * mis-attribution correction (Allison Wheeler -> Kirstyn) out of the training set.
998
1001
  *
999
- * `enrollEmbedding` owns the diversity gate and the FIFO cap, so feeding it the
1000
- * relabelled chunks cannot bloat a profile.
1002
+ * FAILS CLOSED. Without `confirm: true` it reports what it would enrol and writes
1003
+ * nothing.
1001
1004
  */
1002
- const enrolNamedVoice = (sessionId: string, from: string, to: string, changed: number[]): number => {
1003
- if (!isPlaceholderLabel(from) || isPlaceholderLabel(to) || changed.length === 0) return 0
1004
- let enrolled = 0
1005
+ router.post('/meeting/:sessionId/backfill-enrolment', (req, res) => {
1006
+ res.set('Cache-Control', 'private, no-store')
1007
+ const sessionId = String(req.params.sessionId ?? '')
1008
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
1009
+ res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
1010
+ return
1011
+ }
1012
+ const speaker = typeof req.body?.speaker === 'string' ? req.body.speaker.trim() : ''
1013
+ if (!speaker) {
1014
+ res.status(400).json({ error: 'speaker is required', reason: 'invalid_label' })
1015
+ return
1016
+ }
1017
+
1018
+ const operations = cosOperationsMeetingsConfigured()
1019
+ ? findCosOperationsMeetingBySessionId(sessionId)
1020
+ : null
1021
+ const saved = operations ? null : store.findBySessionId(sessionId)
1022
+ if (!operations && !saved) {
1023
+ res.status(404).json({ error: 'No saved meeting for this session', reason: 'meeting_not_found' })
1024
+ return
1025
+ }
1026
+ const sidecarPath = operations?.sidecarPath ?? saved!.sidecarPath
1027
+ let parsedSidecar: Record<string, unknown> | null = null
1005
1028
  try {
1006
- for (const row of chunkEmbeddingsForIndices(sessionId, changed)) {
1007
- if (!row.embedding) continue
1008
- if (enrollEmbedding(to, row.embedding, 'meeting-relabel').success) enrolled += 1
1009
- }
1029
+ const doc = JSON.parse(readFileSync(sidecarPath, 'utf-8')) as unknown
1030
+ if (doc && typeof doc === 'object' && !Array.isArray(doc)) parsedSidecar = doc as Record<string, unknown>
1010
1031
  } catch {
1011
- // Enrolment is a bonus on top of the relabel. A voice store that refuses must
1012
- // not fail the rename the user actually asked for.
1013
- return enrolled
1032
+ res.status(422).json({ error: 'Chunk sidecar is missing or unreadable', reason: 'sidecar_unreadable' })
1033
+ return
1034
+ }
1035
+
1036
+ // Only rows that ACTUALLY landed, and only those that named this speaker.
1037
+ const rows = appliedCorrections(sessionId).filter(r => r.to === speaker && r.chunks.length > 0)
1038
+ if (rows.length === 0) {
1039
+ res.status(404).json({ error: `No applied correction named "${speaker}" in this meeting`, reason: 'no_correction' })
1040
+ return
1014
1041
  }
1015
- return enrolled
1016
- }
1042
+
1043
+ const confirm = req.body?.confirm === true
1044
+ const reports = rows.map(row => ({
1045
+ correctionId: row.id,
1046
+ from: row.from,
1047
+ chunks: row.chunks.length,
1048
+ // Dry run still evaluates every gate — a preview that skips them would be a
1049
+ // guess about what the real call is going to do.
1050
+ report: enrolNamedVoice({
1051
+ sessionId, from: row.from, to: speaker, changed: row.chunks, sidecar: parsedSidecar!, dryRun: !confirm,
1052
+ }),
1053
+ }))
1054
+
1055
+ res.json({
1056
+ ok: true,
1057
+ speaker,
1058
+ confirmed: confirm,
1059
+ corrections: reports,
1060
+ totals: {
1061
+ eligible: reports.filter(r => r.report.attempted > 0).length,
1062
+ skippedNamedSource: reports.filter(r => r.report.attempted === 0 && !r.report.skipped).length,
1063
+ enrolled: reports.reduce((n, r) => n + r.report.enrolled, 0),
1064
+ },
1065
+ })
1066
+ })
1017
1067
 
1018
1068
  router.post('/meeting/:sessionId/relabel', (req, res) => {
1019
1069
  res.set('Cache-Control', 'private, no-store')
@@ -1192,28 +1242,34 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1192
1242
  })
1193
1243
 
1194
1244
  // Enrol AFTER the sidecar and ledger are durable: the rename is the thing the
1195
- // user asked for, and a voice store that refuses must not undo it. Reported so
1196
- // the panel can say a profile was created rather than leaving the user to
1197
- // discover, in another meeting, that it was not.
1198
- // DISABLED in 6.27.11. 6.27.10 shipped this joining the WRONG INDEX SPACE:
1199
- // `plan.value.changed` are positions in the COMPACTED sidecar array
1200
- // (meeting-relabel.ts:119, over rows already filtered to those with text) while
1201
- // the embedding store is keyed on the RAW capture index
1202
- // (transcribe-stream.ts:1868). They diverge at the first text-less chunk.
1245
+ // user asked for, and a voice store that refuses must not undo it.
1203
1246
  //
1204
- // Measured on a live session: naming one voice enrolled 73 of 103 rows belonging
1205
- // to OTHER people, including 22 chunks of the owner. It reported success because
1206
- // rows do come backjust the wrong ones. 73 of 74 live sessions have gaps, so
1207
- // this was the normal case.
1247
+ // `plan.value.changed` are COMPACTED SIDECAR POSITIONS. They are handed to
1248
+ // enrolNamedVoice together with the PARSED SIDECAR precisely so it can convert
1249
+ // them to raw capture indices via attachRawChunkIndices — the join 6.27.10 got
1250
+ // wrong, enrolling 73 of 103 rows belonging to other people including the
1251
+ // device owner. Never pass these positions to anything keyed on raw indices.
1208
1252
  //
1209
- // `attachRawChunkIndices` is the conversion for this and is imported at line 126,
1210
- // used correctly by the review path at 755 and 856. Re-enable only WITH that
1211
- // mapping, a refusal when the mapping is unavailable, a coherence gate (an `Ext`
1212
- // bucket is many voices — 98% of its pairwise cosines fall below the identifier's
1213
- // own 0.55 accept threshold), and a `correction:<sessionId>` source tag so the
1214
- // samples are human-tier, quota-protected and retractable.
1215
- const enrolled = 0
1216
- res.json({ ok: true, correctionId: id, enrolledEmbeddings: enrolled, ...preview })
1253
+ // `sidecarRaw` parses by construction: relabelSidecarJson already parsed it
1254
+ // above and returned ok. The catch is for a caller that reorders those steps.
1255
+ let parsedSidecar: Record<string, unknown> = {}
1256
+ try {
1257
+ const doc = JSON.parse(sidecarRaw) as unknown
1258
+ if (doc && typeof doc === 'object' && !Array.isArray(doc)) parsedSidecar = doc as Record<string, unknown>
1259
+ } catch { /* enrolment refuses on an unusable sidecar; the rename already landed */ }
1260
+
1261
+ const enrolment = enrolNamedVoice({ sessionId, from, to, changed: plan.value.changed, sidecar: parsedSidecar })
1262
+ res.json({
1263
+ ok: true,
1264
+ correctionId: id,
1265
+ // Retained for COS Control builds that read the 6.27.10 field name. The
1266
+ // `enrolment` block is the honest report: a bare count cannot say whether a
1267
+ // profile was created, whether chunks were rejected as a different voice, or
1268
+ // whether the whole thing was skipped for a nameable reason.
1269
+ enrolledEmbeddings: enrolment.enrolled,
1270
+ enrolment,
1271
+ ...preview,
1272
+ })
1217
1273
  })
1218
1274
 
1219
1275
 
@@ -4,7 +4,7 @@ import { Router } from 'express'
4
4
  import { errMsg } from '../lib/utils.js'
5
5
  import { readdirSync, readFileSync, unlinkSync, existsSync, rmdirSync, rmSync } from 'node:fs'
6
6
  import { resolve } from 'node:path'
7
- import { enrollSpeaker, isEnrolled, getAllSpeakerNames, identifySpeaker, extractEmbedding, enrollEmbedding, rawCosineSimilarity, getEmbeddingCount, removeSpeakerProfile, readVoiceProfiles, mergeSpeakerProfiles } from '../lib/speaker-embeddings.js'
7
+ import { enrollSpeaker, isEnrolled, getAllSpeakerNames, identifySpeaker, extractEmbedding, enrollEmbedding, getEmbeddingCount, removeSpeakerProfile, readVoiceProfiles, mergeSpeakerProfiles } from '../lib/speaker-embeddings.js'
8
8
  import { statSync } from 'node:fs'
9
9
  import { trainFromFireflies, getTrainingStatus } from '../lib/speaker-trainer.js'
10
10
  import { getOwnerSpeakerLabel } from '../lib/profile.js'
@@ -13,6 +13,7 @@ import { purgeSpeakerCalibrationRows, relabelSpeakerCalibrationRows } from '../l
13
13
  import { trainingSourceFor } from '../lib/training-audio-provenance.js'
14
14
  import { sendAudioFile } from '../lib/send-audio.js'
15
15
  import { getVoiceDirectorySnapshot, invalidateVoiceDirectory } from '../lib/voice-directory.js'
16
+ import { greedyDiversitySelect } from '../lib/voice-enrolment-selection.js'
16
17
 
17
18
  // These MUST match the writer in transcribe-stream.ts, which saves under
18
19
  // dataPath(). They previously resolved relative to __dirname — i.e. inside the
@@ -736,34 +737,7 @@ voiceRouter.post('/voice/delete-person', (req, res) => {
736
737
  }
737
738
  })
738
739
 
739
- /** Greedy diversity selection pick N most acoustically diverse embeddings */
740
- function greedyDiversitySelect(embeddings: Float32Array[], maxN: number): Float32Array[] {
741
- if (embeddings.length <= maxN) return embeddings
742
-
743
- // Find the most dissimilar pair as seeds
744
- let maxDist = -1, seedA = 0, seedB = 1
745
- for (let i = 0; i < embeddings.length; i++) {
746
- for (let j = i + 1; j < embeddings.length; j++) {
747
- const dist = 1 - rawCosineSimilarity(embeddings[i], embeddings[j])
748
- if (dist > maxDist) { maxDist = dist; seedA = i; seedB = j }
749
- }
750
- }
751
-
752
- const selected = new Set([seedA, seedB])
753
- while (selected.size < maxN) {
754
- let bestIdx = -1, bestMinDist = -1
755
- for (let i = 0; i < embeddings.length; i++) {
756
- if (selected.has(i)) continue
757
- let minDist = Infinity
758
- for (const s of selected) {
759
- const dist = 1 - rawCosineSimilarity(embeddings[i], embeddings[s])
760
- if (dist < minDist) minDist = dist
761
- }
762
- if (minDist > bestMinDist) { bestMinDist = minDist; bestIdx = i }
763
- }
764
- if (bestIdx === -1) break
765
- selected.add(bestIdx)
766
- }
767
-
768
- return [...selected].map(i => embeddings[i])
769
- }
740
+ // `greedyDiversitySelect` moved VERBATIM to lib/voice-enrolment-selection.ts and
741
+ // is imported at the top of this file. The meeting-relabel enrolment path needs
742
+ // the same bound and the same selection, and a second copy of a greedy
743
+ // max-min-distance search is how two callers of "pick the diverse ones" drift.