akm docs

R5 — Longitudinal Collapse/Churn Detector for akm improve

Status (0.9.17): removed. The detector, its collapse_detector_alert event, the improve_cycle_metrics / canary_queries tables and scripts/refresh-canary-set.ts were deleted with the improve ledger change (migration 028 drops the tables); recorded telemetry shows no alert ever fired. The generation and lexical-diversity merge checks, which never refused a merge, went with it; only the random-cluster injection in src/commands/improve/anti-collapse.ts remains. The history below is kept for reference.

Earlier status: Partially implemented. The COLLAPSE alert class (§1, three sub-conditions) and the MERGE-FLOOR advisory are live. The CHURN alert class described throughout this document (§1, §5.2 accepted_actions column, §6.1 churnMinAcceptedActions config key, §8 item 2) was removed in the 0.9.0 backlog burn-down (2026-07) and NOT implemented. Its input signal — acceptedActions — was wired to a hardcoded 0 at every call site from the 0.9.0 confidence-gate deletion onward (the design's prescribed source, computeImproveRunMetrics(result).acceptedCount, is computed after envelope assembly, strictly AFTER the point in the pipeline where the detector fires, so it was never reachable there), which meant acceptedSum >= churnMinAcceptedActions (default 25) could never be satisfied and the alert had never once fired. Rather than wire in a differently-scaled substitute signal (e.g. consolidate's own merged/mergedSecondaries counts) and recalibrate a threshold that was tuned against the design's original (unreachable) quantity, the alert class was deleted: CollapseAlertKind no longer has a "churn" member, evaluateCollapseAlerts no longer evaluates it, and the config schema no longer accepts churnMinAcceptedActions. The improve_cycle_metrics. accepted_actions DB column remains (migrations are append-only; dropping it is a separate, unowned migration change) and is now always written as 0. See src/commands/improve/collapse-detector.ts's module doc comment for the live-code version of this note.

Implementation note (paths below are the ORIGINAL PLAN and no longer match the landed tree in a few places — the plan's intent held, the file layout didn't): the merge guards landed in checkGenerationGuard / checkLexicalDiversity / checkMergeInformationFloor, all in src/commands/improve/anti-collapse.ts (planned below as homeostatic.ts, which does not exist); the rank-metrics module landed at scripts/akm-eval/src/rank-metrics.ts (planned below as src/core/eval/rank-metrics.ts — the move stopped one directory short once it turned out src/ had zero importers of it, so there was no src/-side reason to relocate it there); the canary/cycle-metrics DB helpers landed in src/storage/repositories/canaries-repository.ts (planned below as state-db.ts, part of the broader repository-extraction split of src/core/state-db.ts / src/indexer/db/db.ts); the config schema landed in src/core/config/schema/improve.ts (planned below as the monolithic config-schema.ts); the health advisory landed in src/commands/health/advisories.ts (planned below as health.ts); the akm improve canary subcommand landed as documented. Section §10 carries the same corrections in its file-by-file table. One naming note beyond file layout: the frontmatter field this document calls source_refs landed as xrefs (see injectGenerationFrontmatter, which lives in consolidate.ts alongside its merge-branch call site) — read source_refs below as xrefs.

Scope discipline. The field has measured the two failure modes this detects (repeated LLM merge passes collapsing a store to one entry in ~10 passes; unbounded accumulation dropping task accuracy from 39%→13%) [research-08 §10]. AKM has strong preventive scaffolding (generation guard, lexical-diversity check, dedup, cooldowns, multi-run recombine confirmation) but no longitudinal signal to detect collapse or wasted churn if prevention fails. This is the one place the analysis judged adding a small mechanism justified. Everything below reuses existing machinery where it exists (curate-golden rank metrics, computeBigramDiversity, the anti-collapse generation guard, improve_runs metrics, the events log, the health advisory channel) and states the rejected alternative wherever a judgment call was made.

Hard constraints honored throughout: deterministic only — the detector NEVER calls an LLM and never loads an embedding model; bounded storage (state.db already suffered a 3.95 GB result_json bloat — every row this design writes is < 2 KB and retention is explicit); cheap enough to run on every consolidation/recombine cycle (~a few runs/day; improve itself fires every ~20–30 min but the detector only runs on cycles where consolidate/recombine actually did work); fail-open (a detector error warns and skips, never breaks an improve run).


