@gamaze/hicortex 0.15.2 → 0.16.0

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/retrieval.js CHANGED
@@ -52,12 +52,15 @@ var __importStar = (this && this.__importStar) || (function () {
52
52
  };
53
53
  })();
54
54
  Object.defineProperty(exports, "__esModule", { value: true });
55
- exports.DEFAULT_DECAY_HALF_LIFE_DAYS = void 0;
55
+ exports.SESSION_INTENT_ALPHA = exports.DEFAULT_DECAY_HALF_LIFE_DAYS = void 0;
56
56
  exports.decayConstantForHalfLife = decayConstantForHalfLife;
57
57
  exports.configureDecay = configureDecay;
58
58
  exports.configureRecall = configureRecall;
59
59
  exports.configureScoring = configureScoring;
60
60
  exports.getScoringWeights = getScoringWeights;
61
+ exports.configureSessionIntent = configureSessionIntent;
62
+ exports.getSessionIntent = getSessionIntent;
63
+ exports.blendQueryVector = blendQueryVector;
61
64
  exports.findSupersededIds = findSupersededIds;
62
65
  exports.l2ToCosine = l2ToCosine;
63
66
  exports.effectiveStrength = effectiveStrength;
@@ -65,6 +68,7 @@ exports.computeScore = computeScore;
65
68
  exports.retrieve = retrieve;
66
69
  exports.searchRecent = searchRecent;
67
70
  const storage = __importStar(require("./storage.js"));
71
+ const schema_prototypes_js_1 = require("./schema-prototypes.js");
68
72
  /** Default decay half-life (days) at importance 0.5. #192: was 0.0005/h
69
73
  * (~115-day half-life at base 0.5) — aggressive enough to bury the long tail
70
74
  * in ranking. Long-term remembering is the product; time preference stays,
@@ -124,19 +128,45 @@ const SCORING_DEFAULTS = {
124
128
  freshnessBoostDays: 7,
125
129
  freshnessBoostWeight: 0.15,
126
130
  supersededDemotion: 0.5,
131
+ projectAffinity: 0.15,
132
+ domainAffinity: 0.15,
133
+ // #205 defaults: rrfK + rrfCompositeWeight match the pre-#205 hardcoded
134
+ // values (60 and 0.8) so the no-config path is byte-identical to 0.15.3
135
+ // except for the FTS per-list weight (1.0 → 0.5) — the one deliberate
136
+ // nudge toward vector that the recall-sweep eval gates. The eval showed
137
+ // 0.7 was too timid (Q4 marine contamination persisted) and 0.5 is the
138
+ // bisection point where BM25F + composite-affinity finally flip the
139
+ // token-exact marine body match below the same-scope hardware field
140
+ // (Q4 ON contamination 0.20 → 0.00). 0.5 is still "conservative" — FTS
141
+ // contributes half its RRF share, enough that pure-keyword queries (the
142
+ // focused-family "login/CORS/webhook" turns) keep recall@5 = 1.0.
143
+ rrfK: 60,
144
+ rrfCompositeWeight: 0.8,
145
+ rrfFtsWeight: 0.5,
146
+ rrfVectorWeight: 1.0,
127
147
  };
128
148
  let scoringWeights = { ...SCORING_DEFAULTS };
129
149
  /**
130
150
  * Configure scoring weights + ranking knobs from config. Called at boot by the
131
151
  * server and the nightly (alongside configureDecay/configureRecall) so
132
152
  * retrieval and consolidation rank identically. Invalid/absent values keep the
133
- * shipped default per key. Returns the resolved set for logging/tests.
153
+ * shipped default per key. Returns the resolved set for logging/tests. Also
154
+ * pushes the #205 BM25F field weights into storage (storage.configureBm25Fts)
155
+ * so searchFts ranks with the same config — BM25F weights live in storage.ts
156
+ * (next to the FTS column declaration they mirror) but are read here from the
157
+ * SAME config object for one-place tuning.
134
158
  */
