pi-mega-compact 0.21.1 → 0.21.2

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/dist/config.js CHANGED
@@ -116,8 +116,19 @@ export const NEW_UI = () => ragEnabled("MEGACOMPACT_NEW_UI");
116
116
  // default (0.12) keeps recall permissive within a repo while still rejecting
117
117
  // effectively-unrelated hits. Call-time read so tests can set the env per-test.
118
118
  // ---------------------------------------------------------------------------
119
- /** Same-repo recall cosine floor: top winner must be >= this to be injected. */
120
- export const RECALL_MIN_COSINE = () => Number(process.env.MEGACOMPACT_RECALL_MIN_COSINE ?? "0.12");
119
+ /**
120
+ * Same-repo recall cosine floor: top winner must be >= this to be injected.
121
+ *
122
+ * E1 follow-up (PR #18 review): NaN-safe + clamped to [0,1]. A typo'd env var
123
+ * yielded NaN before; `cosine < NaN` is false, which disabled gate 1 entirely
124
+ * (every candidate passed). Non-finite falls back to 0.12; out-of-range clamps.
125
+ */
126
+ export const RECALL_MIN_COSINE = () => {
127
+ const n = Number(process.env.MEGACOMPACT_RECALL_MIN_COSINE ?? "0.12");
128
+ if (!Number.isFinite(n))
129
+ return 0.12;
130
+ return Math.min(1, Math.max(0, n));
131
+ };
121
132
  // ---------------------------------------------------------------------------
122
133
  // Vector-cortex flags + breaker constants (VC0A+). Positive sprint flags,
123
134
  // default ON, `=0`/`_DISABLED` off. Re-exported from src/config/vector-cortex.ts
@@ -19,6 +19,7 @@ export const SHINGLE_SIZE = 5; // char 5-grams
19
19
  const MAX_SHINGLES = 50_000; // QA #7/#15 complexity cap
20
20
  const SEED = 0xdeadbeef;
21
21
  const P = 2147483647; // 2^31 - 1, Mersenne prime
22
+ const PBigInt = 2147483647n; // BigInt twin for overflow-safe modular reduction
22
23
  /** Per-index universal-hashing coefficients, derived deterministically from SEED. */
