@gamaze/hicortex 0.22.0 → 0.22.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.
Files changed (41) hide show
  1. package/assets/dashboard.html +209 -76
  2. package/dist/calibration.d.ts +92 -12
  3. package/dist/calibration.js +102 -15
  4. package/dist/classify-domains.js +6 -1
  5. package/dist/consolidate.js +87 -19
  6. package/dist/dashboard.d.ts +11 -0
  7. package/dist/dashboard.js +9 -0
  8. package/dist/db.js +74 -0
  9. package/dist/eval/decay-eval.d.ts +4 -2
  10. package/dist/eval/decay-eval.js +4 -4
  11. package/dist/eval/eval-clock.d.ts +32 -0
  12. package/dist/eval/eval-clock.js +47 -0
  13. package/dist/eval/graph-eval.d.ts +15 -2
  14. package/dist/eval/graph-eval.js +51 -5
  15. package/dist/eval/planted-eval.d.ts +4 -0
  16. package/dist/eval/planted-eval.js +27 -2
  17. package/dist/eval/planted-harness.d.ts +7 -0
  18. package/dist/eval/planted-harness.js +2 -0
  19. package/dist/eval/ranking-battery.d.ts +49 -2
  20. package/dist/eval/ranking-battery.js +110 -2
  21. package/dist/eval/ranking-eval.d.ts +26 -6
  22. package/dist/eval/ranking-eval.js +197 -34
  23. package/dist/eval/ranking-fixtures.d.ts +41 -1
  24. package/dist/eval/ranking-fixtures.js +261 -2
  25. package/dist/eval/recall-sweep.d.ts +7 -2
  26. package/dist/eval/recall-sweep.js +42 -13
  27. package/dist/eval/relevance-eval.d.ts +115 -1
  28. package/dist/eval/relevance-eval.js +318 -32
  29. package/dist/eval/run-eval.d.ts +7 -4
  30. package/dist/eval/run-eval.js +36 -9
  31. package/dist/mcp-server.js +37 -9
  32. package/dist/nightly.js +14 -0
  33. package/dist/recall-index.d.ts +46 -3
  34. package/dist/recall-index.js +83 -26
  35. package/dist/recall-precision.d.ts +212 -0
  36. package/dist/recall-precision.js +381 -0
  37. package/dist/retrieval.d.ts +34 -15
  38. package/dist/retrieval.js +132 -59
  39. package/dist/types.d.ts +7 -0
  40. package/package.json +1 -1
  41. package/server.json +3 -3
@@ -24,7 +24,8 @@
24
24
  * the removal is never silent.
25
25
  */
26
26
  Object.defineProperty(exports, "__esModule", { value: true });
27
- exports.DIAGNOSTIC_ENV_TIER = exports.OLLAMA_FLUSH_WAIT_MS = exports.OLLAMA_FLUSH_EVERY = exports.NUM_CTX = exports.DEFAULT_FIRST_RUN_LOOKBACK_DAYS = exports.ENRICH_STRENGTH_DELTA = exports.STAGE_TRUTH_STRENGTH = exports.STAGE_BELIEF_STRENGTH = exports.STAGE_FADING_STRENGTH = exports.STAGE_FADING_DAYS = exports.WEAK_PRIMARY_FLOOR = exports.CORRECTION_REWRITE_MIN_CONFIDENCE = exports.CORRECTION_MIN_SIMILARITY = exports.SUPERSESSION_MIN_SIMILARITY = exports.DEDUP_AUTO_MERGE_THRESHOLD = exports.IMPORTANCE_CEILING = exports.BM25_WEIGHT_DOMAIN = exports.BM25_WEIGHT_PROJECT = exports.BM25_WEIGHT_BODY = exports.BOTH_CHANNEL_BOOST = exports.RRF_VECTOR_WEIGHT = exports.RRF_FTS_WEIGHT = exports.RRF_COMPOSITE_WEIGHT = exports.RRF_K = exports.DOMAIN_AFFINITY_WEIGHT = exports.PROJECT_AFFINITY_WEIGHT = exports.SUPERSEDED_DEMOTION = exports.FRESHNESS_BOOST_WEIGHT = exports.FRESHNESS_BOOST_DAYS = exports.SCORE_RECENCY_WEIGHT = exports.SCORE_CONNECTIONS_WEIGHT = exports.SCORE_STRENGTH_WEIGHT = exports.SCORE_SIMILARITY_WEIGHT = exports.RECALL_USES_AXIS_MAX = exports.RECALL_USES_NORMAL_MAX = exports.RECALL_USES_LOW_MAX = exports.RECALL_RESHOW_TURNS = exports.NOVELTY_FLOOR_SLOTS = exports.RECALL_TITLE_CHARS = exports.RECALL_MIN_PROMPT_CHARS = exports.RECALL_MAX_ITEMS = exports.RECALL_MIN_SIMILARITY = exports.SESSION_INTENT_WEIGHT = exports.COLD_EXPOSURE_SLOTS = exports.RECENT_WINDOW_DAYS = exports.RECENT_LIMIT = exports.SEARCH_LIMIT = exports.PROMOTION_STRENGTH_FLOOR = exports.PROMOTION_RATE = exports.DECAY_HALF_LIFE_DAYS = void 0;
27
+ exports.STAGE_TRUTH_STRENGTH = exports.STAGE_BELIEF_STRENGTH = exports.STAGE_FADING_STRENGTH = exports.STAGE_FADING_DAYS = exports.WEAK_PRIMARY_FLOOR = exports.CORRECTION_REWRITE_MIN_CONFIDENCE = exports.CORRECTION_MIN_SIMILARITY = exports.SUPERSESSION_MIN_SIMILARITY = exports.DEDUP_AUTO_MERGE_THRESHOLD = exports.IMPORTANCE_SETTLE_WINDOW_DAYS = exports.IMPORTANCE_CEILING = exports.BM25_WEIGHT_DOMAIN = exports.BM25_WEIGHT_PROJECT = exports.BM25_WEIGHT_BODY = exports.BOTH_CHANNEL_BOOST = exports.RRF_VECTOR_WEIGHT = exports.RRF_FTS_WEIGHT = exports.RRF_COMPOSITE_WEIGHT = exports.RRF_K = exports.SCOPE_AFFINITY_WEIGHT = exports.SUPERSEDED_DEMOTION = exports.RECENCY_HEAD_DAYS = exports.RECENCY_HEAD_WEIGHT = exports.RECENCY_HOURLY_DECAY = exports.SCORE_RECENCY_WEIGHT = exports.CONNECTIONS_SATURATION_DEGREE = exports.SCORE_CONNECTIONS_WEIGHT = exports.SCORE_STRENGTH_WEIGHT = exports.SCORE_SIMILARITY_WEIGHT = exports.MEMORY_PRECISION_PROMPT_EXCERPT_CHARS = exports.MEMORY_PRECISION_DIVERGENCE_MIN_SHOWN = exports.MEMORY_PRECISION_REDUNDANT_ABOVE = exports.MEMORY_PRECISION_WINDOW_DAYS = exports.RECALL_USES_AXIS_MAX = exports.RECALL_USES_NORMAL_MAX = exports.RECALL_USES_LOW_MAX = exports.RECALL_RESHOW_TURNS = exports.NOVELTY_FLOOR_SLOTS = exports.RECALL_TITLE_CHARS = exports.RECALL_MIN_PROMPT_CHARS = exports.RECALL_MAX_ITEMS = exports.RECALL_MIN_SIMILARITY = exports.SESSION_INTENT_WEIGHT = exports.COLD_EXPOSURE_SLOTS = exports.RECENT_WINDOW_DAYS = exports.RECENT_LIMIT = exports.SEARCH_LIMIT = exports.PROMOTION_STRENGTH_FLOOR = exports.PROMOTION_RATE = exports.DECAY_HALF_LIFE_DAYS = void 0;
28
+ exports.DIAGNOSTIC_ENV_TIER = exports.OLLAMA_FLUSH_WAIT_MS = exports.OLLAMA_FLUSH_EVERY = exports.NUM_CTX = exports.DEFAULT_FIRST_RUN_LOOKBACK_DAYS = exports.ENRICH_STRENGTH_DELTA = void 0;
28
29
  exports.resolveNumCtx = resolveNumCtx;
