R5 — Longitudinal Collapse/Churn Detector for akm improve
Status (0.9.17): removed. The detector, its
collapse_detector_alertevent, theimprove_cycle_metrics/canary_queriestables andscripts/refresh-canary-set.tswere 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 insrc/commands/improve/anti-collapse.tsremains. 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_actionscolumn, §6.1churnMinAcceptedActionsconfig 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 hardcoded0at 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 meantacceptedSum >= 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 ownmerged/mergedSecondariescounts) and recalibrate a threshold that was tuned against the design's original (unreachable) quantity, the alert class was deleted:CollapseAlertKindno longer has a"churn"member,evaluateCollapseAlertsno longer evaluates it, and the config schema no longer acceptschurnMinAcceptedActions. Theimprove_cycle_metrics. accepted_actionsDB column remains (migrations are append-only; dropping it is a separate, unowned migration change) and is now always written as0. Seesrc/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 insrc/commands/improve/anti-collapse.ts(planned below ashomeostatic.ts, which does not exist); the rank-metrics module landed atscripts/akm-eval/src/rank-metrics.ts(planned below assrc/core/eval/rank-metrics.ts— the move stopped one directory short once it turned outsrc/had zero importers of it, so there was nosrc/-side reason to relocate it there); the canary/cycle-metrics DB helpers landed insrc/storage/repositories/canaries-repository.ts(planned below asstate-db.ts, part of the broader repository-extraction split ofsrc/core/state-db.ts/src/indexer/db/db.ts); the config schema landed insrc/core/config/schema/improve.ts(planned below as the monolithicconfig-schema.ts); the health advisory landed insrc/commands/health/advisories.ts(planned below ashealth.ts); theakm improve canarysubcommand 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 callssource_refslanded asxrefs(seeinjectGenerationFrontmatter, which lives inconsolidate.tsalongside its merge-branch call site) — readsource_refsbelow asxrefs.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_runsmetrics, 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_jsonbloat — 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:
meanCanaryRecall(current) ≤ median(meanCanaryRecall over hist) − recallDropThreshold(defaultrecallDropThreshold = 0.15absolute). Median-of-window, not previous-cycle, so a single noisy cycle can't fire or mask the alert.distinct_content_ratiois monotonically non-increasing across allWrows ANDhist[0].distinct_content_ratio − current.distinct_content_ratio ≥ entropyDropThreshold(default0.05).store_memory_count(current) < 0.8 × max(store_memory_count over hist)ANDover_generation_count(current) > over_generation_count(hist[0])— the store is shrinking because of re-merging, not deletion hygiene (over_generation_count = assets with frontmattergeneration > maxGeneration).
CHURN alert fires when ALL of:
Σ 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),|meanCanaryScore(current) − meanCanaryScore(hist[0])| < 0.02, and|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.
- How many:
canaryCount = 40(config; owner-approved 30–50 range so per-type trend lines are computable). Stable mean at K=10; 40 FTS queries cost single-digit milliseconds total. - How chosen (deterministic, at first detector run): rank all
memory,lesson, andknowledgeentries byasset_salience.rank_score(fallback:utility_scoresEMA, fallback: entry_key order), take a type-stratified top slice (⅓ memory, ⅓ lesson, ⅓ knowledge, backfilling from the global list when a type is short), and for each anchor asset build a query string from its name tokens + top 3 tags + first heading line of the body (all read fromentries.entry_json/search_text). The expected relevant ref is the anchor asset itself. - How refreshed: never automatically.
akm improve canary --refresh(CLI, §6.3) deactivates the current set (active = 0, rows retained for history interpretation) and mints a new one; the detector recordscanary_set_idper cycle row so trend queries never compare across sets. Rejected alternative: auto-refresh when a canary's anchor is deleted — silent re-baselining is exactly how a slow collapse hides.
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:
- Deterministic embedder (
AKM_EMBED_DETERMINISTIC=1) against the live index — wrong vector space: the live index's stored embeddings were produced by the real model, so a hash-embedded query would score garbage; the deterministic embedder is only valid when it embedded the corpus too (which is exactly how curate-golden uses it, and why the detector's CI test can still use it, §8.2). - Real embedder / full
akmCuratehybrid path — the local embedding model is deterministic in principle but heavyweight to load per cron cycle, version-drifts, and its failure modes (model missing, download blocked) would make the detector flaky; collapse is about whether canonical content survives and ranks, which BM25 over name/tags/body measures directly.
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
- Per qualifying cycle, always: one
improve_cycle_metricsrow. No event (avoid event-volume creep; history lives in the table). - On any alert (rare): one
collapse_detector_alertevent per fired kind (appendEvent, best-effort, same pattern asoutcome_proxy_invertedatpreparation.ts:1575-1594), and the kinds recorded in the row'salerts_json. - Health surface: the
akm healthadvisory (§6.2) —warnstatus,kind: "deterministic",confidence: "high"for collapse kinds (they're measured, not inferred),confidence: "medium"for churn (volume thresholds are tunable). The message includes the concrete numbers (current vs. window-median recall, entropy delta, accepted-action count) so the operator can act without opening the DB. - Blocking: NO in v1. The detector observes; it never vetoes a consolidation pass.
Recommendation is observe-first with this documented promotion path: (1) v1 ships
observe-only; (2) after ≥ 30 days of live rows and threshold tuning (§9), an opt-in
improve.collapseDetector.blockOnCollapse: truemakesrunConsolidationPassskip the merge phase (not the whole pass — dedup/archival still run) for a run whose previous cycle fired a collapse-kind alert, fail-open on any detector error, and emits aconsolidation_blockednote into warnings; (3) the merge-information floor independently promotes from advisory to per-merge refusal (§4.2). Blocking on the previous cycle (not live re-evaluation) keeps the merge path free of detector latency and makes the block deterministic from persisted state.
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.)
collapse-detector.test.ts— deterministic collapse simulation (the headline test). Seed an isolated bundle with 30 distinct memories (distinct topics, distinct vocabularies),akmIndexit (FTS only — the detector path needs no embeddings; setsemanticSearchMode: "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,xrefswritten for pass 1 then deliberately dropped for pass 2+ to model provenance loss), reindex, runcomputeCycleMetrics+evaluateCollapseAlerts. Assert:distinct_content_ratioandmean_bigram_diversitydecrease monotonically; mean recall survives pass 1 (merge-following viaxrefs) and drops afterward; acollapse-recallorcollapse-entropyalert fires by pass ≤ 4; thecollapse_detector_alertevent is written.- Churn simulation (same file): paraphrase bodies without merging (store shape
and canary hits stable), feed
acceptedActions≥ threshold per cycle forWcycles, assert exactly achurnalert and no collapse alert. evaluateCollapseAlertspure-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 thanWnever alerts).- 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 (enabledabsent ⇒ guards active;enabled: false⇒ inert — byte-identical to the old default for opted-out configs). improve-cycle-metrics.test.ts: migration 016 applies on a fresh and an existing DB; insert/query round-trip;purgeOldCycleMetricsdeletes only rows past retention and returns the count; canary set mint / deactivate / re-mint preserves history rows with the oldcanary_set_id.- Curate-golden reuse guard: the metrics move in §2.4 keeps
curate-golden-eval.test.tsgreen 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 importingndcgAtKfrom 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 withAKM_EMBED_DETERMINISTIC: "1"(exactly aswithSeededGoldendoes), 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. - Wiring test (same
collapse-detector.test.tsfile, "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;dryRunwrites none; a detector that throws (inject a badindexDbPath) leaves the improve run green with a warning (fail-open).
9. Rollout plan
- Phase 0 — land observe-only (this design, one PR). Default-on detector +
default-on generation guard + advisory-only information floor. Gate: full
bun run checkgreen (custom lints included), zero new warnings. - Phase 1 — accumulate & tune (2–4 weeks of live cron). Watch, read-only:
TuneSELECT ts, pass, mean_recall, distinct_content_ratio, mean_bigram_diversity, over_generation_count, accepted_actions, merge_floor_violations, alerts_json FROM improve_cycle_metrics ORDER BY ts DESC LIMIT 30; SELECT ts, json_extract(metadata,'$.kind') FROM events WHERE event_type='collapse_detector_alert' ORDER BY ts DESC LIMIT 20;recallDropThreshold/entropyDropThreshold/churnMinAcceptedActionsagainst observed variance (the live synthesis lanes are currently near-idle, so early rows will be sparse — that sparsity is itself the §6.3 "synthesis lanes idle" signal made durable). Success criterion for the thresholds: zero false alerts on a healthy store over the window, and the simulated-collapse test values sitting well inside the alert region. - Phase 2 — optional enforcement (separate PR, owner sign-off).
blockOnCollapseopt-in (§7) and information-floor refusal (§4.2), each promoted independently and only if Phase 1 shows clean precision.
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:
- 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.
- 365-day retention for
improve_cycle_metrics(§5.3) — confirmed (< 7 MB/yr worst case; the slow-failure detection window outweighs the bloat concern). - 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.