@gotcos/glasses-server 6.21.14 → 6.21.17

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,3 +1,94 @@
1
+ ## 6.21.17
2
+
3
+ - Voice profiles now give up their WEAKEST sample, not their oldest. Eviction is
4
+ by provenance tier — automatic, then unknown, then attendee-metadata, then
5
+ identifier-labelled — and human-supplied samples (manual, ext-retroactive,
6
+ g2-enrollment, correction) are protected.
7
+ - Measured on the live store before changing anything, not assumed:
8
+ * 61 of 77 profiles were ALREADY at the 20-sample cap, so every correction
9
+ cost a sample.
10
+ * The owner profile driving owner detection read: fireflies 10, g2-training 9,
11
+ unknown 1 — ZERO human-verified samples.
12
+ * FOUR profiles at cap would have lost a human sample to plain FIFO while
13
+ weaker samples sat untouched, including one whose only deliberate enrollment
14
+ was its oldest sample with 17 weaker samples available.
15
+ - Cap raised 20 -> 40. Search latency measured at 1 us for 20, 40 AND 80 samples
16
+ per speaker across 77 speakers, so the old cap defended nothing measurable.
17
+ Twenty extra slots each is about 1.2 MB.
18
+ - Corrections are capped at HALF a profile. They come from the acoustically hard
19
+ tail — the segments the identifier got wrong — so a profile of nothing but
20
+ corrections has a centroid displaced from how the speaker actually sounds. At
21
+ quota a new correction replaces the OLDEST CORRECTION, never a typical-voice
22
+ sample.
23
+ - Every eviction logs which sample went and why; none disappear silently.
24
+ - `/api/health` gains `voice_provenance`: tier totals, profiles at cap, and
25
+ `noHumanSample` — the profiles trained entirely on labels the system chose for
26
+ itself. That number was previously invisible.
27
+ - An unrecognised or missing provenance string classifies as `unknown`, the
28
+ weakest tier, never as trusted. A sample whose provenance was lost must not
29
+ inherit the protection given to one a human supplied.
30
+
31
+ ## 6.21.16
32
+
33
+ - `POST /api/meeting/:sessionId/relabel` — correct who a voice was in ONE
34
+ meeting. Body `{ from, to, chunks?, confirm | dryRun, force? }`.
35
+ - Per-meeting by design, not a global merge. Miles: "changing it doesn't mean
36
+ that all previous chunks should also be moved. It should be meeting by
37
+ meeting, with the goal of hardening or refining the voice profiles." The
38
+ identifier mishearing a voice in one room is not evidence that every past
39
+ attribution was wrong.
40
+ - Fails closed like the other destructive endpoints: no `confirm` returns 400
41
+ `confirmation required` with a full preview of what would change.
42
+ - THE LEDGER IS WRITTEN FIRST. An intent row lands before any file is touched,
43
+ and a ledger that cannot be written aborts the correction with the files
44
+ untouched — an unrecorded mutation is the failure this exists to prevent. A
45
+ process dying mid-rewrite therefore leaves a visible pending correction, and a
46
+ later correction on the same meeting refuses with 409 until `force`.
47
+ - A PARTIAL relabel (explicit `chunks`) never touches the meeting markdown.
48
+ Measured on a real meeting: 135 sidecar chunks collapse to 46 speaker runs
49
+ while the markdown carries 70 turns, and they disagree on who spoke — the
50
+ transcript comes from a different segmentation pass, so there is no
51
+ chunk-index-to-turn mapping. Rewriting by label there would relabel turns the
52
+ human never selected. The response says why rather than staying silent.
53
+ - Narrative prose is NEVER rewritten — only reported as `proseStale` with the
54
+ forms found. Verified on a real scribe: 6 of 12 speakers are referred to by
55
+ bare first name in the summary, and with two Kyles, two Jacobuses and two
56
+ Chrises in this org a first-name substitution would rewrite sentences about
57
+ someone else. The confirmation message says this before a human commits.
58
+ - `/api/health` reports `speaker_corrections` (sessions, applied, pending,
59
+ failed). `pending` is the one to watch.
60
+
61
+ ## 6.21.15
62
+
63
+ - Persist the per-chunk voiceprint embedding, so a speaker correction can become
64
+ training instead of only a transcript edit. `identifySpeaker` computed a
65
+ 192-dim embedding for every chunk, used it to pick a name, and discarded it —
66
+ nothing downstream kept it, so a human saying "those six segments were
67
+ actually me" had no acoustic evidence left to learn from. Embeddings now land
68
+ in `chunk-embeddings/<sessionId>.jsonl`, keyed by chunk index, which is the
69
+ join key to the existing chunk sidecar.
70
+ - This only works FORWARD. Meetings recorded before this ships can be relabelled
71
+ but can never harden a profile, because their embeddings are already gone.
72
+ - `Ext` is deliberately included. An unidentified voice is the one case with no
73
+ other route to being trained at all.
74
+ - RETENTION IS 14 DAYS, not the 8 hours used for audio snippets
75
+ (`COS_CHUNK_EMBEDDING_TTL_DAYS` to change it). Two reasons, both worth Miles
76
+ overriding if he disagrees: 8 hours cannot survive a weekend, so a Friday
77
+ meeting could never be corrected on Monday; and an embedding is not audio — it
78
+ is a non-invertible timbre vector that cannot be played back, so the privacy
79
+ argument behind a short audio window does not carry across unchanged.
80
+ - Master switch `COS_CHUNK_EMBEDDINGS=0`. Default ON, because with it off a
81
+ correction can never train anything. The write is append-only JSONL, never
82
+ throws, and is skipped silently on failure: losing an embedding must never
83
+ cost a chunk of transcript.
84
+ - Reject a wrong-DIMENSION vector on read rather than averaging it into a
85
+ profile. A 64-dim row decodes perfectly cleanly and nothing downstream would
86
+ notice, which is precisely why it needs an explicit gate.
87
+ - `/api/health` reports `chunk_embeddings` (sessions, bytes, TTL, oldest age) so
88
+ the loop can be seen banking evidence rather than assumed to be.
89
+ - Size, measured not estimated: ~1.1 KB per chunk, so a 400-chunk meeting is
90
+ under 500 KB.
91
+
1
92
  ## 6.21.14