29
30
  exports.resolveOllamaFlushEvery = resolveOllamaFlushEvery;
30
31
  exports.resolveOllamaFlushWaitMs = resolveOllamaFlushWaitMs;
@@ -126,9 +127,48 @@ exports.RECALL_USES_NORMAL_MAX = 0.25;
126
127
  * PROVISIONAL (owner anchor 2026-09-13, #426) — chosen so Overfetching
127
128
  * keeps a visible span, not a measured bound. */
128
129
  exports.RECALL_USES_AXIS_MAX = 0.30;
130
+ // Memory-precision family (#476) — the console card's deterministic proxies.
131
+ // All four are release-managed per this module's evolution contract: no config
132
+ // keys, nothing hardcoded elsewhere. Values are stored THRESHOLD-FREE (raw
133
+ // cosines in recall_events); the thresholds below are applied at RENDER time
134
+ // only, so recalibrating them never rewrites history.
135
+ /**
136
+ * Retention horizon for the recall_pushes/recall_events sidecar tables AND the
137
+ * longest window the Memory Precision card shows — ONE constant feeds both
138
+ * (the CAPTURE_HEALTH_WINDOW_DAYS single-constant law: the card can never
139
+ * claim a window the store no longer has rows for). 90 = the longest #452
140
+ * range option that the retention can honestly serve; 180d and all clamp to
141
+ * it server-side and the payload echoes the effective window_days.
142
+ */
143
+ exports.MEMORY_PRECISION_WINDOW_DAYS = 90;
144
+ /**
145
+ * Cosine at/above which a pushed line reads "redundant" (restating the
146
+ * session's standing context — the top lessons + identity every session-start
147
+ * hook injects). Direction-only display guidance, never a gate. 0.80 mirrors
148
+ * SUPERSESSION_MIN_SIMILARITY: at/above it a pair reads as the same
149
+ * statement, below it as topical overlap — a reasonable first anchor until
150
+ * the judged-calibration follow-up measures the real distribution (the
151
+ * #426 provisional-anchor posture).
152
+ */
153
+ exports.MEMORY_PRECISION_REDUNDANT_ABOVE = 0.8;
154
+ /**
155
+ * Window showings a live memory needs (with zero window fetches) to count as
156
+ * DIVERGING — the index keeps pushing it, nothing ever reads it. 5 is the
157
+ * spec's anchor (#476): enough showings that the silence is a pattern, not
158
+ * one prompt's turn-suppression.
159
+ */
160
+ exports.MEMORY_PRECISION_DIVERGENCE_MIN_SHOWN = 5;
161
+ /**
162
+ * Cap on the prompt excerpt persisted per recall_pushes row (Option A,
163
+ * owner-blessed 2026-09-19: the first-ever server-side persistence of raw
164
+ * prompt text, bounded to 256 chars and pruned at the retention horizon —
165
+ * the same trust boundary as /distill's denoised conversation text).
166
+ */
167
+ exports.MEMORY_PRECISION_PROMPT_EXCERPT_CHARS = 256;
129
168
  // ---------------------------------------------------------------------------
130
- // Composite ranking weights (was: score*Weight, freshnessBoost*,
131
- // supersededDemotion, *AffinityWeight, rrf*)
169
+ // Composite ranking weights (was: score*Weight, supersededDemotion,
170
+ // *AffinityWeight, rrf*; the pre-#430 freshness bonus constants merged into
171
+ // the RECENCY family below)
132
172
  // ---------------------------------------------------------------------------
133
173
  /** Semantic-similarity share of the composite score. 0.50 (raised from 0.40
134
174
  * in the 0.15.2 rebalance, #191 Phase B): on the production corpus effective
@@ -140,23 +180,58 @@ exports.SCORE_SIMILARITY_WEIGHT = 0.50;
140
180
  exports.SCORE_STRENGTH_WEIGHT = 0.20;
141
181
  /** Graph-centrality share of the composite score (was 0.20; see above). */
142
182
  exports.SCORE_CONNECTIONS_WEIGHT = 0.15;
143
- /** Slow recency curve share of the composite score (was 0.10; see above). */
183
+ /**
184
+ * Log-saturation degree K for the connections term (#449 PR E, items 1+3):
185
+ * the credit is min(1, log1p(k) / log1p(K)) × SCORE_CONNECTIONS_WEIGHT on
186
+ * the ABSOLUTE undirected-degree scale k — full at k = K, exactly +0 at
187
+ * k = 0 (unlinked rows score bit-identically to the linear term they
188
+ * replace). Provenance: p99 of the REAL undirected degree distribution
189
+ * (read-only audit 2026-09-17 of the canonical wave snapshot, 18,798
190
+ * memories / 14,600 linked: p50 = 4, p90 = 8, p95 = 9, p99 = 15, p99.9 =
191
+ * 28.4, max = 40; 135 memories ≥ 16 links = 0.92% of linked) — so the top
192
+ * ~1% of hubs tie at full credit and everything below differentiates
193
+ * modestly. Shape grounded in the #449 research base: ACT-R fan saturation
194
+ * (Anderson & Reder 1999 — activation falls with the LOG of fan), SAM's
195
+ * saturating returns, cue overload (Watkins & Watkins 1975). Replaces the
196
+ * candidate-set-relative normalization #449 deleted (k / the per-query max
197
+ * degree) — a row's score no longer depends on which other rows happened
198
+ * to match. Alternatives K = 8 (p90 — collapses all p90+ rows to
199
+ * full credit) and K = 32 (≈p99.9 — leaves the p99 hub at 0.12 of the
200
+ * term) were tabled in the #449 spec. RELEASE-MANAGED per this module's
201
+ * evolution contract — overridable only through the configureScoring seam.
202
+ */
203
+ exports.CONNECTIONS_SATURATION_DEGREE = 16;
204
+ /** Time-curve share of the composite score — the merged curve's blend weight
205
+ * AND slow-region amplitude (was 0.10; see above). The four blend weights
206
+ * still sum to 1.0; the head amplitude below is a documented overshoot, NOT
207
+ * a fifth blend weight (#430). Zero (via the seam) disables the whole term. */
144
208
  exports.SCORE_RECENCY_WEIGHT = 0.15;
145
- /** Fresh-memory window: the additive bonus fades linearly to 0 over this
146
- * many days. 7 — nightly capture means 1 day is the floor of "fresh"
147
- * (#191 Phase B). */
148
- exports.FRESHNESS_BOOST_DAYS = 7;
149
- /** Fresh-memory bonus size at age 0 (#191 Phase B; 0 = disabled via seam). */
150
- exports.FRESHNESS_BOOST_WEIGHT = 0.15;
209
+ /** Hourly decay of the merged curve's slow region (#430) — promoted from the
210
+ * inline literal computeScore carried; half-life ≈ 57.7 days. At and beyond
211
+ * RECENCY_HEAD_DAYS the curve is bit-identical to the pre-#430 slow term
212
+ * (SCORE_RECENCY_WEIGHT × RECENCY_HOURLY_DECAY^hours). */
213
+ exports.RECENCY_HOURLY_DECAY = 0.9995;
214
+ /** Merged time-curve head amplitude at age 0 (#430) — the pre-#430 slow
215
+ * weight 0.15 + fresh bonus 0.15: the freshness job is now the early steep
216
+ * part of ONE curve. The head joins value-continuously onto the slow region
217
+ * at RECENCY_HEAD_DAYS; its rate is DERIVED from that continuity, never a
218
+ * free constant. */
219
+ exports.RECENCY_HEAD_WEIGHT = 0.30;
220
+ /** Join age in days of the merged time curve (#430) — the head window edge,
221
+ * carried over from the pre-#430 freshness window. Nightly capture means
222
+ * 1 day is the floor of "fresh" (#191 Phase B). */
223
+ exports.RECENCY_HEAD_DAYS = 7;
151
224
  /** Score multiplier for a memory a later decision superseded (0.15.2; the
152
225
  * belief walk (#393 D) is the primary mechanism — this is the safety net
153
226
  * for rows the walk does not reach). */
154
227
  exports.SUPERSEDED_DEMOTION = 0.50;
155
- /** #203 soft boost on exact project match. ADDITIVE, zero-boost neutral,
156
- * never a penalty — a foreign memory ranks equal, not lower. */
157
- exports.PROJECT_AFFINITY_WEIGHT = 0.15;
158
- /** #203 soft boost multiplier on max overlapping domain-tag weight. */
159
- exports.DOMAIN_AFFINITY_WEIGHT = 0.15;
228
+ /** #430 merged scope affinity — ONE term replacing #203's two boosts (the
229
+ * project-affinity and domain-affinity constants) at their shared value.
230
+ * Boost = max(project-match indicator (1 on exact match), max overlapping
231
+ * domain-tag weight) × this weight: the strongest single scope signal counts
232
+ * once, never stacked. ADDITIVE, zero-boost neutral, never a penalty — an
233
+ * absent scope adds exactly 0; a foreign memory ranks equal, not lower. */
234
+ exports.SCOPE_AFFINITY_WEIGHT = 0.15;
160
235
  /** #205 RRF k parameter (1/(k+rank+1)) — matches the pre-#205 hardcoded 60
161
236
  * so the no-config path was byte-identical to 0.15.3. */
162
237
  exports.RRF_K = 60;
@@ -223,6 +298,18 @@ exports.BM25_WEIGHT_DOMAIN = 2.0;
223
298
  * base-1.0 rows decay again.
224
299
  */
225
300
  exports.IMPORTANCE_CEILING = 0.95;
301
+ /**
302
+ * How many days a memory stays in the nightly importance-settle pool after
303
+ * ingest (#478): a row's importance re-settles nightly while young — keyed
304
+ * on PER-ROW age (ingested_at), never the global lastConsolidated
305
+ * watermark, which sticks whenever a run defers and used to drag a growing
306
+ * cohort back through re-scoring (each re-settle overwrites base_strength,
307
+ * erasing promotion gains). Rows leave the pool after the window; promoted
308
+ * or enriched rows leave earlier via the paid-gain guard. Release-managed
309
+ * (#408 discipline): moving it is a release decision with soak evidence,
310
+ * not a config knob.
311
+ */
312
+ exports.IMPORTANCE_SETTLE_WINDOW_DAYS = 3;
226
313
  /** Deterministic merge ceiling of the unified resolution pass (#392): pairs
227
314
  * at/above this cosine merge LLM-free; [CORRECTION_MIN_SIMILARITY, this)
228
315
  * get the one verdict call. 0.92 — measured on the #191 mechanical audit
@@ -175,10 +175,15 @@ async function runClassifyDomains(options = {}) {
175
175
  // everything from the final tag sets.
176
176
  const { prototypes } = await (0, schema_prototypes_js_1.computeDomainPrototypes)(db, domains, getEmbedFn);
177
177
  // Scope filter: default = NULL / not-in-set / no tags yet; --all = everything.
178
+ // #477: absorbed dedup losers are excluded from the default scope (dead
179
+ // evidence — the merge already cleared their tags and nulled domain);
180
+ // conjoined OUTSIDE the parenthesized OR group, same as the nightly
181
+ // stageContentDomains twin. --all stays a wholesale operator re-judge.
178
182
  const placeholders = domains.map(() => "?").join(", ");
179
183
  const scopeSql = all
180
184
  ? "rowid > ?"
181
- : `rowid > ? AND (domain IS NULL OR domain NOT IN (${placeholders}) ` +
185
+ : `rowid > ? AND COALESCE(status, '') != 'absorbed' ` +
186
+ `AND (domain IS NULL OR domain NOT IN (${placeholders}) ` +
182
187
  `OR id NOT IN (SELECT DISTINCT memory_id FROM memory_tags))`;
183
188
  const batchStmt = db.prepare(`SELECT rowid AS __rowid, id, content, project, domain FROM memories
184
189
  WHERE ${scopeSql} ORDER BY rowid ASC LIMIT ?`);
@@ -467,8 +467,13 @@ async function scoreMemoriesImportance(db, memories, llm, opts = {}) {
467
467
  }
468
468
  return { scored, failed, skipped_budget: skippedBudget };
469
469
  }
470
- async function stageImportance(db, memories, llm, budget, dryRun, deadline) {
471
- return scoreMemoriesImportance(db, memories, llm, { budget, deadline, dryRun });
470
+ async function stageImportance(db, memories, llm, budget, dryRun, deadline,
471
+ /** #478: pool candidates the paid-gain guard dropped at the stage
472
+ * boundary — reported, not scored, so the run's evidence shows the
473
+ * promotion/enrichment gains that survived the nightly. */
474
+ guardSkipped = 0) {
475
+ const r = await scoreMemoriesImportance(db, memories, llm, { budget, deadline, dryRun });
476
+ return { ...r, guard_skipped: guardSkipped };
472
477
  }
