Decision records
ADR 0078 — Exploration belongs in the routing seam: the learned router must not collapse
- Status: Accepted (2026-08-12)
- Date: 2026-08-12
- Amends: ADR 0018 (the learning seam —
OUTCOME_RECORDED→rankFromOutcomes→rankedRouter). The loop it closed was real; the policy at the end of it was degenerate. - Context: Continuous learning / RL placement evaluation (
loom/docs/requirements/2026-08-12-continuous-learning-rl-evaluation.md) §3, which found this while scoping whether loom should ship RL. - Part of: the kernel/1P/3P triage discipline (P10). KERNEL_ABI unchanged (1.1.0); EVENT_SCHEMA unchanged (1.17.0) — no new syscall, no new event type, no fold change. Two 1P packages, additive API only.
Context
ADR 0018 closed the decision loop: record how a choice turned out, mine those outcomes
into a ranking, feed the ranking to rankedRouter, and future routes favour what
actually worked — with the route itself still a logged DECISION_RECORDED, so replay
stays deterministic (P9).
The feedback edge worked. The policy consuming it did not, in three compounding ways:
- Pure argmax with no exploration.
rankedRouterselected the max-ranked arm and nothing else. An arm absent from the rankings scored-Infinityand could never be selected while any other arm was ranked. - No sample floor.
rank = sum / count(outcomeAnalytics) is a bare mean, andrankFromOutcomesthen projects the counts away entirely.1.0from one sample outranks0.99from ten thousand, permanently. - No recency weighting. The flat lifetime mean lets a model’s reputation from six months ago outvote its behaviour today — but rewards here are non-stationary by construction (providers ship new checkpoints, tools get fixed, prompts change).
Together these are a textbook bandit collapse, and the feedback edge makes it self-sealing: the arms the router stops choosing stop producing outcomes, so the next ranking mined from the log is even more confident about an arm that got lucky once. A learning loop that destroys its own evidence is worse than no learning loop, because it looks healthy the whole time.
This is a defect in the policy, not in the seam. The seam is right: the route is
resolved inside ctx.decide, so whatever policy runs, the choice is recorded and replay
serves the recording back.
Decision
Fix the policy where the policy lives — in @loomwork/reasoning and @loomwork/analytics.
Nothing moves toward the kernel.
@loomwork/reasoning — routers that can keep learning
ArmStats { key, rank, count }— evidence carries its sample count. Structurally identical to whatoutcomeAnalyticsreturns, so it feeds in with no conversion.rankedRouternow acceptsArmStats[]or the legacyRecord<string, number>.rankedRouter(rankings, { minSamples, fallback })— arms belowminSamplesare treated as unranked rather than as evidence, so thin evidence cannot outvote thick evidence. SettingminSampleswith a count-free record throws at construction rather than silently no-opping the floor.ucb1(stats, { exploration, fallback })— the recommended default for a live loop. Scoresmean + c·sqrt(2·ln N / n): an unsampled arm scores+Infinityand is guaranteed a trial, and a luckyn=1arm cannot pin the router because its rivals keep their own large bonus until they too are tried. Deterministic — no RNG.epsilonGreedy(inner, { epsilon, rng })— the simple escape hatch for any exploit-only router.rngis injectable for tests and DST runs.
rankedRouter stays pure-exploit by default: adding implicit exploration would change
the behaviour of every existing deployment silently, which is the same class of mistake
as the one being fixed. Its docstring now states the collapse property outright and
points at ucb1.
@loomwork/analytics — evidence that ages
outcomeAnalytics(events, { halfLifeMs, now })— optional exponential recency decay.nowdefaults to the newestOUTCOME_RECORDEDtimestamp in the log, not the wall clock, so a ranking is a pure function of its input and identical on every replay.countstays the raw sample count that floors and UCB1 need. OmittinghalfLifeMspreserves the existing flat mean exactly.rankFromOutcomestakes the same options, and now documents that its projection drops the counts — callers wanting a floor or UCB1 should passoutcomeAnalyticsoutput directly.
Consequences
- No breaking change. Every existing call site keeps its behaviour:
rankedRouterwith a plain record and no options is byte-identical to before, andoutcomeAnalytics(events)without options is the same flat mean. - The default path is still exploit-only. This ADR makes non-collapse available and
documented, not automatic. Deployments that want a live loop must opt into
ucb1or a wrapper. That is deliberate (see above), and it is the part most likely to be revisited once there is field evidence. - Thompson sampling was deliberately not shipped. It needs Bernoulli or bounded
rewards, and
scoreis unbounded by contract; UCB1 covers the same need deterministically, which matters more in a replay-first runtime than the marginal empirical edge. Revisit if a normalized-reward contract lands. - Exploration randomness stays replay-safe because
ReasoningRuntimeresolves the route insidectx.decide: the sampled id is committed asDECISION_RECORDEDandselectis never called again on replay. Standalone callers (nodecidesink) get no such guarantee and should injectrng. - Still not RL. This is a bandit policy over an existing seam. The placement
evaluation’s larger verdicts — contextual features on
DECISION_RECORDED, trajectory extraction, off-policy evaluation,@loomwork/learning— are untouched and remain unbuilt.
Source: docs/adr/0078-exploration-in-the-routing-seam.md in the loom repository.