Skip to content

Commit b291ac4

Browse files
ruvnetclaude
andauthored
dream(intelligence): #3048 discounted Thompson sampling for model-router bandit (evaluated, ACCEPT-scoped) (#3049)
* dream(intelligence): discounted Thompson sampling for model-router bandit Adds an opt-in `priorDecay` config field (default 1 = disabled, fully backward compatible) to ModelRouter's Beta-Bernoulli bandit. When enabled, every bucket/model's alpha/beta decays geometrically once per recordOutcome() call before that round's reward is added (arXiv 2305.10718 discounted Thompson sampling), so accumulated routing history from a long-running persisted state file no longer permanently dominates the posterior after a real-world model-quality shift. Benchmark (n=30 paired trials, seeded PRNG, identical stream baseline vs candidate): non-stationary regime-shift recovery 26.5 -> 21.9 rounds (-17.6%, t=7.24); post-shift correct-routing rate +1.3pp (t=6.10); stationary-workload invariant held (delta +0.02pp, not a regression). Receipt: v3/@claude-flow/cli/benchmarks/results/prior-decay-receipt.json. Mirrors the epsilon-decay pattern q-learning-router.ts already applies to its own exploration rate -- an internal-consistency fix, not an imported pattern. Evaluation and adversarial critique in progress; this commit is the checkpointed, test-passing candidate + its baseline/candidate receipt. Co-Authored-By: RuFlo <ruv@ruv.net> * dream(intelligence): #3048 fix adversarial-critique findings, add gist + ledger Independent adversarial critique found and this commit fixes: a paired-t statistics bug (population vs sample stddev inflated every t-value by n/(n-1)), an unguarded priorDecay input (NaN/negative could silently poison persisted .swarm/model-router-state.json forever, bypassing sampleBeta's own defensive fallback), and a single-bucket benchmark blind spot (now covers low + med complexity buckets -- the med bucket shows no significant effect, disclosed rather than hidden). Also adds the research gist and backfills 3 missing ledger rows for 2026-08-14/15/16 (real PRs/issues existed for those nights but the ledger table wasn't updated) plus tonight's own row. Co-Authored-By: RuFlo <ruv@ruv.net> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 41efca8 commit b291ac4

7 files changed

Lines changed: 534 additions & 80 deletions

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Intelligence SOTA Report — 2026-08-17
2+
3+
TL;DR: Tonight (2026-08-17, intelligence deep-dive) found that Ruflo's 3-tier model router (`model-router.ts`, ADR-026/143) accumulates its Beta-Bernoulli Thompson-sampling priors forever with zero temporal decay, while the codebase's *other* router (`q-learning-router.ts`) already solves the analogous problem via epsilon annealing — an internal-consistency gap, not a hypothetical one. Shipped an opt-in `priorDecay` config field (default disabled, zero behavior change) implementing discounted Thompson sampling. A 30-trial paired benchmark shows a real, statistically significant recovery-speed improvement in the low-complexity bucket after a simulated model-quality shift — but the effect does **not** generalize to the med-complexity bucket, a limitation an independent adversarial critic's feedback led directly to testing and disclosing.
4+
5+
## What's New in 2026
6+
7+
| Finding | Source | Confidence |
8+
|---|---|---|
9+
| Discounted Thompson Sampling remains an active, unsolved-by-default production problem | arXiv 2606.23933 (Jun 2026), arXiv 2305.10718 | A |
10+
| Confidence-Adaptive Routing (CARE) reallocates MoE expert budget via gate entropy | arXiv 2607.26052 (Jul 2026) | B |
11+
| Ghost Vectors: soft-deleted embeddings remain reconstructible in HNSW indexes | arXiv 2606.18497 (Jun 2026) | A (general finding); C (Ruflo-specific applicability, unmeasured) |
12+
| No orchestration framework (LangGraph/AutoGen/CrewAI/OpenAI Agents SDK) does learned/adaptive routing in its core loop — a deliberate, field-wide tradeoff, not an oversight | Live doc/repo review, 2026-08 | A |
13+
14+
## Ruflo Current Capability
15+
16+
`model-router.ts:recordOutcome()` does `bp[model].alpha += reward; bp[model].beta += 1-reward` with **zero decay anywhere in the 1490-line file** (confirmed by direct grep). Persisted forever to `.swarm/model-router-state.json`. Meanwhile `q-learning-router.ts` (a sibling, less-used router) already implements exponential epsilon annealing for its own exploration rate. Two more findings from tonight's architecture pass, not acted on tonight: the MoE gate (`moe-router.ts`) computes routing entropy every call but never uses it for expert-count control (dead signal); and `EWCConsolidator.pruneOldPatterns()` mutates its own pattern Map but never propagates deletions to `LocalReasoningBank` or the HNSW-backed store — three independent, unsynced "forgetting" mechanisms in one pipeline.
17+
18+
## Competitor Comparison
19+
20+
| Framework | Adaptive/learned routing | Forgetting mitigation | 2026 status |
21+
|---|---|---|---|
22+
| LangGraph | Developer-written conditionals / LLM-prompted routing only | RAG-style external memory; docs explicitly discourage weight-level fine-tuning | Active |
23+
| AutoGen / AG2 | LLM-prompted "manager" selection | `TeachableAgent` deprecated v0.12, removed v0.14 (Mar 2026 rewrite) | Maintenance-mode (upstream) |
24+
| CrewAI | Hierarchical/manager-based, not learned | Context/vector memory only | Active |
25+
| OpenAI Agents SDK | Developer-defined handoffs | External memory (no built-in continual learning) | Active |
26+
| Letta (MemGPT) | N/A | Self-editing 3-tier (Core/Recall/Archival) memory — explicit token-space alternative to weight-space continual learning | Active, sharpest comparator |
27+
28+
None of the four major orchestration frameworks implement a real bandit/RL routing policy in their core loop — genuine adaptive routing exists only in standalone services (RouteLLM, MetaLLM) and academic work, never integrated. This is a deliberate cost/complexity tradeoff the field has made, not a gap nobody noticed — which makes Ruflo's own bandit (imperfect as tonight's finding shows) still ahead of the field's default posture. Letta's token-space memory is the sharpest available foil for EWC++'s weight/pattern-space approach, not a "competitors have nothing" claim.
29+
30+
## Hypothesis
31+
32+
Given the router's unbounded Beta-prior accumulation, when an exponential discount (`priorDecay < 1`) is applied to all bucket/model priors once per `recordOutcome()` call before that round's reward, then post-shift recovery speed after a simulated model-quality shift should improve relative to baseline, subject to: no material regression under a stationary (non-shifting) workload.
33+
34+
## Benchmarks
35+
36+
Bespoke deterministic simulation (`benchmarks/results/scripts/prior-decay-benchmark.mjs`) — no LLM calls, $0 cost, seeded PRNG (identical random stream fed to baseline and candidate per trial, isolating the decay math as the only variable). 1500 pre-shift rounds (simulating months of accumulated persisted history) + 300 post-shift rounds, n=30 paired trials, two complexity buckets. An independent adversarial-critic pass found and I fixed a real bug in the paired-t formula (population vs. sample stddev, inflating t by n/(n-1)) before finalizing these numbers.
37+
38+
## Evaluation
39+
40+
**evaluated: accepted, scoped.** Low bucket (haiku→sonnet shift): recovery 26.5→21.9 rounds (**-17.6%**, t=7.00), post-shift correct-routing rate +1.3pp (t=5.90), stationary invariant held (Δ=+0.02pp, t=0.70 — no regression). Med bucket (sonnet→opus shift): recovery essentially flat (Δ=-0.17 rounds, t=-0.74, not significant), stationary invariant held (Δ=-0.08pp; statistically distinguishable from zero at t=-3.01 but two orders of magnitude inside the pre-declared -1pp tolerance). **The mechanism's benefit is real but bucket-scoped** — it helps most where reward asymmetry is largest (haiku success reward=1.0 vs. opus=0.4 in `BANDIT_REWARDS`), which is exactly the case that produces the most entrenched stale posteriors. This was found only because an independent critic asked for med/high-bucket coverage; the first version of this benchmark tested only the low bucket and would have overclaimed generality. Shipped **disabled by default** (`priorDecay: 1`); this is an opt-in mechanism, not a default-behavior change.
41+
42+
## Darwin Results
43+
44+
**Skipped — scope mismatch**, same class of skip as 2026-08-16's security night. `@metaharness/darwin`'s discovered real interface (`evolve --bench <suite.json>`) evolves harness/prompt genomes against LLM-scored coding-task corpora; it has no analog for tuning a single continuous scalar (`priorDecay`) in a deterministic-math function. A γ-value sensitivity sweep (0.995/0.99/0.98) was run directly in the bespoke benchmark instead — 0.995 gave the cleanest signal-to-noise (t=7.00 vs. 6.76/3.71 for 0.99/0.98) and is the value used above.
45+
46+
## SOTA Proof & Witness
47+
48+
See the linked issue and PR for the full witness stamp (session commit, report hash, witness hash) and verifier procedure.
49+
50+
## Recommended Next Steps
51+
52+
1. **Merge tonight's opt-in `priorDecay` candidate** (draft PR) — zero-risk (disabled by default), reviewable single-file diff, evidence-backed for the low-complexity bucket, all findings from an independent adversarial critique addressed (paired-t formula fixed, NaN/negative-decay input validation added, med-bucket coverage added).
53+
2. **Follow-up candidate:** investigate why the med bucket shows no effect — likely `BANDIT_REWARDS`' smaller success-reward magnitudes for sonnet/opus change entrenchment dynamics; a bucket-scaled decay rate (stronger where reward asymmetry is largest) is the natural next hypothesis, explicitly flagged here rather than implemented tonight.
54+
3. **Follow-up candidate:** wire the MoE gate's already-computed routing entropy into its `topK` selection (Confidence-Adaptive Routing, arXiv 2607.26052) — currently dead signal, distinct from tonight's candidate, scored highly in tonight's 5-candidate ranking (2nd place).