2
93
 
3
94
  - Carry `sessionId` on the COS operations meetings list as well. 6.21.13 added it
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.21.14",
3
+ "version": "6.21.17",
4
4
  "description": "COS Glasses — 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,260 @@
1
+ // Per-chunk voiceprint embeddings, kept so a correction can become training.
2
+ //
3
+ // WHY THIS EXISTS. identifySpeaker() computes a 192-dim embedding for every
4
+ // audio chunk, uses it to pick a name, and throws it away. Nothing downstream
5
+ // keeps it: the chunk sidecar stores text/speaker/elapsed/similarity, and the
6
+ // calibration log stores ts/speaker/similarity/matched. So when a human later
7
+ // says "those six segments were actually me", the system can fix the transcript
8
+ // but cannot learn anything from the correction — the acoustic evidence is gone.
9
+ //
10
+ // Persisting it turns a correction into real training data:
11
+ // correction → these chunks' embeddings → enrollEmbedding(name, …, 'correction:<id>')
12
+ //
13
+ // This only works forward. A meeting recorded before this ships can be
14
+ // relabelled but can never harden a profile, because its embeddings were
15
+ // discarded. That is the cost of every week this is not running.
16
+ //
17
+ // NOT AUDIO. An embedding is 192 floats describing vocal timbre. It cannot be
18
+ // played back and speech cannot be reconstructed from it, which is why its
19
+ // retention is treated separately from audio snippets — see RETENTION below.
20
+
21
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync } from 'node:fs'
22
+ import { join, resolve } from 'node:path'
23
+ import { dataPath } from './data-dir.js'
24
+
25
+ export const CHUNK_EMBEDDING_DIR = 'chunk-embeddings'
26
+
27
+ /** Expected dimension for the shipped 3dspeaker model. A row of any other
28
+ * length is recorded but flagged, never silently averaged into a profile. */
29
+ export const EXPECTED_EMBEDDING_DIM = 192
30
+
31
+ /**
32
+ * RETENTION — 14 days by default, overridable with COS_CHUNK_EMBEDDING_TTL_DAYS.
33
+ *
34
+ * This is deliberately longer than the 8 hours on record for audio snippets, and
35
+ * the difference is the point: 8 hours cannot survive a weekend, so a Friday
36
+ * meeting could never be corrected on Monday and the compounding loop would
37
+ * only ever work on same-day review. Embeddings are also not audio — they are
38
+ * non-invertible timbre vectors, so the privacy argument that motivates a short
39
+ * audio window does not carry across unchanged.
40
+ *
41
+ * Flagged for Miles rather than assumed: if he wants these on the 8-hour clock
42
+ * too, set the env var to a fraction and the correction loop becomes same-day
43
+ * only.
44
+ */
45
+ export function chunkEmbeddingTtlMs(): number {
46
+ const raw = Number(process.env.COS_CHUNK_EMBEDDING_TTL_DAYS)
47
+ const days = Number.isFinite(raw) && raw > 0 ? raw : 14
48
+ return days * 24 * 60 * 60 * 1000
49
+ }
50
+
51
+ /** Master switch. Default ON: with this off, a correction can never train
52
+ * anything, which is the whole reason the store exists. */
53
+ export function chunkEmbeddingsEnabled(): boolean {
54
+ return process.env.COS_CHUNK_EMBEDDINGS !== '0'
55
+ }
56
+
57
+ export interface ChunkEmbeddingRow {
58
+ /** Chunk index within the session. This is the JOIN KEY to the chunk sidecar,
59
+ * which already carries `elapsed`, `text`, and the label. Time is deliberately
60
+ * NOT duplicated here: the only value available at capture time is the chunk's
61
+ * own duration, and storing that as an offset would be a wrong number in a
62
+ * field named like a right one. */
63
+ i: number
64
+ /** The label the identifier chose at capture time — what a correction corrects. */
65
+ speaker: string
66
+ similarity: number
67
+ embedding: Float32Array
68
+ }
69
+
70
+ /**
71
+ * Base64 of the raw little-endian float32 bytes.
72
+ *
73
+ * Chosen over a JSON number array for size and fidelity: 192 floats as JSON is
74
+ * ~1.5 KB and rounds, while base64 is 1,024 chars and is exact. Over a
75
+ * 400-chunk meeting that difference is roughly 200 KB.
76
+ */
77
+ export function encodeEmbedding(embedding: Float32Array): string {
78
+ return Buffer.from(embedding.buffer, embedding.byteOffset, embedding.byteLength).toString('base64')
79
+ }
80
+
81
+ export function decodeEmbedding(encoded: string): Float32Array | null {
82
+ try {
83
+ const buf = Buffer.from(encoded, 'base64')
84
+ if (buf.byteLength === 0 || buf.byteLength % 4 !== 0) return null
85
+ // Copy rather than aliasing the Buffer's pool. Verified, not assumed: a
86
+ // 768-byte base64 decode lands at an offset inside an 8192-byte pool slab.
87
+ // Node's pool offset only advances, so a later allocation cannot corrupt an
88
+ // existing view — the cost is RETENTION. An aliased view pins the whole 8 KB
89
+ // slab to keep its 768 bytes, so holding a few hundred embeddings would pin
90
+ // ~10x the memory they occupy. The copy owns exactly its own bytes.
91
+ const copy = new ArrayBuffer(buf.byteLength)
92
+ Buffer.from(copy).set(buf)
93
+ return new Float32Array(copy)
94
+ } catch {
95
+ return null
96
+ }
97
+ }
98
+
99
+ function sessionFile(sessionId: string): string | null {
100
+ // Session ids come from the client; keep them to the shape the meeting store
101
+ // already enforces so nothing can escape the directory.
102
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) return null
103
+ const dir = dataPath(CHUNK_EMBEDDING_DIR)
104
+ const path = join(dir, `${sessionId.replace(/:/g, '_')}.jsonl`)
105
+ return resolve(path).startsWith(resolve(dir) + '/') ? path : null
106
+ }
107
+
108
+ /**
109
+ * Append one chunk's embedding. JSONL, not a JSON array, so each write is a
110
+ * single append with no read-modify-write of a growing file — a crash can lose
111
+ * at most the final partial line, and readers skip it.
112
+ *
113
+ * Never throws: this runs inside the live capture path and losing an embedding
114
+ * must never cost a chunk of transcript.
115
+ */
116
+ export function appendChunkEmbedding(sessionId: string, row: ChunkEmbeddingRow): boolean {
117
+ if (!chunkEmbeddingsEnabled()) return false
118
+ const path = sessionFile(sessionId)
119
+ if (!path) return false
120
+ try {
121
+ mkdirSync(dataPath(CHUNK_EMBEDDING_DIR), { recursive: true, mode: 0o700 })
122
+ const line = JSON.stringify({
123
+ i: row.i,
124
+ speaker: row.speaker,
125
+ similarity: Math.round(row.similarity * 1000) / 1000,
126
+ dim: row.embedding.length,
127
+ v: encodeEmbedding(row.embedding),
128
+ })
129
+ appendFileSync(path, line + '\n', { mode: 0o600 })
130
+ return true
131
+ } catch {
132
+ return false
133
+ }
134
+ }
135
+
136
+ export interface ChunkEmbeddingReadResult {
137
+ rows: ChunkEmbeddingRow[]
138
+ /** Lines that could not be parsed or decoded. Reported rather than hidden: a
139
+ * correction built from a partial read would train on the wrong segments. */
140
+ unusable: number
141
+ /** True when the file is absent — the meeting predates this store, or the
142
+ * retention window has passed. Distinct from "present but empty". */
143
+ missing: boolean
144
+ }
145
+
146
+ export function readChunkEmbeddings(sessionId: string): ChunkEmbeddingReadResult {
147
+ const path = sessionFile(sessionId)
148
+ if (!path || !existsSync(path)) return { rows: [], unusable: 0, missing: true }
149
+ let raw: string
150
+ try {
151
+ raw = readFileSync(path, 'utf-8')
152
+ } catch {
153
+ return { rows: [], unusable: 0, missing: true }
154
+ }
155
+ const rows: ChunkEmbeddingRow[] = []
156
+ let unusable = 0
157
+ for (const line of raw.split('\n')) {
158
+ if (line.trim() === '') continue
159
+ try {
160
+ const o = JSON.parse(line) as Record<string, unknown>
161
+ const embedding = typeof o.v === 'string' ? decodeEmbedding(o.v) : null
162
+ if (!embedding || typeof o.i !== 'number' || typeof o.speaker !== 'string') { unusable++; continue }
163
+ // A vector of the wrong dimension decodes cleanly and would be averaged
164
+ // into a profile as if it were a voice. The model changed, or the row is
165
+ // corrupt; either way it must never reach enrollment.
166
+ if (embedding.length !== EXPECTED_EMBEDDING_DIM) { unusable++; continue }
167
+ rows.push({
168
+ i: o.i,
169
+ speaker: o.speaker,
170
+ similarity: typeof o.similarity === 'number' ? o.similarity : 0,
171
+ embedding,
172
+ })
173
+ } catch {
174
+ unusable++
175
+ }
176
+ }
177
+ return { rows, unusable, missing: false }
178
+ }
179
+
180
+ /** The embeddings for a given label — the input to a correction. */
181
+ export function chunkEmbeddingsForSpeaker(sessionId: string, speaker: string): ChunkEmbeddingRow[] {
182
+ return readChunkEmbeddings(sessionId).rows.filter(r => r.speaker === speaker)
183
+ }
184
+
185
+ /** The embeddings for an explicit set of chunk indices, for a partial
186
+ * correction where only some of a voice's segments were misattributed. */
187
+ export function chunkEmbeddingsForIndices(sessionId: string, indices: number[]): ChunkEmbeddingRow[] {
188
+ const wanted = new Set(indices)
189
+ return readChunkEmbeddings(sessionId).rows.filter(r => wanted.has(r.i))
190
+ }
191
+
192
+ export interface SweepResult {
193
+ removed: string[]
194
+ retained: string[]
195
+ }
196
+
197
+ /**
198
+ * Delete session files past the retention window.
199
+ *
200
+ * A file whose mtime cannot be read is RETAINED — treating a failed stat as
201
+ * "ancient" would delete the evidence a pending correction depends on.
202
+ */
203
+ export function sweepExpiredChunkEmbeddings(nowMs: number, ttlMs = chunkEmbeddingTtlMs()): SweepResult {
204
+ const dir = dataPath(CHUNK_EMBEDDING_DIR)
205
+ const result: SweepResult = { removed: [], retained: [] }
206
+ if (!existsSync(dir)) return result
207
+ let names: string[]
208
+ try {
209
+ names = readdirSync(dir).filter(n => n.endsWith('.jsonl'))
210
+ } catch {
211
+ return result
212
+ }
213
+ for (const name of names) {
214
+ const path = join(dir, name)
215
+ let mtimeMs = 0
216
+ try { mtimeMs = statSync(path).mtimeMs } catch { result.retained.push(name); continue }
217
+ if (mtimeMs > 0 && Number.isFinite(mtimeMs) && nowMs - mtimeMs > ttlMs) {
218
+ try { unlinkSync(path); result.removed.push(name) } catch { result.retained.push(name) }
219
+ } else {
220
+ result.retained.push(name)
221
+ }
222
+ }
223
+ return result
224
+ }
225
+
226
+ /** Counts for /api/health, so an operator can see the loop is banking evidence
227
+ * rather than having to trust that it is. */
228
+ export function chunkEmbeddingStoreStats(): {
229
+ enabled: boolean
230
+ sessions: number
231
+ bytes: number
232
+ ttlDays: number
233
+ oldestAgeHours: number | null
234
+ } {
235
+ const dir = dataPath(CHUNK_EMBEDDING_DIR)
236
+ const ttlDays = Math.round((chunkEmbeddingTtlMs() / (24 * 60 * 60 * 1000)) * 10) / 10
237
+ if (!existsSync(dir)) {
238
+ return { enabled: chunkEmbeddingsEnabled(), sessions: 0, bytes: 0, ttlDays, oldestAgeHours: null }
239
+ }
240
+ let sessions = 0, bytes = 0, oldest = Number.POSITIVE_INFINITY
241
+ try {
242
+ for (const name of readdirSync(dir).filter(n => n.endsWith('.jsonl'))) {
243
+ try {
244
+ const st = statSync(join(dir, name))
245
+ sessions++
246
+ bytes += st.size
247
+ oldest = Math.min(oldest, st.mtimeMs)
248
+ } catch { /* skip unreadable */ }
249
+ }
250
+ } catch { /* report what we have */ }
251
+ return {
252
+ enabled: chunkEmbeddingsEnabled(),
253
+ sessions,
254
+ bytes,
255
+ ttlDays,
256
+ oldestAgeHours: Number.isFinite(oldest)
257
+ ? Math.round(((Date.now() - oldest) / 3_600_000) * 10) / 10
258
+ : null,
259
+ }
260
+ }
@@ -0,0 +1,133 @@
1
+ // Which sample gets dropped when a voice profile is full.
2
+ //
3
+ // WHY THIS IS NOT FIFO. Measured on the live store (2026-08-06): 61 of 77
4
+ // profiles are already at the 20-sample cap, so every correction costs a sample
5
+ // today. And the samples are not interchangeable — the owner's own profile, the
6
+ // one driving owner detection, reads:
7
+ //
8
+ // MU: 20/20 — fireflies 10, g2-training 9, unknown 1 (zero human-verified)
9
+ //
10
+ // `fireflies` labels come from meeting ATTENDEE METADATA, never acoustically
11
+ // verified. That is exactly the chain that manufactured the phantom "Erick
12
+ // Hernandez" profile: 3 fireflies seeds compounding to 18 via g2-training. Under
13
+ // plain FIFO, a human correction could evict the single `manual` sample while
14
+ // ten unverified metadata seeds sat untouched. Age is the wrong axis. Who
15
+ // supplied the label is the right one.
16
+ //
17
+ // THE COUNTER-ARGUMENT, honoured by the quota below. Corrections are drawn from
18
+ // the acoustically HARD tail — the chunks the identifier got wrong. A profile
19
+ // made entirely of hard cases has a centroid displaced from the speaker's
20
+ // typical voice, which could make routine matching worse. So corrections are
21
+ // protected from eviction but capped at half a profile, keeping typical-voice
22
+ // mass in the centroid.
23
+
24
+ /** Ordered weakest-first: eviction walks this list and takes from the first
25
+ * non-empty tier. */
26
+ export const EVICTION_ORDER = ['automatic', 'unknown', 'metadata', 'assisted', 'human'] as const
27
+
28
+ export type ProvenanceTier = typeof EVICTION_ORDER[number]
29
+
30
+ /**
31
+ * Classify a sample by WHO supplied its label — not by how the audio arrived.
32
+ *
33
+ * Source strings appear both bare (`g2-training`) and prefixed
34
+ * (`auto:meeting_1774377156653`), both verified in the live store, so matching is
35
+ * on the prefix before any separator.
36
+ */
37
+ export function provenanceTier(source: string | undefined | null): ProvenanceTier {
38
+ const head = String(source ?? '').trim().split(/[:_]/)[0].toLowerCase()
39
+ switch (head) {
40
+ // A human deliberately supplied the audio or named the voice.
41
+ case 'manual':
42
+ case 'correction':
43
+ case 'ext-retroactive':
44
+ case 'g2-enrollment':
45
+ return 'human'
46
+ // The identifier chose the label; a human approved the batch run.
47
+ case 'g2-training':
48
+ return 'assisted'
49
+ // Label came from attendee metadata and was never acoustically verified.
50
+ case 'fireflies':
51
+ return 'metadata'
52
+ // No human involvement at any point.
53
+ case 'auto':
54
+ return 'automatic'
55
+ default:
56
+ return 'unknown'
57
+ }
58
+ }
59
+
60
+ /** True for a sample created by a human correcting a specific meeting. */
61
+ export function isCorrection(source: string | undefined | null): boolean {
62
+ return String(source ?? '').trim().toLowerCase().startsWith('correction')
63
+ }
64
+
65
+ /**
66
+ * How many of a profile's slots corrections may occupy.
67
+ *
68
+ * Half, rounded down. Above this the profile's centroid is dominated by the
69
+ * acoustically difficult segments that needed correcting, which is not what the
70
+ * speaker usually sounds like.
71
+ */
72
+ export function correctionQuota(cap: number): number {
73
+ return Math.max(1, Math.floor(cap / 2))
74
+ }
75
+
76
+ export interface EvictionChoice {
77
+ /** Index into embeddings[]/sources[] to drop. */
78
+ index: number
79
+ tier: ProvenanceTier
80
+ /** Human-readable justification, logged so an eviction is never silent. */
81
+ reason: string
82
+ }
83
+
84
+ /**
85
+ * Choose which sample to drop to make room for `incomingSource`.
86
+ *
87
+ * Returns null when the incoming sample should be REFUSED rather than evicting
88
+ * anything — which happens only when a correction would exceed its quota and
89
+ * every correction present is newer than it could justify replacing.
90
+ */
91
+ export function chooseEviction(
92
+ sources: Array<string | undefined | null>,
93
+ incomingSource: string,
94
+ cap: number,
95
+ ): EvictionChoice | null {
96
+ // No empty-array guard: the tier walk below returns null for an empty
97
+ // profile on its own (every findIndex is -1), and the quota branch cannot
98
+ // fire because the quota floor is 1. Verified by mutation — an explicit
99
+ // guard here is unreachable in effect.
100
+ const correctionIdx = sources.map((s, i) => ({ s, i })).filter(x => isCorrection(x.s)).map(x => x.i)
101
+
102
+ // A correction arriving at or above quota replaces the OLDEST CORRECTION, not
103
+ // a typical-voice sample. Corrections stay protected as a class while the
104
+ // newest human evidence still lands, and the centroid keeps its ordinary mass.
105
+ if (isCorrection(incomingSource) && correctionIdx.length >= correctionQuota(cap)) {
106
+ return {
107
+ index: correctionIdx[0],
108
+ tier: 'human',
109
+ reason: `corrections at quota (${correctionIdx.length}/${correctionQuota(cap)}) — replacing the oldest correction`,
110
+ }
111
+ }
112
+
113
+ for (const tier of EVICTION_ORDER) {
114
+ const idx = sources.findIndex(s => provenanceTier(s) === tier)
115
+ if (idx === -1) continue
116
+ // Reached only when the profile is entirely human-supplied. Age decides,
117
+ // because there is no weaker sample to give up.
118
+ const reason = tier === 'human'
119
+ ? 'every sample is human-supplied — dropping the oldest'
120
+ : `dropping the oldest ${tier} sample (${String(sources[idx])}) rather than a stronger one`
121
+ return { index: idx, tier, reason }
122
+ }
123
+ return null
124
+ }
125
+
126
+ /** Tier counts for a profile, for logs and health output. */
127
+ export function tierBreakdown(sources: Array<string | undefined | null>): Record<ProvenanceTier, number> {
128
+ const out: Record<ProvenanceTier, number> = {
129
+ human: 0, assisted: 0, metadata: 0, automatic: 0, unknown: 0,
130
+ }
131
+ for (const s of sources) out[provenanceTier(s)]++
132
+ return out
133
+ }