1. Problem statement & detection targets

Two distinct pathologies, distinguishable only longitudinally:

Pathology What it is Primary signal Secondary signals
Collapse Repeated merges/generalizations destroy information: the store converges toward fewer, blander, more generic entries. Canonical content stops being retrievable by the queries that used to find it. Fixed-canary Recall@K / NDCG@K downtrend across cycles distinct_content_ratio downtrend; memory count shrinking while mean generation rises; mean_bigram_diversity downtrend
Churn The pipeline burns LLM budget rewriting/merging content with no retrieval-visible effect: accepted changes accumulate but canary answers and store shape don't move. High accepted-change volume AND flat canary AND flat entropy over a window consolidate/recombine plannedCount > 0 with unchanged cycle metrics

Operational definitions (exact, threshold-bearing)

Let a cycle be one improve run in which the consolidate pass processed ≥ 1 op or the recombine pass evaluated ≥ 1 cluster. The detector snapshots one metrics row per cycle (§5). Let W = windowCycles (default 5) and hist = the last W rows.

COLLAPSE alert fires when ANY of:

  1. meanCanaryRecall(current) ≤ median(meanCanaryRecall over hist) − recallDropThreshold (default recallDropThreshold = 0.15 absolute). Median-of-window, not previous-cycle, so a single noisy cycle can't fire or mask the alert.
  2. distinct_content_ratio is monotonically non-increasing across all W rows AND hist[0].distinct_content_ratio − current.distinct_content_ratio ≥ entropyDropThreshold (default 0.05).
  3. store_memory_count(current) < 0.8 × max(store_memory_count over hist) AND over_generation_count(current) > over_generation_count(hist[0]) — the store is shrinking because of re-merging, not deletion hygiene (over_generation_count = assets with frontmatter generation > maxGeneration).