v3/@claude-flow/cli/__tests__/router-bandit.test.ts

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { describe, expect, it, beforeEach, afterEach } from 'vitest';
1818
import { rmSync, mkdtempSync, writeFileSync, mkdirSync } from 'node:fs';
1919
import { tmpdir } from 'node:os';
2020
import { join } from 'node:path';
21-
import { ModelRouter } from '../src/ruvector/model-router.js';
21+
import { ModelRouter, sampleBeta } from '../src/ruvector/model-router.js';
2222

2323
let cwdRestore: string;
2424
let tmpDir: string;
@@ -188,3 +188,118 @@ describe('ModelRouter — Thompson sampling bandit (#1772, ADR-142 bucketed)', (
188188
expect(meanHaiku).toBeGreaterThan(meanOpus);
189189
}, 30_000);
190190
});
191+
192+
describe('ModelRouter — priorDecay (discounted Thompson sampling, arXiv 2305.10718)', () => {
193+
beforeEach(setupTempCwd);
194+
afterEach(cleanupTempCwd);
195+
196+
it('defaults priorDecay to 1 (no-op) — identical behavior to a router with no decay config', () => {
197+
const withDefault = new ModelRouter();
198+
const task = 'simple task';
199+
for (let i = 0; i < 5; i++) withDefault.recordOutcome(task, 'haiku', 'success');
200+
for (let i = 0; i < 3; i++) withDefault.recordOutcome(task, 'sonnet', 'failure');
201+
const p = withDefault.getBanditPriors(bucketOf(withDefault, task));
202+
// Same math as the pre-priorDecay assertions above: reward accumulates
203+
// with no decay applied anywhere in the call chain.
204+
expect(p.haiku.alpha).toBeCloseTo(6.0, 5); // 1 + 5*1.0
205+
expect(p.haiku.beta).toBeCloseTo(1.0, 5);
206+
expect(p.sonnet.beta).toBeCloseTo(4.0, 5); // 1 + 3*1.0 (failure reward=0)
207+
});
208+
209+
it('decays ALL bucket/model priors once per recordOutcome call, before adding the new reward', () => {
210+
const router = new ModelRouter({ priorDecay: 0.5 });
211+
const task = 'simple task';
212+
const bucket = bucketOf(router, task);
213+
// Round 1: decay Beta(1,1)→Beta(1,1) (no-op at the uniform prior), then
214+
// haiku success (reward 1.0) → alpha 1*0.5 + 1.0 = 1.5, beta 1*0.5 = 0.5.
215+
router.recordOutcome(task, 'haiku', 'success');
216+
let p = router.getBanditPriors(bucket);
217+
expect(p.haiku.alpha).toBeCloseTo(1.5, 5);
218+
expect(p.haiku.beta).toBeCloseTo(0.5, 5);
219+
// Round 2: decay is applied to EVERY bucket/model first (this is the
220+
// "every routing decision is one time-step for every arm" semantics) —
221+
// so sonnet, though untouched in round 1's outcome, already decayed
222+
// from Beta(1,1) to Beta(0.5,0.5) as a side effect of round 1's call.
223+
// Now: haiku decays 1.5*0.5=0.75, 0.5*0.5=0.25 (no reward added, it
224+
// wasn't this round's outcome). sonnet decays 0.5*0.5=0.25, 0.5*0.5=0.25,
225+
// then gets this round's failure (reward 0) → alpha stays 0.25, beta
226+
// becomes 0.25 + (1 - 0) = 1.25.
227+
router.recordOutcome(task, 'sonnet', 'failure');
228+
p = router.getBanditPriors(bucket);
229+
expect(p.haiku.alpha).toBeCloseTo(0.75, 5);
230+
expect(p.haiku.beta).toBeCloseTo(0.25, 5);
231+
expect(p.sonnet.alpha).toBeCloseTo(0.25, 5);
232+
expect(p.sonnet.beta).toBeCloseTo(1.25, 5);
233+
});
234+
235+
it('floors decayed alpha/beta at PRIOR_DECAY_FLOOR (0.05) instead of collapsing to 0', () => {
236+
const router = new ModelRouter({ priorDecay: 0.1 }); // aggressive decay
237+
const task = 'simple task';
238+
// One bucket/model (opus, in the 'low' bucket) never receives an outcome
239+
// directly — but every recordOutcome call for ANY model in this bucket
240+
// still decays it. Hammer haiku with outcomes many times; opus's
241+
// untouched Beta(1,1) should decay toward the floor, never below it,
242+
// and never go non-positive (which would break sampleBeta's Gamma draws).
243+
for (let i = 0; i < 50; i++) router.recordOutcome(task, 'haiku', 'failure');
244+
const p = router.getBanditPriors(bucketOf(router, task));
245+
expect(p.opus.alpha).toBeCloseTo(0.05, 5);
246+
expect(p.opus.beta).toBeCloseTo(0.05, 5);
247+
expect(p.opus.alpha).toBeGreaterThan(0);
248+
expect(p.opus.beta).toBeGreaterThan(0);
249+
expect(sampleBeta(p.opus.alpha, p.opus.beta)).not.toBeNaN();
250+
});
251+
252+
it('rejects an out-of-range priorDecay (NaN/negative/>1) by falling back to disabled (1)', () => {
253+
// Caught by an independent adversarial-critic pass: Math.max's NaN-
254+
// poisoning bypasses sampleBeta's own alpha<=0||beta<=0 fallback, and a
255+
// negative decay pins every prior at PRIOR_DECAY_FLOOR on the first
256+
// call — either would silently corrupt persisted router state forever.
257+
for (const [i, bad] of [NaN, -1, 0, 1.5, Infinity, -Infinity].entries()) {
258+
const router = new ModelRouter({
259+
priorDecay: bad,
260+
statePath: join(tmpDir, `.swarm/state-${i}.json`),
261+
});
262+
router.recordOutcome('t', 'haiku', 'success');
263+
const p = router.getBanditPriors(bucketOf(router, 't'));
264+
// Falls through to priorDecay=1 (disabled): plain accumulation, no decay.
265+
expect(p.haiku.alpha).toBeCloseTo(2.0, 5);
266+
expect(p.haiku.beta).toBeCloseTo(1.0, 5);
267+
}
268+
});
269+
270+
it('regression: candidate must not degrade routing under a stationary workload', async () => {
271+
// Pre-declared invariant (STEP 3.3 hypothesis, checked in the receipt at
272+
// benchmarks/results/prior-decay-receipt.json with n=30 paired trials):
273+
// decay must not reduce accuracy when the correct model never changes.
274+
// This is the fast in-suite version of that same check.
275+
async function runStationary(priorDecay: number): Promise<number> {
276+
const router = new ModelRouter({ priorDecay });
277+
let seed = 0x2468ace;
278+
const rng = () => {
279+
seed |= 0;
280+
seed = (seed + 0x6D2B79F5) | 0;
281+
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
282+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
283+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
284+
};
285+
const origRandom = Math.random;
286+
Math.random = rng;
287+
let correct = 0;
288+
const N = 150;
289+
try {
290+
for (let i = 0; i < N; i++) {
291+
const r = await router.route('fix a typo in the readme file');
292+
const outcome = r.model === 'haiku' ? 'success' : 'failure';
293+
router.recordOutcome('fix a typo in the readme file', r.model, outcome);
294+
if (r.model === 'haiku') correct++;
295+
}
296+
} finally {
297+
Math.random = origRandom;
298+
}
299+
return correct / N;
300+
}
301+
const baseline = await runStationary(1);
302+
const candidate = await runStationary(0.995);
303+
expect(candidate).toBeGreaterThanOrEqual(baseline - 0.05); // no material regression
304+
}, 30_000);
305+
});
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
{
2+
"config": {
3+
"TRIALS": 30,
4+
"SHIFT_AT": 1500,
5+
"POST_ROUNDS": 300,
6+
"TOTAL_ROUNDS": 1800,
7+
"STATIONARY_ROUNDS": 400,
8+
"CANDIDATE_DECAY": 0.995,
9+
"RECOVERY_WINDOW": 20,
10+
"RECOVERY_THRESHOLD": 14
11+
},
12+
"scenarios": [
13+
{
14+
"bucket": "low",
15+
"task": "fix a typo in the readme file",
16+
"shift": "haiku -> sonnet",
17+
"nonStationary": {
18+
"baselineMeanRecoveryRound": 26.533333333333335,
19+
"candidateMeanRecoveryRound": 21.866666666666667,
20+
"recoveryRoundDeltaMean": 4.666666666666667,
21+
"recoveryRoundDeltaT": 7,
22+
"baselineMeanPostShiftCorrectRate": 0.9582222222222222,
23+
"candidateMeanPostShiftCorrectRate": 0.9712222222222221,
24+
"postShiftRateDeltaMean": 0.013000000000000012,
25+
"postShiftRateDeltaT": 5.8956649538645785
26+
},
27+
"stationary": {
28+
"baselineMeanCorrectRate": 0.9976666666666668,
29+
"candidateMeanCorrectRate": 0.9978333333333335,
30+
"deltaMean": 0.00016666666666666682,
31+
"deltaT": 0.7010887416930878,
32+
"invariantHeld": true
33+
}
34+
},
35+
{
36+
"bucket": "med",
37+
"task": "refactor the payment processing module to support multiple currencies and add integration tests",
38+
"shift": "sonnet -> opus",
39+
"nonStationary": {
40+
"baselineMeanRecoveryRound": 20.4,
41+
"candidateMeanRecoveryRound": 20.566666666666666,
42+
"recoveryRoundDeltaMean": -0.16666666666666666,
43+
"recoveryRoundDeltaT": -0.7397092748646286,
44+
"baselineMeanPostShiftCorrectRate": 0.9802222222222222,
45+
"candidateMeanPostShiftCorrectRate": 0.9798888888888888,
46+
"postShiftRateDeltaMean": -0.000333333333333341,
47+
"postShiftRateDeltaT": -0.3324851555379874
48+
},
49+
"stationary": {
50+
"baselineMeanCorrectRate": 0.9955,
51+
"candidateMeanCorrectRate": 0.9946666666666669,
52+
"deltaMean": -0.0008333333333333452,
53+
"deltaT": -3.010398644698114,
54+
"invariantHeld": true
55+
}
56+
}
57+
],
58+
"generatedAt": "2026-08-17T06:29:03.189Z"
59+
}

0 commit comments

Comments
 (0)