135
159
  function configureScoring(config) {
136
160
  const num = (key, dflt, min, max) => {
137
161
  const v = Number(config?.[key]);
138
162
  return Number.isFinite(v) && v >= min && v <= max ? v : dflt;
139
163
  };
164
+ // #205 BM25F weights use a [0, ∞) range (no upper bound — a field can dominate
165
+ // if the operator wills it; 0 drops the field entirely). Invalid ⇒ default.
166
+ const numW = (key, dflt) => {
167
+ const v = Number(config?.[key]);
168
+ return Number.isFinite(v) && v >= 0 ? v : dflt;
169
+ };
140
170
  scoringWeights = {
141
171
  similarity: num("scoreSimilarityWeight", SCORING_DEFAULTS.similarity, 0, 1),
142
172
  strength: num("scoreStrengthWeight", SCORING_DEFAULTS.strength, 0, 1),
@@ -145,13 +175,80 @@ function configureScoring(config) {
145
175
  freshnessBoostDays: num("freshnessBoostDays", SCORING_DEFAULTS.freshnessBoostDays, 0, 365),
146
176
  freshnessBoostWeight: num("freshnessBoostWeight", SCORING_DEFAULTS.freshnessBoostWeight, 0, 1),
147
177
  supersededDemotion: num("supersededDemotion", SCORING_DEFAULTS.supersededDemotion, 0, 1),
178
+ projectAffinity: num("projectAffinityWeight", SCORING_DEFAULTS.projectAffinity, 0, 1),
179
+ domainAffinity: num("domainAffinityWeight", SCORING_DEFAULTS.domainAffinity, 0, 1),
180
+ rrfK: numW("rrfK", SCORING_DEFAULTS.rrfK),
181
+ rrfCompositeWeight: num("rrfCompositeWeight", SCORING_DEFAULTS.rrfCompositeWeight, 0, 1),
182
+ rrfFtsWeight: numW("rrfFtsWeight", SCORING_DEFAULTS.rrfFtsWeight),
183
+ rrfVectorWeight: numW("rrfVectorWeight", SCORING_DEFAULTS.rrfVectorWeight),
148
184
  };
185
+ // #205: push BM25F field weights into storage so searchFts uses them. Same
186
+ // config object, one tuning surface; storage owns the module-level mirror
187
+ // next to the FTS column declaration (the positional order matters there).
188
+ storage.configureBm25Fts({
189
+ bm25WeightBody: numW("bm25WeightBody", 1.0),
190
+ bm25WeightProject: numW("bm25WeightProject", 2.0),
191
+ bm25WeightDomain: numW("bm25WeightDomain", 2.0),
192
+ });
149
193
  return { ...scoringWeights };
150
194
  }
151
195
  /** Current resolved weights (tests + status output). */
152
196
  function getScoringWeights() {
153
197
  return { ...scoringWeights };
154
198
  }
199
+ // ---------------------------------------------------------------------------
200
+ // Session-intent keying (#192, 0.15.3). ONE config knob:
201
+ // sessionIntentWeight 0.33 blend weight of the rolling centroid in the
202
+ // search vector: query = (1-w)·prompt + w·centroid.
203
+ // 0 = DISABLED (pure prompt, the kill-switch —
204
+ // current behavior). Range [0, 1].
205
+ //
206
+ // The EMA rate α is a shipped constant (SESSION_INTENT_ALPHA, 0.4), not a
207
+ // second knob — owner directive 0.15.3: one knob is enough to tune/disable;
208
+ // exposing α was speculative generality.
209
+ //
210
+ // The centroid itself lives on SessionRecallRegistry; retrieval only needs to
211
+ // ACCEPT a pre-blended query vector (options.queryEmbedding) so the recall
212
+ // closure can do the one-embed-per-recall + blend without retrieve()
213
+ // re-embedding. /search and other unblended callers omit queryEmbedding and
214
+ // get pure-prompt behavior unchanged.
215
+ // ---------------------------------------------------------------------------
216
+ /** EMA rate for the session-intent centroid: centroid_new = (1-α)·old + α·prompt. */
217
+ exports.SESSION_INTENT_ALPHA = 0.4;
218
+ const SESSION_INTENT_DEFAULT_WEIGHT = 0.33;
219
+ let sessionIntentWeight = SESSION_INTENT_DEFAULT_WEIGHT;
220
+ /**
221
+ * Configure session-intent keying from config. Called at server boot next to
222
+ * configureScoring (the nightly does no recall, so it does not need this).
223
+ * Reads only `sessionIntentWeight` ([0,1]; 0 = disabled). Invalid/out-of-range
224
+ * values keep the shipped default. Returns `{ weight, alpha }` — alpha is the
225
+ * fixed constant, surfaced so the recall closure passes it to the registry in
226
+ * one call.
227
+ */
228
+ function configureSessionIntent(config) {
229
+ const v = Number(config?.sessionIntentWeight);
230
+ sessionIntentWeight =
231
+ Number.isFinite(v) && v >= 0 && v <= 1 ? v : SESSION_INTENT_DEFAULT_WEIGHT;
232
+ return { weight: sessionIntentWeight, alpha: exports.SESSION_INTENT_ALPHA };
233
+ }
234
+ /** Current resolved session-intent weight + the shipped alpha (closure + tests). */
235
+ function getSessionIntent() {
236
+ return { weight: sessionIntentWeight, alpha: exports.SESSION_INTENT_ALPHA };
237
+ }
238
+ /**
239
+ * Blend the prompt embedding with the session-intent centroid for the vector
240
+ * search: `query = l2Normalize((1-w)·prompt + w·centroid)`. Returns the prompt
241
+ * UNCHANGED when `centroid` is undefined (first turn — no behavior change) or
242
+ * `weight` is 0 (the kill-switch — pure prompt). Extracted from the
243
+ * /recall-index closure (mcp-server.ts) so the exact blend decision is
244
+ * unit-testable directly, locking the ternary against a refactor without a
245
+ * closure-integration harness.
246
+ */
247
+ function blendQueryVector(promptEmb, centroid, weight) {
248
+ return centroid && weight > 0
249
+ ? (0, schema_prototypes_js_1.l2Normalize)((0, schema_prototypes_js_1.weightedAdd)(promptEmb, 1 - weight, centroid, weight))
250
+ : promptEmb;
251
+ }
155
252
  /**
156
253
  * Ids among `candidateIds` that have been superseded by a later memory — i.e.
157
254
  * they are the SOURCE of a `superseded_by` link (stageSupersession links
@@ -176,7 +273,12 @@ function findSupersededIds(db, candidateIds) {
176
273
  * these candidates to cosine 0.875, outranking most true vector matches.
177
274
  */
178
275
  const DEFAULT_GRAPH_DISTANCE = 1.0;
179
- const RRF_K = 60;
276
+ // RRF_K is no longer a module constant (#205): it lives in scoringWeights.rrfK
277
+ // (default 60, the pre-#205 hardcoded value) and is read at every retrieve()
278
+ // call so config changes apply without a restart. The DEFAULT_RRF_K here is a
279
+ // fallback for reciprocalRankFusion's optional k argument (tests + the rare
280
+ // non-retrieve caller), NOT the production path.
281
+ const DEFAULT_RRF_K = 60;
180
282
  /**
181
283
  * Convert an L2 distance (as returned by sqlite-vec's vec0 `distance`) to
182
284
  * cosine similarity. Valid because our embeddings are L2-normalized
@@ -228,10 +330,6 @@ function effectiveStrength(baseStrength, lastAccessed, now, options) {
228
330
  const floor = baseStrength * importance * 0.1;
229
331
  return floor + (baseStrength - floor) * Math.pow(decayRate, hours);
230
332
  }
231
- /**
232
- * Return a composite relevance score in [0, 1] for a candidate memory.
233
- * Exported for exact-value tests of the similarity component (#145).
234
- */
235
333
  function computeScore(memory, distance, connectionCount, maxConnections, now, options) {
236
334
  // TRUE cosine similarity (#145). The old `1 − distance` compressed real
237
335
  // cosines (cos 0.8 scored 0.37) and the 0-clamp at that scale flattened
@@ -270,6 +368,35 @@ function computeScore(memory, distance, connectionCount, maxConnections, now, op
270
368
  const freshness = 1 - ageDays / scoringWeights.freshnessBoostDays;
271
369
  score += freshness * scoringWeights.freshnessBoostWeight;
272
370
  }
371
+ // #203 soft affinity (retrieval scoping). Two graded, additive terms — both
372
+ // ZERO when the scope is absent (byte-identical ranking) and ZERO for a
373
+ // non-matching memory (never a penalty). Project affinity is a flat boost on
374
+ // exact project match; domain affinity is max(overlapping tag weight) × the
375
+ // domain weight. NULL tag weights (not yet computed by the nightly
376
+ // reconsolidation) count as 0 — we never invent a boost from missing
377
+ // association strength. Affinity rides the 0.8 composite side only (the RRF
378
+ // 0.2 side is #205 territory and untouched here).
379
+ const scope = options?.scope;
380
+ if (scope) {
381
+ if (scope.project && memory.project === scope.project) {
382
+ score += scoringWeights.projectAffinity;
383
+ }
384
+ const domains = scope.missionDomains;
385
+ if (domains && domains.length > 0 && options.tagWeights && options.tagWeights.length > 0) {
386
+ const domainSet = domains.length === 1 ? null : new Set(domains);
387
+ let maxWeight = 0;
388
+ for (const tw of options.tagWeights) {
389
+ const overlaps = domainSet ? domainSet.has(tw.tag) : tw.tag === domains[0];
390
+ if (overlaps) {
391
+ const w = tw.weight ?? 0;
392
+ if (w > maxWeight)
393
+ maxWeight = w;
394
+ }
395
+ }
396
+ if (maxWeight > 0)
397
+ score += maxWeight * scoringWeights.domainAffinity;
398
+ }
399
+ }
273
400
  // Superseded demotion (#191 Phase B): a memory whose decision was reversed by
274
401
  // a later one keeps its content and strength but must not outrank the
275
402
  // decision that replaced it. Applied as an explicit multiplier here rather
@@ -325,6 +452,7 @@ function formatResult(memory, score, effStr, connections, provenance) {
325
452
  access_count: memory.access_count ?? 0,
326
453
  memory_type: memory.memory_type ?? "episode",
327
454
  project: memory.project ?? null,
455
+ source_agent: memory.source_agent ?? null,
328
456
  created_at: memory.created_at ?? "",
329
457
  connections,
330
458
  similarity: provenance ? provenance.similarity : undefined,
@@ -350,12 +478,28 @@ function strengthen(db, memories, now) {
350
478
  // ---------------------------------------------------------------------------
351
479
  // Reciprocal Rank Fusion
352
480
  // ---------------------------------------------------------------------------
353
- function reciprocalRankFusion(rankedLists, k = RRF_K) {
481
+ /**
482
+ * Reciprocal Rank Fusion (#205 per-list weights).
483
+ *
484
+ * Each list contributes `weight / (k + rank + 1)` per item. The pre-#205 form
485
+ * (symmetric 1.0 weight on every list) is recovered by omitting `weight`:
486
+ * `{ ids }` defaults to weight 1.0 — so callers that don't care about per-list
487
+ * rebalancing (tests, alternative uses) keep working unchanged.
488
+ *
489
+ * Per-list weights are the #205 lever for "nudging toward vector": FTS was
490
+ * winning cross-scope collisions on raw token overlap (marine "battery" beat
491
+ * hardware "battery" because the marine row had a tighter token match), so the
492
+ * shipped default drops FTS to 0.5 while vector stays at 1.0 (0.7 was too
493
+ * timid — Q4 marine contamination persisted; see SCORING_DEFAULTS). The composite
494
+ * score (which carries the #203 affinity boost) then breaks the tie in scope.
495
+ */
496
+ function reciprocalRankFusion(rankedLists, k = DEFAULT_RRF_K) {
354
497
  const scores = new Map();
355
- for (const ranked of rankedLists) {
356
- for (let rank = 0; rank < ranked.length; rank++) {
357
- const mid = ranked[rank];
358
- scores.set(mid, (scores.get(mid) ?? 0) + 1.0 / (k + rank + 1));
498
+ for (const list of rankedLists) {
499
+ const w = list.weight ?? 1.0;
500
+ for (let rank = 0; rank < list.ids.length; rank++) {
501
+ const mid = list.ids[rank];
502
+ scores.set(mid, (scores.get(mid) ?? 0) + w / (k + rank + 1));
359
503
  }
360
504
  }
361
505
  return scores;
@@ -363,26 +507,42 @@ function reciprocalRankFusion(rankedLists, k = RRF_K) {
363
507
  /**
364
508
  * Main retrieval: BM25 + vector search with RRF fusion, graph traversal,
365
509
  * and composite scoring. Strengthens accessed memories.
510
+ *
511
+ * #203 retrieval scoping: `project` and `missionDomains` are SOFT affinity
512
+ * terms in computeScore (zero-boost neutral, never a penalty), NOT filters.
513
+ * `privacy` remains a hard filter (security boundary). `sourceAgent` remains a
514
+ * hard filter (kept for completeness; no production caller currently passes
515
+ * it). When neither project nor missionDomains is sent, scoring is byte-
516
+ * identical to pre-#203 — the kill-switch / no-op guarantee.
366
517
  */
367
518
  async function retrieve(db, embedFn, query, options) {
368
519
  const limit = options?.limit ?? recallDefaults.searchLimit;
369
520
  const project = options?.project;
370
521
  const privacy = options?.privacy;
371
522
  const sourceAgent = options?.sourceAgent;
523
+ const missionDomains = options?.missionDomains;
372
524
  const now = new Date();
373
- // 1. Embed
374
- const queryEmbedding = await embedFn(query);
525
+ // #203 affinity scope — passed to computeScore for every candidate. Built
526
+ // once; absent fields yield no boost (zero-boost neutral).
527
+ const scope = project || (missionDomains && missionDomains.length > 0)
528
+ ? { project: project ?? undefined, missionDomains }
529
+ : undefined;
530
+ // 1. Embed — or reuse the caller-provided vector (session-intent blend).
531
+ const queryEmbedding = options?.queryEmbedding ?? (await embedFn(query));
375
532
  // 2. Dual retrieval — vector + BM25.
376
533
  // #192: sqlite-vec can't push filters into the KNN, so filtered queries must
377
534
  // over-fetch — the old flat limit*3 intersected a global top-15 with (for the
378
535
  // median project) ~1% of the corpus, starving every filtered query.
379
- const filtered = Boolean(project || privacy || sourceAgent);
536
+ // #203: project is NO LONGER a filter (soft affinity now), so it does not
537
+ // trigger over-fetch; privacy/sourceAgent still do (they remain hard filters).
538
+ const filtered = Boolean(privacy || sourceAgent);
380
539
  const fetchLimit = filtered ? Math.min(limit * 20, 200) : limit * 3;
381
540
  let vecCandidates = storage.vectorSearch(db, queryEmbedding, fetchLimit, []);
382
541
  let ftsCandidates = [];
383
542
  try {
384
- // privacy/sourceAgent/project are all pushed into the FTS SQL.
385
- ftsCandidates = storage.searchFts(db, query, fetchLimit, privacy, sourceAgent, project ?? undefined);
543
+ // privacy/sourceAgent are pushed into the FTS SQL (hard filters). project
544
+ // is NOT (it is a soft affinity boost in computeScore as of #203).
545
+ ftsCandidates = storage.searchFts(db, query, fetchLimit, privacy, sourceAgent);
386
546
  }
387
547
  catch {
388
548
  // FTS5 search can fail on special characters; fall back to vector-only
@@ -390,20 +550,25 @@ async function retrieve(db, embedFn, query, options) {
390
550
  if (vecCandidates.length === 0 && ftsCandidates.length === 0) {
391
551
  return [];
392
552
  }
393
- // Post-filter vector candidates (sqlite-vec can't filter)
394
- if (project) {
395
- vecCandidates = vecCandidates.filter((c) => c.project === project);
396
- }
553
+ // Post-filter vector candidates (sqlite-vec can't filter). privacy stays a
554
+ // hard filter (security boundary); project was removed here (#203 — it is now
555
+ // scored, not filtered). sourceAgent stays (see options doc).
397
556
  if (privacy) {
398
557
  vecCandidates = vecCandidates.filter((c) => privacy.includes(c.privacy));
399
558
  }
400
559
  if (sourceAgent) {
401
560
  vecCandidates = vecCandidates.filter((c) => c.source_agent === sourceAgent);
402
561
  }
403
- // 3. RRF fusion
562
+ // 3. RRF fusion (#205: per-list weights + config-driven k). The vector list
563
+ // carries the composite-side affinity in the next step, so we let it dominate
564
+ // RRF too — the FTS list is down-weighted to break token-collision ties that
565
+ // the affinity alone cannot reach (marine "battery" vs hardware "battery").
404
566
  const vecRanked = vecCandidates.map((c) => c.id);
405
567
  const ftsRanked = ftsCandidates.map((c) => c.id);
406
- const rrfScores = reciprocalRankFusion([vecRanked, ftsRanked]);
568
+ const rrfScores = reciprocalRankFusion([
569
+ { ids: vecRanked, weight: scoringWeights.rrfVectorWeight },
570
+ { ids: ftsRanked, weight: scoringWeights.rrfFtsWeight },
571
+ ], scoringWeights.rrfK);
407
572
  // Build unified candidate map (with retrieval-channel provenance, #192)
408
573
  const candidateMap = new Map();
409
574
  for (const c of vecCandidates) {
@@ -427,8 +592,8 @@ async function retrieve(db, embedFn, query, options) {
427
592
  const mem = storage.getMemory(db, gid);
428
593
  if (!mem)
429
594
  continue;
430
- if (project && mem.project !== project)
431
- continue;
595
+ // #203: project check removed — project is a soft affinity in computeScore,
596
+ // not a filter. privacy (security) and sourceAgent stay as hard filters.
432
597
  if (privacy && !privacy.includes(mem.privacy))
433
598
  continue;
434
599
  if (sourceAgent && mem.source_agent !== sourceAgent)
@@ -444,10 +609,19 @@ async function retrieve(db, embedFn, query, options) {
444
609
  // One query for the whole candidate set (#191 Phase B): superseded memories
445
610
  // are demoted in computeScore rather than strength-penalized.
446
611
  const supersededIds = findSupersededIds(db, [...candidateMap.keys()]);
612
+ // #203: ONE batched load of every candidate's graded domain tags — fed to
613
+ // computeScore for domain affinity. Only needed when the scope carries
614
+ // missionDomains; absent otherwise (skips the query entirely on /search and
615
+ // other unscoped callers — byte-identical to pre-#203).
616
+ const tagWeightsByMemory = scope?.missionDomains && scope.missionDomains.length > 0
617
+ ? storage.getMemoryTagsWeightedBatched(db, [...candidateMap.keys()])
618
+ : undefined;
447
619
  for (const [mid, { mem, distance, source }] of candidateMap) {
448
620
  const connCount = connectionCounts.get(mid) ?? 0;
449
621
  const composite = computeScore(mem, distance, connCount, maxConnections, now, {
450
622
  superseded: supersededIds.has(mid),
623
+ scope,
624
+ tagWeights: tagWeightsByMemory?.get(mid),
451
625
  });
452
626
  const effStr = effectiveStrength(mem.base_strength ?? 0.5, mem.last_accessed, now, {
453
627
  accessCount: mem.access_count ?? 0,
@@ -455,7 +629,11 @@ async function retrieve(db, embedFn, query, options) {
455
629
  });
456
630
  const rrf = rrfScores.get(mid) ?? 0;
457
631
  const normalizedRrf = maxRrf > 0 ? rrf / maxRrf : 0;
458
- const finalScore = composite * 0.8 + normalizedRrf * 0.2;
632
+ // #205: composite/RRF blend is now config-driven (rrfCompositeWeight, 0.8
633
+ // default = pre-#205 behavior). The RRF share is the complement so the two
634
+ // always sum to 1.0 — the knob tunes the BALANCE, not the total.
635
+ const compositeWeight = scoringWeights.rrfCompositeWeight;
636
+ const finalScore = composite * compositeWeight + normalizedRrf * (1 - compositeWeight);
459
637
  // Measured cosine only for vector-matched candidates; FTS/graph hits carry
460
638
  // the neutral placeholder distance, which is not a real similarity.
461
639
  const similarity = source === "vector" || source === "both"
@@ -38,6 +38,21 @@ export declare function blobToVec(buf: Buffer): Float32Array;
38
38
  * is returned as an all-zero copy rather than dividing by zero.
39
39
  */
40
40
  export declare function l2Normalize(vec: Float32Array): Float32Array;
41
+ /**
42
+ * Weighted sum of two vectors into a NEW Float32Array (inputs untouched). The
43
+ * result is NOT renormalized — callers normalize explicitly via `l2Normalize`
44
+ * when they need a unit vector (both call sites below do, because embeddings
45
+ * are L2-normalized and the blend must stay on the unit sphere to keep cosine
46
+ * meaningful). Throws on a dimension mismatch rather than silently truncating:
47
+ * the embedding dim is fixed at 384 in practice, so a mismatch signals a
48
+ * mid-process model swap or a bug, which must surface (CLAUDE.md: fail
49
+ * explicitly), not get quietly papered over.
50
+ *
51
+ * Used by the session-intent centroid EMA and the recall query blend (#192
52
+ * session-intent keying): `weightedAdd(a, 1-α, b, α)` is the EMA step,
53
+ * `weightedAdd(prompt, 1-w, centroid, w)` is the blended search vector.
54
+ */
55
+ export declare function weightedAdd(a: Float32Array, wA: number, b: Float32Array, wB: number): Float32Array;
41
56
  /**
42
57
  * Association weight of a memory for a tag = cosine(memory embedding, domain
43
58
  * prototype). Both inputs are L2-normalized (embedder.ts normalizes memory
@@ -28,6 +28,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
28
28
  exports.PROTOTYPE_MIN_MEMBERS = void 0;
29
29
  exports.blobToVec = blobToVec;
30
30
  exports.l2Normalize = l2Normalize;
31
+ exports.weightedAdd = weightedAdd;
31
32
  exports.tagWeight = tagWeight;
32
33
  exports.compartmentSet = compartmentSet;
33
34
  exports.derivePrimary = derivePrimary;
@@ -70,6 +71,29 @@ function l2Normalize(vec) {
70
71
  out[i] = vec[i] / norm;
71
72
  return out;
72
73
  }
74
+ /**
75
+ * Weighted sum of two vectors into a NEW Float32Array (inputs untouched). The
76
+ * result is NOT renormalized — callers normalize explicitly via `l2Normalize`
77
+ * when they need a unit vector (both call sites below do, because embeddings
78
+ * are L2-normalized and the blend must stay on the unit sphere to keep cosine
79
+ * meaningful). Throws on a dimension mismatch rather than silently truncating:
80
+ * the embedding dim is fixed at 384 in practice, so a mismatch signals a
81
+ * mid-process model swap or a bug, which must surface (CLAUDE.md: fail
82
+ * explicitly), not get quietly papered over.
83
+ *
84
+ * Used by the session-intent centroid EMA and the recall query blend (#192
85
+ * session-intent keying): `weightedAdd(a, 1-α, b, α)` is the EMA step,
86
+ * `weightedAdd(prompt, 1-w, centroid, w)` is the blended search vector.
87
+ */
88
+ function weightedAdd(a, wA, b, wB) {
89
+ if (a.length !== b.length) {
90
+ throw new Error(`weightedAdd: dimension mismatch (${a.length} vs ${b.length}) — expected equal-length L2-normalized embeddings`);
91
+ }
92
+ const out = new Float32Array(a.length);
93
+ for (let i = 0; i < a.length; i++)
94
+ out[i] = a[i] * wA + b[i] * wB;
95
+ return out;
96
+ }
73
97
  /**
74
98
  * Association weight of a memory for a tag = cosine(memory embedding, domain
75
99
  * prototype). Both inputs are L2-normalized (embedder.ts normalizes memory
package/dist/storage.d.ts CHANGED
@@ -89,6 +89,17 @@ export declare function getMemoryTagsWeighted(db: Database.Database, memoryId: s
89
89
  tag: string;
90
90
  weight: number | null;
91
91
  }>;
92
+ /**
93
+ * Batched weighted-tag load for a candidate set (#203 domain affinity). ONE
94
+ * query for the whole set — never call getMemoryTagsWeighted per-candidate in
95
+ * a ranking loop. Returns a Map keyed by memory_id; memories with no tags are
96
+ * simply absent from the map (caller treats missing as "no domain boost").
97
+ * Ordering within each memory mirrors getMemoryTagsWeighted (weight DESC).
98
+ */
99
+ export declare function getMemoryTagsWeightedBatched(db: Database.Database, memoryIds: string[]): Map<string, Array<{
100
+ tag: string;
101
+ weight: number | null;
102
+ }>>;
92
103
  /**
93
104
  * Read the stored embedding for a memory from memory_vectors.
94
105
  * Returns null when the row is missing (caller falls back to re-embedding).
@@ -106,10 +117,52 @@ export declare function vectorSearch(db: Database.Database, queryEmbedding: Floa
106
117
  distance: number;
107
118
  }>;
108
119
  /**
109
- * Full-text search using FTS5 BM25 ranking.
110
- * Returns memories with a rank field (lower is better).
120
+ * BM25F field weights (config-driven via {@link configureBm25Fts}, called from
121
+ * retrieval.configureScoring at boot). The order mirrors the FTS5 column
122
+ * declaration in db.ts (content, project, domain) — `bm25(memories_fts, …)`
123
+ * takes weights POSITIONALLY, so a new FTS column MUST be added here in the
124
+ * same position or the weighting silently shifts. Defaults favor scope fields
125
+ * (project/domain) over body so cross-scope noise that wins on raw token
126
+ * frequency (the marine "battery" memory on a hardware query) is demoted
127
+ * without excluding it — the same "graded, never binary" discipline as
128
+ * computeScore's affinity terms.
129
+ */
130
+ export interface Bm25Weights {
131
+ body: number;
132
+ project: number;
133
+ domain: number;
134
+ }
135
+ /**
136
+ * Configure BM25F weights from config. Called by retrieval.configureScoring
137
+ * (which itself is called at server + nightly boot) so storage and retrieval
138
+ * rank identically. Invalid/out-of-range values keep the shipped default per
139
+ * key. Range [0, ∞) — a 0 weight effectively drops that field from the score;
140
+ * negative values are rejected (BM25F sign semantics break otherwise). Returns
141
+ * the resolved set for logging/tests.
111
142
  */
112
- export declare function searchFts(db: Database.Database, query: string, limit?: number, privacy?: string[], sourceAgent?: string, project?: string): Array<Memory & {
143
+ export declare function configureBm25Fts(config?: Record<string, unknown> | null): Bm25Weights;
144
+ /** Current resolved weights (tests + status output). */
145
+ export declare function getBm25Weights(): Bm25Weights;
146
+ /**
147
+ * Full-text search using FTS5 fielded BM25 (BM25F) ranking.
148
+ * Returns memories with a rank field (lower is better — see sign note below).
149
+ *
150
+ * `project` is NOT a filter here (#203): the hard project WHERE from #192 was
151
+ * removed — project is now a soft affinity boost in retrieval.computeScore AND
152
+ * a weighted field in BM25F (#205). `privacy` stays a hard filter (security
153
+ * boundary, not a relevance signal). `sourceAgent` stays a hard filter (kept
154
+ * for completeness; no production caller of retrieve() currently passes it).
155
+ *
156
+ * #205 sign handling: FTS5's `bm25(table, w0, w1, …)` returns a NEGATIVE score
157
+ * where MORE-negative = better match (it is 1 − the normalized BM25 score,
158
+ * which is itself positive — the negation is the FTS5 convention so that
159
+ * `ORDER BY bm25(…)` ASC gives best-first, matching the legacy `ORDER BY
160
+ * fts.rank` direction). Higher field weight ⇒ that column contributes MORE to
161
+ * the per-row score ⇒ matches in that field float up. We bind weights
162
+ * positionally as parameters (NOT string-interpolated) so query-planner
163
+ * caching is unaffected and the config path is the only editor.
164
+ */
165
+ export declare function searchFts(db: Database.Database, query: string, limit?: number, privacy?: string[], sourceAgent?: string): Array<Memory & {
113
166
  rank: number;
114
167
  }>;
115
168
  /**
package/dist/storage.js CHANGED
@@ -15,8 +15,11 @@ exports.deleteMemory = deleteMemory;
15
15
  exports.setMemoryTags = setMemoryTags;
16
16
  exports.getMemoryTags = getMemoryTags;
17
17
  exports.getMemoryTagsWeighted = getMemoryTagsWeighted;
18
+ exports.getMemoryTagsWeightedBatched = getMemoryTagsWeightedBatched;
18
19
  exports.getStoredEmbedding = getStoredEmbedding;
19
20
  exports.vectorSearch = vectorSearch;
21
+ exports.configureBm25Fts = configureBm25Fts;
22
+ exports.getBm25Weights = getBm25Weights;
20
23
  exports.searchFts = searchFts;
21
24
  exports.addLink = addLink;
22
25
  exports.getLinks = getLinks;
@@ -244,6 +247,33 @@ function getMemoryTagsWeighted(db, memoryId) {
244
247
  .all(memoryId);
245
248
  return rows;
246
249
  }
250
+ /**
251
+ * Batched weighted-tag load for a candidate set (#203 domain affinity). ONE
252
+ * query for the whole set — never call getMemoryTagsWeighted per-candidate in
253
+ * a ranking loop. Returns a Map keyed by memory_id; memories with no tags are
254
+ * simply absent from the map (caller treats missing as "no domain boost").
255
+ * Ordering within each memory mirrors getMemoryTagsWeighted (weight DESC).
256
+ */
257
+ function getMemoryTagsWeightedBatched(db, memoryIds) {
258
+ const out = new Map();
259
+ if (memoryIds.length === 0)
260
+ return out;
261
+ const placeholders = memoryIds.map(() => "?").join(", ");
262
+ const rows = db
263
+ .prepare(`SELECT memory_id, tag, weight FROM memory_tags
264
+ WHERE memory_id IN (${placeholders})
265
+ ORDER BY memory_id, (weight IS NULL) ASC, weight DESC, rowid ASC`)
266
+ .all(...memoryIds);
267
+ for (const r of rows) {
268
+ let arr = out.get(r.memory_id);
269
+ if (!arr) {
270
+ arr = [];
271
+ out.set(r.memory_id, arr);
272
+ }
273
+ arr.push({ tag: r.tag, weight: r.weight });
274
+ }
275
+ return out;
276
+ }
247
277
  /**
248
278
  * Read the stored embedding for a memory from memory_vectors.
249
279
  * Returns null when the row is missing (caller falls back to re-embedding).
@@ -286,14 +316,56 @@ function vectorSearch(db, queryEmbedding, limit = 10, excludeIds = []) {
286
316
  }
287
317
  return results;
288
318
  }
289
- // ---------------------------------------------------------------------------
290
- // FTS5 search
291
- // ---------------------------------------------------------------------------
319
+ const BM25_DEFAULTS = {
320
+ body: 1.0,
321
+ project: 2.0,
322
+ domain: 2.0,
323
+ };
324
+ let bm25Weights = { ...BM25_DEFAULTS };
292
325
  /**
293
- * Full-text search using FTS5 BM25 ranking.
294
- * Returns memories with a rank field (lower is better).
326
+ * Configure BM25F weights from config. Called by retrieval.configureScoring
327
+ * (which itself is called at server + nightly boot) so storage and retrieval
328
+ * rank identically. Invalid/out-of-range values keep the shipped default per
329
+ * key. Range [0, ∞) — a 0 weight effectively drops that field from the score;
330
+ * negative values are rejected (BM25F sign semantics break otherwise). Returns
331
+ * the resolved set for logging/tests.
295
332
  */
296
- function searchFts(db, query, limit = 10, privacy, sourceAgent, project) {
333
+ function configureBm25Fts(config) {
334
+ const num = (key, dflt) => {
335
+ const v = Number(config?.[key]);
336
+ return Number.isFinite(v) && v >= 0 ? v : dflt;
337
+ };
338
+ bm25Weights = {
339
+ body: num("bm25WeightBody", BM25_DEFAULTS.body),
340
+ project: num("bm25WeightProject", BM25_DEFAULTS.project),
341
+ domain: num("bm25WeightDomain", BM25_DEFAULTS.domain),
342
+ };
343
+ return { ...bm25Weights };
344
+ }
345
+ /** Current resolved weights (tests + status output). */
346
+ function getBm25Weights() {
347
+ return { ...bm25Weights };
348
+ }
349
+ /**
350
+ * Full-text search using FTS5 fielded BM25 (BM25F) ranking.
351
+ * Returns memories with a rank field (lower is better — see sign note below).
352
+ *
353
+ * `project` is NOT a filter here (#203): the hard project WHERE from #192 was
354
+ * removed — project is now a soft affinity boost in retrieval.computeScore AND
355
+ * a weighted field in BM25F (#205). `privacy` stays a hard filter (security
356
+ * boundary, not a relevance signal). `sourceAgent` stays a hard filter (kept
357
+ * for completeness; no production caller of retrieve() currently passes it).
358
+ *
359
+ * #205 sign handling: FTS5's `bm25(table, w0, w1, …)` returns a NEGATIVE score
360
+ * where MORE-negative = better match (it is 1 − the normalized BM25 score,
361
+ * which is itself positive — the negation is the FTS5 convention so that
362
+ * `ORDER BY bm25(…)` ASC gives best-first, matching the legacy `ORDER BY
363
+ * fts.rank` direction). Higher field weight ⇒ that column contributes MORE to
364
+ * the per-row score ⇒ matches in that field float up. We bind weights
365
+ * positionally as parameters (NOT string-interpolated) so query-planner
366
+ * caching is unaffected and the config path is the only editor.
367
+ */
368
+ function searchFts(db, query, limit = 10, privacy, sourceAgent) {
297
369
  const conditions = ["memories_fts MATCH ?"];
298
370
  const params = [query];
299
371
  if (privacy && privacy.length > 0) {
@@ -305,23 +377,27 @@ function searchFts(db, query, limit = 10, privacy, sourceAgent, project) {
305
377
  conditions.push("m.source_agent = ?");
306
378
  params.push(sourceAgent);
307
379
  }
308
- // #192: project is pushed into SQL (like privacy/sourceAgent) instead of
309
- // being post-filtered by the caller — post-filtering a global top-N against
310
- // a small project starves the result set regardless of corpus content.
311
- if (project) {
312
- conditions.push("m.project = ?");
313
- params.push(project);
314
- }
315
380
  const where = conditions.join(" AND ");
316
- params.push(limit);
381
+ // SQLite binds `?` parameters in LEXICAL SQL order (left-to-right) — the
382
+ // `bm25(memories_fts, ?, ?, ?)` in the SELECT clause comes BEFORE the WHERE
383
+ // and LIMIT `?`s, so the weights must be pushed FIRST. Get this order wrong
384
+ // and FTS5 ends up with a numeric weight as its MATCH expression (parsed as
385
+ // FTS5 query syntax → "syntax error near '.'" on the decimal point).
386
+ const boundParams = [
387
+ bm25Weights.body,
388
+ bm25Weights.project,
389
+ bm25Weights.domain,
390
+ ...params,
391
+ limit,
392
+ ];
317
393
  const rows = db
318
- .prepare(`SELECT m.*, fts.rank
394
+ .prepare(`SELECT m.*, bm25(memories_fts, ?, ?, ?) AS rank
319
395
  FROM memories_fts fts
320
396
  JOIN memories m ON m.rowid = fts.rowid
321
397
  WHERE ${where}
322
- ORDER BY fts.rank
398
+ ORDER BY rank
323
399
  LIMIT ?`)
324
- .all(...params);
400
+ .all(...boundParams);
325
401
  return rows.map((r) => {
326
402
  const rank = r.rank;
327
403
  const mem = rowToMemory(r);