CHURN alert fires when ALL of:

  1. Σ acceptedCount over hist ≥ churnMinAcceptedActions (default 25 — from live data: ~471 accepts/3 days on the default profile means a genuinely idle store won't trip this, but a busy one accumulates it in ~1–2 days of cycles),
  2. |meanCanaryScore(current) − meanCanaryScore(hist[0])| < 0.02, and
  3. |distinct_content_ratio(current) − distinct_content_ratio(hist[0])| < 0.02.

i.e. real write volume, zero retrieval-visible or shape-visible movement. Churn is a budget-waste advisory, never a candidate for blocking.

MERGE-FLOOR violation (per-merge, not windowed): a consolidate merge whose output fails the information floor (§4). Counted per cycle; an advisory fires when merge_floor_violations > 0 in the current cycle.

Both alert evaluations are pure functions of the stored history rows — trivially unit testable with hand-built rows (§8).


2. Canary query mechanism

2.1 Where the canary set lives

Derived from the live bundle, frozen in state.db (table canary_queries, §5) — NOT a checked-in file. Rejected alternative: a checked-in fixture like tests/fixtures/stashes/curate-golden — a fixed corpus cannot measure the live bundle, and the live bundle content differs per install, so the canary set must be minted from what the store actually contains and then held fixed.

2.2 Merge-following (the false-positive killer)

A canary anchor being legitimately merged must not read as collapse. When scoring, a canary counts as a hit if the top-K contains (a) the anchor ref itself, OR (b) any entry whose frontmatter source_refs array contains the anchor ref (consolidate's injectGenerationFrontmatter in src/commands/improve/consolidate.ts already writes source_refs = all merge participants; recombine writes source_refs at recombine.ts:878). One level of indirection only — if a merged asset is merged again and provenance is dropped, the canary misses, and that is precisely the information loss we want to detect. This makes the recall metric a provenance-aware survival probe: "can the store still answer what this asset answered, and can it say where the answer came from."

2.3 Retrieval path: FTS-only BM25 (decision + justification)

The canary runs runFtsQuery-path BM25 search against the live index.db (searchFts in src/storage/repositories/index-fts-repository.ts; bm25 weights name 10 / description 5 / tags 3 / hints 2 / content 1), via a thin exported wrapper (§6.1). No embeddings, no LLM, no model download, byte-deterministic given the same index.

Rejected alternatives, in one sentence each:

What we give up: the canary won't see a vector-only ranking regression. That is the curate-golden CI gate's job (frozen corpus, deterministic embedder, per-commit); the detector's job is longitudinal store health, not ranker health. The two are complementary and share their metric code (§2.4).

2.4 Metrics — reuse curate-golden's rank metrics (with a small move)

scripts/akm-eval/src/curate-metrics.ts already implemented (at design time) pure, IO-free ndcgAtK, recallAtK, mrr, scoreCurateCase, summarizeCurateMetrics. The detector must not duplicate them, and src/ should not import from scripts/ (bundling + layering). Move the module to src/core/eval/rank-metrics.ts and make scripts/akm-eval/src/curate-metrics.ts a one-line re-export so the bench, the CI test, and the detector share one implementation. The module has no imports, so the move is mechanical. Rejected alternative: copy the three functions into the detector — that's the parallel-mechanism accretion this repo bans. (Landed as scripts/akm-eval/src/rank-metrics.ts instead of src/core/eval/rank-metrics.ts — see the implementation note at the top of this document.)

Per-canary the detector computes recall@K, ndcg@K, rank of first hit (K = collapseDetector.k, default 10, binary relevance with the single anchor + its merge-followers as the relevant set); per-cycle it stores the means and a compact per-canary array ([canaryId, rankOfHit|-1] pairs — ints only, ≤ ~400 bytes for 40 canaries).

2.5 When it runs

Once per qualifying improve cycle, in the post-loop stage (runImprovePostLoopStage, src/commands/improve/loop-stages.ts), gated on: detector enabled AND consolidationRan === true AND not options.dryRun. The pass column is always written as "consolidate" today — there is no recombine pass wired into the loop (no recombination value reaches this gate), so the "recombine"/"both" pass values and the recombine-triggered snapshots this section originally described are not implemented. On non-qualifying runs (the ~93% of default-profile runs that touch no merges) the detector does nothing — zero cost on the 20–30-min hot path.


3. Entropy / store-shape metrics

All computed from a single pass over index.db entries for entry_type IN ('memory','lesson','knowledge') (columns: entry_key, entry_type, search_text, entry_json — see the entries table DDL in src/storage/repositories/index-schema.ts). No filesystem reads.

Metric Definition Source
store_total, store_by_type_json Row counts, total and per type (also per-type for the remaining asset types, one GROUP BY entry_type) entries
distinct_content_ratio COUNT(DISTINCT normHash(search_text)) / COUNT(*) over the three learning types. normHash = FNV-1a-64 of lowercased, whitespace-collapsed search_text. 1.0 = all distinct; downtrend = convergence. entries.search_text (no JSON parse)
mean_bigram_diversity Mean of computeBigramDiversity(search_text) (reuse computeBigramDiversity in src/commands/improve/anti-collapse.ts) over a deterministic sample: sort by entry_key, take every ⌈N/2000⌉-th row, cap 2,000. entries.search_text
over_generation_count Count of entries whose entry_json frontmatter carries generation > maxGeneration (parse generation from entry_json with a cheap LIKE '%"generation"%' SQL pre-filter so only the few merged assets get JSON.parsed). entries.entry_json
accepted_actions This run's acceptedCount from the already-computed computeImproveRunMetrics result (threaded in, not re-derived). improve run in flight
merge_floor_violations Count from §4, threaded from the consolidate pass result. consolidate result

Cost analysis at 5–10k assets (the stated ceiling): one indexed SELECT of ~10k rows ≈ 10–30 ms in bun:sqlite; FNV-64 over ~1–2 KB × 10k ≈ 20–40 ms; bigram diversity over the 2,000-row sample ≈ 30–60 ms; 40 FTS queries ≈ 10–40 ms. Total < 250 ms and zero LLM/model cost, on a pass (consolidate/recombine) that already spends minutes in LLM calls. Memory: search_text strings are streamed row-by-row (prepare/iterate), never all materialized — peak overhead is the 2,000-row sample.


4. Merge-information floor

The analysis asks for two things: (a) a hard floor on re-generalization count, (b) a requirement that merges strictly increase information, not just shorten.

4.1 (a) The hard re-merge floor already exists — turn it on

checkGenerationGuard (src/commands/improve/anti-collapse.ts) already refuses a merge when ≥ 2 participants exceed maxGeneration (default 2), and computeMergedGeneration/readAssetGeneration maintain the frontmatter generation counter through injectGenerationFrontmatter, called from the merge branch of consolidate.ts. It is gated on processes.consolidate.antiCollapse.enabled, default OFF.

Change: flip the anti-collapse guard suite to default-ON (opt out via antiCollapse: { enabled: false }). Concretely, the three call-site gates change from config.enabled truthy-checks to config.enabled !== false: checkGenerationGuard and checkLexicalDiversity (both in anti-collapse.ts, landed name — planned below as homeostatic.ts), and the random-cluster injection gate in consolidate.ts. This matches this branch's precedent (distill quality judge and extract schema-similarity gate both flipped to default-ON, fail-open) and is narrow: the generation guard only refuses the pathological case of merging two already-twice-merged assets. Rejected alternative: a new separate re-merge counter — generation IS that counter; adding another would be the parallel-mechanism malfunction.

4.2 (b) Information floor — new deterministic check beside the generation guard

New pure function in homeostatic.ts (colocated with the other anti-collapse guards; landed in anti-collapse.ts as checkMergeInformationFloor):

export interface MergeInformationFloorResult {
  passed: boolean;
  /** Provenance: |union(source_refs ∪ participants)| after vs. before. */
  provenanceBefore: number;
  provenanceAfter: number;
  /** Specificity proxy: distinct-token retention of merged body vs. union of sources. */
  specificityRetention: number; // 0..1
  reason?: string;
}

/**
 * A merge must strictly increase information:
 *  1. Provenance: the merged asset's source_refs must be a superset of the union of
 *     all participants' source_refs plus the participant refs themselves
 *     (provenance never shrinks through a merge).
 *  2. Specificity: distinctTokens(mergedBody) >= minSpecificityRetention *
 *     distinctTokens(concat(participant bodies)) — a merge that only shortens/
 *     genericizes fails. Tokenization = the same lowercase whitespace split
 *     computeBigramDiversity uses. Default minSpecificityRetention = 0.6.
 */
export function checkMergeInformationFloor(
  mergedBody: string,
  mergedSourceRefs: string[],
  participants: Array<{ ref: string; body: string; sourceRefs: string[] }>,
  config: AntiCollapseConfig,
): MergeInformationFloorResult;

Enforcement point: consolidate.ts merge branch, immediately after the generation guard block (~line 1998, after injectGenerationFrontmatter) where mergedContent and all participant bodies are in hand. Provenance is made to pass mechanically: injectGenerationFrontmatter is extended to write source_refs as the union of participants + their existing source_refs (today it only sets it when absent — consolidate.ts:779-781 — which drops second-generation provenance; that is a bug this fixes). Specificity is measured, and in v1 a failure does not block the merge: it increments merge_floor_violations, pushes a warning, and emits the per-cycle advisory (§7). The documented promotion path (§9) turns it into a refusal (skipReason: "merge_information_floor", mirroring merge_generation_guard at consolidate.ts:1966) once live data confirms the 0.6 threshold's false-positive rate. Rejected alternative: blocking from day one — a wrong threshold silently freezing consolidation is worse than a wrong threshold generating advisories.

Config: two keys added to the existing antiCollapse object (§6.2). No new config surface.


5. Storage schema

5.1 Why not events alone

The events log is capped at 90-day retention (purgeOldEvents, purgeOldEvents in src/storage/repositories/events-repository.ts) and stores metadata as JSON blobs. Collapse is a slow failure — the field's measured case took ~10 merge passes, and AKM's consolidate/recombine cycles are days-to-weeks apart in practice (consolidate 4 runs and recombine-only 1 run in a recent 7-day window) — so a meaningful trend window exceeds 90 days, and trend queries need indexed numeric columns, not json_extract over a 100k-row event table. Decision: cycle history in its own table (365-day retention); alerts (rare, actionable) additionally go to the events log so they surface through the existing readEvents-based health plumbing exactly like outcome_proxy_inverted does. Rejected alternative: rows in improve_runs.metrics_json — that JSON is per-run not per-cycle, is the exact blob that already bloated to 3.95 GB, and is slated for retention-nulling (R9).

5.2 Migration 016 (append-only; 015 verified current tail of MIGRATIONS in src/core/state/migrations.ts)

-- ── Migration 016 — collapse/churn detector (R5) ─────────────────────────────
CREATE TABLE IF NOT EXISTS canary_queries (
  id            INTEGER PRIMARY KEY AUTOINCREMENT,
  canary_set_id TEXT    NOT NULL,          -- mint token; all rows of one set share it
  anchor_ref    TEXT    NOT NULL,          -- e.g. 'memory:alpha'
  query         TEXT    NOT NULL,          -- deterministic FTS query string
  source        TEXT    NOT NULL DEFAULT 'auto',  -- 'auto' | 'manual'
  active        INTEGER NOT NULL DEFAULT 1,
  created_at    TEXT    NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_canary_queries_active
  ON canary_queries(active, canary_set_id);

CREATE TABLE IF NOT EXISTS improve_cycle_metrics (
  id                      INTEGER PRIMARY KEY AUTOINCREMENT,
  run_id                  TEXT    NOT NULL,  -- improve_runs.id of the cycle
  ts                      TEXT    NOT NULL,  -- ISO-8601 UTC
  pass                    TEXT    NOT NULL,  -- 'consolidate' | 'recombine' | 'both'
  canary_set_id           TEXT    NOT NULL,
  mean_recall             REAL    NOT NULL,
  mean_ndcg               REAL    NOT NULL,
  mean_mrr                REAL    NOT NULL,
  canary_ranks_json       TEXT    NOT NULL,  -- [[canaryId, rankOfHit|-1], ...] ≤ ~200B
  store_total             INTEGER NOT NULL,
  store_by_type_json      TEXT    NOT NULL,  -- {"memory":n,"lesson":n,...} ≤ ~200B
  distinct_content_ratio  REAL    NOT NULL,
  mean_bigram_diversity   REAL    NOT NULL,
  over_generation_count   INTEGER NOT NULL,
  accepted_actions        INTEGER NOT NULL,
  merge_floor_violations  INTEGER NOT NULL DEFAULT 0,
  alerts_json             TEXT    NOT NULL DEFAULT '[]'  -- fired alert kinds, ≤ ~100B
);
CREATE INDEX IF NOT EXISTS idx_improve_cycle_metrics_ts
  ON improve_cycle_metrics(ts);

5.3 Footprint & retention

Every column is a scalar or a size-capped JSON array; a row is < 2 KB by construction (the only variable-size fields are the three small JSON columns, each bounded by canaryCount/type-count). At the realistic maximum of ~10 qualifying cycles/day, one year of history is < 7 MB. Retention: purgeOldCycleMetrics(db, retentionDays = 365) — same shape as purgeOldEvents — called from the same maintenance point that already runs purgeOldEvents in runImproveMaintenancePasses, emitting the purge count into the existing events_purged-style observability (new metadata key, no new event type needed). canary_queries rows are never purged (tens of rows, needed to interpret history).


6. Wiring points

6.1 New module: src/commands/improve/collapse-detector.ts (~250 LOC)

export interface CollapseDetectorConfig {
  enabled?: boolean;            // default true
  canaryCount?: number;         // default 40 (owner-approved 30–50 range)
  k?: number;                   // default 10
  windowCycles?: number;        // default 5
  recallDropThreshold?: number; // default 0.15
  entropyDropThreshold?: number;// default 0.05
  churnMinAcceptedActions?: number; // default 25
  retentionDays?: number;       // default 365
}

export interface CycleMetrics { /* mirrors improve_cycle_metrics columns */ }
export type CollapseAlertKind = "collapse-recall" | "collapse-entropy"
  | "collapse-shrink" | "churn" | "merge-floor";
export interface CollapseAlert { kind: CollapseAlertKind; detail: string;
  metrics: Record<string, number>; }

/** Mint (or return) the active canary set. Deterministic given the index +
 *  salience tables. Called lazily by runCollapseDetector on first use. */
export function ensureCanarySet(stateDb: Database, indexDb: IndexDatabase,
  cfg: CollapseDetectorConfig): { canarySetId: string;
  canaries: Array<{ id: number; anchorRef: string; query: string }> };

/** Compute one cycle's metrics. FTS + entries scan only; no LLM, no model. */
export function computeCycleMetrics(stateDb: Database, indexDb: IndexDatabase,
  args: { runId: string; pass: "consolidate" | "recombine" | "both";
    acceptedActions: number; mergeFloorViolations: number;
    cfg: CollapseDetectorConfig }): CycleMetrics;

/** PURE. Evaluate §1's alert definitions over stored history. */
export function evaluateCollapseAlerts(history: CycleMetrics[],
  current: CycleMetrics, cfg: CollapseDetectorConfig): CollapseAlert[];

/** Orchestrator: ensure canaries → compute → evaluate → persist row →
 *  appendEvent per alert. Fail-open: catches, warns, returns undefined. */
export function runCollapseDetector(args: { stateDbPath?: string;
  indexDbPath?: string; runId: string; pass: "consolidate"|"recombine"|"both";
  acceptedActions: number; mergeFloorViolations: number;
  config: AkmConfig; eventsCtx?: EventsContext }): CycleMetrics | undefined;

Canary retrieval goes through searchFts (src/storage/repositories/index-fts-repository.ts); rank metrics import from the relocated rank-metrics module (§2.4, landed at scripts/akm-eval/src/rank-metrics.ts); bigram diversity imports computeBigramDiversity from ./anti-collapse (landed name).

6.2 Hook + config + existing-file edits

File Change
src/commands/improve/loop-stages.ts (end of runImprovePostLoopStage, after the procedural block) Call runCollapseDetector(...) when enabled AND (consolidationRan OR recombination?.processed > 0) AND !options.dryRun. Thread consolidationRan (already a param of runImproveMaintenancePasses) and the run's accepted/merge-floor counts. Attach the returned CycleMetrics to the post-loop result so it lands in result_json for free.
src/core/config/schema/improve.ts (ImproveCollapseDetectorSchema; landed here, not in the monolithic config-schema.ts as originally planned) Add collapseDetector: z.object({ enabled, canaryCount, k, windowCycles, recallDropThreshold, entropyDropThreshold, churnMinAcceptedActions, retentionDays }).passthrough().optional() with the bounds above (all .optional(), numeric mins/maxes mirroring §6.1 defaults). Add mergeInformationFloor: z.boolean().optional() and minSpecificityRetention: z.number().min(0).max(1).optional() to the existing antiCollapse object.
Config types (inferred via z.infer from the schema above, not a hand-mirrored config-types.ts type as originally planned) Keep the schema as the single source of truth (this repo's config audit flagged two-source-of-truth drift as the root rot).
src/core/state/migrations.ts (append after 015) Migration 016-collapse-churn-detector (§5.2 DDL, verbatim).
src/storage/repositories/canaries-repository.ts (landed here as part of the broader repository-extraction split, not in state-db.ts as originally planned) insertCycleMetrics, queryRecentCycleMetrics(db, canarySetId, limit), purgeOldCycleMetrics(db, retentionDays) + canary CRUD (insertCanaries, getActiveCanaries, deactivateCanarySet).
src/commands/improve/anti-collapse.ts (landed name; originally planned as homeostatic.ts) checkMergeInformationFloor (§4.2); the three !config.enabled gates read config.enabled === false (default-on).
src/commands/improve/consolidate.ts Call the floor check after injectGenerationFrontmatter; injectGenerationFrontmatter unions xrefs (the landed field name for what this document calls source_refs) instead of set-if-absent; count violations into the pass result.
src/core/events.ts (EventType union) Add "collapse_detector_alert" with a doc comment (metadata: {kind, detail, metrics, canarySetId, runId}).
src/commands/health/advisories.ts (landed here, not in the monolithic health.ts as originally planned), beside the outcome_proxy_inverted advisory Advisory "collapse-churn-detector": read collapse_detector_alert events in the health window plus the latest improve_cycle_metrics row; warn on any alert, pass with the latest mean recall / entropy in the message otherwise, unknown when no cycle row exists yet.
scripts/akm-eval/src/rank-metrics.ts (new; landed here instead of src/core/eval/rank-metrics.ts — see the implementation note at the top of this document) + scripts/akm-eval/src/curate-metrics.ts (becomes re-export) §2.4.
src/commands/improve/improve-cli.ts akm improve canary subcommand: default = print active set + last windowCycles rows (table); --refresh mints a new set; --json for scripting. Rejected alternative: a new top-level akm collapse command — this is improve-internal observability and the health advisory is the primary surface.

6.3 Default-on vs opt-in: default-ON, justified

The branch precedent is explicit: quality gates that are deterministic, cheap, and fail-open ship default-ON with a config opt-out (distill qualityGate, extract schemaSimilarity, outcome weights). The detector is strictly observational in v1 (never blocks anything), costs < 250 ms only on merge-active cycles, uses no LLM/model, and fails open. An opt-in longitudinal detector is a contradiction — the operator who remembered to enable it is the one who didn't need it. Opt-out: improve.collapseDetector.enabled: false.


7. Alert semantics


8. Test plan

All tests use the _helpers/sandbox withIsolatedAkmStorage helper (per withSeededGolden in tests/integration/curate-golden-eval.test.ts); no raw process.env mutation (scripts/lint-tests-isolation.ts enforces this). No test touches a live DB. All new tests are CI-fast unit tests under tests/ (no Bun.spawn/Bun.serve/60s timeouts — the unit-vs-integration boundary rule).

(Landed layout differs from the plan below: the simulation/pure-function/orchestrator tests (items 1-3, 7) and the curate-golden reuse guard (item 6) all landed together in tests/integration/commands/improve/collapse-detector.test.ts; item 4 landed in tests/commands/improve/anti-collapse.test.ts; item 5 landed in tests/integration/state-db/improve-cycle-metrics.test.ts. See the implementation note at the top of this document.)

  1. collapse-detector.test.ts — deterministic collapse simulation (the headline test). Seed an isolated bundle with 30 distinct memories (distinct topics, distinct vocabularies), akmIndex it (FTS only — the detector path needs no embeddings; set semanticSearchMode: "off" in the sandbox config), mint the canary set, snapshot cycle 0. Then simulate merge passes directly on the bundle files: each pass replaces groups of 3 memories with one generic merged body (progressively blander shared phrasing, xrefs written for pass 1 then deliberately dropped for pass 2+ to model provenance loss), reindex, run computeCycleMetrics + evaluateCollapseAlerts. Assert: distinct_content_ratio and mean_bigram_diversity decrease monotonically; mean recall survives pass 1 (merge-following via xrefs) and drops afterward; a collapse-recall or collapse-entropy alert fires by pass ≤ 4; the collapse_detector_alert event is written.
  2. Churn simulation (same file): paraphrase bodies without merging (store shape and canary hits stable), feed acceptedActions ≥ threshold per cycle for W cycles, assert exactly a churn alert and no collapse alert.
  3. evaluateCollapseAlerts pure-function table tests: hand-built history rows for each §1 clause boundary (just-below/just-above each threshold; median-window robustness against a single-cycle recall spike; window shorter than W never alerts).
  4. Merge-information floor unit tests (anti-collapse.test.ts): provenance-union enforcement, specificity retention at the 0.6 boundary, shortening merge fails, genuinely-additive merge passes; plus the default-on flip (enabled absent ⇒ guards active; enabled: false ⇒ inert — byte-identical to the old default for opted-out configs).
  5. improve-cycle-metrics.test.ts: migration 016 applies on a fresh and an existing DB; insert/query round-trip; purgeOldCycleMetrics deletes only rows past retention and returns the count; canary set mint / deactivate / re-mint preserves history rows with the old canary_set_id.
  6. Curate-golden reuse guard: the metrics move in §2.4 keeps curate-golden-eval.test.ts green unchanged (it imports through the re-export) — that existing test IS the regression guard for the shared module; add one assertion in the detector test importing ndcgAtK from the landed rank-metrics module (scripts/akm-eval/src/rank-metrics.ts) to pin the canonical path. Additionally, one detector test seeds the curate-golden fixture corpus with AKM_EMBED_DETERMINISTIC: "1" (exactly as withSeededGolden does), mints canaries against it, and asserts cycle-0 mean recall ≥ 0.9 — proving the canary mechanism itself finds well-formed content on a known-good corpus.
  7. Wiring test (same collapse-detector.test.ts file, "post-loop hook gating" group): an improve run with a stubbed consolidate that reports work writes exactly one cycle row; a run with no consolidate/recombine work writes none; dryRun writes none; a detector that throws (inject a bad indexDbPath) leaves the improve run green with a warning (fail-open).

9. Rollout plan

Non-goals (explicit): no LLM-judged merge quality (deterministic-only is a design invariant here); no embedding-drift tracking of the live vector space (curate-golden owns ranker quality); no automatic canary refresh; no changes to user-facing search/curate ranking; no retention change to improve_runs.result_json (that is R9, separately owned and sign-off-gated); no resurrection of the deleted homeostatic demotion pass.


10. Estimated diff size & file-by-file change list

Estimates below are as planned; paths marked "landed at" are where §6.2's implementation note says the code actually ended up.

File Change Est. LOC
src/commands/improve/collapse-detector.ts new module (§6.1) +250
src/core/eval/rank-metrics.ts (landed at scripts/akm-eval/src/rank-metrics.ts) moved from scripts/akm-eval/src/curate-metrics.ts (net zero; new home) +200 / −0
scripts/akm-eval/src/curate-metrics.ts body replaced by re-export −195
src/core/state/migrations.ts migration 016 +60
src/core/state-db.ts (landed at src/storage/repositories/canaries-repository.ts) cycle-metrics + canary CRUD, purge +90
src/commands/improve/homeostatic.ts (landed at src/commands/improve/anti-collapse.ts) checkMergeInformationFloor; default-on flips +70
src/commands/improve/consolidate.ts floor call site; xrefs union fix (this document's source_refs) +30
src/commands/improve/loop-stages.ts post-loop hook +25
src/core/config/config-schema.ts (landed at src/core/config/schema/improve.ts) collapseDetector + 2 antiCollapse keys +30
src/core/config/config-types.ts (landed as inferred z.infer types, no hand-mirrored file) mirrored types +30
src/core/events.ts 1 event type + comment +8
src/commands/health.ts (landed at src/commands/health/advisories.ts) advisory +30
src/commands/improve/improve-cli.ts canary subcommand +40
tests/commands/improve/collapse-detector.test.ts (landed at tests/integration/commands/improve/collapse-detector.test.ts) simulations + pure tests (§8.1-3, 6-7) +350
tests/commands/improve/homeostatic.test.ts (landed at tests/commands/improve/anti-collapse.test.ts) floor + default-flip tests +80
tests/state-db/improve-cycle-metrics.test.ts (landed at tests/integration/state-db/improve-cycle-metrics.test.ts) schema/CRUD/purge +90

Total: ~+1,290 / −195 (net ~+1,100 incl. ~520 test LOC; production code ~+660). For calibration: the moved metrics module and the simulation tests are the two biggest blocks; the runtime footprint added to the improve hot path is one gated function call.

Owner decisions (resolved 2026-07-02)

All three open questions were put to the owner and approved:

  1. Anti-collapse guard suite flips to default-ON (§4.1) — approved. The generation guard may newly refuse the narrow two-participants-both-gen>2 merge case; acceptable as a live-pipeline behavior change.
  2. 365-day retention for improve_cycle_metrics (§5.3) — confirmed (< 7 MB/yr worst case; the slow-failure detection window outweighs the bloat concern).
  3. Canary count: 30–50 with per-type trend lines — approved. Size the canary set toward the top of the range (type-stratified across memory/lesson/knowledge so per-type collapse divergence is visible); cost stays negligible.