23
24
  function coeffA(i) {
24
25
  return (SEED + i * 2 + 1) % P;
@@ -67,10 +68,10 @@ export function minhashSignature(text) {
67
68
  const b = coeffB(i);
68
69
  let min = P;
69
70
  for (const x of grams) {
70
- // (a*x + b) mod p — use Number math; a,x < 2^31 so a*x < 2^62, within
71
- // double-precision integer range (2^53) only if reduced; reduce a*x first.
72
- const ax = (a * (x % P)) % P;
73
- const h = (ax + b) % P;
71
+ // (a*x + b) mod p. a, x < 2^31 so a*x < 2^62 — EXCEEDS 2^53. The naive
72
+ // (a*(x%P))%P loses precision (verified: a=x=p-1 lossy 2147483644 vs
73
+ // exact 1). BigInt is correct + cheap (~5ms per signature).
74
+ const h = Number((BigInt(a) * BigInt(x % P) + BigInt(b)) % PBigInt);
74
75
  if (h < min)
75
76
  min = h;
76
77
  }
@@ -83,6 +83,16 @@ export default function (pi) {
83
83
  console.warn("[mega-compact] MEGACOMPACT_POISONED_REPEAT_THRESHOLD must be >= 1; using default 3");
84
84
  config.poisonedContextRepeatThreshold = 3;
85
85
  }
86
+ // E1: validate similarity thresholds — NaN or out of range silently disables
87
+ // recall dedup (anything >= NaN is false). envFlag guards NaN; clamp the rest.
88
+ if (!(config.dedupSim > 0 && config.dedupSim <= 1)) {
89
+ console.warn("[mega-compact] MEGACOMPACT_DEDUP_SIM must be in (0,1]; using default 0.9");
90
+ config.dedupSim = 0.9;
91
+ }
92
+ if (!(config.crossRepoCosine >= 0 && config.crossRepoCosine <= 1)) {
93
+ console.warn("[mega-compact] MEGACOMPACT_CROSSREPO_COSINE must be in [0,1]; using default 0.9");
94
+ config.crossRepoCosine = 0.9;
95
+ }
86
96
  const runtime = new MegaRuntime(config);
87
97
  registerEventHandlers(pi, runtime, config);
88
98
  registerCommands(pi, runtime, config);
@@ -171,7 +171,7 @@ export function loadConfig() {
171
171
  advisoryChannel: envBool("MEGACOMPACT_ADVISORY_CHANNEL", true),
172
172
  autoPctTrigger,
173
173
  autoInlineK: envFlag("MEGACOMPACT_AUTO_INLINE_K", 3),
174
- dedupSim: Number(process.env.MEGACOMPACT_DEDUP_SIM ?? "0.9"),
174
+ dedupSim: envFlag("MEGACOMPACT_DEDUP_SIM", 0.9),
175
175
  raptorEnabled: envBool("MEGACOMPACT_RAPTOR_ENABLED", true),
176
176
  legacyDurableTrim: envBool("MEGACOMPACT_LEGACY_DURABLE_TRIM", false),
177
177
  dbMirror: envBool("MEGACOMPACT_DB_MIRROR", false),
@@ -180,13 +180,16 @@ export function loadConfig() {
180
180
  turnsDbEnabled: envBool("MEGACOMPACT_TURNS_DB", true),
181
181
  autoWikiEnabled: envBool("MEGACOMPACT_AUTO_WIKI", true),
182
182
  crossRepoEnabled: envBool("MEGACOMPACT_CROSSREPO_ENABLED", true),
183
- crossRepoCosine: Number(process.env.MEGACOMPACT_CROSSREPO_COSINE ?? "0.90"),
183
+ crossRepoCosine: envFlag("MEGACOMPACT_CROSSREPO_COSINE", 0.9),
184
184
  // 3WF-3: SAME-repo recall cosine floor applied by the 3-source validator to
185
185
  // the top winner. SEPARATE from crossRepoCosine (S17, default 0.90, stricter
186
186
  // and cross-repo only). This same-repo floor is permissive by default (0.12)
187
187
  // so recall still surfaces loosely-relevant within-repo context while
188
188
  // rejecting effectively-unrelated hits. Mirrors src/config.ts RECALL_MIN_COSINE.
189
- recallMinCosine: Number(process.env.MEGACOMPACT_RECALL_MIN_COSINE ?? "0.12"),
189
+ // E1 follow-up (PR #18 review): envFlag (Number.isFinite-guarded) like the
190
+ // dedupSim/crossRepoCosine fix in PR #18 — a typo'd env var must fall back
191
+ // to 0.12, not yield NaN.
192
+ recallMinCosine: envFlag("MEGACOMPACT_RECALL_MIN_COSINE", 0.12),
190
193
  memoryAutoReview: envBool("MEGACOMPACT_MEMORY_AUTO_REVIEW", true),
191
194
  memoryReviewInterval: envFlag("MEGACOMPACT_MEMORY_REVIEW_INTERVAL", 10),
192
195
  recallMaxTokens: envFlag("MEGACOMPACT_RECALL_MAX_TOKENS", 1500),
@@ -116,8 +116,19 @@ export const NEW_UI = () => ragEnabled("MEGACOMPACT_NEW_UI");
116
116
  // default (0.12) keeps recall permissive within a repo while still rejecting
117
117
  // effectively-unrelated hits. Call-time read so tests can set the env per-test.
118
118
  // ---------------------------------------------------------------------------
119
- /** Same-repo recall cosine floor: top winner must be >= this to be injected. */
120
- export const RECALL_MIN_COSINE = () => Number(process.env.MEGACOMPACT_RECALL_MIN_COSINE ?? "0.12");
119
+ /**
120
+ * Same-repo recall cosine floor: top winner must be >= this to be injected.
121
+ *
122
+ * E1 follow-up (PR #18 review): NaN-safe + clamped to [0,1]. A typo'd env var
123
+ * yielded NaN before; `cosine < NaN` is false, which disabled gate 1 entirely
124
+ * (every candidate passed). Non-finite falls back to 0.12; out-of-range clamps.
125
+ */
126
+ export const RECALL_MIN_COSINE = () => {
127
+ const n = Number(process.env.MEGACOMPACT_RECALL_MIN_COSINE ?? "0.12");
128
+ if (!Number.isFinite(n))
129
+ return 0.12;
130
+ return Math.min(1, Math.max(0, n));
131
+ };
121
132
  // ---------------------------------------------------------------------------
122
133
  // Vector-cortex flags + breaker constants (VC0A+). Positive sprint flags,
123
134
  // default ON, `=0`/`_DISABLED` off. Re-exported from src/config/vector-cortex.ts
@@ -19,6 +19,7 @@ export const SHINGLE_SIZE = 5; // char 5-grams
19
19
  const MAX_SHINGLES = 50_000; // QA #7/#15 complexity cap
20
20
  const SEED = 0xdeadbeef;
21
21
  const P = 2147483647; // 2^31 - 1, Mersenne prime
22
+ const PBigInt = 2147483647n; // BigInt twin for overflow-safe modular reduction
22
23
  /** Per-index universal-hashing coefficients, derived deterministically from SEED. */
23
24
  function coeffA(i) {
24
25
  return (SEED + i * 2 + 1) % P;
@@ -67,10 +68,10 @@ export function minhashSignature(text) {
67
68
  const b = coeffB(i);
68
69
  let min = P;
69
70
  for (const x of grams) {
70
- // (a*x + b) mod p — use Number math; a,x < 2^31 so a*x < 2^62, within
71
- // double-precision integer range (2^53) only if reduced; reduce a*x first.
72
- const ax = (a * (x % P)) % P;
73
- const h = (ax + b) % P;
71
+ // (a*x + b) mod p. a, x < 2^31 so a*x < 2^62 — EXCEEDS 2^53. The naive
72
+ // (a*(x%P))%P loses precision (verified: a=x=p-1 lossy 2147483644 vs
73
+ // exact 1). BigInt is correct + cheap (~5ms per signature).
74
+ const h = Number((BigInt(a) * BigInt(x % P) + BigInt(b)) % PBigInt);
74
75
  if (h < min)
75
76
  min = h;
76
77
  }
@@ -76,7 +76,14 @@ export function validateRecall(winners, opts, store) {
76
76
  // No comparable cosine available => cannot clear a cosine gate.
77
77
  continue;
78
78
  }
79
- if (cosine < floor)
79
+ // E1 follow-up (PR #18 review): NaN/Infinity must NEVER clear the floor.
80
+ // `NaN < floor` is false, so an unguarded comparison lets a NaN cosine
81
+ // PASS gate 1 and inject — one NaN source poisons the whole 3WF-3
82
+ // quorum. Reject non-finite scores explicitly; the candidate is skipped
83
+ // and, if all fail, the provenance floor ("no recall") is returned —
84
+ // never a zero-score injection. The default TrigramEmbedder cannot
85
+ // produce NaN (zero-norm guard), but a BYO localhost embedder can.
86
+ if (!Number.isFinite(cosine) || cosine < floor)
80
87
  continue;
81
88
  // Gate 2: not already resident in the live window.
82
89
  if (liveVecs.length > 0) {
@@ -31,17 +31,36 @@ import { listCheckpoints } from "../store/sqlite.js";
31
31
  import { computeContentDigest } from "../dedup/digest.js";
32
32
  import { recallRawHits } from "./readonly.js";
33
33
  import { Logger } from "../log.js";
34
- /** Per-source normalization: map raw scores to 0..1 via min-max within source. */
35
- function normalizeScores(scores) {
34
+ /**
35
+ * Per-source normalization: map raw scores to 0..1 via min-max within source.
36
+ *
37
+ * E1 follow-up (PR #18 review): non-finite scores (NaN/±Infinity) are DROPPED
38
+ * before the min/max fold — a single NaN silently propagates through Math.min/
39
+ * max and turns EVERY normalized score of that source into NaN (verified),
40
+ * poisoning the whole 3-source quorum. Dropping the bad entry degrades that
41
+ * source gracefully instead. Exported so the guard is unit-testable directly.
42
+ */
43
+ export function normalizeScores(scores) {
36
44
  const map = new Map();
37
- if (scores.length === 0)
38
- return map;
39
- const min = Math.min(...scores);
40
- const max = Math.max(...scores);
41
- const span = max - min;
45
+ const finite = [];
42
46
  scores.forEach((s, i) => {
43
- map.set(i, span === 0 ? 1 : (s - min) / span);
47
+ if (Number.isFinite(s))
48
+ finite.push({ idx: i, s });
44
49
  });
50
+ if (finite.length === 0)
51
+ return map;
52
+ let min = Infinity;
53
+ let max = -Infinity;
54
+ for (const { s } of finite) {
55
+ if (s < min)
56
+ min = s;
57
+ if (s > max)
58
+ max = s;
59
+ }
60
+ const span = max - min;
61
+ for (const { idx, s } of finite) {
62
+ map.set(idx, span === 0 ? 1 : (s - min) / span);
63
+ }
45
64
  return map;
46
65
  }
47
66
  /**
@@ -130,7 +149,13 @@ export function voteRecall(opts, store) {
130
149
  const norm = perSource.find((p) => p.name === src.name).norm;
131
150
  const bestByCp = new Map();
132
151
  src.cands.forEach((c, i) => {
133
- const n = norm.get(i) ?? 0;
152
+ // E1 follow-up: normalizeScores dropped non-finite scores; such a hit is
153
+ // NOT a valid nomination — skip it entirely instead of defaulting it to
154
+ // 0 (which would still name the checkpoint and let it rank last into the
155
+ // fallback ranking).
156
+ const n = norm.get(i);
157
+ if (n === undefined)
158
+ return;
134
159
  const prev = bestByCp.get(c.checkpointId);
135
160
  if (prev === undefined || n > prev)
136
161
  bestByCp.set(c.checkpointId, n);
@@ -87,7 +87,12 @@ export function hydrateFts5Hits(hits, sessionId, stateDir) {
87
87
  const out = [];
88
88
  for (const h of hits) {
89
89
  const cp = cpMap.get(h.id);
90
- if (cp)
90
+ // H1 follow-up (PR #18 review): exclude SemDeDup-'removed' rows. The FTS5
91
+ // index is NOT pruned by vectorSemDedup, so a removed row can still MATCH;
92
+ // hydrating it would let the fts5 voter NAME a dead checkpoint (a removed
93
+ // row plus a recency vote is a 2/3 agreement that passes the validator).
94
+ // Mirrors vectorSearch's read-time filter.
95
+ if (cp && cp.dedupStatus !== "removed")
91
96
  out.push({ checkpointId: h.id, score: h.score, summary: cp.summary });
92
97
  }
93
98
  return out;
@@ -72,7 +72,10 @@ export function vectorDedupe(store, sessionId, regionHashOrText, isText = false)
72
72
  const state = loadSessionState(sid, stateDir);
73
73
  if (state.storedRegionHashes.includes(hash))
74
74
  return true;
75
- return listCheckpoints(sid, stateDir).some((c) => c.regionHash === hash);
75
+ // H1 follow-up (PR #18 review): a SemDeDup-'removed' row's regionHash must not
76
+ // report "already represented" — its content is excluded from recall, so the
77
+ // incoming region is NOT deduplicated in any retrievable sense.
78
+ return listCheckpoints(sid, stateDir).some((c) => c.dedupStatus !== "removed" && c.regionHash === hash);
76
79
  }
77
80
  // ---------------------------------------------------------------------------
78
81
  // Injection tracking
@@ -115,7 +118,8 @@ export function vectorTopSimilar(store, sessionId, n) {
115
118
  const ordered = [...checkpoints].sort((a, b) => a.checkpointId.localeCompare(b.checkpointId));
116
119
  const current = ordered[ordered.length - 1];
117
120
  const scored = ordered
118
- .filter((cp) => cp.checkpointId !== current.checkpointId)
121
+ .filter((cp) => cp.checkpointId !== current.checkpointId &&
122
+ cp.dedupStatus !== "removed")
119
123
  .map((cp) => ({
120
124
  checkpoint: cp,
121
125
  score: cosineSimilarity(current.embedding, cp.embedding),
@@ -40,7 +40,11 @@ export function addCheckpoint(store, input) {
40
40
  const t0 = Date.now();
41
41
  const sessionId = normalizeSessionId(input.sessionId);
42
42
  const regionHash = computeRegionHash(input.regionText);
43
- const all = listCheckpoints(sessionId, store.stateDir);
43
+ // H1: exclude SemDeDup-'removed' rows from dedup matching. search() already
44
+ // filters these, but add() did NOT — so an L0/L1/L2 match against a previously
45
+ // removed duplicate would upsertCheckpoint it back to active, resurrecting it
46
+ // into recall and defeating SemDeDup.
47
+ const all = listCheckpoints(sessionId, store.stateDir).filter((cp) => cp.dedupStatus !== "removed");
44
48
  // Honest "tokens saved" base for this region. For a deduped add the whole
45
49
  // original region is discarded (nothing new stored); for a new checkpoint
46
50
  // we persist (orig − stored). Falls back to stored when orig is unknown.
@@ -98,6 +98,16 @@ export default function (pi: ExtensionAPI) {
98
98
  );
99
99
  config.poisonedContextRepeatThreshold = 3;
100
100
  }
101
+ // E1: validate similarity thresholds — NaN or out of range silently disables
102
+ // recall dedup (anything >= NaN is false). envFlag guards NaN; clamp the rest.
103
+ if (!(config.dedupSim > 0 && config.dedupSim <= 1)) {
104
+ console.warn("[mega-compact] MEGACOMPACT_DEDUP_SIM must be in (0,1]; using default 0.9");
105
+ config.dedupSim = 0.9;
106
+ }
107
+ if (!(config.crossRepoCosine >= 0 && config.crossRepoCosine <= 1)) {
108
+ console.warn("[mega-compact] MEGACOMPACT_CROSSREPO_COSINE must be in [0,1]; using default 0.9");
109
+ config.crossRepoCosine = 0.9;
110
+ }
101
111
  const runtime = new MegaRuntime(config);
102
112
  registerEventHandlers(pi, runtime, config);
103
113
  registerCommands(pi, runtime, config);
@@ -207,7 +207,7 @@ export function loadConfig(): MegaConfig {
207
207
  advisoryChannel: envBool("MEGACOMPACT_ADVISORY_CHANNEL", true),
208
208
  autoPctTrigger,
209
209
  autoInlineK: envFlag("MEGACOMPACT_AUTO_INLINE_K", 3),
210
- dedupSim: Number(process.env.MEGACOMPACT_DEDUP_SIM ?? "0.9"),
210
+ dedupSim: envFlag("MEGACOMPACT_DEDUP_SIM", 0.9),
211
211
  raptorEnabled: envBool("MEGACOMPACT_RAPTOR_ENABLED", true),
212
212
  legacyDurableTrim: envBool("MEGACOMPACT_LEGACY_DURABLE_TRIM", false),
213
213
  dbMirror: envBool("MEGACOMPACT_DB_MIRROR", false),
@@ -216,13 +216,16 @@ export function loadConfig(): MegaConfig {
216
216
  turnsDbEnabled: envBool("MEGACOMPACT_TURNS_DB", true),
217
217
  autoWikiEnabled: envBool("MEGACOMPACT_AUTO_WIKI", true),
218
218
  crossRepoEnabled: envBool("MEGACOMPACT_CROSSREPO_ENABLED", true),
219
- crossRepoCosine: Number(process.env.MEGACOMPACT_CROSSREPO_COSINE ?? "0.90"),
219
+ crossRepoCosine: envFlag("MEGACOMPACT_CROSSREPO_COSINE", 0.9),
220
220
  // 3WF-3: SAME-repo recall cosine floor applied by the 3-source validator to
221
221
  // the top winner. SEPARATE from crossRepoCosine (S17, default 0.90, stricter
222
222
  // and cross-repo only). This same-repo floor is permissive by default (0.12)
223
223
  // so recall still surfaces loosely-relevant within-repo context while
224
224
  // rejecting effectively-unrelated hits. Mirrors src/config.ts RECALL_MIN_COSINE.
225
- recallMinCosine: Number(process.env.MEGACOMPACT_RECALL_MIN_COSINE ?? "0.12"),
225
+ // E1 follow-up (PR #18 review): envFlag (Number.isFinite-guarded) like the
226
+ // dedupSim/crossRepoCosine fix in PR #18 — a typo'd env var must fall back
227
+ // to 0.12, not yield NaN.
228
+ recallMinCosine: envFlag("MEGACOMPACT_RECALL_MIN_COSINE", 0.12),
226
229
  memoryAutoReview: envBool("MEGACOMPACT_MEMORY_AUTO_REVIEW", true),
227
230
  memoryReviewInterval: envFlag("MEGACOMPACT_MEMORY_REVIEW_INTERVAL", 10),
228
231
  recallMaxTokens: envFlag("MEGACOMPACT_RECALL_MAX_TOKENS", 1500),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mega-compact",
3
- "version": "0.21.1",
3
+ "version": "0.21.2",
4
4
  "description": "Layered, local, vector-backed context compressor for pi — supersede/collapse/cluster compaction with deduped inline recall.",
5
5
  "type": "module",
6
6
  "license": "BSD-3-Clause",
package/src/config.ts CHANGED
@@ -152,9 +152,18 @@ export const NEW_UI = (): boolean => ragEnabled("MEGACOMPACT_NEW_UI");
152
152
  // effectively-unrelated hits. Call-time read so tests can set the env per-test.
153
153
  // ---------------------------------------------------------------------------
154
154
 
155
- /** Same-repo recall cosine floor: top winner must be >= this to be injected. */
156
- export const RECALL_MIN_COSINE = (): number =>
157
- Number(process.env.MEGACOMPACT_RECALL_MIN_COSINE ?? "0.12");
155
+ /**
156
+ * Same-repo recall cosine floor: top winner must be >= this to be injected.
157
+ *
158
+ * E1 follow-up (PR #18 review): NaN-safe + clamped to [0,1]. A typo'd env var
159
+ * yielded NaN before; `cosine < NaN` is false, which disabled gate 1 entirely
160
+ * (every candidate passed). Non-finite falls back to 0.12; out-of-range clamps.
161
+ */
162
+ export const RECALL_MIN_COSINE = (): number => {
163
+ const n = Number(process.env.MEGACOMPACT_RECALL_MIN_COSINE ?? "0.12");
164
+ if (!Number.isFinite(n)) return 0.12;
165
+ return Math.min(1, Math.max(0, n));
166
+ };
158
167
 
159
168
  // ---------------------------------------------------------------------------
160
169
  // Vector-cortex flags + breaker constants (VC0A+). Positive sprint flags,
@@ -21,6 +21,7 @@ export const SHINGLE_SIZE = 5; // char 5-grams
21
21
  const MAX_SHINGLES = 50_000; // QA #7/#15 complexity cap
22
22
  const SEED = 0xdeadbeef;
23
23
  const P = 2147483647; // 2^31 - 1, Mersenne prime
24
+ const PBigInt = 2147483647n; // BigInt twin for overflow-safe modular reduction
24
25
 
25
26
  /** Per-index universal-hashing coefficients, derived deterministically from SEED. */
26
27
  function coeffA(i: number): number {
@@ -69,10 +70,10 @@ export function minhashSignature(text: string): number[] {
69
70
  const b = coeffB(i);
70
71
  let min = P;
71
72
  for (const x of grams) {
72
- // (a*x + b) mod p — use Number math; a,x < 2^31 so a*x < 2^62, within
73
- // double-precision integer range (2^53) only if reduced; reduce a*x first.
74
- const ax = (a * (x % P)) % P;
75
- const h = (ax + b) % P;
73
+ // (a*x + b) mod p. a, x < 2^31 so a*x < 2^62 — EXCEEDS 2^53. The naive
74
+ // (a*(x%P))%P loses precision (verified: a=x=p-1 lossy 2147483644 vs
75
+ // exact 1). BigInt is correct + cheap (~5ms per signature).
76
+ const h = Number((BigInt(a) * BigInt(x % P) + BigInt(b)) % PBigInt);
76
77
  if (h < min) min = h;
77
78
  }
78
79
  sig[i] = min;
@@ -113,7 +113,14 @@ export function validateRecall(
113
113
  // No comparable cosine available => cannot clear a cosine gate.
114
114
  continue;
115
115
  }
116
- if (cosine < floor) continue;
116
+ // E1 follow-up (PR #18 review): NaN/Infinity must NEVER clear the floor.
117
+ // `NaN < floor` is false, so an unguarded comparison lets a NaN cosine
118
+ // PASS gate 1 and inject — one NaN source poisons the whole 3WF-3
119
+ // quorum. Reject non-finite scores explicitly; the candidate is skipped
120
+ // and, if all fail, the provenance floor ("no recall") is returned —
121
+ // never a zero-score injection. The default TrigramEmbedder cannot
122
+ // produce NaN (zero-norm guard), but a BYO localhost embedder can.
123
+ if (!Number.isFinite(cosine) || cosine < floor) continue;
117
124
 
118
125
  // Gate 2: not already resident in the live window.
119
126
  if (liveVecs.length > 0) {
@@ -46,16 +46,32 @@ export interface VoteOptions {
46
46
  recencyCount?: number;
47
47
  }
48
48
 
49
- /** Per-source normalization: map raw scores to 0..1 via min-max within source. */
50
- function normalizeScores(scores: number[]): Map<number, number> {
49
+ /**
50
+ * Per-source normalization: map raw scores to 0..1 via min-max within source.
51
+ *
52
+ * E1 follow-up (PR #18 review): non-finite scores (NaN/±Infinity) are DROPPED
53
+ * before the min/max fold — a single NaN silently propagates through Math.min/
54
+ * max and turns EVERY normalized score of that source into NaN (verified),
55
+ * poisoning the whole 3-source quorum. Dropping the bad entry degrades that
56
+ * source gracefully instead. Exported so the guard is unit-testable directly.
57
+ */
58
+ export function normalizeScores(scores: number[]): Map<number, number> {
51
59
  const map = new Map<number, number>();
52
- if (scores.length === 0) return map;
53
- const min = Math.min(...scores);
54
- const max = Math.max(...scores);
55
- const span = max - min;
60
+ const finite: { idx: number; s: number }[] = [];
56
61
  scores.forEach((s, i) => {
57
- map.set(i, span === 0 ? 1 : (s - min) / span);
62
+ if (Number.isFinite(s)) finite.push({ idx: i, s });
58
63
  });
64
+ if (finite.length === 0) return map;
65
+ let min = Infinity;
66
+ let max = -Infinity;
67
+ for (const { s } of finite) {
68
+ if (s < min) min = s;
69
+ if (s > max) max = s;
70
+ }
71
+ const span = max - min;
72
+ for (const { idx, s } of finite) {
73
+ map.set(idx, span === 0 ? 1 : (s - min) / span);
74
+ }
59
75
  return map;
60
76
  }
61
77
 
@@ -150,7 +166,12 @@ export function voteRecall(opts: VoteOptions, store: VectorStore): VoteResult {
150
166
  const norm = perSource.find((p) => p.name === src.name)!.norm;
151
167
  const bestByCp = new Map<string, number>();
152
168
  src.cands.forEach((c, i) => {
153
- const n = norm.get(i) ?? 0;
169
+ // E1 follow-up: normalizeScores dropped non-finite scores; such a hit is
170
+ // NOT a valid nomination — skip it entirely instead of defaulting it to
171
+ // 0 (which would still name the checkpoint and let it rank last into the
172
+ // fallback ranking).
173
+ const n = norm.get(i);
174
+ if (n === undefined) return;
154
175
  const prev = bestByCp.get(c.checkpointId);
155
176
  if (prev === undefined || n > prev) bestByCp.set(c.checkpointId, n);
156
177
  seenIds.add(c.checkpointId);
@@ -124,7 +124,13 @@ export function hydrateFts5Hits(
124
124
  const out: HydratedFts5Hit[] = [];
125
125
  for (const h of hits) {
126
126
  const cp = cpMap.get(h.id);
127
- if (cp) out.push({ checkpointId: h.id, score: h.score, summary: cp.summary });
127
+ // H1 follow-up (PR #18 review): exclude SemDeDup-'removed' rows. The FTS5
128
+ // index is NOT pruned by vectorSemDedup, so a removed row can still MATCH;
129
+ // hydrating it would let the fts5 voter NAME a dead checkpoint (a removed
130
+ // row plus a recency vote is a 2/3 agreement that passes the validator).
131
+ // Mirrors vectorSearch's read-time filter.
132
+ if (cp && cp.dedupStatus !== "removed")
133
+ out.push({ checkpointId: h.id, score: h.score, summary: cp.summary });
128
134
  }
129
135
  return out;
130
136
  }
@@ -100,8 +100,11 @@ export function vectorDedupe(
100
100
  : regionHashOrText;
101
101
  const state = loadSessionState(sid, stateDir);
102
102
  if (state.storedRegionHashes.includes(hash)) return true;
103
+ // H1 follow-up (PR #18 review): a SemDeDup-'removed' row's regionHash must not
104
+ // report "already represented" — its content is excluded from recall, so the
105
+ // incoming region is NOT deduplicated in any retrievable sense.
103
106
  return listCheckpoints(sid, stateDir).some(
104
- (c) => c.regionHash === hash,
107
+ (c) => c.dedupStatus !== "removed" && c.regionHash === hash,
105
108
  );
106
109
  }
107
110
 
@@ -157,7 +160,11 @@ export function vectorTopSimilar(store: VectorStore, sessionId: string, n: numbe
157
160
  const current = ordered[ordered.length - 1];
158
161
 
159
162
  const scored: SearchHit[] = ordered
160
- .filter((cp) => cp.checkpointId !== current.checkpointId)
163
+ .filter(
164
+ (cp) =>
165
+ cp.checkpointId !== current.checkpointId &&
166
+ cp.dedupStatus !== "removed", // H1 follow-up: mirror vectorSearch's filter
167
+ )
161
168
  .map((cp) => ({
162
169
  checkpoint: cp,
163
170
  score: cosineSimilarity(current.embedding, cp.embedding),
@@ -54,7 +54,13 @@ export function addCheckpoint(store: VectorStore, input: AddInput): AddResult {
54
54
  const t0 = Date.now();
55
55
  const sessionId = normalizeSessionId(input.sessionId);
56
56
  const regionHash = computeRegionHash(input.regionText);
57
- const all = listCheckpoints(sessionId, store.stateDir);
57
+ // H1: exclude SemDeDup-'removed' rows from dedup matching. search() already
58
+ // filters these, but add() did NOT — so an L0/L1/L2 match against a previously
59
+ // removed duplicate would upsertCheckpoint it back to active, resurrecting it
60
+ // into recall and defeating SemDeDup.
61
+ const all = listCheckpoints(sessionId, store.stateDir).filter(
62
+ (cp) => cp.dedupStatus !== "removed",
63
+ );
58
64
  // Honest "tokens saved" base for this region. For a deduped add the whole
59
65
  // original region is discarded (nothing new stored); for a new checkpoint
60
66
  // we persist (orig − stored). Falls back to stored when orig is unknown.