0007 — Bound and checkpoint consolidation for slow LLM adapters¶
Status: Accepted Date: 2026-07-01 Deciders: Ben Emson, elf
Context¶
elfmem's default configuration routes LLM scoring through a local
OpenAI-compatible server (LM Studio, google/gemma-4-26b-a4b) rather than a
cloud API. In practice, elfmem dream was killed three times in a row
against a 211-active-block corpus, producing zero output each time, before
the operator worked around it with dream --no-llm (embed-only) and
deferred LLM scoring to --rescore.
Two structural properties of the pipeline explain this, independent of any specific external kill limit:
-
Unbounded per-block contradiction cost.
consolidate()issues one sequentialprocess_blockLLM call per inbox block, then for each inbox block loops over every active block whose cached cosine similarity clears thecontradiction_similarity_prefilter(default 0.40,consolidate.py:442) and issues a sequentialdetect_contradictioncall. The prefilter cuts ~95% of pairs by precision, but there is no cap on cardinality — a block with many similar neighbours still costs O(n_active) LLM calls. At ~14s/call locally, 211 actives, this alone can run into the tens of minutes; at thousands of actives it is unbounded. -
One all-or-nothing transaction.
consolidate()(api.py:791) andrescore()(api.py:1011) each wrap every read, LLM call, and write for the entire batch in a singleasync with self._engine.begin(). Nothing is durably committed until the whole call returns. A kill at any point — background task-runner limit, OOM, Ctrl-C — loses every block processed so far, not just the one in flight. (Verified directly inrescore_blocks(),rescore.py:232-273: per-block LLM failures are skipped gracefully, but all successful per-block writes still share the caller's one outer transaction — there is no per-block durability, only per-block error isolation.) -
No self-imposed run budget.
rescore()already hasconsolidation.rescore.max_per_run(default 20) bounding how much work one call takes on.consolidate()/dream()has no equivalent — one call always processes the entire inbox, however large, with no way to stop itself before an external limit does.
Both per-call timeout sites (consolidate.py:391-393, 445-451) already
catch TimeoutError gracefully and fall back rather than raise, so the
failure mode is confirmed to be cumulative duration, not an unhandled
exception propagating out of the transaction.
Alternatives considered¶
A — Concurrent LLM calls (asyncio.gather + semaphore across blocks or
contradiction checks). Disqualified as the primary fix: the default
adapter is a single local model server serving one GPU; concurrent client
requests mostly queue server-side rather than execute in parallel, so
wall-clock barely improves against the actual reported failure, while adding
concurrency risk (SQLite write-lock contention) for no compensating gain.
Left as a possible future opt-in for adapters known to support real
server-side concurrency (cloud APIs) — not the fix for this issue.
B — Background daemon / trickle consolidation (elfmem serve
consolidates continuously in small increments instead of one-shot CLI
invocations). Would sidestep the CLI-vs-external-kill-limit problem
entirely. Disqualified for now: materially larger scope (process
lifecycle, idle cost, coordination with concurrent foreground CLI use) than
the problem warrants. Recorded as a future ROADMAP "Exploring" item, not
built here.
C — Raise the cosine prefilter threshold (e.g. 0.40 → 0.60) instead of adding a hard cap. Disqualified as the sole fix: a threshold bounds match precision, not cardinality — a sufficiently large or dense active set can still pass hundreds of candidates above any fixed threshold. Only a hard per-block cap bounds worst-case cost independent of corpus size. (Threshold and cap are complementary, not substitutes — the decision keeps both.)
D — Document "use --no-llm + --rescore for local models"; no code
change. Disqualified: rescore() does not do contradiction detection
(only process_block), so deferring via --rescore means those blocks
never get contradiction-checked, silently trading a performance problem for
a quality gap. The workaround also does not resolve --rescore itself
running into the same unbounded-duration transaction risk (point 2 above)
once deferred debt is large.
Decision¶
-
Cap contradiction checks per inbox block to the top-K most similar active blocks that clear the existing cosine prefilter (
memory.contradiction_top_k), replacing "all blocks above threshold" with "at most K blocks above threshold." Bounds worst-case per-block LLM cost to O(K), independent of active-set size. Default: 10 — provisional, chosen the same way ADR 0006 chose its static thresholds (round-number default, not fitted to data we don't have), with the same kind of reopen trigger: revisit via the newConsolidationHealthMetrics.contradiction_cap_rateif it's regularly nonzero on a real deployment, at which point the correct move is raising the config value, not removing the cap. Tracked in issue #79 — ADR 0006's pattern of tying "provisional" defaults to a tracked issue + observable trigger, not a comment that never gets revisited. -
Commit consolidation decisions incrementally, per inbox block, instead of one all-or-nothing transaction — apply and commit each block's decisions (promotion, dedup, contradictions, edges) before moving to the next block. Apply the identical pattern to
rescore_blocks(). A kill at any point preserves every fully-processed block; only the one block in flight is lost and is naturally retried by the nextdream()/--rescorecall (both already have idempotent, priority-ordered selection). -
Add a self-terminating per-run budget to
dream()/consolidate()(count-based, newconsolidation.max_inbox_per_runconfig field, default 5). A run stops itself and returns a typed result reporting blocks processed vs. remaining, rather than depending on an external process-lifetime limit to end it. No such field exists today — verified againstMemoryConfig/RescoreConfiginconfig.py.
Unlike contradiction_top_k, this default is not unbounded-by-default
despite the general preference for zero-behaviour-change defaults
(see "minimum-earned change" precedent): the per-block durability fix
(item 2) is the one that makes an interrupted run harmless, and it is the
riskier of the two changes in this ADR — see the companion plan's staged
rollout (durability lands on a follow-up branch after the lower-risk cap
and budget). Until durability ships, a default of None would leave
elfmem dream's plain, zero-flag invocation exactly as exposed to the
original failure as before, which fails this project's own "Tier 1
(zero config, zero ceremony) must always work" contract. Default 5 is
grounded in the numbers from the reported incident, not guessed: at 5
inbox blocks/run × (1 process_block call + up to contradiction_top_k=10
contradiction calls) = at most 55 LLM calls/run; at the ~14s/call
observed against the local adapter that triggered this issue, that is
≤~13 minutes worst case, and typically much less. It is also half of
inbox_threshold (10, the existing auto-trigger point for should_dream),
so a normal-sized batch still drains in one call under typical operation.
Revisit this default once (2) ships, at which point unbounded may become
the better default since a kill would no longer be catastrophic.
--max reuses the existing dream --max flag (previously only
meaningful with --rescore) rather than introducing a second flag name.
When both a pending inbox and --rescore are processed in the same
invocation, --max applies the same numeric cap to both stages
independently (inbox processing and rescore catch-up each stop at that
count). This keeps the CLI surface unchanged at the cost of not letting
one invocation set two different budgets — acceptable because the two
stages are already separately invocable (elfmem dream --max N vs.
elfmem dream --rescore --max N vs. running both flags in sequence with
different values across two calls).
- No change to per-call LLM timeouts or adapter selection logic.
LLMConfig.timeoutis already overridable per deployment; document the recommendation to raise it for slow local adapters rather than adding local/cloud branching in code.
Explicitly not doing, recorded with trigger conditions to revisit: concurrent LLM calls (A) — revisit if a cloud-only deployment is demonstrably bottlenecked on sequential LLM round-trips with health metrics to prove it; background daemon mode (B) — revisit if one-shot CLI consolidation recurs as a pain point after (1)-(3) ship.
Consequences¶
consolidate()/rescore()commit incrementally; a partial run always leaves the DB in a consistent, resumable state — consistent with the existing idempotent-operation contract (should_dream, duplicatelearn()reject).ConsolidateResultgains an additive field reporting blocks remaining in the inbox after a budget-limited run (non-breaking; existing consumers unaffected).- Two new config fields:
consolidation.max_inbox_per_run,consolidation.contradiction_top_k, both with defaults chosen not to change behaviour for small/typical corpora. - A dense corpus with many blocks above the similarity prefilter for a given
new block may now skip contradiction-checking some pairs beyond the top-K
cap. Mitigated by keeping the cap generous relative to observed
prefilter_pass_rate; if this proves to matter in practice, the smallest correct fix is raising K via config, not re-introducing the unbounded scan. - Precludes treating "the whole inbox in one call" as a correctness
invariant anywhere downstream — any code relying on
consolidate()fully draining the inbox in a single call must instead check the returned remaining count and loop if it needs full drainage (e.g.learn_documentatapi.py:944-948already loops onshould_dream, so this is naturally compatible; verify at implementation time).
References¶
src/elfmem/operations/consolidate.py:338-478— per-block scoring and contradiction loopsrc/elfmem/operations/rescore.py:232-273— per-block rescore loop, shares the caller's outer transactionsrc/elfmem/api.py:759-836(consolidate()),:960-1019(rescore()),:1021-(dream())src/elfmem/config.py—MemoryConfig(contradiction thresholds),RescoreConfig(existingmax_per_runprecedent)- ADR 0006 — related but orthogonal: that ADR is about scoring parameter tuning (thresholds/decay), this one is about runtime budgeting and durability of the consolidation pipeline itself
docs/plans/plan_elfmem_optimise.md— prior performance work (embedding batching); does not touch contradiction-loop scaling or transaction granularitydocs/plans/plan_dreaming_architecture.md,docs/plans/plan_contradiction_detection_band.md— introduced the existing cosine prefilter and near-dup/contradiction ordering; neither proposes a hard cap, per-block commit, or a consolidate-level run budget