473
478
  // ---------------------------------------------------------------------------
474
479
  // Stage 2.5: Reflection
@@ -639,12 +644,18 @@ async function stageContentDomains(db, domains, llm, budget, embedFn, dryRun, st
639
644
  // - domain NOT IN the current vocabulary (a rename/removal re-files), OR
640
645
  // - no memory_tags rows yet (single-domain memories from feat/content-domains
641
646
  // that have a primary but no tag set — backfill them to multi-tag).
647
+ // #477: absorbed dedup losers are excluded — the merge clears their tags
648
+ // and nulls domain before absorbing, so without the predicate every dead
649
+ // loser re-enters this scope nightly (one classify call + a halving on
650
+ // evidence no recall can ever see). Conjoined OUTSIDE the parenthesized OR
651
+ // group so SQL precedence cannot let an OR arm absorb it.
642
652
  const placeholders = domains.map(() => "?").join(", ");
643
653
  const rows = db
644
654
  .prepare(`SELECT id, content, project FROM memories
645
- WHERE domain IS NULL
646
- OR domain NOT IN (${placeholders})
647
- OR id NOT IN (SELECT DISTINCT memory_id FROM memory_tags)`)
655
+ WHERE COALESCE(status, '') != 'absorbed'
656
+ AND (domain IS NULL
657
+ OR domain NOT IN (${placeholders})
658
+ OR id NOT IN (SELECT DISTINCT memory_id FROM memory_tags))`)
648
659
  .all(...domains.map((d) => d.name));
649
660
  if (dryRun) {
650
661
  return { curated: false, domains: domains.length, classified: 0, reason: `dry_run (${rows.length} would classify)` };
@@ -1376,6 +1387,7 @@ function stagePromotion(db, dryRun) {
1376
1387
  const demoted = (0, retrieval_js_1.findDemotedIds)(db, rows.map((r) => r.id));
1377
1388
  let promoted = 0;
1378
1389
  let demotedSkipped = 0;
1390
+ let totalGain = 0;
1379
1391
  const writes = [];
1380
1392
  for (const row of rows) {
1381
1393
  const accessCount = row.access_count ?? 0;
@@ -1389,18 +1401,25 @@ function stagePromotion(db, dryRun) {
1389
1401
  }
1390
1402
  // base_strength is NOT NULL after scoring; the `?? 0.5` mirrors
1391
1403
  // stageDecayPrune's defensive default for unscored rows (inserts at 0.5).
1392
- let strength = row.base_strength ?? 0.5;
1404
+ const startStrength = row.base_strength ?? 0.5;
1405
+ let strength = startStrength;
1393
1406
  for (let i = 0; i < row.delta; i++)
1394
1407
  strength = applyStrengthPromotion(strength);
1408
+ totalGain += strength - startStrength;
1395
1409
  promoted++;
1396
1410
  writes.push({
1397
1411
  id: row.id,
1398
1412
  fields: { base_strength: strength, promotion_last_count: accessCount },
1399
1413
  });
1400
1414
  }
1415
+ // #459: the stage's one-line summary in the shared stage idiom (rows
1416
+ // examined / promoted / total gain, like the supersession summary) — the
1417
+ // stage writes its report in-memory only, so the log line is the soak-time
1418
+ // health signal. Zero examined rows stay silent (the supersession gate).
1401
1419
  if (dryRun) {
1402
- console.log(`[hicortex] Strength promotion (dry-run): would promote ${promoted} memories ` +
1403
- `(${demotedSkipped} demotion-set rows advance their baseline only).`);
1420
+ console.log(`[hicortex] Strength promotion (dry-run): ${rows.length} examined, would promote ` +
1421
+ `${promoted} (+${totalGain.toFixed(3)} total strength, ` +
1422
+ `${demotedSkipped} demotion-set rows advance their baseline only).`);
1404
1423
  return { promoted, demoted_skipped: demotedSkipped };
1405
1424
  }
1406
1425
  // One transaction for the whole stage (the stageMemoryCapEviction pattern):
@@ -1411,8 +1430,9 @@ function stagePromotion(db, dryRun) {
1411
1430
  storage.updateMemory(db, w.id, w.fields);
1412
1431
  });
1413
1432
  tx();
1414
- console.log(`[hicortex] Strength promotion: promoted ${promoted} memories ` +
1415
- `(${demotedSkipped} demotion-set rows advance their baseline only).`);
1433
+ console.log(`[hicortex] Strength promotion: ${rows.length} examined, promoted ${promoted} ` +
1434
+ `(+${totalGain.toFixed(3)} total strength, ` +
1435
+ `${demotedSkipped} demotion-set rows advance their baseline only).`);
1416
1436
  return { promoted, demoted_skipped: demotedSkipped };
1417
1437
  }
1418
1438
  // ---------------------------------------------------------------------------
@@ -1655,12 +1675,29 @@ deadline) {
1655
1675
  };
1656
1676
  // Stage 1: Pre-check
1657
1677
  const precheck = stagePrecheck(db, stateDir);
1658
- // Also check for unscored memories
1678
+ // #478: the importance pool is ROW-AGE bound — young (ingested within
1679
+ // IMPORTANCE_SETTLE_WINDOW_DAYS) ∪ never-scored. The lastConsolidated
1680
+ // watermark no longer defines any part of it: a deferred run's stuck
1681
+ // watermark used to re-settle a growing cohort nightly, and every
1682
+ // re-settle overwrites base_strength outright (erasing gains
1683
+ // stagePromotion had already paid — 5/5 observed erasures). The watermark
1684
+ // cohort (precheck.newMemories) STILL feeds reflection + links below —
1685
+ // their work is batch retry by design; only importance's per-row settling
1686
+ // was mis-bound to the batch marker.
1687
+ const settleCutoff = new Date(Date.now() - CALIBRATION.IMPORTANCE_SETTLE_WINDOW_DAYS * 86_400_000).toISOString();
1688
+ // getMemoriesSince keys on ingested_at only — absorbed rows (invisible to
1689
+ // recall) are filtered here in the same vocabulary getUnscoredMemories
1690
+ // uses in SQL: no LLM call on dead evidence.
1691
+ const young = storage
1692
+ .getMemoriesSince(db, settleCutoff)
1693
+ .filter((m) => m.status !== "absorbed");
1694
+ const youngIds = new Set(young.map((m) => m.id));
1695
+ // Also check for unscored memories (#425 watermark pool) — first settle is
1696
+ // age-independent, so a row used before its first score is never stranded.
1659
1697
  const unscored = storage.getUnscoredMemories(db);
1660
- const newIds = new Set(precheck.newMemories.map((m) => m.id));
1661
1698
  const scoreMemories = [
1662
- ...precheck.newMemories,
1663
- ...unscored.filter((m) => !newIds.has(m.id)),
1699
+ ...young,
1700
+ ...unscored.filter((m) => !youngIds.has(m.id)),
1664
1701
  ];
1665
1702
  // #194 no-fit scope: untagged rows (domain IS NULL) stay in the
1666
1703
  // re-evaluation scope — the decay/re-attempt contract ("re-halves once per
@@ -1669,15 +1706,26 @@ deadline) {
1669
1706
  // never caught because stagePrecheck used to read the AMBIENT (always
1670
1707
  // empty in the suite) state instead of the run's own watermark — threading
1671
1708
  // stateDir (#357) exposed the divergence between test and production.
1672
- const nofitInScope = db.prepare("SELECT COUNT(*) AS n FROM memories WHERE domain IS NULL").get().n;
1673
- const skip = scoreMemories.length === 0 && nofitInScope === 0;
1709
+ // #477: absorbed dedup losers (tags cleared, domain NULLed by the merge)
1710
+ // are dead evidence — they must not keep the no-fit scope (and the run)
1711
+ // alive every night. Same predicate spelling as getUnscoredMemories.
1712
+ const nofitInScope = db
1713
+ .prepare("SELECT COUNT(*) AS n FROM memories WHERE domain IS NULL AND COALESCE(status, '') != 'absorbed'")
1714
+ .get().n;
1715
+ // #478: the quiet-night gate is the UNION — watermark cohort OR young OR
1716
+ // unscored OR no-fit scope. A stuck watermark alone must never silence
1717
+ // reflection/links (their pool is the watermark cohort, and their retry
1718
+ // semantics are the reason a deferred run holds the watermark at all).
1719
+ const skip = scoreMemories.length === 0 &&
1720
+ precheck.newMemories.length === 0 &&
1721
+ nofitInScope === 0;
1674
1722
  report.stages.precheck = {
1675
1723
  skip,
1676
1724
  reason: skip
1677
1725
  ? precheck.reason
1678
- : `${precheck.newMemories.length} new + ${scoreMemories.length - precheck.newMemories.length} unscored memories`,
1726
+ : `${precheck.newMemories.length} new (watermark) + ${young.length} young + ${scoreMemories.length - young.length} unscored memories`,
1679
1727
  new_memory_count: precheck.newMemories.length,
1680
- unscored_count: scoreMemories.length - precheck.newMemories.length,
1728
+ unscored_count: unscored.length,
1681
1729
  };
1682
1730
  // Strength promotion (#448) — runs in the pre-skip deterministic zone, in
1683
1731
  // the same placement discipline as memory_cap BELOW it: accesses happen on
@@ -1715,7 +1763,27 @@ deadline) {
1715
1763
  // deferred. Deferred stages drain next run (cursors hold below them).
1716
1764
  // Stage 2: Importance Scoring
1717
1765
  if (!deadline?.hit("importance")) {
1718
- report.stages.importance = await stageImportance(db, scoreMemories, llm, budget, dryRun, deadline);
1766
+ // #478 paid-gain guard — evaluated HERE, at the stage boundary, not at
1767
+ // pool-build time: the pool above was built before this run's
1768
+ // stagePromotion payment, so a row whose FIRST use lands this run
1769
+ // passes a pool-build check and its just-paid gain is overwritten at
1770
+ // the first re-settle. This read sits after promotion by construction
1771
+ // and sees the advanced baseline. Already-scored rows carrying a paid
1772
+ // gain (promotion baseline advanced OR owner corroboration) keep it —
1773
+ // re-settling would stomp base_strength; `hicortex rescore-importance`
1774
+ // remains the wholesale operator re-judge. Never-scored rows
1775
+ // (importance_scored_at IS NULL) are NEVER skipped: first settle
1776
+ // always happens, whatever their use history.
1777
+ const paidGainIds = scoreMemories.length
1778
+ ? new Set(db.prepare(`SELECT id FROM memories
1779
+ WHERE importance_scored_at IS NOT NULL
1780
+ AND (COALESCE(promotion_last_count, 0) > 0
1781
+ OR COALESCE(corroboration_count, 0) > 0)
1782
+ AND id IN (${scoreMemories.map(() => "?").join(", ")})`).all(...scoreMemories.map((m) => m.id))
1783
+ .map((r) => r.id))
1784
+ : new Set();
1785
+ const settleCandidates = scoreMemories.filter((m) => !paidGainIds.has(m.id));
1786
+ report.stages.importance = await stageImportance(db, settleCandidates, llm, budget, dryRun, deadline, scoreMemories.length - settleCandidates.length);
1719
1787
  }
1720
1788
  // Stage 2.5: Reflection
1721
1789
  if (deadline?.hit("reflection")) {
@@ -23,6 +23,7 @@
23
23
  import type express from "express";
24
24
  import type Database from "better-sqlite3";
25
25
  import { type CaptureHealthRow } from "./capture-health.js";
26
+ import { type MemoryPrecision } from "./recall-precision.js";
26
27
  import { type Stage } from "./stages.js";
27
28
  /** Corpus-shape snapshot. `adoption` is null in backfilled rows (point-in-time,
28
29
  * can't be reconstructed from created_at). */
@@ -257,6 +258,16 @@ export interface DashboardData {
257
258
  last_outcome: string;
258
259
  }>;
259
260
  };
261
+ /**
262
+ * #476 — the Memory Precision card: Level 1 (pushed-index precision
263
+ * proxies over the recall_pushes/recall_events window) beside Level 2
264
+ * (recall depth = the uses-per-showing ratio over the SAME window, from
265
+ * snapshot deltas) + the divergence list. ALWAYS present; edges echoed
266
+ * like every threshold (the page never hardcodes), and the block measures
267
+ * ≤ 2 KB against the live 30-day payload. The page degrades to the
268
+ * pre-#476 recall-card rendering when the block is absent.
269
+ */
270
+ memory_precision: MemoryPrecision;
260
271
  digest: {
261
272
  date: string | null;
262
273
  run_at: string | null;
package/dist/dashboard.js CHANGED
@@ -45,6 +45,7 @@ const recall_index_js_1 = require("./recall-index.js");
45
45
  const config_read_js_1 = require("./config-read.js");
46
46
  const consolidate_js_1 = require("./consolidate.js");
47
47
  const capture_health_js_1 = require("./capture-health.js");
48
+ const recall_precision_js_1 = require("./recall-precision.js");
48
49
  const capture_pause_js_1 = require("./capture-pause.js");
49
50
  const state_js_1 = require("./state.js");
50
51
  const retrieval_js_1 = require("./retrieval.js");
@@ -576,6 +577,14 @@ function handleDashboardData(db, query, config) {
576
577
  by_source_machine: live.by_source_machine,
577
578
  },
578
579
  capture_health: { ...(0, capture_health_js_1.readCaptureHealth)(db), ...(0, capture_health_js_1.readCaptureHealthWindow)(db) },
580
+ // #476 Memory Precision: the window aggregation over the precision
581
+ // event tables + snapshot deltas. Level 2's "latest" is the SAME live
582
+ // adoption this handler already computed (one definition of corpus
583
+ // shape — computeDashboardMetrics); readMemoryPrecision owns the rest.
584
+ memory_precision: (0, recall_precision_js_1.readMemoryPrecision)(db, rangeParam, {
585
+ shown_sum: live.adoption?.shown_sum ?? 0,
586
+ used_sum: live.adoption?.used_sum ?? 0,
587
+ }),
579
588
  // #423 phase 3: pauses + presence in one block — one source of truth
580
589
  // for the rail's dots, toggles and the capture card's PAUSED badges.
581
590
  fleet: {
package/dist/db.js CHANGED
@@ -668,6 +668,80 @@ const MIGRATIONS = [
668
668
  db.exec("UPDATE memories SET promotion_last_count = access_count WHERE promotion_last_count IS NULL");
669
669
  },
670
670
  },
671
+ {
672
+ version: 21,
673
+ name: "memory_layout",
674
+ up: (db) => {
675
+ // #464 — the console field's server-computed placement. One row per
676
+ // placed memory at one epoch; the CURRENT epoch is MAX(epoch) and the
677
+ // payload (dashboard.ts handleDashboardField) LEFT JOINs on it. A full
678
+ // re-projection (`hicortex layout --reproject`) writes a NEW epoch and
679
+ // drops older ones in the SAME transaction (atomic swap — a mid-write
680
+ // failure leaves the previous epoch fully intact). Epochs exist so the
681
+ // whole store moves at once (one announced visual shift), never as a
682
+ // mix of projections. Idempotent: IF NOT EXISTS (the v18 pattern —
683
+ // plain CREATE, no ALTER).
684
+ // Kept after the #464/#475 field revert (#474): nothing reads or writes
685
+ // this table now — a dormant orphan. Rc databases already applied v21
686
+ // (user_version 21), so removing it would fork schema versions; it
687
+ // stays per the append-only migration discipline.
688
+ db.exec(`
689
+ CREATE TABLE IF NOT EXISTS memory_layout (
690
+ memory_id TEXT PRIMARY KEY,
691
+ x REAL NOT NULL,
692
+ y REAL NOT NULL,
693
+ epoch INTEGER NOT NULL
694
+ )
695
+ `);
696
+ db.exec("CREATE INDEX IF NOT EXISTS idx_memory_layout_epoch ON memory_layout(epoch)");
697
+ },
698
+ },
699
+ {
700
+ version: 22,
701
+ name: "recall_precision_events",
702
+ up: (db) => {
703
+ // #476 — the Memory Precision card's event store (Option A, owner
704
+ // decision 2026-09-19: per-push event rows incl. a ≤256-char prompt
705
+ // excerpt — the exact-window Level-1 measures + the judge follow-up's
706
+ // sample frame). Two sidecar tables, the v16 distill_activity pattern:
707
+ // OPERATIONS telemetry, no memories FK — recall_events.memory_id is
708
+ // DATA (absorbed memories' history stays queryable; rows outliving
709
+ // their memory are valid). recall_pushes: one row per NON-skipped
710
+ // /recall-index call (short-prompt skips and resets record nothing; a
711
+ // silent turn records its push row with zero events — the silence rate
712
+ // is computable). recall_events: one kind='shown' row per pushed line
713
+ // (similarity = cosine vs the PURE prompt embedding, redundancy = max
714
+ // cosine vs the standing-context basis — both RAW, verdicts computed at
715
+ // render so recalibration never rewrites history) + one kind='fetch'
716
+ // row per handleMemoryGet (push_id NULL). Pruned nightly at the
717
+ // retention horizon (= the longest window, the capture-health
718
+ // single-constant law). Idempotent: IF NOT EXISTS everywhere.
719
+ db.exec(`
720
+ CREATE TABLE IF NOT EXISTS recall_pushes (
721
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
722
+ ts TEXT NOT NULL,
723
+ day TEXT NOT NULL,
724
+ session_id TEXT,
725
+ prompt_excerpt TEXT
726
+ )
727
+ `);
728
+ db.exec("CREATE INDEX IF NOT EXISTS idx_recall_pushes_day ON recall_pushes(day)");
729
+ db.exec(`
730
+ CREATE TABLE IF NOT EXISTS recall_events (
731
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
732
+ ts TEXT NOT NULL,
733
+ day TEXT NOT NULL,
734
+ push_id INTEGER,
735
+ memory_id TEXT NOT NULL,
736
+ similarity REAL,
737
+ redundancy REAL,
738
+ kind TEXT NOT NULL
739
+ )
740
+ `);
741
+ db.exec("CREATE INDEX IF NOT EXISTS idx_recall_events_day ON recall_events(day)");
742
+ db.exec("CREATE INDEX IF NOT EXISTS idx_recall_events_memory_kind ON recall_events(memory_id, kind)");
743
+ },
744
+ },
671
745
  ];
672
746
  /**
673
747
  * Run all pending migrations against the database.
@@ -70,8 +70,10 @@ export interface StructuralStrengthStats {
70
70
  * "if nothing changes" projection), plus the asymptotic floor per memory.
71
71
  * Requires `configureDecay` to already reflect the desired half-life (call
72
72
  * `runPruneDryRun` first, or `configureDecay` directly, in the same process).
73
+ * `now` (#458): the instant "now" means — pinned by run-eval's --now for
74
+ * wall-clock-independent before/after audits; default the live clock.
73
75
  */
74
- export declare function runStructuralStrengthStats(db: Database.Database): StructuralStrengthStats;
76
+ export declare function runStructuralStrengthStats(db: Database.Database, now?: Date): StructuralStrengthStats;
75
77
  export interface AdoptionStats {
76
78
  totalMemories: number;
77
79
  shownCountHistogram: Record<string, number>;
@@ -106,4 +108,4 @@ export interface AdoptionStats {
106
108
  * feature's youth as much as its effectiveness. Report callers should state
107
109
  * that caveat alongside the numbers, not just the numbers.
108
110
  */
109
- export declare function runAdoptionStats(db: Database.Database): AdoptionStats;
111
+ export declare function runAdoptionStats(db: Database.Database, now?: Date): AdoptionStats;
@@ -97,12 +97,13 @@ function histogram(values, edges = STRENGTH_BUCKET_EDGES) {
97
97
  * "if nothing changes" projection), plus the asymptotic floor per memory.
98
98
  * Requires `configureDecay` to already reflect the desired half-life (call
99
99
  * `runPruneDryRun` first, or `configureDecay` directly, in the same process).
100
+ * `now` (#458): the instant "now" means — pinned by run-eval's --now for
101
+ * wall-clock-independent before/after audits; default the live clock.
100
102
  */
101
- function runStructuralStrengthStats(db) {
103
+ function runStructuralStrengthStats(db, now = new Date()) {
102
104
  const rows = db
103
105
  .prepare("SELECT id, base_strength, last_accessed FROM memories")
104
106
  .all();
105
- const now = new Date();
106
107
  const nowVals = [];
107
108
  const at180 = [];
108
109
  const at365 = [];
@@ -147,11 +148,10 @@ function ageBucketLabel(ageDays) {
147
148
  * feature's youth as much as its effectiveness. Report callers should state
148
149
  * that caveat alongside the numbers, not just the numbers.
149
150
  */
150
- function runAdoptionStats(db) {
151
+ function runAdoptionStats(db, now = new Date()) {
151
152
  const rows = db
152
153
  .prepare("SELECT id, shown_count, access_count, source_agent, created_at FROM memories")
153
154
  .all();
154
- const now = new Date();
155
155
  const shownVals = [];
156
156
  const accessVals = [];
157
157
  let totalShown = 0;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * #458 — the shared pinned-clock helper for eval harnesses.
3
+ *
4
+ * The recall evals score against wall-clock time (recency + decay terms in
5
+ * computeScore/effectiveStrength), so a pre-photo and a post-run hours apart
6
+ * drift even on identical code — PR D evidenced mode-OFF units moving with no
7
+ * changed code in the path. The fix is a harness-visible pin: every eval that
8
+ * calls retrieve() (or the decay/adoption stats) accepts a `--now <ISO>` flag
9
+ * and threads the SAME instant into the retrieve() `now` seam (retrieval.ts —
10
+ * the injectable-clock idiom of run-deadline.ts), making before/after runs
11
+ * wall-clock-independent. Default stays the live clock — pinning is opt-in,
12
+ * for comparability.
13
+ *
14
+ * Fail-explicit by design (the repo convention): an invalid pin THROWS with a
15
+ * clear message rather than silently falling back to the live clock — a run
16
+ * that believed it was pinned but wasn't would silently reintroduce the drift
17
+ * class this helper exists to remove.
18
+ */
19
+ /**
20
+ * Parse a `--now` flag value into the pinned instant.
21
+ *
22
+ * @param raw the raw flag value. undefined or empty/whitespace → null (the
23
+ * live clock — the flag was not passed). Anything else must parse as a
24
+ * Date; garbage or an invalid instant (e.g. month 13) throws.
25
+ * @returns the pinned Date, or null for the live clock.
26
+ */
27
+ export declare function parsePinnedNow(raw: string | undefined): Date | null;
28
+ /**
29
+ * Human-readable clock mode for report headers / photo JSON: "pinned <ISO>"
30
+ * or "live" — so artifacts are self-describing about which clock produced them.
31
+ */
32
+ export declare function clockLabel(now: Date | null): string;
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ /**
3
+ * #458 — the shared pinned-clock helper for eval harnesses.
4
+ *
5
+ * The recall evals score against wall-clock time (recency + decay terms in
6
+ * computeScore/effectiveStrength), so a pre-photo and a post-run hours apart
7
+ * drift even on identical code — PR D evidenced mode-OFF units moving with no
8
+ * changed code in the path. The fix is a harness-visible pin: every eval that
9
+ * calls retrieve() (or the decay/adoption stats) accepts a `--now <ISO>` flag
10
+ * and threads the SAME instant into the retrieve() `now` seam (retrieval.ts —
11
+ * the injectable-clock idiom of run-deadline.ts), making before/after runs
12
+ * wall-clock-independent. Default stays the live clock — pinning is opt-in,
13
+ * for comparability.
14
+ *
15
+ * Fail-explicit by design (the repo convention): an invalid pin THROWS with a
16
+ * clear message rather than silently falling back to the live clock — a run
17
+ * that believed it was pinned but wasn't would silently reintroduce the drift
18
+ * class this helper exists to remove.
19
+ */
20
+ Object.defineProperty(exports, "__esModule", { value: true });
21
+ exports.parsePinnedNow = parsePinnedNow;
22
+ exports.clockLabel = clockLabel;
23
+ /**
24
+ * Parse a `--now` flag value into the pinned instant.
25
+ *
26
+ * @param raw the raw flag value. undefined or empty/whitespace → null (the
27
+ * live clock — the flag was not passed). Anything else must parse as a
28
+ * Date; garbage or an invalid instant (e.g. month 13) throws.
29
+ * @returns the pinned Date, or null for the live clock.
30
+ */
31
+ function parsePinnedNow(raw) {
32
+ if (raw === undefined || raw.trim() === "")
33
+ return null;
34
+ const parsed = new Date(raw);
35
+ if (Number.isNaN(parsed.getTime())) {
36
+ throw new Error(`eval clock: invalid --now value "${raw}" — pass a full ISO 8601 instant ` +
37
+ `(e.g. 2026-09-17T09:00:00.000Z) or omit the flag to run on the live clock`);
38
+ }
39
+ return parsed;
40
+ }
41
+ /**
42
+ * Human-readable clock mode for report headers / photo JSON: "pinned <ISO>"
43
+ * or "live" — so artifacts are self-describing about which clock produced them.
44
+ */
45
+ function clockLabel(now) {
46
+ return now === null ? "live" : `pinned ${now.toISOString()}`;
47
+ }