mirror of
https://github.com/Mesh-LLM/mesh-llm.git
synced 2026-08-08 22:23:19 -04:00
MoA: mesh answers improve as capable nodes join (#1116)
* feat(moa): give MoA workers tools, synthesize disagreement, survive flaky peers
Agentic MoA turns now reliably produce tool calls, and diverging answers are
synthesized instead of relaying one arbitrarily-chosen worker's text.
Fixes found by replaying recorded traces from 9 open-weight models:
* Tools were withheld from workers. `query_uses_tools` was derived from
`looks_like_tool_intent`, an English keyword match on the user's text. "The
test suite is failing. Find out which test fails and why" matches nothing, so
workers were dispatched without tool schemas and the arbiter ran with
has_tools=false — a unanimous tool proposal then fell through to the answer
path and leaked "calling search" as prose. 5 of 10 recorded scenarios hit
this. Tool availability is now the caller's declaration.
* Argument outliers could win a unanimous tool call. Native tool calls are all
normalized to a fixed 0.9 confidence, so the confidence-only tiebreak could
not separate 8 workers proposing {"path":"src"} from one hallucinating
{"path":"rust_project/src"}. Added argument clustering (key-order
independent); the largest cluster wins and argument-free calls never outvote
filled-in ones.
* Early exit committed on tied arguments. Early exit runs on whoever has
arrived, so two fast models agreeing on a tool name with different arguments
was a coin flip that also aborted the workers who would have broken the tie.
It now waits.
* Truncation was invisible. `finish_reason` was never read; 39/140 recorded
responses came back "length" and 24 carried partial text, which parsed as a
normal answer and could be returned verbatim. Plumbed through as
BackendReply.truncated -> WorkerOutput.truncated; such answers are barred
from consensus and verbatim output but still feed synthesis, labelled
incomplete.
* A strict endpoint could kill a worker permanently. minimax-m2.5 returns
HTTP 400 "Reasoning is mandatory for this endpoint" and failed 12/12
requests; HttpBackend now drops the thinking flags and retries once.
Policy changes:
* Thinking is always off for MoA workers, not merely defaulted off. The
previous escape hatch only let callers request the broken configuration:
qwen3-32b spent 408 reasoning tokens against a 384-token cap and returned
null content. Ignored overrides are logged.
* Diverging answers go to the reducer. Returning the top-confidence answer was
near-arbitrary because models rarely emit our confidence envelope, so
everything defaults to 0.5 and max_by returns whichever worker finished
first. Agreement still short-circuits without paying for synthesis.
* Reducer prompt adopts Together's aggregator framing (synthesize, critically
evaluate, agreement is not proof), keeping our per-worker attribution,
structured tool proposals, and 500-char payload bound.
Tests: new sim_real_traces.rs replays 56 recorded cases from 9 models through
handle_turn, plus regression unit tests for each bug. Recorders and corpora
live in evals/moa-openrouter/.
* feat(moa): asymmetric tool turns — references advise, best tool-caller acts
Tool-bearing turns now use the Hermes/Nous-style asymmetric shape instead of a
majority vote across workers:
- references run TOOL-FREE and only advise in prose
- the single best tool-caller (the "actor") acts on that advice with the real
tools and emits the tool call
Tool authority now tracks capability, not popularity. The old path let several
weak models proposing a popular-but-wrong tool outvote the one strong
tool-caller that picked correctly (observed: qwen3-32b alone chose run_command
for a failing-test triage while the smaller models chose list_dir, and the vote
shipped list_dir). An actor model removes that failure class instead of patching
the vote arithmetic.
Stays a pure stateless /v1/chat/completions turn — references are regenerated
from the caller's transcript each request, and the external client still owns
tool execution. Text-only turns are unchanged (symmetric fan-out +
synthesis-on-divergence).
Actor selection is capability-first: the host ranks candidates by gossiped
`tool_use` level (Supported > Likely > None), then model size tier, then stable
order, and passes the ordering to the engine via the new
`GatewayConfig.actor_candidates`. Empty ordering falls back to the engine's
name-derived size tier, so existing callers and tests are unaffected.
Mesh guardrails for mixed/public meshes: references are gathered with a bounded
wait (proceed at a majority of advisors, never block on the slow tail), and the
actor is called through the existing hedged ladder so a slow/broken best
candidate falls through to the next tool-capable peer.
New:
- crates/mesh-mixture-of-agents/src/tool_turn.rs — asymmetric tool-turn handler
- context::pack_for_actor — "advise, then act" framing (vs synthesis framing)
- fanout::gather_references — bounded, tool-free reference collection
- GatewayConfig.actor_candidates + reducer_candidates honours it
- host compute_actor_candidates from gossiped tool_use + size
The dead majority-vote code in the arbiter (now unreachable — tool turns bypass
it) is removed in the follow-up commit.
Tests: eval_openrouter.rs adds a live OpenRouter harness (ignored by default)
that runs the real handle_turn over 6 open-weight models with mesh-realism
latency/failure injection, for benchmarking asymmetric MoA vs single models.
192 engine tests pass; clippy -D warnings and fmt clean on both crates.
* refactor(moa): remove dead majority tool-vote from the arbiter
Tool turns now take the asymmetric actor path (previous commit), so the
arbiter's tool-proposal voting is unreachable — and it *was* the
majority-of-weakness bug: a popular-but-wrong tool choice from several weak
models could outvote the one strong tool-caller that picked correctly.
Removes it rather than leaving dead code:
- delete best_tool_proposal, best_tool_proposal_by_consensus, and
decisive_argument_cluster
- drop the tool-arbitration and tool-vs-answer branches from arbitrate, and
the tool-consensus branch from try_early_decision
- drop the now-unused has_tools parameter from arbitrate, try_early_decision,
single_output_decision, and gather_workers_incremental
- the arbiter is now purely an answer/critique/uncertainty arbiter; tool-shaped
text on the text path is already demoted to Uncertainty by
enforce_tool_call_contract (tools disabled ⇒ empty allow-list)
- drop the arbiter tests that pinned the removed tool-vote behavior; keep and
re-point every answer-consensus, truncation, synthesis, and tier-gate test
No behavior change for text turns. 178 engine tests pass; host-runtime lib
tests (1851) pass; clippy -D warnings and fmt clean on
mesh-mixture-of-agents, mesh-llm-host-runtime, and mesh-llm.
* test(moa): controlled actor-ablation eval — do references actually help?
Replaces the confounded "MoA vs best single model" comparison with a
within-actor ablation that isolates the one variable that matters: the
references. One pinned actor, identical sampling / token budget / system
prompt across all arms; only the advice changes:
A. actor alone (no references)
B. actor + real references (the production tool path)
C. actor + shuffled references (advice from a different task, length-similar)
Metric: rescue (A✗→B✓) minus harm (A✓→B✗). Arm C is the key control — it
separates "useful information" from "extra tokens + a think-carefully prompt".
Why the change: the earlier best-single comparison ran solo models at different
sampling (0.8/512) than the actor (0.3/2048) and with a different system
prompt, and scored transient 429/502/504 and tool-unsupported endpoints as
capability failures. Those numbers measured sampling+prompt+infra, not the
design, and are not citable. This ablation removes all of that by construction:
the same scorer hits all three arms of the same actor, so an imperfect label
largely cancels in the rescue-minus-harm delta.
Also:
- chat_completion_retrying: predeclared retry on transient infra errors, which
are then excluded (∅) from the capability analysis rather than scored as
wrong answers.
- MOA_ABLATION_ACTOR / MOA_ABLATION_DRAWS env overrides.
- the old best-single test is kept but documented as a confounded smoke test,
not evidence.
Pilot findings (5 draws, 4 tasks, ignored/live):
- Strong actor (qwen3-32b): 20/20 all arms — aces every task alone, so
references are inert-but-harmless (no headroom to measure rescue).
- Weak actor (qwen3-8b): A 15/20, B 20/20, C 15/20 — net uplift +5, all on the
one task with headroom (triage). Real advice rescued the weak actor every
draw; SHUFFLED advice did not (C=A), so the gain is advice CONTENT, not token
count or the decision prompt. Zero harm.
Reading: reference value tracks actor-alone headroom — a safety net when the
actor is weak, dead weight (not damage) when strong. Suggests gating the
reference phase on actor strength. This is a directional pilot, not the
merge-blocking study (that needs ~40 preregistered stratified tasks, k>=10
draws, a paired hierarchical bootstrap CI, and the production-selected actor).
* test(moa): preregistered scaled actor-ablation study + bootstrap analyzer
The defensible version of the pilot, committed BEFORE running so the labels
are preregistered and can't be seen to chase results.
- tests/fixtures/ablation_tasks.json: 40 preregistered tasks, 4 strata x 10
(inspect / search / execute / no_tool). Set-valued accept labels
(accept_tools is a SET; empty = "no tool call"), with optional arg substring
constraints — addresses the brittle single-tool label from the pilot.
- ablation_scaled_study: loads the fixture, runs A/B/C arms at k draws
(default 10) with bounded concurrency, writes one JSONL trial per
(draw,task,arm) to MOA_ABLATION_OUT. Live measurement only; no stats here.
- evals/moa-openrouter/analyze_ablation.py: deterministic paired HIERARCHICAL
bootstrap — resample tasks within stratum, then draws within task — for a
95% CI on net uplift = P(rescue) - P(harm), plus the shuffled-arm control
(content effect = B_uplift - C_uplift). Validated on synthetic data: 3/40
rescue tasks -> +0.075 point estimate, shuffled +0.000, and the CI honestly
spans 0 when rescue is sparse (no over-claiming).
Arms (one pinned actor, identical sampling/prompt; only references vary):
A actor alone · B actor + real references · C actor + shuffled references.
Env: MOA_ABLATION_ACTOR, MOA_ABLATION_DRAWS, MOA_ABLATION_CONCURRENCY,
MOA_ABLATION_OUT. Ignored by default (live network + cost).
* docs(moa): trim over-long rationale comments in the asymmetric tool path
Cut the essay-length doc/inline comments added with the actor design down to the
non-obvious "why". No code change.
- tool_turn.rs: 26-line module preamble -> 6 lines; inline comments tightened
(20% -> 10% comment density)
- context.rs: pack_for_actor docstring 16 -> 5 lines; reducer synthesis-framing
block 14 -> 6 lines
- workers.rs: compute_actor_candidates docstring 21 -> 6 lines (kept the terse
sort-key comments, which are load-bearing)
* test(moa): pre-hoc structured-proposal and post-hoc correction studies
Two more ablation arms to answer "can peers help tool selection at all?",
reusing the A/B/C JSONL schema + analyze_ablation.py.
matched_peer_structured_study — do similar-strength, different-family peers
help a fixed finalizer via STRUCTURED candidate tool calls (not prose)?
A solo · B diverse (2 different-family peers) · C homogeneous (2 resamples
of the finalizer's own model). B-C isolates cross-family diversity from
extra sampling; also records oracle-union.
correction_rescues_weak_tool_caller — the mesh scenario neither Hermes nor
Together handles: a weak tool-caller with no strong peer. Tests correction of
the CONCRETE drafted call instead of pre-hoc advice.
A draft-alone · B deterministic (schema-validate + re-prompt on structural
failure) · C semantic (different-family critic reviews the concrete call,
finalizer revises once).
Findings (live, directional): every tool-selection intervention was
inert-to-harmful vs routing to a capable model. Pre-hoc structured proposals
were flat (37-39/40 all arms). Deterministic correction fired ~never
(qwen3-8b already drafts structurally valid calls ~95%); the residual failures
are semantic (wrong tool choice), which neither validation nor a strong critic
fixed because the revision still runs through the weak actor. Conclusion: tool
selection is a "best capable model acts" task; MoA's value is on the
answer/chat path (untested).
* test(moa): committee study on realistic reasoning/answer turns
Tests where MoA's value should live per Together's validated claim: open-ended
answer QUALITY on realistic agent-session turns (reason-over-tool-output,
planning, explanation) — not tool selection.
Fixed aggregator; only its input varies:
A alone · B committee (synthesize 3 diverse-peer drafts, 1 round)
C layered (peers refine seeing each other first, then synthesize — Together's
`layers`)
Judged pairwise by an out-of-pool different-family judge (gpt-4o-mini),
position-swapped (win only if consistent both orders), output lengths logged.
- tests/fixtures/committee_tasks.json: 15 realistic reasoning/answer turns
- committee_beats_solo_on_reasoning: writes per-trial JSONL
Pilot findings (2 draws, live; DIRECTIONAL, underpowered):
- committee(B) vs solo(A): win 6 / tie 2 / loss 2 — positive but sign-test
p=0.29, NOT significant at n=10. Every B win was also the longer answer, so
length is not cleanly ruled out.
- layered(C) vs committee(B): loss 6 / tie 2 / win 2 — Together's extra
refinement round is negative value on these tasks, at extra cost.
- 20/30 trials skipped because the aggregator (qwen3-32b) returned empty
content (reasoning-budget exhaustion — the content:null bug Hermes'
troubleshooting.md documents). A flaky aggregator degrades the committee;
the instrument needs empty-output handling before a real run.
Net: first directionally-positive result for MoA in this investigation, but
unproven. Contrast with 5 tool-selection experiments that were all
null-to-harmful. Supports the task-split: route tools to one caller, convene a
(single-round) committee on reasoning turns.
* feat(moa): Hermes-style reference packing — strip system prompt + tool transcript
Our references were packed very differently from Hermes', and it was measurably
costly. Adds `pack_for_reference`, which gives advisors only the conversation's
user/assistant prose:
- strips the agent system prompt (an advisor told "you are a coding agent, run
the tests" role-plays the actor instead of advising it)
- strips the tool transcript (prior tool_calls + results anchored every advisor
on the trajectory already taken, collapsing the error-independence that makes
aggregation worth anything)
- drops the "respond with your best answer or tool call" instruction: advisors
hold no schemas, so asking for a tool call yields tool-shaped prose — exactly
the advice that pulled the actor off its own better choice
- uniform view across advisors (no per-role trimming), so the packing is a
stable function of history and caches across iterations
- caps advisor output at 600 tokens (advisor generation dominates turn latency;
the turn waits for the slowest advisor)
Head-to-head on the same preregistered study (strong actor qwen3-32b, 40 tasks
x 10 draws, identical everything except packing):
packing B pass net uplift 95% CI
original 359/400 -0.102 [-0.170, -0.045]
hermes 385/400 -0.037 [-0.090, -0.003]
Harm cut by ~64%. Per-category: search -14 -> -2, execute -25 -> -13,
inspect -2 -> 0. The content-specific component (B-C differential) fell from
-0.075 to -0.015, i.e. with correct packing the residual harm is no longer
mostly "bad advice content".
Honest read: most of the harm I previously attributed to "references" was an
artifact of how WE packed them, not a property of reference-based MoA. The
direction still stands though — even correctly packed, references remain a
small but statistically real regression for a STRONG actor on tool selection
(CI still entirely below zero).
Adds 4 unit tests pinning the packing contract (no tool-call request, no system
prompt leak, no tool-transcript leak, prose preserved), and
MOA_REFERENCE_PACKING=hermes to select the style in the eval harness.
* docs(moa): record the MoA evidence — packing bug, actor headroom, what failed
Writes up every live study in one place (evals/moa-openrouter/RESULTS.md) so
the conclusions and their caveats survive the investigation.
Headline 2x2 (40 preregistered tasks x 10 draws, paired hierarchical bootstrap):
actor packing B pass net uplift 95% CI
strong original 359/400 -0.102 [-0.170, -0.045] <- the bug
strong hermes 385/400 -0.037 [-0.090, -0.003]
weak original 365/400 -0.013 [-0.090, +0.070]
weak hermes 377/400 +0.017 [-0.053, +0.100]
Two monotonic effects: fixing the packing helps in both actor conditions, and
references are worth more to a weaker actor. The only significant cell is the
original-packing strong-actor harm — i.e. our bug, not a property of MoA.
Per-stratum (weak + hermes) shows references help exactly where the actor has
headroom (search +10, execute +4) and hurt where it was already perfect
(inspect -7). That is a gating signal.
Also records what did NOT work for tool selection (pre-hoc structured
proposals: flat; deterministic correction: never fires, the weak actor already
emits valid calls ~95%; semantic correction: negative, since the revision still
runs through the weak actor), and the committee pilot on reasoning turns
(directionally positive, n=10, not significant; Together's layering loses to
single-round).
* fix(moa): use the validated reference packing in production
The Hermes-style packing was measured and unit-tested but only wired into the
eval harness — the production tool path still used pack_for_worker_selected,
the exact packing measured at -0.102 net uplift.
tool_turn now calls pack_for_reference: conversation prose only, no agent
system prompt, no tool transcript, no request for a tool call. Same head-to-head
(strong actor, 40 tasks x 10 draws) puts this at -0.037 vs -0.102, and it is the
only configuration where references show a positive point estimate for a weak
actor (+0.017). See evals/moa-openrouter/RESULTS.md.
* feat(moa): gate advisory references on actor headroom
References are not universally good or bad — their value tracks how much
headroom the acting model has. Measured over 40 preregistered tool tasks x 10
draws with correct advisor packing (evals/moa-openrouter/RESULTS.md):
weak actor (qwen3-8b): +0.017 net uplift -> advice helps
strong actor (qwen3-32b): -0.037 net uplift -> advice costs
Per-stratum the split is sharper: references gained where the actor had
headroom (search +10, execute +4) and lost where it was already perfect
(inspect -7).
Adds `GatewayConfig.reference_policy`:
Auto (default) - advise a small-tier actor, let a big-tier actor act alone
Always - always fan out advisors
Never - actor acts alone (Hermes' `enabled: false`)
A one-model pool never gathers references under any policy (nobody left to
advise once the actor is excluded), and an unknown actor keeps the prior
advise-by-default behaviour rather than silently degrading.
The host sets `Auto`. sim_real_traces pins `Always` because that suite exists
to verify fan-out accounting; the gate itself is covered by six new unit tests
in tool_turn.
189 tests pass; clippy -D warnings and fmt clean on mesh-mixture-of-agents and
mesh-llm-host-runtime.
* test(moa): scale the committee study — 40 preregistered prompts, concurrent, empty-output fix
Prepares the reasoning-turn committee study for a defensible run. Committed
before executing so the prompts are preregistered.
Three fixes to the pilot's weaknesses:
1. Empty-output bug. `response_text` read only `/message/content`, so a
reasoning model that spends its budget in `reasoning` and returns
`content: null` looked like an empty answer — that silently dropped 20/30
pilot trials. Now falls back to `reasoning`, mirroring the in-tree backend's
chain. Remaining skips are counted and reported rather than hidden.
2. Serial execution. ~18 calls x 30 trials was far too slow to scale. Trials
are independent, so they now run under a bounded semaphore
(MOA_COMMITTEE_CONCURRENCY, default 4), with peer drafts and refinements
gathered concurrently within each trial. Output is sorted for stable
reporting.
3. Fixture too small. 15 -> 40 prompts, 4 strata x 10: reason_over_output,
planning, explain, code_review. All realistic agent-session reasoning/answer
turns (several embed simulated tool output or code, as an agent loop would).
Arms and controls are unchanged: fixed aggregator, A alone / B committee /
C layered, judged pairwise by an out-of-pool different-family judge with
position swap, output lengths logged for the length-vs-preference check.
* docs(moa): committee wins decisively on reasoning turns (120 trials, p<1e-11)
Scaled the reasoning-turn committee study from the 15-prompt pilot to 40
preregistered prompts (4 strata x 10) x 3 draws = 120 trials, 0 skipped.
comparison win/tie/loss mean 95% CI sign p
committee(B) vs solo(A) 86/16/18 +0.567 [+0.392,+0.733] 8.2e-12
layered(C) vs solo(A) 90/11/19 +0.592 [+0.408,+0.758] 3.1e-12
layered(C) vs committee(B) 57/30/33 +0.200 [+0.008,+0.392] 1.5e-02
Consistent across every stratum. The length confound runs the OPPOSITE way and
strengthens the result: the committee's answers are SHORTER than solo (2548 vs
3136 chars) and still win; restricted to the 61 trials where B was shorter, B
wins 40-14 (p=5.4e-4).
This reverses two pilot conclusions, both of which were artifacts of the
empty-output bug that silently dropped 20/30 pilot trials (biased toward
exactly the cases where the aggregator struggled):
- "committee is directionally positive but not significant" -> it is
significant by a wide margin;
- "Together's layering is negative value, don't build it" -> RETRACTED;
layered beats solo about as strongly as single-round, and edges single-round
itself (though that is the weakest result and costs an extra peer round).
Net across the whole investigation: tool selection shows no win for
multi-model (route to the best tool-caller); reasoning/answer turns show a
clear one (convene the committee). That task split is now evidence-backed
rather than assumed.
* feat(moa): cross-peer refinement round — a small-model mesh beats its best member
The mesh value proposition, measured: a pool of four 8B-class models
(qwen3-8b aggregating llama-3.1-8b + granite-4.1-8b + ministral-8b) produces
better answers than the best single member — but ONLY with a refinement round.
40 preregistered reasoning prompts x 3 draws = 120 trials, judged pairwise by
an out-of-pool different-family judge (gpt-4o-mini), position-swapped:
small pool (all 8B):
committee (1 round) vs solo 26/75/19 +0.058 p=0.37 ns
layered (2 rounds) vs solo 42/66/12 +0.250 p=5.2e-05 ***
layered vs committee 39/73/ 8 +0.258 p=5.5e-06 ***
Single-round synthesis does nothing for a small pool; the cross-peer
refinement round is what creates the gain. With a 32B aggregator both work and
the extra round adds much less (p=0.015), so it isn't worth a second fan-out
there.
Adds `refinement::refine_round`: every worker sees all round-1 drafts
(anonymized, length-bounded) and rewrites its own answer, then the reducer
synthesizes the refined set. Runs on the text path only, and only when we were
going to synthesize anyway — an early-exit consensus already has its answer.
`GatewayConfig.refinement_policy`:
Auto (default) - refine when the pool is all small-tier
Always / Never - explicit override
Mesh flavour: the round is best-effort. It bounds its own wait at
worker_timeout, refines with whichever drafts arrived, tolerates workers that
fail or time out, and returns the round-1 outputs unchanged on any shortfall —
so a slow or absent peer can never cost the turn.
This also retracts, for the second time, the pilot claim that "Together's
layering is negative value". It is essential for exactly the pool shape a
consumer mesh has.
194 tests pass; clippy -D warnings and fmt clean on both crates.
* test(moa): pin refinement behaviour under mesh conditions; record small-pool evidence
Refinement runs on exactly the hardware where peers are slowest and least
reliable, and a second fan-out is a second chance to hang a turn. Four
deterministic sim tests pin it as strictly best-effort:
- refined drafts (not round-1) are what reach the client
- a refiner that never returns cannot extend the turn past worker_timeout
- every refiner failing degrades to the round-1 drafts, turn still answers
- a big-tier pool skips the extra fan-out entirely under Auto
Two of these failed first and both were my test's fault, not the engine's:
identical round-1 text clustered into consensus so the turn early-exited before
synthesis (peers now return distinct answers), and the "reducer must synthesize"
assertion was too narrow — when refined drafts agree, the arbiter takes
consensus and returns refined text directly, which is equally correct. The
contract pinned is now "refined text reaches the client, round-1 text does not".
RESULTS.md records the small-pool study that motivated the feature: an all-8B
diverse pool (qwen3-8b aggregating llama-3.1-8b + granite-4.1-8b +
ministral-8b) beats its best member only with the refinement round —
single-round 26/75/19 (p=0.37, ns) vs layered 42/66/12 (p=5.2e-05), with
layered over single-round at p=5.5e-06. Includes the honest caveats: ties
dominate for the small pool, and unlike the strong-pool result the length
control is not clean (answers are longer than solo).
* fix(moa): bound the refinement round to half the worker budget
Refinement sits between round 1 and the reducer, so giving it the full
`worker_timeout` let one text turn pay three sequential worker budgets — worst
on exactly the slow, high-latency meshes the feature exists to serve.
The round now gets half the budget: enough for a pool that answered round 1
promptly, and it falls back to the round-1 drafts if the pool can't refine in
time. Worst case drops from ~3x a plain turn to ~2.5x.
* feat(moa): shorter patience on public meshes
A public mesh is a pathological availability case — unknown peers, wider
latency spread, more churn — not a trust case. Both patience knobs are "hold a
usable answer hoping for a better one", which is exactly the wait that hurts
when the tail is long.
knob private public
first_answer_grace 3s 1.5s
strong_patience 20s 8s
Hard bounds (worker_timeout, reducer_timeout) are unchanged; only the optional
waiting shrinks, so quality paths still run when peers are prompt. Sourced from
the node's existing `public_mesh` flag — no new config.
Three tests pin the contract: public waits less on both knobs, public still
gives a prompt strong peer a real chance (grace non-zero, patience >= 5s), and
the private defaults don't drift silently.
1854 host-runtime tests pass; clippy -D warnings and fmt clean.
* test(moa): prove refinement runs under the production grace setting
Every refinement test so far set `first_answer_grace: ZERO`, but production
uses 3s (1.5s public). Grace produces an `early_decision`, and refinement is
skipped whenever one exists — so if grace fired on a fast all-small pool the
whole feature would have been dead code on real traffic. That was untested.
Two tests close the gap:
- `refinement_still_runs_under_production_grace` — at the real 3s default, a
prompt all-small pool still refines and the refined drafts reach the client.
Grace only arms once its window has *elapsed*, so a pool that answers
promptly reaches synthesis first.
- `grace_shipping_a_slow_answer_skips_refinement` — pins the opposite case
honestly: when round-1 answers straggle past the grace window, grace ships an
early answer and refinement does not run. That tradeoff is now visible in a
test rather than assumed.
195 engine tests pass; clippy -D warnings and fmt clean.
* fix(moa): straggling peers must not cost the refinement round
The answer grace was silently disabling the feature that makes a small mesh
worth running.
Grace fires at 3s (1.5s public) and produces an `early_decision`; refinement is
skipped whenever one exists. On an all-small pool a single synthesis round is
worth nothing (26/75/19, p=0.37) — refinement is the only step that beats the
best member (42/66/12, p=5.2e-05). So one fast peer answering while others
straggle would ship a lone small-model answer and skip the round, while still
paying for the fan-out. Variable peer latency is the norm on consumer hardware,
so this hit exactly the case the feature exists for.
Now: when refinement is expected (policy + pool shape, decidable before
dispatch via `refinement_expected`), the answer grace is disabled for that
turn. Round 1 is still bounded by `worker_timeout` and refinement by its own
half-budget deadline, so this costs bounded latency, never an unbounded wait.
Pools that don't refine — anything with a big-tier model — keep the fast chat
path untouched.
Found by re-reading my own test against the goal: the previous
`grace_shipping_a_slow_answer_skips_refinement` pinned this as an acceptable
tradeoff. It wasn't; it was the feature not firing. Replaced with
`straggling_peers_do_not_cost_the_refinement_round`, plus
`grace_still_short_circuits_when_refinement_is_not_expected` to pin that the
fast path survives where refinement is off.
202 engine tests + 1854 host tests pass; clippy -D warnings and fmt clean.
* fix(moa): grace must bound the wait, not forfeit the refinement round
On an all-small pool the answer grace could ship a lone fast answer and skip
refinement — the only step that makes such a pool beat its best member
(26/75/19, p=0.37 without it vs 42/66/12 with). Variable peer latency is the
norm on consumer hardware, so this silently disabled the feature exactly where
it exists to help, while still paying for the fan-out.
Fix: `GatherPolicy.min_grace_answers`. Normally 1 (ship the good answer, stop
waiting for the tail). When refinement is expected it becomes MIN_DRAFTS, so
grace still caps the round-1 wait but will not finalize before refinement is
even possible.
Two earlier attempts were wrong and are worth recording:
- Disabling grace outright for all-small pools removed the only straggler
bound on round 1 (worst case round1 + refine + reducer), which is backwards
on a public mesh whose whole profile is "wait less".
- Letting refinement override an existing `early_decision` broke
`same_tier_pool_keeps_early_exit`, and rightly: that decision is *consensus*
(workers agreed — a real signal and the cheap path), not a timeout. Grace and
consensus both surface as `early_decision` but mean opposite things; only the
timeout should be prevented from pre-empting refinement.
202 engine tests + 1854 host tests pass; clippy -D warnings and fmt clean.
* fix(moa): let the reducer and refiners actually see the answers
Eval-vs-production fidelity gap, same class as the reference-packing bug.
The small-pool result (42/66/12, p=5.2e-05) was measured on untruncated drafts
averaging ~3.8k chars. Production truncated much harder:
refinement input 1200 chars -> refiners saw ~30% of each peer draft
reducer payload 500 chars -> reducer saw ~13% of each refined answer
So the shipped path discarded most of exactly the content the refinement round
produces. The measured gain could not have survived it.
Both budgets are now 4000 chars, which passes realistic answers intact while
still bounding a pathological worker. Tool turns keep the tight 500-char bound
on purpose: there the signal is the proposal itself, and long prose crowds out
the tool schemas.
Three tests pin the contract (realistic text answers survive, tool payloads
stay tight, refiners see full peer drafts), and the existing
`reducer_truncates_long_worker_payloads` now probes above the new bound so it
still guards the pathological case it was written for.
205 engine tests pass; clippy -D warnings and fmt clean.
* fix(moa): ship the refinement prompt that was actually measured
Final eval-vs-production fidelity check. Sampling already matched exactly
(SamplingParams::worker, thinking off, 1024 tokens), but the prompt did not:
measured (eval) "You have been given a user request and several candidate
responses... Synthesize them into one high-quality
response..." + "Candidate responses:" + [Response N]
shipped (before) "Several models independently answered... write a better
answer of your own..." + [Answer N]
The eval wording is also what Together's advanced-moa.py uses — it reuses the
aggregator prompt for refinement layers. A refinement-specific wording may well
read better, but the eval wording is the one with evidence behind it, and
"surely equivalent" prompt differences have already caused two wrong
conclusions in this branch (the reference-packing bug and the layering
dismissal). Changing it should be a measured change, not an assumed
improvement.
Production now matches the measured configuration exactly: prompt text, header,
and per-draft labels.
The sim fixture detected refinement calls by matching the old prompt string, so
it needed updating too — it now keys on the "Candidate responses:" header.
205 engine tests pass; clippy -D warnings and fmt clean.
* docs(moa): record the eval-vs-production fidelity gaps and their fixes
A measured gain only counts if the shipped path reproduces the measured
configuration. Three gaps were found after the numbers were collected:
refinement input truncated to 1200 chars (~30% of a real draft), reducer
payload to 500 (~13%), and a different refinement prompt than the one measured.
All closed; tool turns keep the tight 500-char bound on purpose.
Also states the honest limit: production now matches the measured
configuration, but the small-pool result has not been re-measured through the
shipped path since. The gain should carry — that is an expectation, not an
observation.
* fix(guardrails): panic slicing tool-call text with a multi-byte prefix
`parse_parenthesized_tool_call` resumed parsing at `after_open[json_text.len()..]`,
but `first_balanced_object` returns the object starting at its `{` — so any
characters before the brace skew the index. With a multi-byte character in that
prefix the index lands mid-character and slicing panics.
Hit for real during an end-to-end MoA eval against live models:
"start byte index 13 is not a char boundary; it is inside '‑'" (U+2011,
non-breaking hyphen) — a worker task panic that would have taken down the turn.
Adds `first_balanced_object_span` returning the byte span, so callers resume at
the object's end rather than guessing from its length. Three regression tests:
multi-byte prefix must not panic, happy path still parses, multi-byte content
inside the arguments round-trips.
19 guardrails tests pass; clippy -D warnings and fmt clean.
* fix(moa): stop telling the text reducer to "Be concise"
End-to-end through `handle_turn`, MoA lost 7/8/65 to a single small model. Two
causes; this is the second.
The text reducer's instruction framed the turn as reconciling a *disagreement*
and ended with "Be concise". Measured output: ~2.0k chars against a ~4.1k-char
solo baseline — and the judge preferred the fuller answer. Terseness is not the
goal on a reasoning turn; accuracy and completeness are.
Text turns now use the wording the committee study actually measured
(evals/moa-openrouter/RESULTS.md): "produce the most accurate, well-structured
reply. Be direct." Tool turns keep the tight framing — there the output is an
action and the reducer must stay free to emit a tool call.
Also adds the e2e harness (`e2e_handle_turn_beats_best_single_small_model`)
that found this: it drives the real `moa::handle_turn` over a live all-8B pool
and judges it against the pool's strongest member. The committee study measures
the mechanism with its own helpers; this measures the shipped path, which is
where all three fidelity bugs actually hid.
205 engine tests pass; clippy -D warnings and fmt clean.
* fix(moa): give every peer the full budget when refinement is expected
Third and last cause of the end-to-end loss.
Role tiers (Fast 256 tokens, Specialist 512, Strong 1024) encode a capability
spread so the cheap worker can answer the grace path quickly. A homogeneous
all-8B pool has no such spread, and when refinement is expected every draft is
an *input* to the round — a 256-token draft is a ~1000-char stub that drags the
refined answer down.
Measured: production's tiered budgets produced ~3.1k-char answers against a
~4.1k-char solo baseline. The study that showed the +0.250 gain gave every peer
the full 1024.
When refinement is expected, all workers now pack as Generalist (full budget).
Role identity is unchanged, so tier-gating and accounting still see the real
roles; only the packing budget is uniform.
205 engine tests pass; clippy -D warnings and fmt clean.
* fix(moa): text workers get a text preamble, not the tool-turn one
Fourth divergence between the measured study and the shipped path.
Every production worker was prepended MOA_PREAMBLE: "Respond with your best
answer or tool call. Be direct." On a text turn that is wrong twice: there is
no tool to call, and "be direct" pushes workers toward stubs. These drafts are
the *input* to the refinement round, so brevity compounds — measured
end-to-end, MoA answered ~3.3k chars against a ~4.1k-char solo baseline and
lost on judged quality. The study that showed the gain gave workers no such
instruction.
Text turns now get a preamble that says the parts will be combined and asks for
the most accurate and complete answer. Tool turns keep the original wording.
205 engine tests pass; clippy -D warnings and fmt clean.
* fix(moa): don't give the reducer the worker preamble
Fifth divergence between the measured study and the shipped path.
The reducer's system prompt began with the *worker* preamble — "multiple models
are answering this in parallel, give your most accurate answer" — and then
immediately contradicted it with "you have been given several candidate
responses; synthesize them". The combiner was being told it is also one of the
answerers. The measured study gave the synthesizer the synthesis framing alone.
`reducer_system_prompt` now passes the agent's own system prompt (tool guidance
stripped on text turns) without the worker preamble.
205 engine tests pass; clippy -D warnings and fmt clean.
* Revert "fix(moa): don't give the reducer the worker preamble"
This reverts commit fb1281a0.
Measured, the change made things worse, not better:
with preamble (17/39/24) -> without (15/28/37)
MoA mean output 3697 chars -> 3534 chars
My reasoning was that the worker preamble ("multiple models are answering in
parallel") gave the reducer the wrong role and contradicted the synthesis
instruction. The e2e says otherwise: that preamble also says "the best parts of
each will be combined; give your most accurate and complete answer", and the
reducer was evidently using it to produce fuller answers. Removing it shortened
the output and lost ground.
Fifth hypothesis, first one the measurement rejected. Keeping the behaviour the
evidence supports.
* fix(eval): the e2e judge was scoring length, not quality
Analysis of the 80-trial e2e run, conditioned on relative output length:
MoA LONGER than solo: n=25 win 13 tie 12 loss 0 (100% winrate, p=2e-4)
MoA SHORTER than solo: n=55 win 4 tie 27 loss 24 ( 14% winrate, p=2e-4)
point-biserial r(length delta, verdict) = +0.681
mean delta when MoA wins: +682 chars; when it loses: -1337 chars
Zero losses whenever MoA was longer. The judge prompt asked for "accurate,
complete, and useful" — "complete" reads as "longer" — so the metric was
verbosity, not quality. This is the exact bias the committee study controls for
(position swap + length reporting) and that I failed to carry into the e2e
harness.
Consequence: "MoA loses 17/39/24 end-to-end" is NOT a valid quality finding,
and four of the five fixes measured against it were partly optimising toward a
biased judge. The correctness fixes stand on their own merits (a real panic,
truncation destroying content, prompts contradicting the evidence); the
*scoreboard* they were measured against did not.
Judge now scores correctness/relevance only and is explicitly told length is
not quality.
* docs(moa): withdraw the length-biased numbers, record the corrected ones
The judge that produced the small-pool and e2e results scored length, not
quality (r = +0.681 between length delta and verdict; the longer answer won
13-0). Re-run with a length-controlled judge, r fell to +0.132.
Corrected small-pool result (n=80, length-controlled):
committee (1 round) vs solo 6 / 73 / 1 p = 0.125 ns
layered (2 rounds) vs solo 11 / 68 / 1 p = 0.0063 significant
layered vs committee 3 / 77 / 0 p = 0.25 ns
The core claim survives: a small-model mesh beats its best member, but only
with the refinement round. The magnitude does not — 42/66/12 is withdrawn, most
of those "wins" were length artifacts and are now ties. Ties dominate (68/80):
on most prompts a small mesh and a single small model are indistinguishable.
The strong-pool section keeps its numbers and gains a note explaining why: the
bias ran against the winner there (the committee's answers were shorter and won
anyway, 40-14 on the shorter-only subset), so that result is unaffected.
Status now records end-to-end parity (5/65/10, p=0.30) rather than the earlier
loss, and lists closing the harness-vs-production gap as outstanding.
* fix(moa): commit the grace_finalizes field definition
HEAD did not compile. `lib.rs` set `GatherPolicy.grace_finalizes` (committed in
"grace must bound the wait, not forfeit the refinement round") but the field
definition in `fanout.rs` was never staged — it sat unstaged in my working tree
across several commits, so every local build passed while the pushed tree was
broken.
Adds the field, its use in the gather loop (grace expiry stops collecting but
does not finalize when refinement is expected), and `grace_finalizes: true` at
the eight fanout test sites.
Verified against a clean worktree checkout of HEAD, which is how the breakage
was found: `cargo build -p mesh-mixture-of-agents` now succeeds there.
* fix(moa): anonymize reducer inputs on text turns
Sixth divergence between the measured study and the shipped path.
Production labelled reducer inputs "[Worker N — model_name]"; the study that
measured the gain used anonymous "[Response N]". Hermes anonymizes reference
outputs for the same reason — a named model invites the aggregator to defer to
the name rather than the content.
Text turns now use "[Response N]". Tool turns keep attribution: there the
reducer arbitrates between proposals, provenance is genuinely useful, and the
tool-path tests pin it. The "## Worker outputs" header is unchanged — two sim
fixtures use it to identify reducer calls.
205 engine tests pass; clippy -D warnings and fmt clean.
* docs(moa): record that anonymization did not close the harness gap
Sixth divergence fixed (named -> anonymous reducer inputs) and measured:
before 5 / 65 / 10 p=0.30
after 9 / 59 / 12 p=0.66
Decided trials went 5W/10L -> 9W/12L. That is not an improvement at this sample
size; both runs are parity. Recorded as such rather than as progress.
Also records the gap that remains unexplained — harness 11/68/1 (p=0.0063) vs
shipped 9/59/12 (p=0.66) on the same models, prompts, judge and packing — and
lists the candidates not yet ruled out.
Adds a caution that r(length,verdict) was +0.465 in the latest e2e run vs
+0.132 in the small-pool study, so length bias is not fully suppressed even
with the corrected judge; single-run e2e deltas are directional only.
* fix(moa): text reducer gets only the synthesis framing
Seventh divergence between the measured study and the shipped path, and the
last known one in the reducer prompt.
Production prepended two things the harness never had:
* the worker preamble ("several models are answering this in parallel") —
the reducer is the combiner, not one of the answerers;
* "Reason for synthesis: {reason}" — an internal arbiter string such as
"3 workers answered with no agreement", which reads to the model as a
warning that its inputs are unreliable.
Text turns now get exactly what the study measured: the synthesis instruction
plus the candidate answers. Tool turns keep both extras — there the reducer is
arbitrating a real disagreement, the reason is informative, and the agent's
tool guidance matters.
Note: an earlier commit removed only the preamble and appeared to make things
worse, but that was measured with the length-biased judge, so the result was
invalid and was reverted. This re-tests both extras together under the
corrected judge.
205 engine tests pass; clippy -D warnings and fmt clean.
* Revert "fix(moa): text reducer gets only the synthesis framing"
This reverts commit 031a349a. Measurement rejected it, and this time the
finding replicates.
v7 preamble + reason kept 5/65/10 decided 33% MoA len 3606
v8 + anonymized inputs 9/59/12 decided 43% MoA len 3679
v9 - preamble - reason 8/57/15 decided 35% MoA len 3314
Removing the worker preamble shortened MoA's output on both occasions it was
tried (v6 3534, v9 3314) versus keeping it (v7 3606, v8 3679), and lost ground
on decided trials both times. The first rejection was under the length-biased
judge so it did not count; this one is under the corrected judge and agrees.
Reading: the preamble ("the best parts of each will be combined; give your most
accurate and complete answer") is doing useful work on the reducer even though
it is nominally addressed to a worker. Matching the harness exactly is not
automatically right — the harness had no system prompt at all, while production
has one, and the preamble evidently compensates.
Second hypothesis rejected by measurement rather than argument. Keeping the
behaviour the evidence supports.
* docs(moa): record the two rejected hypotheses for the harness gap
Both prompt-level explanations for the harness-vs-shipped gap were tested and
rejected:
baseline (v7) decided 5/15 = 33% MoA len 3606
anonymize reducer inputs (v8) decided 9/21 = 43% MoA len 3679
also drop preamble + reason (v9) decided 8/23 = 35% MoA len 3314
Dropping the worker preamble shortened output and lost ground both times it
was tried (v6, v9) and was reverted both times. The preamble does useful work
on the reducer even though it is nominally addressed to a worker — so matching
the harness exactly is not automatically right: the harness sent no system
prompt at all, production sends one, and the preamble compensates.
Anonymization is retained (Hermes does it, the study measured it, it did not
hurt) but 43% vs 33% on ~20 decided trials is not a result.
Notes what remains untested (arbiter short-circuit on converged drafts,
normalize_worker_output reshaping prose) and the honest conclusion: after two
rejected guesses, the gap needs a byte-level diff of the prompts each path
actually sends, not more hypotheses.
* test(moa): diagnostic that dumps every prompt handle_turn actually sends
Two prompt-level hypotheses for the harness-vs-shipped gap were tested live and
rejected, so rather than guess a third this captures what production actually
sends on a text turn — every role/content pair, in order. Deterministic and
offline (canned backends, no network).
What it shows for a 4-model all-small pool:
calls 1-4 round-1 workers — text preamble + user prompt, max_tokens=1024
calls 5-8 refinement round — byte-identical to the harness's refine()
call 9 reducer — harness synthesis prompt, plus the preamble,
a "Reason for synthesis" line and a
"## Worker outputs" header
So the refinement round now matches the harness exactly, and the round-1 and
reducer calls differ only by the preamble and the reducer scaffolding — both of
which were removed in an experiment and measured *worse* (v9), then reverted.
This is the instrument for the next person: run it, diff against the harness
block it prints alongside, and the remaining delta is visible without guessing.
206 engine tests pass; clippy -D warnings and fmt clean.
* fix(moa): refine over all drafts, not the bare minimum
Eighth eval-vs-production divergence, found by reading the gather policy rather
than guessing at prompts.
`min_grace_answers` was set to MIN_DRAFTS (2) — the minimum for the refinement
round to *run*. On a 4-model pool that let grace stop collecting at 2 of 4
drafts, so production refined over half the perspectives while the study that
measured the gain always refined over all of them. Refinement quality scales
with how many drafts it sees, so this directly undercuts the mechanism.
Now waits for all but one draft (floored at MIN_DRAFTS), so a single straggler
still cannot hold the turn and `worker_timeout` still bounds the wait.
206 engine tests pass; clippy -D warnings and fmt clean.
* fix(moa): always synthesize after a refinement round
Ninth divergence, and the first one found from outcome data rather than by
reading code.
Pooling the e2e runs showed the shipped path wins at roughly the harness rate
(22/240 vs 11/80) but loses 32 times where the harness loses once. Splitting
those turns by whether the reducer actually ran located the asymmetry:
reducer ran n=66-74 win 8-9 loss 6-10 loss rate 9-14%
reducer skipped n= 6-14 win 0 loss 2- 4 loss rate 29-33%
MoA won 0 of 20 turns where synthesis was skipped, at triple the loss rate.
That is a pure loss mode.
Cause: `arbitrate` returns `Answer(payload)` when drafts agree, shipping one
worker's text verbatim. That is the right cheap path for raw round-1 answers,
but after refinement it discards the round we just paid for and returns
whichever single small model represented the cluster. Refined drafts agreeing
is not a reason to skip synthesis — it is the best possible input to it.
Text turns that refined now always route to the reducer. Three sim assertions
updated: the client now sees the reducer's synthesis of refined drafts rather
than raw refined text.
206 engine tests pass; clippy -D warnings and fmt clean.
* docs(moa): N=2 is not enough — pool-size comparison
Direct test of "do 2 different models beat 1 of the same strength?", same 40
prompts / same judge / same harness, only pool size differs:
N=2 (qwen3-8b + llama-3.1-8b) 2W 75T 3L p=1.00 5 decided
N=4 (+ granite-4.1-8b, ministral) 11W 68T 1L p=0.0063 12 decided
Fisher exact on decided win/loss, N=2 vs N=4: p=0.053.
At 8B scale two peers produced no measurable gain — 2 wins against 3 losses.
The same code with four models across four families wins 11-1. Two quantities
move together as peers are added: decided trials rise (5 -> 12; more peers
produce differentiated output instead of near-identical answers the judge ties)
and the win share among them rises (40% -> 92%).
Caveats recorded: 5 decided trials is weak evidence of absence, not evidence of
no effect; and this is a claim about small models — Hermes reports a two-model
frontier preset beating its stronger member by ~6 points, which is untested
here.
* docs(moa): N=2 null was scale, not count — mid-scale N=2 wins cleanly
Mid-scale re-run refutes the "two peers isn't enough" reading. Same prompts,
judge and shipped path, layered arm:
N=2 8B (qwen3-8b + llama-3.1-8b) 2W 75T 3L p=1.00
N=4 8B (+ granite, ministral) 11W 68T 1L p=0.006
N=2 mid (qwen3-32b + mistral-24b) 49W 24T 6L p=2e-9 length r=-0.04
N=4 str (32b agg + 14b/24b/minimax) 90W 11T 19L p=3e-12 length r=+0.30
The N=2-mid result is the study's cleanest: no length confound, MoA answers
shorter than solo, still won 93% of shorter-MoA trials. So member capability,
not raw count, is the active variable — 8B needs ~4 models, 24-32B needs 2.
Records the deciding caveat: this shows *ensembling* beats the best single
member, not that cross-model *diversity* causes it — the baseline is
strength-matched, not compute-matched. A compute-matched Self-MoA arm (two
samples of the same model) is running to settle it; if it ties Mixed, the claim
becomes "test-time ensembling beats one draw".
* docs(moa): Self-MoA settles it — ensembling, not diversity, is the mechanism
Compute-matched test (both spend 2 drafts + refine + synthesize; only member
identity differs):
Mixed (qwen3-32b + mistral-24b) 49W 24T 6L p=1.8e-9 length r=-0.04
Self (qwen3-32b x2) 48W 23T 2L p=2.3e-12 length r=-0.04
Mixed vs Self: Fisher exact p=0.27 — indistinguishable. Both crush the single
best member, both confound-free (MoA answers shorter than solo, ~93% winrate on
shorter-MoA trials).
Different-family membership is not required. The active ingredient is test-time
ensembling — several sampled drafts (temp 0.8), a refinement round, synthesis —
and it works from repeated samples of one model as well as from different ones.
Matches the Self-MoA paper (arXiv:2502.00674).
Practical: the mesh does not need a curated diverse pool; any >=2 sufficiently
capable participants beat picking one, distinct models or repeated instances.
* docs(moa): the 8B ladder — need ~4, and same-vs-different doesn't matter
Same prompts/judge/harness, layered arm:
2x 8B different (Qwen+Meta) 2W 75T 3L p=1.00
2x 8B same (Qwen x2) 2W 78T 0L p=0.50
3x 8B different 3W 76T 1L p=0.63
4x 8B different 11W 68T 1L p=0.006
Two 8B instances of the SAME model do exactly what two different ones do
(nothing) — confirming count/strength drive the result, not family identity.
Four 8B models is where it starts winning. Open gap: 4x same-model 8B not run,
so whether the 8B N=4 win needs distinct models or just four drafts is
extrapolated (mid-scale Self-MoA says four drafts alone should suffice).
* fix(moa): refine on correlated drafts (homogeneous pools), not just small ones
The refinement gate keyed on tier: `Auto` refined only when every model was
small, and skipped whenever a big-tier model was present. That is wrong for the
case the mesh goal cares about — a pool of one model repeated across nodes.
Measured (evals/moa-openrouter/RESULTS.md), refinement's value tracks draft
*correlation*, not size:
same-model 32B x2: 48/2 with refinement vs 35/10 without (decisive)
diverse mid 32B+24B: 49/6 layered vs 47/4 single (~no gain)
all-small 8B x4: needs the round to beat its best member
So `Auto` now refines when the pool is homogeneous (same canonical base — the
repeated-instance / quant case) OR all small-tier, and skips the extra fan-out
only for a diverse pool that already has a big-tier synthesizer.
Adds `worker::canonical_base_name` (mirrors the host's dedup) and
`pool_is_homogeneous`, with unit tests; refinement tests updated to pin the new
homogeneous-big-tier behaviour.
212 engine tests pass; clippy -D warnings and fmt clean.
* feat(moa): admission control — weak workers don't join a strong pool
A modest node must not drag down a committee that already has a stronger
member. Aggregation quality tracks proposal quality (Self-MoA,
arXiv:2502.00674), and an 8B draft added to a 24-32B pool is expected
noise-to-harm.
`build_moa_config` now applies admission control before the >=2 check: when the
pool mixes tiers, small-tier workers are dropped and only the big-tier ones
kept. Consequences:
* mixed pool (32B + 8B) -> 8B excluded, 32B committee (or solo if alone)
* all-small (8B x4) -> untouched (the case that wins)
* all-big / homogeneous -> untouched
A lone strong model after exclusion falls through the >=2 check and simply
serves solo — the safe outcome. `backends`/`models` are parallel vecs linked by
backend_index, so both are rebuilt and reindexed together.
Four unit tests pin the matrix (drops small when big present, keeps all-small,
keeps all-big, keeps homogeneous-big). 68 moa_gateway tests pass; clippy -D
warnings and fmt clean.
* feat(moa): same-model mesh forms a pool (self-fill from extra instances)
A mesh where every node serves the same model got no MoA at all:
build_moa_config resolves one worker per canonical base, so N same-model nodes
collapsed to 1 worker and failed the >=2 check. Self-MoA (arXiv:2502.00674)
shows repeated sampling of one model ensembles as well as different models, so
this is exactly the "add a modest node and it helps" case that was missing.
When distinct-model resolution leaves a single model, `self_fill_from_extra_
instances` now adds extra reachable *nodes* serving that same model as workers
(distinct remote endpoints only, never the same node twice, capped at 2). So:
1 node, 1 model -> 1 worker -> solo (unchanged, no surprise cost)
2 nodes, same model -> 2 workers -> MoA committee
Combined with the refinement-gate fix (homogeneous pools now refine), a
same-model pool runs the layered path that measured 48/2 for 32B x2.
Extracted pool assembly (resolve loop + admission + self-fill) into
`assemble_worker_pool` to keep build_moa_config under the cognitive-complexity
limit. 68 moa_gateway tests pass; clippy -D warnings and fmt clean.
* chore(moa): drop diag_prompt_diff scratch test
It was an assertion-free diagnostic that dumps prompts to stdout — useful
during the harness-vs-shipped investigation, but it always passes and does not
belong in the merge set. The prompts it printed are now understood and the
findings are recorded in evals/moa-openrouter/RESULTS.md.
* docs(moa): admission control validated — weak node in strong pool is no-upside
Arm C (32B x2 + one 8B, weak node admitted) vs arm B (32B x2):
B 48W 23T 2L p=2e-12 decided winrate 96%
C 50W 25T 5L p=2e-10 decided winrate 91%
Fisher B-vs-C p=0.44 (not separable at n=80) but one-way: admitting the weak
node never helped and raised losses 2 -> 5. Both still beat solo, so a weak
node doesn't collapse the pool, but it adds cost for no upside and a small tail
risk. This is the measured basis for tier-based apply_admission_control.
* fix(moa): admission control must not collapse a lone-strong pool to solo
You asked the right question: we showed admitting a weak node HURTS when the
pool already has two strong (arm C), but never tested where admitting HELPS.
The missing cell — one strong + one weak — is decisive and overturns the
first-cut rule:
32B + 8B layered vs solo 32B: 47W 27T 5L p=1.3e-9 length-clean (r=-0.01)
Admission control rejected the 8B here, collapsing the pool to a solo 32B. But
the mixed committee beats solo decisively, so rejecting it threw away MoA in
exactly the core "modest node joins a strong node and helps" case.
Rule corrected: drop small-tier workers only when >=2 big-tier remain (a real
committee survives). If dropping would collapse to solo, keep the mix.
32B x2 + 8B -> drop 8B (committee survives; 8B adds nothing, arm C)
32B + 8B -> keep both (dropping = solo, and the mix wins 47/5)
Two admission tests updated/added to pin both cases. 69 moa_gateway tests pass;
clippy -D warnings and fmt clean.
* feat(moa): model=mesh gracefully degrades to single-model instead of 503
Before: a `model=mesh` request on a lone node (or a mesh with <2 workers)
returned 503 "MoA requires ≥2 models". That makes `mesh` unusable as a default
model — it only worked once a committee could form.
Now: when `build_moa_config` can't form a committee but a real model is
available, `try_handle_moa` rewrites `model=mesh` to that model and hands the
stream back to normal single-model routing. `mesh` works everywhere —
passthrough on one node, committee once a second worker joins. Only a node with
no model at all still 503s.
Both call sites handle the new fall-through:
- passive/transport path already routes a returned `Some(stream)` normally;
- host/ingress path: `try_handle_moa_intercept` gained a `Degraded { stream,
model }` result. The pre-computed `decision.effective_model` is stale
("mesh") after degradation, so the degraded model name is threaded into
`route_request`, and the pipeline classifier (computed against "mesh") is
skipped for degraded turns.
Unchanged from main was the hard 503; this is the missing piece for `mesh` as a
superior always-on drop-in. 1859 host tests pass; clippy -D warnings and fmt
clean.
* feat(moa): cap the committee at 4 workers on large meshes
Fan-out cost is ~2N+1 model calls per turn (N drafts + N refines + 1
synthesis), and measured quality is flat past ~4 workers while latency and
spend keep climbing. On a big shared mesh — say 20 nodes — an uncapped pool
fanned out to all of them: 41 calls for no quality gain.
`cap_committee` now trims the assembled pool to the best MAX_COMMITTEE_WORKERS
(4) by the same capability ranking used for actor selection (gossiped tool_use,
then size, then stable index). Nodes beyond the cap are standbys — they still
serve direct traffic, just not this committee.
Runs after admission control and self-fill, so the cap applies to the final
worker set. Extracted as a helper to keep assemble_worker_pool under the
cognitive-complexity limit. 69 moa_gateway tests pass; clippy -D warnings and
fmt clean.
* test(moa): pin partial-worker-survival robustness
sim_all_workers_fail covers total failure (clean structured error). This covers
the common mesh reality: nodes flicker, so a subset of the committee dies
mid-turn while the rest answer. MoA must degrade to survivors, not the error
path.
Three cases pinned:
- half the committee dies, rest answer -> turn completes, all 4 accounted
(2 succeeded, 2 recorded failed, none silently dropped)
- lone survivor (3 of 4 die) -> turn still answers
- slow-failing worker -> does not stall the turn past the survivors
Locks the pre-existing best-effort fanout behaviour against regression now that
assembly (admission, self-fill, cap) is more complex. 214 moa tests pass;
clippy -D warnings and fmt clean.
* refactor(moa): split pool assembly out of workers.rs into pool.rs
workers.rs had grown to 1215 lines (over the 1k guideline) mixing two
responsibilities: worker-pool assembly/selection and the model backends +
config orchestration. Per CodeRabbit review, extract the assembly half.
pool.rs (658 lines) now owns: assemble_worker_pool, resolve_one_worker_from_
aliases, add_worker_backend, group_aliases_by_canonical_base, is_locally_served,
apply_admission_control, self_fill_from_extra_instances, cap_committee,
compute_actor_candidates, canonical_base_name, WorkerBackendResolution, the two
size caps, and their tests.
workers.rs (578 lines) keeps: build_moa_config (orchestrator), the
thinking-override helpers, patience_profile, the Local/Remote model backends +
parse_quic_http_response, and their tests.
Pure mechanical move, no behavior change. Backends and canonical_base_name made
pub(super) for cross-module use. 69 moa_gateway tests pass (unchanged); clippy
-D warnings and fmt clean.
* test(moa): loosen partial-survival assertion that raced under early-exit
`turn_completes_when_some_workers_die` asserted exactly succeeded==2/failed==2,
but early-exit consensus can abort a live worker once a usable answer is in
hand, so the succeeded/aborted split is timing-dependent. It passed locally but
raced red in CI (Rust crate tests shard 0).
The robustness contract is unchanged and still pinned: all four dispatched
workers appear in summaries (none silently dropped) and the two dead ones are
recorded as failed (>=2). Dropped the exact success-count assertion. Stable
across 5 consecutive local runs.
* fix(moa): model=mesh degrades to single-model serving, verified live
`model=mesh` now works as a universal virtual model from one node upward:
1 node / 1 model -> serves that model (single-model passthrough)
>=2 workers -> real MoA committee (fan-out + synthesis)
0 models -> 503 (correct: nothing to serve)
Two bugs found and fixed against a live 2-node mesh (MacBook 3B + mini 1B):
1. request.raw not rewritten. degrade patched model_name/body_json but the
forwarded request rides on request.raw bytes, which still said "mesh", so
the embedded openai-frontend 404'd. Now uses rewrite_model_field (patches
raw + body + Content-Length together; already unit-tested).
2. wrong model source. degrade read models_being_served(), empty on a fresh
serve node. /v1/models draws from three sources; degrade now tries them in
order: callable_models(targets) -> models_being_served() -> serving_models().
Live-verified: single node model=mesh returns HTTP 200 with a real answer
(was 503/404); 2-node mesh model=mesh returns x-moa-turn=fanout workers=2.
Known cold-start transient: the very first mesh request on a fresh serve node
can 404 for ~1-2s until the routing table populates; solid once warm.
* fix(moa): tier from verified GGUF/gossiped size, not name guessing
Addresses two i386 P1s: destructive admission and committee-cap decisions were
made from a name heuristic that treats every unparseable name as big-tier, so a
small fine-tune/alias (`my-assistant`) could bypass the weak-worker filter or
become the reducer — contradicting the branch's core "weak can't soil strong"
claim.
Producer (each serving node, has its own GGUF): scan_gguf_total_parameters sums
tensor element counts for the exact stored-parameter count and publishes it via
the existing ServedModelMetadata.parameter_count_b. profile.rs prefers this over
name parsing; missing => None (unknown), never a guess.
Consumer (MoA orchestrator, has NO peer GGUFs): reads the gossiped
parameter_count_b off served descriptors. New SizeTier {Small,Big,Unknown}:
* admission excludes only VERIFIED small, counts only VERIFIED big toward the
">=2 big remain" gate; Unknown is never strong and never filtered.
* cap_committee ranks by verified size, NOT compute_actor_candidates
(tool_use-first), which could evict a big model for tool-advertising small
ones on an answer turn.
Name parsing (model_param_size_b) remains a lower-confidence fallback only.
71 moa_gateway tests (2 new: unknown-never-filtered, name-parse fallback);
model-artifact + host clippy -D warnings and fmt clean.
* fix(moa): drop name-based size fallback — no GGUF size ⇒ weakest
Per i386: parsing NNb from a served name is brittle and a destructive
admission decision must not rest on an unverified label. Removed the name
fallback entirely.
Producer (profile.rs): parameter_count_b comes ONLY from the GGUF tensor sum.
No local GGUF to sum ⇒ None (no guessed count). Deleted the now-dead
parameter_count_b_from_text + its test.
Consumer (pool.rs): tier_for uses only the gossiped verified size. Dropped
SizeTier::Unknown — a model with no verified size is Small (weakest), so an
unverifiable label can never masquerade as big and displace a real strong
worker. cap_committee ranking updated (no-size ranks last). Admission excludes
only verified-small; with no verified big there is nothing to protect so an
all-unsized pool is untouched.
Tests updated: unsized-worker-is-weakest (excluded next to verified big),
no-verified-sizes keeps all. 71 moa_gateway + 28 profile tests pass; clippy -D
warnings and fmt clean.
* fix(moa): iron law — single physical endpoint never fakes a committee
Self-fill could re-add the same physical node (or an endpoint already backing
the sole worker), turning ONE box into a fake 2-worker committee that hits it
twice for near-identical drafts. Catastrophic for mesh mode: a lone node must
degrade to single-model serving, never pretend to be a committee.
Rewrote self_fill_from_extra_instances to build the pool from DISTINCT physical
endpoints only: the local skippy port (if this node serves the model and
context fits) plus each distinct remote peer from hosts_for_model. If fewer
than two distinct endpoints serve the model, the pool stays the single worker
and MoA degrades to single-model. A single endpoint can no longer appear twice.
Also fixes the CodeRabbit self-fill bugs (re-adds same peer; skipped context
eligibility) — context fit is now checked on the local endpoint, and endpoints
are distinct by construction.
71 moa_gateway tests pass; clippy -D warnings and fmt clean.
* fix(moa): reject missing/malformed messages with 400 (homelab API-1)
A `model=mesh` request with no `messages`, a non-array `messages`, or an empty
array fell through to the workers and fabricated a 200 answer from nothing
(homelab validation API-1). try_handle_moa now requires a present, non-empty
`messages` array and returns 400 otherwise, before any model call.
Verified live: {"model":"mesh"}, string messages, and [] all now 400; a valid
request still returns 200. 71 moa_gateway tests pass; clippy -D warnings + fmt
clean.
* feat(moa): tier-aware committee width — small pools fan out to 6
Width sprint (evals/moa-openrouter, aggregator qwen3-8b, 8B peers, shipped
committee path) shows the fan-out cap was throttling the pools that benefit
most:
6× diverse 8B vs best member: 12W/65T/2L, p=0.013 (wins)
4× diverse 8B: 5W/73T/0L, p=0.06 (marginal)
2× diverse 8B: 2W/77T/1L, p=1.0 (null)
Small, weak drafts need WIDTH — more independent proposals — before
aggregation clears the best member. A verified big model, by contrast, wins at
2 and gains nothing past ~4.
- committee_cap is now tier-aware: COMMITTEE_CAP_SMALL=6 for all-small pools,
COMMITTEE_CAP_BIG=4 when a verified big model is present (replaces the flat
MAX_COMMITTEE_WORKERS=4).
- eval harness: the three per-trial judge comparisons now run concurrently
(tokio::join!) instead of three serial awaits — ~3x faster judging phase, no
behaviour change.
Also measured (recorded in RESULTS.md): the refine round never beats single
aggregation at 8B (refine-vs-single null in every cell) — Hermes' cheaper
single-synth cadence matches Together's layered shape here; and at 8B diversity
matters (6 diverse >> 6 same), unlike mid-scale.
72 moa_gateway tests (new: committee_cap_is_wide_for_small_pools_tight_for_big);
clippy -D warnings and fmt clean.
* fix(moa): don't refine small pools — width + single-agg wins, refine is dead cost
The width sprint measured refine-vs-single-aggregation as null in every 8B cell
(2/4/6 workers, diverse and same). Small pools win by WIDTH under a single
aggregation, not by the extra serial refine pass. Previously RefinementPolicy::
Auto ran the round for ALL-small pools — paying two synthesis passes for no
measured gain.
Auto now refines only for a homogeneous pool that is NOT all-small (i.e.
same-model at real scale, where correlated drafts + the round measured 48/2 vs
35/10). All-small and diverse pools skip it — matching Hermes' cheaper
single-synth cadence, where refine buys nothing.
Effect: an all-small mesh turn drops from 2 serial synthesis passes to 1,
roughly halving added latency, with no measured quality loss.
Tests: auto_skips_an_all_small_pool (was auto_refines_...); the 5 all-small
mechanics sim tests (straggler/grace/degradation) pinned with Always so they
still exercise the round; big-pool gate tests keep Auto. 178 + sim tests pass;
clippy -D warnings and fmt clean.
* fix(moa): make the shipped path deliver the measured win
Three shipped-path bugs meant `handle_turn` never ran the committee it was
supposed to. Found by measuring the shipped entrypoint at capable scale.
1. Grace pre-empted synthesis. `first_answer_grace` armed on the FIRST answer
and finalized the turn, so on fast backends it fired ~every turn and shipped
one worker's text. Measured 80/80 EarlyExit at 32B scale. Now grace
finalizes on TOOL turns only; on answer turns it is a collection deadline —
stop waiting for the tail, then synthesize what arrived. Private-mesh window
widened 3s -> 10s so a normal committee completes before it arms.
2. Role-tiered draft budgets (Fast 256 / Specialist 512) truncated the drafts
that synthesis consumes. Those tiers existed only to make the grace
fast-path cheap; grace no longer finalizes answer turns, so every answer-turn
worker now gets the full budget.
3. Answer turns shipped one worker verbatim when drafts agreed. Agreeing drafts
are the best input to synthesis, not a reason to skip it. Answer turns with
>=2 workers always synthesize now. Also drops the worker preamble from the
reducer prompt — the synthesizer is not one of the parallel answerers, and
the contradictory framing cost a weak aggregator.
Measured through `moa::handle_turn` at production defaults, 40 prompts x 2
draws, out-of-family judge, length-controlled:
32B/24B/35B-MoE/14B pool vs best member: 71W / 8T / 1L p<0.0001
(was 26W/14T/40L before these fixes; wins 28/29 even when MoA is SHORTER,
so it is not a length artifact)
Robustness contracts unchanged and still verified: slow worker cannot stall a
turn, lone survivor answers, hanging refiner cannot hold the turn, patience
expiry releases held consensus.
* fix(moa): all-small pools serve the best member instead of a committee
Measured through the shipped path (OpenRouter, 8B-class peers with an 8B
reducer, 40 preregistered prompts x 2 draws, out-of-family position-swapped
judge, vs the pool's best member alone):
2x 8B: 0W/43T/37L p<0.0001
6x 8B: 5W/52T/23L p=0.0009
The committee never won and lost about a third of decided trials, with
consistently shorter answers (3236-3372 chars vs ~4070 solo). A capable pool is
the opposite (71W/8T/1L, p<0.0001), so this is a statement about *this*
configuration -- a weak reducer synthesizing weak drafts -- not about
small-model MoA in general. The untested cell is small peers with a strong
reducer; if a mesh gains a big-tier model the pool is no longer all-small and
MoA engages again.
Rather than ship a measured regression, an all-small pool now collapses to its
single strongest member, so the caller degrades to serving that model directly.
Also corrects the private-mesh grace assertion to the widened 10s default.
The earlier "6x 8B beats its best member (12W/2L, p=0.013)" harness result did
not replicate: the same rig on the same 40 tasks now gives 3W/76T/1L (p=0.63),
with 11 of 40 tasks flipping verdict. It rested on ~14 decided trials out of 80
(the rest ties) and was a single unreplicated run. Withdrawn.
* docs(moa): withdraw the unreplicated 6x8B win, record all-small shipped results
The width-sprint headline (12W/2L, p=0.013) did not replicate: same rig, same
tasks, same pool now gives 3W/76T/1L (p=0.63), 11 of 40 tasks flipping. It rested
on ~14 decided trials from a single unreplicated run.
Records the shipped-path all-small measurements that motivated the best-member
gate (2x: 0W/37L, 4x: 5W/12L, 6x: 5W/23L), scopes the claim to weak-reducer
configurations, and documents known methodology limitations (tie bucket conflates
failures, per-draw vs per-prompt significance, unverified best member, single judge).
* fix(moa): min_grace_answers back to 1 — N-1 stalled turns to worker_timeout
Public-mesh comparison against released v0.74.0 (same 3B model, same prompts,
`model=mesh`) exposed a liveness regression I introduced earlier today:
released v0.74.0: 4.0s / 3.2s / 11.3s (reducer never ran)
branch (N-1 gate): 61.0s / 6.7s / 61.7s (reducer ran, SHORTER answers)
`min_grace_answers` gates whether grace can ARM at all, so any value above 1 is
a liveness hazard. With N-1 on a 6-worker public-mesh pool where two peers never
returned, only 4 answers ever arrived, grace could never arm, and the turn rode
`worker_timeout` (60s) instead. The two 61s turns had 6 workers/4 ok and 6/3; the
fast 6.7s turn had 4 workers/3 ok, where N-1=3 was satisfiable. 3/3 consistent.
Width comes from the grace WINDOW (10s), not a count gate: healthy peers land
inside it and are all synthesized, while a dead peer costs 10s rather than 60s.
This also restores the exact configuration the capable-pool win was measured
under (71W/8T/1L, p<0.0001) — that run predates the N-1 gate — and the gate
bought nothing on small pools either (8W/17L with it, 9W/17L without).
* docs(moa): correct the gateway doc and flag the withdrawn small-pool claim
MOA_GATEWAY.md described behaviour that no longer holds:
- "requires >=2 distinct models, returns 503 if fewer" -> a single model (or an
all-small pool collapsed to its best member) now degrades: the virtual `mesh`
name is rewritten to a real served model and routed normally. Only a node
serving nothing returns 503.
- name-derived tiering -> tiering now comes from verified GGUF tensor sums
gossiped as `parameter_count_b`, split at 10B, with unverified sizes ranking
small so an unparseable alias cannot pose as big-tier. Name parsing mis-tiered
real models (gemma-4-E4B stores 7.5B, not 4B).
- documents pool shaping in order (admission, all-small best-member fallback,
committee cap) with the measured numbers behind each.
- records that answer turns pack every worker at the full budget.
- stale peer-timeout row (15s) -> 60s worker timeout with the 10s grace window.
RESULTS.md: the width-sprint section now carries an explicit WITHDRAWN banner
pointing at the replication failure, so the 6x8B number cannot be cited from the
middle of the file.
* test(moa): pin the tool-turn contract against a live multi-model pool
The tool path is deliberately asymmetric — route to the single best tool-caller
rather than fan out and vote, because voting on tool calls measured
null-to-harmful. That is only safe if structured `tool_calls` still come back
intact when several models are on the wire, so this asserts the contract through
the shipped `handle_turn` with a real pool: a tool prompt yields exactly one
well-formed call whose arguments parse as JSON and whose name is an offered
tool, and a no-tool prompt does not invent one.
Measured live (OpenRouter, both cells pass):
4x 8B pool -> dispatched=4 ok=3 reducer=true
list_dir({"path":"src"})
search({"path":".","pattern":"MeshError::Timeout\\(.*?\\)"})
run_command({"cmd":"pytest --verbose"})
no_tool_concept: no call invented
4x 24-35B pool -> dispatched=1 ok=1 reducer=true
same shapes, single actor
The dispatch difference is `ReferencePolicy::Auto` working as measured: a
small-tier actor gets tool-free advisors (+0.017 uplift), a big-tier actor acts
alone (advisors measured -0.037 there). The 4-worker cell also survived a worker
flaking (ok=3 of 4) and still emitted a valid call, which is the ensemble-active
case that was previously only covered by replay fixtures.
Asserts the contract, not a quality delta — the tool chosen may legitimately
differ from a single model's first move, so only a non-offered tool or malformed
arguments fail.
---------
Co-authored-by: Michael Neale <14976+michaelneale@users.noreply.github.com>
This commit is contained in:
parent
32eedbe452
commit
96ef6227f2
44 changed files with 16657 additions and 897 deletions
|
|
@ -1 +1,3 @@
|
|||
pub use model_artifact::gguf::{GgufCompactMeta, GgufKvCacheQuant, scan_gguf_compact_meta};
|
||||
pub use model_artifact::gguf::{
|
||||
GgufCompactMeta, GgufKvCacheQuant, scan_gguf_compact_meta, scan_gguf_total_parameters,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -24,11 +24,16 @@ pub(crate) fn served_model_metadata_for_path(
|
|||
.parameter_size
|
||||
.clone()
|
||||
.or_else(|| parameter_size_from_text(model_name));
|
||||
let parameter_count_b = parameter_count_b_from_text(&format!(
|
||||
"{} {}",
|
||||
model_name,
|
||||
meta.parameter_size.as_deref().unwrap_or("")
|
||||
));
|
||||
// Authoritative size: sum the GGUF tensor element counts. This is
|
||||
// the ONLY source — no name-based fallback. If a served model
|
||||
// cannot be summed from its GGUF, it advertises no size and MoA
|
||||
// tiering treats it as the lowest-param (weakest) model rather than
|
||||
// guessing from a brittle name label (per i386 review).
|
||||
let parameter_count_b = path
|
||||
.exists()
|
||||
.then(|| crate::models::gguf::scan_gguf_total_parameters(path))
|
||||
.flatten()
|
||||
.map(|total| total as f64 / 1e9);
|
||||
let kv_head_count = meta.effective_kv_head_count();
|
||||
crate::mesh::ServedModelMetadata {
|
||||
architecture: non_empty(meta.architecture),
|
||||
|
|
@ -51,7 +56,10 @@ pub(crate) fn served_model_metadata_for_path(
|
|||
}
|
||||
None => crate::mesh::ServedModelMetadata {
|
||||
parameter_size: parameter_size_from_text(model_name),
|
||||
parameter_count_b: parameter_count_b_from_text(model_name),
|
||||
// No GGUF to sum -> no authoritative size. Advertise none rather
|
||||
// than a name-guessed count (per i386 review); MoA treats a
|
||||
// sizeless model as the weakest.
|
||||
parameter_count_b: None,
|
||||
quant: quant_from_text(model_name),
|
||||
..Default::default()
|
||||
},
|
||||
|
|
@ -99,50 +107,9 @@ fn parameter_size_from_text(text: &str) -> Option<String> {
|
|||
})
|
||||
}
|
||||
|
||||
fn parameter_count_b_from_text(text: &str) -> Option<f64> {
|
||||
static MULTIPLIED_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"(?i)(\d+(?:\.\d+)?)x(\d+(?:\.\d+)?)([bm])").unwrap());
|
||||
static SIMPLE_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"(?i)(\d+(?:\.\d+)?)([bm])").unwrap());
|
||||
|
||||
let mut best: Option<f64> = None;
|
||||
for captures in MULTIPLIED_RE.captures_iter(text) {
|
||||
let Some(left) = captures.get(1).and_then(|m| m.as_str().parse::<f64>().ok()) else {
|
||||
continue;
|
||||
};
|
||||
let Some(right) = captures.get(2).and_then(|m| m.as_str().parse::<f64>().ok()) else {
|
||||
continue;
|
||||
};
|
||||
let Some(unit) = captures.get(3).map(|m| m.as_str().to_ascii_lowercase()) else {
|
||||
continue;
|
||||
};
|
||||
let value = match unit.as_str() {
|
||||
"b" => left * right,
|
||||
"m" => (left * right) / 1000.0,
|
||||
_ => continue,
|
||||
};
|
||||
best = Some(best.map_or(value, |current| current.max(value)));
|
||||
}
|
||||
for captures in SIMPLE_RE.captures_iter(text) {
|
||||
let Some(count) = captures.get(1).and_then(|m| m.as_str().parse::<f64>().ok()) else {
|
||||
continue;
|
||||
};
|
||||
let Some(unit) = captures.get(2).map(|m| m.as_str().to_ascii_lowercase()) else {
|
||||
continue;
|
||||
};
|
||||
let value = match unit.as_str() {
|
||||
"b" => count,
|
||||
"m" => count / 1000.0,
|
||||
_ => continue,
|
||||
};
|
||||
best = Some(best.map_or(value, |current| current.max(value)));
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{parameter_count_b_from_text, parameter_size_from_text};
|
||||
use super::parameter_size_from_text;
|
||||
|
||||
#[test]
|
||||
fn extracts_parameter_size_labels() {
|
||||
|
|
@ -155,11 +122,4 @@ mod tests {
|
|||
Some("8x7B")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_total_parameter_count_b() {
|
||||
assert_eq!(parameter_count_b_from_text("Qwen3-32B-Q4_K_M"), Some(32.0));
|
||||
assert_eq!(parameter_count_b_from_text("mixtral-8x7b"), Some(56.0));
|
||||
assert_eq!(parameter_count_b_from_text("235B-A22B"), Some(235.0));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -743,6 +743,14 @@ enum MoaInterceptResult {
|
|||
/// Not an MoA request — caller should continue with normal routing,
|
||||
/// reusing the returned stream.
|
||||
NotMoa(tokio::net::TcpStream),
|
||||
/// MoA could not form a committee but degraded `model=mesh` to a real
|
||||
/// single model (already rewritten on the request). Caller routes it
|
||||
/// normally, but must use this model rather than the stale
|
||||
/// `decision.effective_model` (still "mesh").
|
||||
Degraded {
|
||||
stream: tokio::net::TcpStream,
|
||||
model: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Dispatch to the MoA gateway when `model == "mesh"`. Self-gates on the
|
||||
|
|
@ -756,14 +764,15 @@ async fn try_handle_moa_intercept(
|
|||
if decision.effective_model.as_deref() != Some(moa::VIRTUAL_MODEL_NAME) {
|
||||
return MoaInterceptResult::NotMoa(tcp_stream);
|
||||
}
|
||||
// `try_handle_moa` self-gates on the model name and consumes the
|
||||
// stream when it accepts. The outer gate above guarantees the gate
|
||||
// matches, so the inner call always returns `None` here — the stream
|
||||
// is gone, either with the MoA response, a 503, or a 400. Discard
|
||||
// the return value explicitly. The previous shape kept an
|
||||
// `if let Some(_) = … { tracing::error!(...) }` branch that could
|
||||
// never fire and made the control flow confusing to read.
|
||||
let _ = crate::network::openai::moa_gateway::try_handle_moa(
|
||||
// `try_handle_moa` self-gates on the model name. It consumes the stream and
|
||||
// returns `None` when it owns the response (a MoA turn, a 400, or — no
|
||||
// model at all — a 503). But when it cannot form a committee yet still has
|
||||
// a model to serve, it degrades: it rewrites `model=mesh` to a real model
|
||||
// (on `request`) and hands the stream *back* as `Some`, so a lone node
|
||||
// answers as an ordinary single-model request instead of erroring. In that
|
||||
// case the pre-computed `decision.effective_model` is stale ("mesh"), so we
|
||||
// carry the degraded model name out for the caller to route on.
|
||||
match crate::network::openai::moa_gateway::try_handle_moa(
|
||||
ctx.route.node,
|
||||
tcp_stream,
|
||||
request,
|
||||
|
|
@ -771,9 +780,18 @@ async fn try_handle_moa_intercept(
|
|||
Some(ctx.route.targets),
|
||||
decision.required_tokens,
|
||||
)
|
||||
.await;
|
||||
proxy::release_request_objects(ctx.route.node, &request.request_object_request_ids).await;
|
||||
MoaInterceptResult::Handled
|
||||
.await
|
||||
{
|
||||
Some(stream) => MoaInterceptResult::Degraded {
|
||||
stream,
|
||||
model: request.model_name.clone(),
|
||||
},
|
||||
None => {
|
||||
proxy::release_request_objects(ctx.route.node, &request.request_object_request_ids)
|
||||
.await;
|
||||
MoaInterceptResult::Handled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_buffered_api_request(
|
||||
|
|
@ -817,14 +835,25 @@ async fn handle_buffered_api_request(
|
|||
}
|
||||
};
|
||||
|
||||
// Effective model for downstream routing. Normally the pre-computed
|
||||
// decision, but a degraded MoA turn overrides it with the single model it
|
||||
// fell back to (the decision still says "mesh").
|
||||
let mut routing_model = decision.effective_model.clone();
|
||||
let tcp_stream = match try_handle_moa_intercept(tcp_stream, &mut request, &ctx, &decision).await
|
||||
{
|
||||
MoaInterceptResult::Handled => return,
|
||||
MoaInterceptResult::NotMoa(stream) => stream,
|
||||
MoaInterceptResult::Degraded { stream, model } => {
|
||||
routing_model = model;
|
||||
stream
|
||||
}
|
||||
};
|
||||
|
||||
let mut tcp_stream = tcp_stream;
|
||||
if try_pipeline_route(&mut tcp_stream, &mut request, &ctx.route, &decision).await {
|
||||
// A degraded turn is a plain single-model request; skip the pipeline
|
||||
// classifier (computed against "mesh") and route it directly.
|
||||
let degraded = routing_model != decision.effective_model;
|
||||
if !degraded && try_pipeline_route(&mut tcp_stream, &mut request, &ctx.route, &decision).await {
|
||||
proxy::release_request_objects(ctx.route.node, &request.request_object_request_ids).await;
|
||||
return;
|
||||
}
|
||||
|
|
@ -833,7 +862,7 @@ async fn handle_buffered_api_request(
|
|||
tcp_stream,
|
||||
&mut request,
|
||||
&ctx.route,
|
||||
decision.effective_model.as_deref(),
|
||||
routing_model.as_deref(),
|
||||
decision.required_tokens,
|
||||
)
|
||||
.await;
|
||||
|
|
|
|||
|
|
@ -20,6 +20,57 @@ use tokio::net::TcpStream;
|
|||
|
||||
pub use self::workers::build_moa_config;
|
||||
|
||||
/// Fall back to serving a single real model when MoA cannot form a committee.
|
||||
///
|
||||
/// Picks any model advertised in the mesh (local or peer), rewrites the
|
||||
/// request's `model` from the virtual `"mesh"` name to it, and hands the stream
|
||||
/// back so the caller routes it as an ordinary single-model request. Returns
|
||||
/// `None` (503) only when the node genuinely has no model at all.
|
||||
async fn degrade_to_single_model(
|
||||
node: &mesh::Node,
|
||||
targets: Option<&election::ModelTargets>,
|
||||
tcp_stream: TcpStream,
|
||||
request: &mut proxy::BufferedHttpRequest,
|
||||
) -> Option<TcpStream> {
|
||||
// Prefer the same source `/v1/models` and routing use — the local
|
||||
// targets table (`callable_models`) — since `models_being_served()` can be
|
||||
// empty at request time on a fresh serve node. Fall back to the gossiped
|
||||
// served set for a pure client node that has no local targets.
|
||||
// Try each source /v1/models draws from, cheapest-first: the local targets
|
||||
// table (`callable_models`), the gossiped served set, then the node's own
|
||||
// `serving_models` — the last is what a fresh serve node populates first
|
||||
// (the others can lag at request time).
|
||||
let mut candidates = targets
|
||||
.map(super::ingress::callable_models)
|
||||
.unwrap_or_default();
|
||||
if candidates.is_empty() {
|
||||
candidates = node.models_being_served().await;
|
||||
}
|
||||
if candidates.is_empty() {
|
||||
candidates = node.serving_models().await;
|
||||
}
|
||||
let Some(target) = candidates
|
||||
.into_iter()
|
||||
.find(|m| m != moa::VIRTUAL_MODEL_NAME)
|
||||
else {
|
||||
let _ = proxy::send_503(tcp_stream, "no models available in the mesh").await;
|
||||
return None;
|
||||
};
|
||||
|
||||
tracing::info!("MoA: <2 workers, degrading model=mesh to single model {target}");
|
||||
|
||||
// Rewrite every surface the downstream router reads. The forwarded request
|
||||
// is driven by `request.raw` (the raw HTTP bytes), so `rewrite_model_field`
|
||||
// patches raw + body + Content-Length together — rewriting only
|
||||
// `model_name`/`body_json` left `raw` saying "mesh", so the embedded
|
||||
// frontend still saw the virtual model and 404'd.
|
||||
proxy::rewrite_model_field(request, &target);
|
||||
request.model_name = Some(target);
|
||||
|
||||
// Hand the stream back: the caller falls through to normal routing.
|
||||
Some(tcp_stream)
|
||||
}
|
||||
|
||||
/// Detect `model: "mesh"`, build a mesh-wide MoA config, run the turn,
|
||||
/// and write the HTTP response (JSON or SSE) directly to the stream.
|
||||
///
|
||||
|
|
@ -52,11 +103,28 @@ pub async fn try_handle_moa(
|
|||
return None;
|
||||
};
|
||||
|
||||
// Contract check: `messages` must be a present, non-empty array. Without
|
||||
// this a request like `{"model":"mesh"}` or a string `messages` field fell
|
||||
// through to the workers, which fabricated a 200 answer from nothing
|
||||
// (homelab API-1 defect). Reject before any model call.
|
||||
match body_json.get("messages") {
|
||||
Some(serde_json::Value::Array(msgs)) if !msgs.is_empty() => {}
|
||||
_ => {
|
||||
let _ = proxy::send_400(tcp_stream, "MoA requires a non-empty `messages` array").await;
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
let enable_thinking = effective_enable_thinking_for_moa(&body_json);
|
||||
|
||||
let Some(mut config) = build_moa_config(node, targets, required_tokens).await else {
|
||||
let _ = proxy::send_503(tcp_stream, "MoA requires ≥2 models available in the mesh").await;
|
||||
return None;
|
||||
// Graceful degradation: MoA needs ≥2 workers, but a lone node (or a
|
||||
// mesh with a single model) should still answer a `model=mesh`
|
||||
// request rather than 503. Rewrite the virtual model to a real served
|
||||
// model and fall through to normal single-model routing by handing the
|
||||
// stream back. `mesh` thus works everywhere: passthrough on one node,
|
||||
// committee once a second worker joins.
|
||||
return degrade_to_single_model(node, targets, tcp_stream, request).await;
|
||||
};
|
||||
config.enable_thinking = enable_thinking;
|
||||
|
||||
|
|
@ -65,6 +133,7 @@ pub async fn try_handle_moa(
|
|||
}
|
||||
|
||||
pub(in crate::network::openai) mod context_selection;
|
||||
mod pool;
|
||||
mod progress;
|
||||
mod streaming;
|
||||
mod workers;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,977 @@
|
|||
//! MoA worker-pool assembly.
|
||||
//!
|
||||
//! Owns the discovery-and-assembly side of the MoA gateway: turning the
|
||||
//! node's mesh-wide model view into a concrete `(backends, models)` worker
|
||||
//! pool. `build_moa_config` (in [`super::workers`]) is the orchestrator that
|
||||
//! calls [`assemble_worker_pool`] and [`compute_actor_candidates`] here.
|
||||
|
||||
use super::context_selection;
|
||||
use super::workers::{LocalModelBackend, RemoteModelBackend};
|
||||
use crate::inference::election;
|
||||
use crate::mesh;
|
||||
use mesh_mixture_of_agents as moa;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Boundary between small- and big-tier in billions of parameters. Matches the
|
||||
/// old single-digit-B name heuristic (1–9B = small).
|
||||
const SMALL_TIER_MAX_B: f64 = 10.0;
|
||||
|
||||
/// Model size class used for the destructive admission/cap decisions.
|
||||
///
|
||||
/// Only a verified gossiped GGUF size can make a model `Big`. A model with no
|
||||
/// verified size is `Small` — the weakest — so an unverifiable label can never
|
||||
/// masquerade as strong and displace a real big worker (i386 P1).
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum SizeTier {
|
||||
Small,
|
||||
Big,
|
||||
}
|
||||
|
||||
/// Map of canonical base name → verified size (billions of params) as gossiped
|
||||
/// by peers through `ServedModelMetadata.parameter_count_b`. In MoA the
|
||||
/// orchestrator has no peer GGUFs — this is the only authoritative size source.
|
||||
async fn gossiped_sizes(node: &mesh::Node) -> HashMap<String, f64> {
|
||||
let mut by_base: HashMap<String, f64> = HashMap::new();
|
||||
for descriptor in node.all_served_model_descriptors().await {
|
||||
if let Some(b) = descriptor
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.parameter_count_b)
|
||||
{
|
||||
let base = canonical_base_name(&descriptor.identity.model_name);
|
||||
by_base
|
||||
.entry(base)
|
||||
.and_modify(|e| {
|
||||
if b > *e {
|
||||
*e = b;
|
||||
}
|
||||
})
|
||||
.or_insert(b);
|
||||
}
|
||||
}
|
||||
by_base
|
||||
}
|
||||
|
||||
/// Tier a model: verified gossiped size first, model-name parse as a
|
||||
/// lower-confidence fallback, `Unknown` when neither yields a size.
|
||||
fn tier_for(name: &str, sizes: &HashMap<String, f64>) -> SizeTier {
|
||||
// Verified gossiped GGUF size is the ONLY tiering signal (per i386 review).
|
||||
// No name-based fallback: a model with no gossiped size is treated as the
|
||||
// weakest (Small), so an unverifiable label can never masquerade as big and
|
||||
// displace a real strong worker. `SMALL_TIER_MAX_B` splits small/big.
|
||||
match sizes.get(&canonical_base_name(name)) {
|
||||
Some(b) if *b >= SMALL_TIER_MAX_B => SizeTier::Big,
|
||||
_ => SizeTier::Small,
|
||||
}
|
||||
}
|
||||
|
||||
/// Try each alias in `aliases` until one resolves to a backend, then stop.
|
||||
///
|
||||
/// Aliases are pre-sorted by `group_aliases_by_canonical_base` so the most
|
||||
/// preferred (locally-served first, then shortest) is tried first. Falls
|
||||
/// back to longer aliases when the preferred one's peer is unreachable.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn resolve_one_worker_from_aliases(
|
||||
node: &mesh::Node,
|
||||
targets: Option<&election::ModelTargets>,
|
||||
http: &reqwest::Client,
|
||||
aliases: &[String],
|
||||
required_tokens: Option<u32>,
|
||||
backends: &mut Vec<std::sync::Arc<dyn moa::ModelBackend>>,
|
||||
models: &mut Vec<moa::ModelEntry>,
|
||||
local_count: &mut usize,
|
||||
) {
|
||||
let resolution = WorkerBackendResolution {
|
||||
node,
|
||||
targets,
|
||||
http,
|
||||
required_tokens,
|
||||
};
|
||||
for name in aliases {
|
||||
if add_worker_backend(&resolution, name, backends, models, local_count).await {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Group all advertised model names by their canonical base so each
|
||||
/// canonical model contributes exactly one worker, but the resolver gets
|
||||
/// to pick the alias that actually has a reachable backend.
|
||||
///
|
||||
/// The earlier shape committed to a single alias per base *before* trying
|
||||
/// to resolve a backend. Two failure modes:
|
||||
///
|
||||
/// 1. The chosen alias is advertised only by a peer that drops between
|
||||
/// gossip refresh and orchestration — `hosts_for_model` returns
|
||||
/// empty, the worker is dropped, and longer-form aliases for the
|
||||
/// same canonical model from still-reachable peers are rejected as
|
||||
/// duplicates.
|
||||
/// 2. The local node advertises a longer convention
|
||||
/// (e.g. `unsloth/Qwen3-8B-GGUF:Q4_K_M`) while a peer advertises a
|
||||
/// shorter variant (e.g. `Qwen3-8B-Q4_K_M`). The shortest-name rule
|
||||
/// picks the peer alias, `add_worker_backend` looks for a local port
|
||||
/// under that specific string, finds nothing, and forces a
|
||||
/// QUIC-tunnel backend even though the model is right here.
|
||||
///
|
||||
/// Both failure modes are fixed by grouping first and resolving second.
|
||||
/// Within each group the aliases are ordered so the most likely
|
||||
/// optimization wins first try: locally-served name (skippy-port fast
|
||||
/// path) before remote names, then shortest first as a tiebreaker.
|
||||
fn group_aliases_by_canonical_base(
|
||||
names: Vec<String>,
|
||||
targets: Option<&election::ModelTargets>,
|
||||
) -> Vec<Vec<String>> {
|
||||
let mut by_base: std::collections::HashMap<String, Vec<String>> =
|
||||
std::collections::HashMap::new();
|
||||
for name in names {
|
||||
by_base
|
||||
.entry(canonical_base_name(&name))
|
||||
.or_default()
|
||||
.push(name);
|
||||
}
|
||||
// Deterministic group order so the worker list is stable across
|
||||
// builds even though HashMap iteration is not. Sort group entries
|
||||
// (locally-served first, then shortest), then sort groups by their
|
||||
// first ("best") alias.
|
||||
let mut groups: Vec<Vec<String>> = by_base
|
||||
.into_values()
|
||||
.map(|mut aliases| {
|
||||
aliases.sort_by(|a, b| {
|
||||
let la = is_locally_served(a, targets);
|
||||
let lb = is_locally_served(b, targets);
|
||||
lb.cmp(&la) // local (true) before remote (false)
|
||||
.then_with(|| a.len().cmp(&b.len()))
|
||||
.then_with(|| a.cmp(b))
|
||||
});
|
||||
aliases
|
||||
})
|
||||
.collect();
|
||||
groups.sort_by(|a, b| a[0].cmp(&b[0]));
|
||||
groups
|
||||
}
|
||||
|
||||
/// Does the local routing table have a backend port for this exact name?
|
||||
fn is_locally_served(name: &str, targets: Option<&election::ModelTargets>) -> bool {
|
||||
targets
|
||||
.and_then(|t| {
|
||||
t.targets.get(name).map(|tv| {
|
||||
tv.iter()
|
||||
.any(|t| matches!(t, election::InferenceTarget::Local(_)))
|
||||
})
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Resolve `name` to a backend (local skippy port if available, else first
|
||||
/// remote host) and append it to `backends`/`models`. Returns true if a
|
||||
/// backend was added.
|
||||
struct WorkerBackendResolution<'a> {
|
||||
node: &'a mesh::Node,
|
||||
targets: Option<&'a election::ModelTargets>,
|
||||
http: &'a reqwest::Client,
|
||||
required_tokens: Option<u32>,
|
||||
}
|
||||
|
||||
async fn add_worker_backend(
|
||||
resolution: &WorkerBackendResolution<'_>,
|
||||
name: &str,
|
||||
backends: &mut Vec<std::sync::Arc<dyn moa::ModelBackend>>,
|
||||
models: &mut Vec<moa::ModelEntry>,
|
||||
local_count: &mut usize,
|
||||
) -> bool {
|
||||
// Prefer local skippy port when this node serves the model.
|
||||
let local_port = resolution.targets.and_then(|t| {
|
||||
t.targets.get(name).and_then(|tv| {
|
||||
tv.iter().find_map(|t| match t {
|
||||
election::InferenceTarget::Local(p) => Some(*p),
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
});
|
||||
if let Some(port) = local_port {
|
||||
let context_length = resolution.node.local_model_context_length(name).await;
|
||||
if context_selection::context_can_satisfy(resolution.required_tokens, context_length) {
|
||||
let backend_idx = backends.len();
|
||||
backends.push(std::sync::Arc::new(LocalModelBackend {
|
||||
port,
|
||||
http: resolution.http.clone(),
|
||||
}));
|
||||
models.push(moa::ModelEntry {
|
||||
name: name.to_string(),
|
||||
backend_index: backend_idx,
|
||||
});
|
||||
*local_count += 1;
|
||||
return true;
|
||||
} else {
|
||||
tracing::info!(
|
||||
"MoA: skipping local worker {name}; context {:?} cannot fit {:?} required tokens",
|
||||
context_length,
|
||||
resolution.required_tokens
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise find a remote host. hosts_for_model returns peers in
|
||||
// hash-preferred order; prefer hosts with enough advertised context.
|
||||
let remote_hosts = resolution.node.hosts_for_model(name).await;
|
||||
if let Some(peer_id) = context_selection::select_remote_host(
|
||||
resolution.node,
|
||||
name,
|
||||
resolution.required_tokens,
|
||||
remote_hosts,
|
||||
)
|
||||
.await
|
||||
{
|
||||
let backend_idx = backends.len();
|
||||
backends.push(std::sync::Arc::new(RemoteModelBackend {
|
||||
node: resolution.node.clone(),
|
||||
peer_id,
|
||||
}));
|
||||
models.push(moa::ModelEntry {
|
||||
name: name.to_string(),
|
||||
backend_index: backend_idx,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Discover and assemble the MoA worker pool: resolve one worker per distinct
|
||||
/// model, apply admission control, then self-fill same-model instances.
|
||||
///
|
||||
/// Returns parallel `(backends, models)` vecs linked by `backend_index`.
|
||||
pub(super) async fn assemble_worker_pool(
|
||||
node: &mesh::Node,
|
||||
targets: Option<&election::ModelTargets>,
|
||||
required_tokens: Option<u32>,
|
||||
http: &reqwest::Client,
|
||||
) -> (
|
||||
Vec<std::sync::Arc<dyn moa::ModelBackend>>,
|
||||
Vec<moa::ModelEntry>,
|
||||
) {
|
||||
let mut backends: Vec<std::sync::Arc<dyn moa::ModelBackend>> = Vec::new();
|
||||
let mut models: Vec<moa::ModelEntry> = Vec::new();
|
||||
let mut local_count = 0usize;
|
||||
|
||||
// Full mesh-wide model list (local + every peer's advertised routable
|
||||
// models).
|
||||
let all_models: Vec<String> = node
|
||||
.models_being_served()
|
||||
.await
|
||||
.into_iter()
|
||||
.filter(|n| n != moa::VIRTUAL_MODEL_NAME)
|
||||
.collect();
|
||||
|
||||
// Group aliases by canonical base and resolve one worker per base, trying
|
||||
// aliases in order so a longer-named reachable alias still resolves when
|
||||
// the shortest one is offline (PR #566).
|
||||
for aliases in group_aliases_by_canonical_base(all_models, targets) {
|
||||
resolve_one_worker_from_aliases(
|
||||
node,
|
||||
targets,
|
||||
http,
|
||||
&aliases,
|
||||
required_tokens,
|
||||
&mut backends,
|
||||
&mut models,
|
||||
&mut local_count,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Admission control: a weak worker must not drag down a pool that already
|
||||
// has a stronger one. Aggregation is sensitive to proposal quality
|
||||
// (Self-MoA, arXiv:2502.00674), so an 8B draft added to a 24-32B pool is
|
||||
// expected noise-to-harm. When tiers are mixed, keep only big-tier workers;
|
||||
// an all-small or all-big pool is untouched. A lone big model then serves
|
||||
// solo (fails the caller's <2 check), the safe outcome.
|
||||
// Verified sizes gossiped by peers (metadata.parameter_count_b). The
|
||||
// orchestrator has no peer GGUFs, so this is the only authoritative size
|
||||
// source for the destructive admission/cap decisions.
|
||||
let sizes = gossiped_sizes(node).await;
|
||||
apply_admission_control(&mut backends, &mut models, &sizes);
|
||||
|
||||
// Same-model fill: if only one model resolved but it is served by >=2
|
||||
// DISTINCT physical endpoints, form a committee from them. Self-MoA shows
|
||||
// repeated sampling of one model ensembles as well as different models, so
|
||||
// a same-model mesh should still get MoA. Iron law: a single physical
|
||||
// endpoint must never become a fake 2-worker committee — one node stays
|
||||
// single-model.
|
||||
if models.len() == 1 {
|
||||
self_fill_from_extra_instances(
|
||||
node,
|
||||
targets,
|
||||
required_tokens,
|
||||
http,
|
||||
&mut backends,
|
||||
&mut models,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// All-small pools do not convene a committee.
|
||||
//
|
||||
// Measured through the shipped path (evals/moa-openrouter/RESULTS.md),
|
||||
// 8B-class peers with an 8B reducer, vs the pool's best member alone:
|
||||
// 2x 8B: 0W/43T/37L 4x 8B: see RESULTS 6x 8B: 5W/52T/23L (p=0.0009)
|
||||
// The committee never won and lost roughly a third of decided trials, with
|
||||
// consistently shorter answers. A capable pool is the opposite (71W/8T/1L,
|
||||
// p<0.0001), so this is a statement about *this* configuration — a weak
|
||||
// reducer synthesizing weak drafts — not about small-model MoA in general.
|
||||
// The untested cell is small peers with a strong reducer; if a mesh gains a
|
||||
// big-tier model the pool stops being all-small and MoA engages again.
|
||||
//
|
||||
// So: fall back to best-member routing rather than ship a measured
|
||||
// regression. Keeping the strongest worker means `build_moa_config` sees a
|
||||
// single model and the caller degrades to serving it directly.
|
||||
if !models.is_empty()
|
||||
&& models
|
||||
.iter()
|
||||
.all(|m| tier_for(&m.name, &sizes) == SizeTier::Small)
|
||||
{
|
||||
keep_best_member_only(&mut backends, &mut models, &sizes);
|
||||
return (backends, models);
|
||||
}
|
||||
|
||||
// Committee cap: fan-out cost is ~2N+1 model calls per turn (N drafts + N
|
||||
// refines + 1 synthesis), and measured quality is flat past ~4 workers
|
||||
// while latency and spend keep climbing. On a big shared mesh (say 20
|
||||
// nodes) an uncapped pool would fan out to all of them — 41 calls for no
|
||||
// quality gain. Keep the best MAX_COMMITTEE_WORKERS by capability ranking;
|
||||
// the rest are standbys (they still serve direct traffic, just not this
|
||||
// committee).
|
||||
cap_committee(node, &mut backends, &mut models).await;
|
||||
|
||||
(backends, models)
|
||||
}
|
||||
|
||||
/// Committee width caps, by pool tier. Measured (evals/moa-openrouter,
|
||||
/// aggregator = qwen3-8b, 8B-class peers):
|
||||
///
|
||||
/// - all-small pool: 6x diverse 8B beats solo (12W/2L, p=0.013); 4 is only
|
||||
/// marginal (5W/0L, p=0.06); 2 is null. Small, weak drafts need WIDTH — more
|
||||
/// independent proposals — before aggregation clears the best member.
|
||||
/// - big-tier present: a 24-32B pair already wins (49W/6L, p=2e-9); extra
|
||||
/// workers past ~4 are latency/cost with no measured gain.
|
||||
///
|
||||
/// So the cap scales with size: wide for small pools, tight when a big model is
|
||||
/// present.
|
||||
const COMMITTEE_CAP_SMALL: usize = 6;
|
||||
const COMMITTEE_CAP_BIG: usize = 4;
|
||||
|
||||
/// Reduce an all-small pool to its single strongest member, so the caller
|
||||
/// degrades to serving that model directly instead of convening a committee
|
||||
/// that measured worse than the member alone.
|
||||
fn keep_best_member_only(
|
||||
backends: &mut Vec<std::sync::Arc<dyn moa::ModelBackend>>,
|
||||
models: &mut Vec<moa::ModelEntry>,
|
||||
sizes: &HashMap<String, f64>,
|
||||
) {
|
||||
// Largest verified size wins; unsized models rank last, stable index
|
||||
// breaks ties (same ordering rule as `cap_committee`).
|
||||
let best = (0..models.len())
|
||||
.max_by(|&a, &b| {
|
||||
let key = |i: usize| {
|
||||
sizes
|
||||
.get(&canonical_base_name(&models[i].name))
|
||||
.copied()
|
||||
.unwrap_or(0.0)
|
||||
};
|
||||
key(a)
|
||||
.partial_cmp(&key(b))
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
.then_with(|| b.cmp(&a))
|
||||
})
|
||||
.unwrap_or(0);
|
||||
|
||||
tracing::info!(
|
||||
"MoA: all-small pool ({} workers) — serving best member {} directly \
|
||||
(committee measured worse than the member alone)",
|
||||
models.len(),
|
||||
models[best].name,
|
||||
);
|
||||
|
||||
let backend = backends[models[best].backend_index].clone();
|
||||
let name = models[best].name.clone();
|
||||
*backends = vec![backend];
|
||||
*models = vec![moa::ModelEntry {
|
||||
name,
|
||||
backend_index: 0,
|
||||
}];
|
||||
}
|
||||
|
||||
fn committee_cap(models: &[moa::ModelEntry], sizes: &HashMap<String, f64>) -> usize {
|
||||
let has_big = models
|
||||
.iter()
|
||||
.any(|m| tier_for(&m.name, sizes) == SizeTier::Big);
|
||||
if has_big {
|
||||
COMMITTEE_CAP_BIG
|
||||
} else {
|
||||
COMMITTEE_CAP_SMALL
|
||||
}
|
||||
}
|
||||
|
||||
/// Trim the pool to the best workers for its tier (see [`committee_cap`]).
|
||||
async fn cap_committee(
|
||||
node: &mesh::Node,
|
||||
backends: &mut Vec<std::sync::Arc<dyn moa::ModelBackend>>,
|
||||
models: &mut Vec<moa::ModelEntry>,
|
||||
) {
|
||||
let sizes = gossiped_sizes(node).await;
|
||||
let cap = committee_cap(models, &sizes);
|
||||
if models.len() <= cap {
|
||||
return;
|
||||
}
|
||||
// Rank by verified size, NOT the tool-actor ranking. The committee serves
|
||||
// ordinary answer turns where `tool_use` is irrelevant; ranking by it
|
||||
// (i386 P1) could evict a 32B/70B model with `tool_use=None` in favour of
|
||||
// four small models whose metadata advertises tool use — the opposite of
|
||||
// the admission goal. Keep the largest verified models; a model with no
|
||||
// verified size ranks as weakest, and stable index breaks ties.
|
||||
let mut ranked: Vec<usize> = (0..models.len()).collect();
|
||||
ranked.sort_by(|&a, &b| {
|
||||
let key = |i: usize| match tier_for(&models[i].name, &sizes) {
|
||||
SizeTier::Big => 0,
|
||||
SizeTier::Small => 1,
|
||||
};
|
||||
key(a).cmp(&key(b)).then_with(|| a.cmp(&b))
|
||||
});
|
||||
let keep: std::collections::HashSet<usize> = ranked.into_iter().take(cap).collect();
|
||||
|
||||
let mut kept_backends: Vec<std::sync::Arc<dyn moa::ModelBackend>> = Vec::new();
|
||||
let mut kept_models: Vec<moa::ModelEntry> = Vec::new();
|
||||
for (i, m) in models.iter().enumerate() {
|
||||
if !keep.contains(&i) {
|
||||
tracing::info!("MoA: capping committee, dropping worker {}", m.name);
|
||||
continue;
|
||||
}
|
||||
let new_idx = kept_backends.len();
|
||||
kept_backends.push(backends[m.backend_index].clone());
|
||||
kept_models.push(moa::ModelEntry {
|
||||
name: m.name.clone(),
|
||||
backend_index: new_idx,
|
||||
});
|
||||
}
|
||||
*backends = kept_backends;
|
||||
*models = kept_models;
|
||||
}
|
||||
|
||||
/// Cap on same-model instances added by self-fill. Two is enough to switch a
|
||||
/// single-model mesh from solo to a working committee; beyond that the extra
|
||||
/// draft's marginal value falls and it is just latency/cost.
|
||||
const SELF_FILL_TARGET_WORKERS: usize = 2;
|
||||
|
||||
/// When only one model resolved, add extra reachable *nodes* serving that same
|
||||
/// model as additional workers, up to [`SELF_FILL_TARGET_WORKERS`].
|
||||
///
|
||||
/// Only genuinely distinct remote endpoints are added — never the local backend
|
||||
/// again and never the same peer twice — so each added worker is real capacity
|
||||
/// from a node that joined the mesh. This is what makes a same-model mesh get
|
||||
/// MoA at all; without it `build_moa_config` returns None for such a mesh.
|
||||
async fn self_fill_from_extra_instances(
|
||||
node: &mesh::Node,
|
||||
targets: Option<&election::ModelTargets>,
|
||||
required_tokens: Option<u32>,
|
||||
http: &reqwest::Client,
|
||||
backends: &mut Vec<std::sync::Arc<dyn moa::ModelBackend>>,
|
||||
models: &mut Vec<moa::ModelEntry>,
|
||||
) {
|
||||
let Some(existing) = models.first().cloned() else {
|
||||
return;
|
||||
};
|
||||
let name = existing.name.clone();
|
||||
|
||||
// Rebuild the pool from DISTINCT physical endpoints serving this model:
|
||||
// the local skippy port (if this node serves it and context fits) plus
|
||||
// each distinct remote peer. `hosts_for_model` returns distinct peers, and
|
||||
// the local endpoint is a different physical box from any of them, so no
|
||||
// endpoint can appear twice.
|
||||
//
|
||||
// Iron law: a single physical endpoint must NEVER become a fake 2-worker
|
||||
// committee. If fewer than two distinct endpoints serve the model we leave
|
||||
// the pool as the single worker and MoA degrades to single-model serving.
|
||||
let mut endpoints: Vec<std::sync::Arc<dyn moa::ModelBackend>> = Vec::new();
|
||||
|
||||
if let Some(port) = targets.and_then(|t| {
|
||||
t.targets.get(&name).and_then(|tv| {
|
||||
tv.iter().find_map(|t| match t {
|
||||
election::InferenceTarget::Local(p) => Some(*p),
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
}) {
|
||||
let context_length = node.local_model_context_length(&name).await;
|
||||
if context_selection::context_can_satisfy(required_tokens, context_length) {
|
||||
endpoints.push(std::sync::Arc::new(LocalModelBackend {
|
||||
port,
|
||||
http: http.clone(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
for peer_id in node.hosts_for_model(&name).await {
|
||||
if endpoints.len() >= SELF_FILL_TARGET_WORKERS {
|
||||
break;
|
||||
}
|
||||
endpoints.push(std::sync::Arc::new(RemoteModelBackend {
|
||||
node: node.clone(),
|
||||
peer_id,
|
||||
}));
|
||||
}
|
||||
|
||||
if endpoints.len() < 2 {
|
||||
return; // single physical endpoint -> stay single-model (iron law)
|
||||
}
|
||||
endpoints.truncate(SELF_FILL_TARGET_WORKERS);
|
||||
|
||||
tracing::info!(
|
||||
"MoA: self-fill formed a {}-worker committee for {name} from distinct endpoints",
|
||||
endpoints.len()
|
||||
);
|
||||
*backends = endpoints;
|
||||
*models = (0..backends.len())
|
||||
.map(|i| moa::ModelEntry {
|
||||
name: name.clone(),
|
||||
backend_index: i,
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
/// Drop small-tier workers when any big-tier worker is present.
|
||||
///
|
||||
/// A weak draft can contaminate synthesis, and aggregation quality tracks
|
||||
/// proposal quality (Self-MoA, arXiv:2502.00674), so a modest node must not be
|
||||
/// admitted into a committee that already has a stronger member. When the pool
|
||||
/// is mixed we keep only the big-tier workers; an all-small or all-big pool is
|
||||
/// left untouched. `backends` and `models` are parallel vecs linked by
|
||||
/// `backend_index`, so we rebuild both and reindex.
|
||||
fn apply_admission_control(
|
||||
backends: &mut Vec<std::sync::Arc<dyn moa::ModelBackend>>,
|
||||
models: &mut Vec<moa::ModelEntry>,
|
||||
sizes: &HashMap<String, f64>,
|
||||
) {
|
||||
// Only *verified* big-tier models count as strong, and only *verified*
|
||||
// small-tier models are eligible for exclusion. An `Unknown` size is never
|
||||
// treated as strong (so it can't anchor the ">=2 big" gate) and is never
|
||||
// filtered out (so an unverified label can't get a worker dropped). This
|
||||
// is the i386 P1 fix: a destructive admission decision must rest on
|
||||
// verified size, not a guessed tier.
|
||||
let big_count = models
|
||||
.iter()
|
||||
.filter(|m| tier_for(&m.name, sizes) == SizeTier::Big)
|
||||
.count();
|
||||
let has_small = models
|
||||
.iter()
|
||||
.any(|m| tier_for(&m.name, sizes) == SizeTier::Small);
|
||||
// Only exclude small-tier workers when doing so still leaves a committee
|
||||
// (>=2 big-tier). Measured:
|
||||
// * 32B x2 + 8B -> dropping the 8B leaves 32B x2, and the 8B added
|
||||
// nothing (arm C: no upside, losses 2->5) — so drop it.
|
||||
// * 32B + 8B -> dropping the 8B collapses to a solo 32B, but the
|
||||
// mixed committee beats solo decisively (47W/27T/5L, p=1e-9) — so
|
||||
// KEEP the 8B. Admission must not throw away MoA to protect a pool
|
||||
// that no longer exists.
|
||||
// See `evals/moa-openrouter/RESULTS.md`.
|
||||
if !(has_small && big_count >= 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut kept_backends: Vec<std::sync::Arc<dyn moa::ModelBackend>> = Vec::new();
|
||||
let mut kept_models: Vec<moa::ModelEntry> = Vec::new();
|
||||
for m in models.iter() {
|
||||
if tier_for(&m.name, sizes) == SizeTier::Small {
|
||||
tracing::info!(
|
||||
"MoA: excluding verified small-tier worker {} (>=2 big-tier present)",
|
||||
m.name
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let new_idx = kept_backends.len();
|
||||
kept_backends.push(backends[m.backend_index].clone());
|
||||
kept_models.push(moa::ModelEntry {
|
||||
name: m.name.clone(),
|
||||
backend_index: new_idx,
|
||||
});
|
||||
}
|
||||
*backends = kept_backends;
|
||||
*models = kept_models;
|
||||
}
|
||||
|
||||
/// Rank the pool best-tool-caller-first (indices into `models`) for the actor.
|
||||
///
|
||||
/// Ordering: gossiped `tool_use` (`Supported` > `Likely` > `None`), then size
|
||||
/// tier, then stable index. Capabilities match pool entries by canonical base
|
||||
/// name (so `unsloth/Qwen3-8B-GGUF:Q4_K_M` supplies `Qwen3-8B-Q4_K_M`). Always
|
||||
/// returns a full ranking; the engine reads an empty vec as "no host guidance".
|
||||
pub(super) async fn compute_actor_candidates(
|
||||
node: &mesh::Node,
|
||||
models: &[moa::ModelEntry],
|
||||
) -> Vec<usize> {
|
||||
// canonical base name -> best tool_use level seen across the mesh.
|
||||
let mut tool_use_by_base: std::collections::HashMap<String, crate::models::CapabilityLevel> =
|
||||
std::collections::HashMap::new();
|
||||
for descriptor in node.all_served_model_descriptors().await {
|
||||
let base = canonical_base_name(&descriptor.identity.model_name);
|
||||
let level = descriptor.capabilities.tool_use;
|
||||
tool_use_by_base
|
||||
.entry(base)
|
||||
.and_modify(|existing| {
|
||||
if level > *existing {
|
||||
*existing = level;
|
||||
}
|
||||
})
|
||||
.or_insert(level);
|
||||
}
|
||||
|
||||
let mut ranked: Vec<usize> = (0..models.len()).collect();
|
||||
ranked.sort_by(|&a, &b| {
|
||||
let ma = &models[a];
|
||||
let mb = &models[b];
|
||||
let tool_a = tool_use_by_base
|
||||
.get(&canonical_base_name(&ma.name))
|
||||
.copied()
|
||||
.unwrap_or(crate::models::CapabilityLevel::None);
|
||||
let tool_b = tool_use_by_base
|
||||
.get(&canonical_base_name(&mb.name))
|
||||
.copied()
|
||||
.unwrap_or(crate::models::CapabilityLevel::None);
|
||||
// 1) higher tool_use first
|
||||
tool_b
|
||||
.cmp(&tool_a)
|
||||
// 2) big-tier before small-tier
|
||||
.then_with(|| {
|
||||
let small_a = moa::model_name_is_small_tier(&ma.name);
|
||||
let small_b = moa::model_name_is_small_tier(&mb.name);
|
||||
small_a.cmp(&small_b) // false (big) sorts before true (small)
|
||||
})
|
||||
// 3) stable index order
|
||||
.then_with(|| a.cmp(&b))
|
||||
});
|
||||
ranked
|
||||
}
|
||||
|
||||
/// Canonical name used for cross-peer dedup. Different peers advertise the
|
||||
/// same model under different conventions (`unsloth/Qwen3-8B-GGUF:Q4_K_M`
|
||||
/// vs `Qwen3-8B-Q4_K_M`); normalize before comparing.
|
||||
///
|
||||
/// Strategy: strip the publisher prefix, the `-gguf` suffix, any `@branch`
|
||||
/// suffix, then keep only `[a-z0-9]` characters so `:` vs `-` separators
|
||||
/// don't matter.
|
||||
pub(super) fn canonical_base_name(name: &str) -> String {
|
||||
let lower = name.to_lowercase();
|
||||
// Drop an `@branch` segment if present, keeping anything after the
|
||||
// next `:` so quant tags survive (e.g. `repo@main:q4_k_m` → `repo:q4_k_m`).
|
||||
let no_branch = match lower.find('@') {
|
||||
Some(at) => {
|
||||
let after = &lower[at + 1..];
|
||||
let rest = after.find(':').map(|c| &after[c..]).unwrap_or("");
|
||||
format!("{}{}", &lower[..at], rest)
|
||||
}
|
||||
None => lower,
|
||||
};
|
||||
let stripped = no_branch
|
||||
.replace("-gguf", "")
|
||||
.replace("unsloth/", "")
|
||||
.replace("meshllm/", "");
|
||||
stripped
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Minimal backend stub for admission-control tests.
|
||||
struct FakeBackend;
|
||||
#[async_trait::async_trait]
|
||||
impl moa::ModelBackend for FakeBackend {
|
||||
async fn chat_completion(
|
||||
&self,
|
||||
_model: &str,
|
||||
_messages: &[serde_json::Value],
|
||||
_tools: Option<&serde_json::Value>,
|
||||
_max_tokens: u32,
|
||||
_timeout: std::time::Duration,
|
||||
_sampling: moa::SamplingParams,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
Ok(serde_json::json!({"choices":[{"message":{"content":"x"}}]}))
|
||||
}
|
||||
}
|
||||
|
||||
fn pool(
|
||||
names: &[&str],
|
||||
) -> (
|
||||
Vec<std::sync::Arc<dyn moa::ModelBackend>>,
|
||||
Vec<moa::ModelEntry>,
|
||||
) {
|
||||
let mut b: Vec<std::sync::Arc<dyn moa::ModelBackend>> = Vec::new();
|
||||
let mut m = Vec::new();
|
||||
for name in names {
|
||||
m.push(moa::ModelEntry {
|
||||
name: (*name).to_string(),
|
||||
backend_index: b.len(),
|
||||
});
|
||||
b.push(std::sync::Arc::new(FakeBackend));
|
||||
}
|
||||
(b, m)
|
||||
}
|
||||
|
||||
/// Build a verified-size map keyed by canonical base name, as if gossiped.
|
||||
fn sizes_of(entries: &[(&str, f64)]) -> HashMap<String, f64> {
|
||||
entries
|
||||
.iter()
|
||||
.map(|(n, b)| (canonical_base_name(n), *b))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_small_pool_keeps_only_the_best_member() {
|
||||
// Measured: an all-small committee never beat its best member and lost
|
||||
// ~a third of decided trials (2x8B 0W/37L; 6x8B 5W/23L p=0.0009). So an
|
||||
// all-small pool collapses to the single strongest member and the
|
||||
// caller degrades to serving it directly.
|
||||
let (mut b, mut m) = pool(&["Qwen3-8B", "Llama-3.1-8B", "Qwen3.5-9B"]);
|
||||
let sizes = sizes_of(&[
|
||||
("Qwen3-8B", 8.0),
|
||||
("Llama-3.1-8B", 8.0),
|
||||
("Qwen3.5-9B", 9.0),
|
||||
]);
|
||||
keep_best_member_only(&mut b, &mut m, &sizes);
|
||||
assert_eq!(m.len(), 1, "all-small pool must collapse to one member");
|
||||
assert_eq!(m[0].name, "Qwen3.5-9B", "largest verified size wins");
|
||||
assert_eq!(b.len(), 1);
|
||||
assert_eq!(m[0].backend_index, 0, "backend index reindexed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn best_member_falls_back_to_stable_order_when_unsized() {
|
||||
// No verified sizes: every member ranks equal, so the stable first
|
||||
// entry is kept rather than an arbitrary one.
|
||||
let (mut b, mut m) = pool(&["alpha-model", "beta-model"]);
|
||||
let sizes = sizes_of(&[]);
|
||||
keep_best_member_only(&mut b, &mut m, &sizes);
|
||||
assert_eq!(m.len(), 1);
|
||||
assert_eq!(m[0].name, "alpha-model");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admission_drops_small_when_two_big_remain() {
|
||||
// Dropping the small workers still leaves a committee (2x 32B), and the
|
||||
// small drafts add nothing there — so exclude them.
|
||||
let (mut b, mut m) = pool(&["Qwen3-32B", "Qwen3-32B", "Qwen3-8B", "Ministral-8B"]);
|
||||
let sizes = sizes_of(&[
|
||||
("Qwen3-32B", 32.0),
|
||||
("Qwen3-8B", 8.0),
|
||||
("Ministral-8B", 8.0),
|
||||
]);
|
||||
apply_admission_control(&mut b, &mut m, &sizes);
|
||||
assert_eq!(m.len(), 2);
|
||||
assert!(m.iter().all(|e| e.name == "Qwen3-32B"));
|
||||
assert_eq!(b.len(), 2);
|
||||
// backends stay aligned and reindexed
|
||||
assert_eq!(m[0].backend_index, 0);
|
||||
assert_eq!(m[1].backend_index, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admission_keeps_mix_when_dropping_would_collapse_to_solo() {
|
||||
// One strong + one weak: dropping the 8B leaves a solo 32B, but the
|
||||
// mixed committee beats solo (47W/27T/5L) — so keep the mix.
|
||||
let (mut b, mut m) = pool(&["Qwen3-32B", "Qwen3-8B"]);
|
||||
let sizes = sizes_of(&[("Qwen3-32B", 32.0), ("Qwen3-8B", 8.0)]);
|
||||
apply_admission_control(&mut b, &mut m, &sizes);
|
||||
assert_eq!(m.len(), 2, "must not collapse a lone-strong pool to solo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admission_keeps_all_small_pool() {
|
||||
let (mut b, mut m) = pool(&["Qwen3-8B", "Llama-3.1-8B", "Ministral-8B"]);
|
||||
let sizes = sizes_of(&[
|
||||
("Qwen3-8B", 8.0),
|
||||
("Llama-3.1-8B", 8.0),
|
||||
("Ministral-8B", 8.0),
|
||||
]);
|
||||
apply_admission_control(&mut b, &mut m, &sizes);
|
||||
assert_eq!(m.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admission_keeps_all_big_pool() {
|
||||
let (mut b, mut m) = pool(&["Qwen3-32B", "Mistral-Small-24B"]);
|
||||
let sizes = sizes_of(&[("Qwen3-32B", 32.0), ("Mistral-Small-24B", 24.0)]);
|
||||
apply_admission_control(&mut b, &mut m, &sizes);
|
||||
assert_eq!(m.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admission_keeps_homogeneous_big_pool() {
|
||||
let (mut b, mut m) = pool(&["Qwen3-32B", "Qwen3-32B"]);
|
||||
let sizes = sizes_of(&[("Qwen3-32B", 32.0)]);
|
||||
apply_admission_control(&mut b, &mut m, &sizes);
|
||||
assert_eq!(m.len(), 2);
|
||||
}
|
||||
|
||||
/// i386: a model with NO verified GGUF size is the weakest (Small), so
|
||||
/// next to two verified big models it is excluded like any other small
|
||||
/// worker. An unverifiable label can never masquerade as big.
|
||||
#[test]
|
||||
fn admission_treats_unsized_worker_as_weakest() {
|
||||
let (mut b, mut m) = pool(&["Qwen3-32B", "Qwen3-32B", "my-assistant"]);
|
||||
// Only the two big models have gossiped sizes; "my-assistant" has none.
|
||||
let sizes = sizes_of(&[("Qwen3-32B", 32.0)]);
|
||||
apply_admission_control(&mut b, &mut m, &sizes);
|
||||
assert_eq!(m.len(), 2, "unsized worker ranks weakest and is excluded");
|
||||
assert!(m.iter().all(|e| e.name == "Qwen3-32B"));
|
||||
}
|
||||
|
||||
/// No gossiped sizes at all: every worker is weakest (Small), so it is an
|
||||
/// all-small pool and admission excludes nothing (there is no verified big
|
||||
/// to protect). Name is never consulted.
|
||||
#[test]
|
||||
fn admission_keeps_all_when_no_verified_sizes() {
|
||||
let (mut b, mut m) = pool(&["Qwen3-32B", "Qwen3-32B", "Qwen3-8B"]);
|
||||
let sizes: HashMap<String, f64> = HashMap::new();
|
||||
apply_admission_control(&mut b, &mut m, &sizes);
|
||||
assert_eq!(
|
||||
m.len(),
|
||||
3,
|
||||
"no verified big => all-small pool => nothing excluded"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn committee_cap_is_wide_for_small_pools_tight_for_big() {
|
||||
// All-small pool: cap is wide (6) so a 6× 8B mesh keeps its width —
|
||||
// measured 12W/2L p=0.013 at width 6, only marginal at 4.
|
||||
let small: Vec<moa::ModelEntry> = (0..8)
|
||||
.map(|i| moa::ModelEntry {
|
||||
name: "Qwen3-8B".to_string(),
|
||||
backend_index: i,
|
||||
})
|
||||
.collect();
|
||||
let sizes = sizes_of(&[("Qwen3-8B", 8.0)]);
|
||||
assert_eq!(committee_cap(&small, &sizes), COMMITTEE_CAP_SMALL);
|
||||
|
||||
// A verified big model present -> tight cap (4); extra workers past a
|
||||
// 24–32B pair buy nothing.
|
||||
let mixed = vec![
|
||||
moa::ModelEntry {
|
||||
name: "Qwen3-32B".to_string(),
|
||||
backend_index: 0,
|
||||
},
|
||||
moa::ModelEntry {
|
||||
name: "Qwen3-8B".to_string(),
|
||||
backend_index: 1,
|
||||
},
|
||||
];
|
||||
let sizes = sizes_of(&[("Qwen3-32B", 32.0), ("Qwen3-8B", 8.0)]);
|
||||
assert_eq!(committee_cap(&mixed, &sizes), COMMITTEE_CAP_BIG);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_base_dedupes_unsloth_and_gguf_variants() {
|
||||
assert_eq!(
|
||||
canonical_base_name("unsloth/Qwen3-8B-GGUF:Q4_K_M"),
|
||||
canonical_base_name("Qwen3-8B-Q4_K_M")
|
||||
);
|
||||
assert_eq!(
|
||||
canonical_base_name("unsloth/Qwen3-8B-GGUF@main:Q4_K_M"),
|
||||
canonical_base_name("Qwen3-8B-Q4_K_M")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_base_keeps_distinct_models_distinct() {
|
||||
assert_ne!(
|
||||
canonical_base_name("unsloth/Qwen3-8B-GGUF:Q4_K_M"),
|
||||
canonical_base_name("unsloth/Qwen3-32B-GGUF:Q4_K_M")
|
||||
);
|
||||
assert_ne!(
|
||||
canonical_base_name("unsloth/Qwen3-32B-GGUF:Q4_K_M"),
|
||||
canonical_base_name("unsloth/MiniMax-M2.5-GGUF:Q4_K_M")
|
||||
);
|
||||
}
|
||||
fn make_targets(local_names: &[&str]) -> election::ModelTargets {
|
||||
let mut t = election::ModelTargets::default();
|
||||
for (i, name) in local_names.iter().enumerate() {
|
||||
t.targets.insert(
|
||||
(*name).to_string(),
|
||||
vec![election::InferenceTarget::Local(50000 + i as u16)],
|
||||
);
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_aliases_keeps_all_aliases_per_canonical_base() {
|
||||
// Regression for PR #566 review (item #10): the dedup-then-resolve
|
||||
// shape committed to a single alias per base before checking
|
||||
// backend reachability. Now every alias is retained so the
|
||||
// resolver can fall back if the preferred alias is unreachable.
|
||||
let groups = group_aliases_by_canonical_base(
|
||||
vec![
|
||||
"Qwen3-8B-Q4_K_M".to_string(),
|
||||
"unsloth/Qwen3-8B-GGUF:Q4_K_M".to_string(),
|
||||
],
|
||||
None,
|
||||
);
|
||||
assert_eq!(groups.len(), 1, "both names share a canonical base");
|
||||
assert_eq!(groups[0].len(), 2, "both aliases retained");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_aliases_prefers_locally_served_alias_even_when_longer() {
|
||||
// Without a targets table, length-order wins and the shorter peer
|
||||
// alias would be tried first — forcing an unnecessary QUIC hop
|
||||
// when the model is right here under a different alias.
|
||||
// With targets, the local-served alias must come first.
|
||||
let local = "unsloth/Qwen3-8B-GGUF:Q4_K_M";
|
||||
let peer = "Qwen3-8B-Q4_K_M";
|
||||
let targets = make_targets(&[local]);
|
||||
let groups = group_aliases_by_canonical_base(
|
||||
vec![peer.to_string(), local.to_string()],
|
||||
Some(&targets),
|
||||
);
|
||||
assert_eq!(groups.len(), 1);
|
||||
assert_eq!(
|
||||
groups[0].first().map(String::as_str),
|
||||
Some(local),
|
||||
"locally-served alias must win even though it's longer"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_aliases_falls_back_to_shortest_when_no_local() {
|
||||
// No targets table at all (pure --client --auto node) — shortest
|
||||
// alias should win, but the longer alias is still in the group so
|
||||
// it can be tried if the shortest one is unreachable.
|
||||
let groups = group_aliases_by_canonical_base(
|
||||
vec![
|
||||
"unsloth/Qwen3-8B-GGUF:Q4_K_M".to_string(),
|
||||
"Qwen3-8B-Q4_K_M".to_string(),
|
||||
],
|
||||
None,
|
||||
);
|
||||
assert_eq!(groups.len(), 1);
|
||||
assert_eq!(
|
||||
groups[0].first().map(String::as_str),
|
||||
Some("Qwen3-8B-Q4_K_M")
|
||||
);
|
||||
assert_eq!(groups[0].len(), 2, "longer alias kept as fallback");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_aliases_distinct_models_stay_in_separate_groups() {
|
||||
let groups = group_aliases_by_canonical_base(
|
||||
vec![
|
||||
"unsloth/Qwen3-8B-GGUF:Q4_K_M".to_string(),
|
||||
"unsloth/Qwen3-32B-GGUF:Q4_K_M".to_string(),
|
||||
"unsloth/MiniMax-M2.5-GGUF:Q4_K_M".to_string(),
|
||||
],
|
||||
None,
|
||||
);
|
||||
assert_eq!(groups.len(), 3);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
use super::context_selection;
|
||||
use super::pool::{assemble_worker_pool, compute_actor_candidates};
|
||||
use crate::inference::election;
|
||||
use crate::mesh;
|
||||
use mesh_mixture_of_agents as moa;
|
||||
|
|
@ -14,7 +14,36 @@ use mesh_mixture_of_agents as moa;
|
|||
/// [`extract_enable_thinking_override`]. When no preference is
|
||||
/// expressed, MoA picks for them: off (always `Some(false)`).
|
||||
pub(super) fn effective_enable_thinking_for_moa(body: &serde_json::Value) -> Option<bool> {
|
||||
extract_enable_thinking_override(body).or(Some(false))
|
||||
// Always off. Not a default — a policy.
|
||||
//
|
||||
// Reasoning is actively harmful inside MoA fan-out, and recorded traces
|
||||
// from 9 open-weight models make the failure mode concrete
|
||||
// (`evals/moa-openrouter/`):
|
||||
//
|
||||
// * Workers run on a short budget (the fast worker gets 256 tokens).
|
||||
// With thinking on, qwen3-32b spent 408 reasoning tokens against a
|
||||
// 384-token cap and returned `finish_reason=length` with
|
||||
// `content: null` — 1620 characters of reasoning and no answer. The
|
||||
// worker contributed nothing but still cost a full inference.
|
||||
// * Across a recorded corpus, 15/140 responses came back truncated at
|
||||
// the limit, concentrated in exactly the reasoning-capable models.
|
||||
// * The reducer synthesizes from worker answers; reasoning prose is bad
|
||||
// candidate input regardless of budget.
|
||||
//
|
||||
// The previous shape honoured a caller's `reasoning_effort` /
|
||||
// `enable_thinking` override. That escape hatch only let callers ask for
|
||||
// the broken configuration, so it is gone: MoA decides this, not the
|
||||
// caller. Callers who want a reasoning model's thinking output should
|
||||
// address that model directly instead of going through `model=mesh`.
|
||||
//
|
||||
// We still parse the caller's preference so an ignored override is
|
||||
// visible in logs rather than silently dropped.
|
||||
if extract_enable_thinking_override(body) == Some(true) {
|
||||
tracing::info!(
|
||||
"moa: caller asked for reasoning, ignoring — MoA workers always run with thinking off"
|
||||
);
|
||||
}
|
||||
Some(false)
|
||||
}
|
||||
|
||||
/// Pull the caller's "disable / enable thinking" preference out of an
|
||||
|
|
@ -103,56 +132,31 @@ pub async fn build_moa_config(
|
|||
required_tokens: Option<u32>,
|
||||
) -> Option<moa::GatewayConfig> {
|
||||
let http = reqwest::Client::new();
|
||||
let mut backends: Vec<std::sync::Arc<dyn moa::ModelBackend>> = Vec::new();
|
||||
let mut models: Vec<moa::ModelEntry> = Vec::new();
|
||||
let mut local_count = 0usize;
|
||||
|
||||
// Full mesh-wide model list (local + every peer's advertised
|
||||
// routable models).
|
||||
let all_models: Vec<String> = node
|
||||
.models_being_served()
|
||||
.await
|
||||
.into_iter()
|
||||
.filter(|n| n != moa::VIRTUAL_MODEL_NAME)
|
||||
.collect();
|
||||
|
||||
// Group aliases by canonical base. The old shape sorted by name
|
||||
// length, took the *first* alias per base, and dropped the rest —
|
||||
// which silently dropped the model from the worker pool whenever the
|
||||
// shortest-named peer was unreachable (regression flagged by PR #566
|
||||
// review). Now we keep every alias per base and try them in order so
|
||||
// a longer-named reachable alias can still resolve when the shortest
|
||||
// one is offline.
|
||||
let groups = group_aliases_by_canonical_base(all_models, targets);
|
||||
for aliases in groups {
|
||||
resolve_one_worker_from_aliases(
|
||||
node,
|
||||
targets,
|
||||
&http,
|
||||
&aliases,
|
||||
required_tokens,
|
||||
&mut backends,
|
||||
&mut models,
|
||||
&mut local_count,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let (backends, models) = assemble_worker_pool(node, targets, required_tokens, &http).await;
|
||||
|
||||
if models.len() < 2 {
|
||||
tracing::warn!(
|
||||
"MoA: only {} model(s) reachable, need ≥2 (models={:?})",
|
||||
"MoA: only {} qualified model(s) after admission, need ≥2 (models={:?})",
|
||||
models.len(),
|
||||
models.iter().map(|m| &m.name).collect::<Vec<_>>()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
// Actor priority for the asymmetric tool path: best tool-caller first.
|
||||
// The actor is the one model that actually emits the tool call, so it must
|
||||
// be the best available tool-caller — a judgement the engine crate cannot
|
||||
// make because it can't see gossiped capabilities.
|
||||
let actor_candidates = compute_actor_candidates(node, &models).await;
|
||||
|
||||
// Public meshes are a pathological availability case, not a trust case:
|
||||
// unknown peers, wider latency spread, more churn. Wait less for perfect.
|
||||
let patience = patience_profile(node.public_mesh);
|
||||
|
||||
tracing::info!(
|
||||
required_tokens = ?required_tokens,
|
||||
"MoA config: {} workers ({} local, {} remote): {:?}",
|
||||
"MoA config: {} workers (admitted): {:?}",
|
||||
models.len(),
|
||||
local_count,
|
||||
models.len() - local_count,
|
||||
models.iter().map(|m| m.name.as_str()).collect::<Vec<_>>(),
|
||||
);
|
||||
|
||||
|
|
@ -194,7 +198,9 @@ pub async fn build_moa_config(
|
|||
// With the relaxed eligibility added in this change, the timer
|
||||
// is the dominant chat path, so a tighter default is the right
|
||||
// default.
|
||||
first_answer_grace: std::time::Duration::from_secs(3),
|
||||
//
|
||||
// Tightened further on a public mesh — see `patience_profile`.
|
||||
first_answer_grace: patience.first_answer_grace,
|
||||
// Tier-gate patience: how long small-tier-only answers/consensus
|
||||
// are held when a big-tier strong worker (e.g. MiniMax) is still
|
||||
// running. 20s covers the strong worker's typical first-token
|
||||
|
|
@ -203,219 +209,65 @@ pub async fn build_moa_config(
|
|||
// decision rules revert to ungated behavior. Same-tier pools are
|
||||
// unaffected, so "many small models lift each other" keeps its
|
||||
// current latency profile.
|
||||
strong_patience: std::time::Duration::from_secs(20),
|
||||
strong_patience: patience.strong_patience,
|
||||
// Defaults to leaving each model's thinking behavior alone.
|
||||
// `try_handle_moa` overrides this from the inbound request body
|
||||
// when the caller has expressed a preference
|
||||
// (`reasoning_effort: "none"`, `enable_thinking: false`, etc.).
|
||||
enable_thinking: None,
|
||||
// Actor priority for tool turns / synthesis: best tool-caller first.
|
||||
// Computed below from gossiped `tool_use`, model size, and peer health.
|
||||
actor_candidates,
|
||||
// Gate advisory references on actor strength: they help a weak actor
|
||||
// and cost a strong one (evals/moa-openrouter/RESULTS.md).
|
||||
reference_policy: moa::ReferencePolicy::Auto,
|
||||
refinement_policy: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Try each alias in `aliases` until one resolves to a backend, then stop.
|
||||
/// How long a turn waits for better answers before shipping what it has.
|
||||
struct PatienceProfile {
|
||||
first_answer_grace: std::time::Duration,
|
||||
strong_patience: std::time::Duration,
|
||||
}
|
||||
|
||||
/// Timing profile for the turn, tightened on a public mesh.
|
||||
///
|
||||
/// Aliases are pre-sorted by `group_aliases_by_canonical_base` so the most
|
||||
/// preferred (locally-served first, then shortest) is tried first. Falls
|
||||
/// back to longer aliases when the preferred one's peer is unreachable.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn resolve_one_worker_from_aliases(
|
||||
node: &mesh::Node,
|
||||
targets: Option<&election::ModelTargets>,
|
||||
http: &reqwest::Client,
|
||||
aliases: &[String],
|
||||
required_tokens: Option<u32>,
|
||||
backends: &mut Vec<std::sync::Arc<dyn moa::ModelBackend>>,
|
||||
models: &mut Vec<moa::ModelEntry>,
|
||||
local_count: &mut usize,
|
||||
) {
|
||||
let resolution = WorkerBackendResolution {
|
||||
node,
|
||||
targets,
|
||||
http,
|
||||
required_tokens,
|
||||
};
|
||||
for name in aliases {
|
||||
if add_worker_backend(&resolution, name, backends, models, local_count).await {
|
||||
return;
|
||||
/// A public mesh is a pathological *availability* case (unknown peers, wider
|
||||
/// latency spread, more churn), not a trust case. Both knobs here are
|
||||
/// "how long do we hold a usable answer hoping for a better one" — exactly the
|
||||
/// wait that hurts most when the tail is long. The hard bounds are unchanged;
|
||||
/// only the optional waiting shrinks, so quality paths still run when peers are
|
||||
/// prompt.
|
||||
fn patience_profile(public_mesh: bool) -> PatienceProfile {
|
||||
if public_mesh {
|
||||
PatienceProfile {
|
||||
// Ship a good answer sooner rather than wait out a long tail.
|
||||
first_answer_grace: std::time::Duration::from_millis(1500),
|
||||
// Still give a strong peer a real chance, but don't hold a usable
|
||||
// small-tier answer for 20s against an unknown remote worker.
|
||||
strong_patience: std::time::Duration::from_secs(8),
|
||||
}
|
||||
} else {
|
||||
PatienceProfile {
|
||||
// Widened 3s -> 10s: at 3s a fast small worker landed inside the
|
||||
// window while a larger peer was still generating, so grace armed
|
||||
// and (before the finalize fix) shipped the small answer, skipping
|
||||
// synthesis on ~every turn. 10s lets a normal committee complete
|
||||
// and synthesize; grace still bounds a genuinely stuck tail. Paired
|
||||
// with grace-finalizes-on-tool-turns-only, so even if it does fire
|
||||
// on an answer turn it synthesizes what arrived rather than shipping
|
||||
// one worker. See `evals/moa-openrouter/RESULTS.md`.
|
||||
first_answer_grace: std::time::Duration::from_secs(10),
|
||||
strong_patience: std::time::Duration::from_secs(20),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Group all advertised model names by their canonical base so each
|
||||
/// canonical model contributes exactly one worker, but the resolver gets
|
||||
/// to pick the alias that actually has a reachable backend.
|
||||
///
|
||||
/// The earlier shape committed to a single alias per base *before* trying
|
||||
/// to resolve a backend. Two failure modes:
|
||||
///
|
||||
/// 1. The chosen alias is advertised only by a peer that drops between
|
||||
/// gossip refresh and orchestration — `hosts_for_model` returns
|
||||
/// empty, the worker is dropped, and longer-form aliases for the
|
||||
/// same canonical model from still-reachable peers are rejected as
|
||||
/// duplicates.
|
||||
/// 2. The local node advertises a longer convention
|
||||
/// (e.g. `unsloth/Qwen3-8B-GGUF:Q4_K_M`) while a peer advertises a
|
||||
/// shorter variant (e.g. `Qwen3-8B-Q4_K_M`). The shortest-name rule
|
||||
/// picks the peer alias, `add_worker_backend` looks for a local port
|
||||
/// under that specific string, finds nothing, and forces a
|
||||
/// QUIC-tunnel backend even though the model is right here.
|
||||
///
|
||||
/// Both failure modes are fixed by grouping first and resolving second.
|
||||
/// Within each group the aliases are ordered so the most likely
|
||||
/// optimization wins first try: locally-served name (skippy-port fast
|
||||
/// path) before remote names, then shortest first as a tiebreaker.
|
||||
fn group_aliases_by_canonical_base(
|
||||
names: Vec<String>,
|
||||
targets: Option<&election::ModelTargets>,
|
||||
) -> Vec<Vec<String>> {
|
||||
let mut by_base: std::collections::HashMap<String, Vec<String>> =
|
||||
std::collections::HashMap::new();
|
||||
for name in names {
|
||||
by_base
|
||||
.entry(canonical_base_name(&name))
|
||||
.or_default()
|
||||
.push(name);
|
||||
}
|
||||
// Deterministic group order so the worker list is stable across
|
||||
// builds even though HashMap iteration is not. Sort group entries
|
||||
// (locally-served first, then shortest), then sort groups by their
|
||||
// first ("best") alias.
|
||||
let mut groups: Vec<Vec<String>> = by_base
|
||||
.into_values()
|
||||
.map(|mut aliases| {
|
||||
aliases.sort_by(|a, b| {
|
||||
let la = is_locally_served(a, targets);
|
||||
let lb = is_locally_served(b, targets);
|
||||
lb.cmp(&la) // local (true) before remote (false)
|
||||
.then_with(|| a.len().cmp(&b.len()))
|
||||
.then_with(|| a.cmp(b))
|
||||
});
|
||||
aliases
|
||||
})
|
||||
.collect();
|
||||
groups.sort_by(|a, b| a[0].cmp(&b[0]));
|
||||
groups
|
||||
}
|
||||
|
||||
/// Does the local routing table have a backend port for this exact name?
|
||||
fn is_locally_served(name: &str, targets: Option<&election::ModelTargets>) -> bool {
|
||||
targets
|
||||
.and_then(|t| {
|
||||
t.targets.get(name).map(|tv| {
|
||||
tv.iter()
|
||||
.any(|t| matches!(t, election::InferenceTarget::Local(_)))
|
||||
})
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Resolve `name` to a backend (local skippy port if available, else first
|
||||
/// remote host) and append it to `backends`/`models`. Returns true if a
|
||||
/// backend was added.
|
||||
struct WorkerBackendResolution<'a> {
|
||||
node: &'a mesh::Node,
|
||||
targets: Option<&'a election::ModelTargets>,
|
||||
http: &'a reqwest::Client,
|
||||
required_tokens: Option<u32>,
|
||||
}
|
||||
|
||||
async fn add_worker_backend(
|
||||
resolution: &WorkerBackendResolution<'_>,
|
||||
name: &str,
|
||||
backends: &mut Vec<std::sync::Arc<dyn moa::ModelBackend>>,
|
||||
models: &mut Vec<moa::ModelEntry>,
|
||||
local_count: &mut usize,
|
||||
) -> bool {
|
||||
// Prefer local skippy port when this node serves the model.
|
||||
let local_port = resolution.targets.and_then(|t| {
|
||||
t.targets.get(name).and_then(|tv| {
|
||||
tv.iter().find_map(|t| match t {
|
||||
election::InferenceTarget::Local(p) => Some(*p),
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
});
|
||||
if let Some(port) = local_port {
|
||||
let context_length = resolution.node.local_model_context_length(name).await;
|
||||
if context_selection::context_can_satisfy(resolution.required_tokens, context_length) {
|
||||
let backend_idx = backends.len();
|
||||
backends.push(std::sync::Arc::new(LocalModelBackend {
|
||||
port,
|
||||
http: resolution.http.clone(),
|
||||
}));
|
||||
models.push(moa::ModelEntry {
|
||||
name: name.to_string(),
|
||||
backend_index: backend_idx,
|
||||
});
|
||||
*local_count += 1;
|
||||
return true;
|
||||
} else {
|
||||
tracing::info!(
|
||||
"MoA: skipping local worker {name}; context {:?} cannot fit {:?} required tokens",
|
||||
context_length,
|
||||
resolution.required_tokens
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise find a remote host. hosts_for_model returns peers in
|
||||
// hash-preferred order; prefer hosts with enough advertised context.
|
||||
let remote_hosts = resolution.node.hosts_for_model(name).await;
|
||||
if let Some(peer_id) = context_selection::select_remote_host(
|
||||
resolution.node,
|
||||
name,
|
||||
resolution.required_tokens,
|
||||
remote_hosts,
|
||||
)
|
||||
.await
|
||||
{
|
||||
let backend_idx = backends.len();
|
||||
backends.push(std::sync::Arc::new(RemoteModelBackend {
|
||||
node: resolution.node.clone(),
|
||||
peer_id,
|
||||
}));
|
||||
models.push(moa::ModelEntry {
|
||||
name: name.to_string(),
|
||||
backend_index: backend_idx,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Canonical name used for cross-peer dedup. Different peers advertise the
|
||||
/// same model under different conventions (`unsloth/Qwen3-8B-GGUF:Q4_K_M`
|
||||
/// vs `Qwen3-8B-Q4_K_M`); normalize before comparing.
|
||||
///
|
||||
/// Strategy: strip the publisher prefix, the `-gguf` suffix, any `@branch`
|
||||
/// suffix, then keep only `[a-z0-9]` characters so `:` vs `-` separators
|
||||
/// don't matter.
|
||||
fn canonical_base_name(name: &str) -> String {
|
||||
let lower = name.to_lowercase();
|
||||
// Drop an `@branch` segment if present, keeping anything after the
|
||||
// next `:` so quant tags survive (e.g. `repo@main:q4_k_m` → `repo:q4_k_m`).
|
||||
let no_branch = match lower.find('@') {
|
||||
Some(at) => {
|
||||
let after = &lower[at + 1..];
|
||||
let rest = after.find(':').map(|c| &after[c..]).unwrap_or("");
|
||||
format!("{}{}", &lower[..at], rest)
|
||||
}
|
||||
None => lower,
|
||||
};
|
||||
let stripped = no_branch
|
||||
.replace("-gguf", "")
|
||||
.replace("unsloth/", "")
|
||||
.replace("meshllm/", "");
|
||||
stripped
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Backend that calls a local model directly on its skippy HTTP port.
|
||||
struct LocalModelBackend {
|
||||
port: u16,
|
||||
http: reqwest::Client,
|
||||
pub(super) struct LocalModelBackend {
|
||||
pub(super) port: u16,
|
||||
pub(super) http: reqwest::Client,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
|
|
@ -468,9 +320,9 @@ impl moa::ModelBackend for LocalModelBackend {
|
|||
}
|
||||
|
||||
/// Backend that calls a remote model over the QUIC tunnel.
|
||||
struct RemoteModelBackend {
|
||||
node: mesh::Node,
|
||||
peer_id: iroh::EndpointId,
|
||||
pub(super) struct RemoteModelBackend {
|
||||
pub(super) node: mesh::Node,
|
||||
pub(super) peer_id: iroh::EndpointId,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
|
|
@ -554,110 +406,6 @@ fn parse_quic_http_response(response: &[u8]) -> Result<serde_json::Value, String
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn canonical_base_dedupes_unsloth_and_gguf_variants() {
|
||||
assert_eq!(
|
||||
canonical_base_name("unsloth/Qwen3-8B-GGUF:Q4_K_M"),
|
||||
canonical_base_name("Qwen3-8B-Q4_K_M")
|
||||
);
|
||||
assert_eq!(
|
||||
canonical_base_name("unsloth/Qwen3-8B-GGUF@main:Q4_K_M"),
|
||||
canonical_base_name("Qwen3-8B-Q4_K_M")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_base_keeps_distinct_models_distinct() {
|
||||
assert_ne!(
|
||||
canonical_base_name("unsloth/Qwen3-8B-GGUF:Q4_K_M"),
|
||||
canonical_base_name("unsloth/Qwen3-32B-GGUF:Q4_K_M")
|
||||
);
|
||||
assert_ne!(
|
||||
canonical_base_name("unsloth/Qwen3-32B-GGUF:Q4_K_M"),
|
||||
canonical_base_name("unsloth/MiniMax-M2.5-GGUF:Q4_K_M")
|
||||
);
|
||||
}
|
||||
fn make_targets(local_names: &[&str]) -> election::ModelTargets {
|
||||
let mut t = election::ModelTargets::default();
|
||||
for (i, name) in local_names.iter().enumerate() {
|
||||
t.targets.insert(
|
||||
(*name).to_string(),
|
||||
vec![election::InferenceTarget::Local(50000 + i as u16)],
|
||||
);
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_aliases_keeps_all_aliases_per_canonical_base() {
|
||||
// Regression for PR #566 review (item #10): the dedup-then-resolve
|
||||
// shape committed to a single alias per base before checking
|
||||
// backend reachability. Now every alias is retained so the
|
||||
// resolver can fall back if the preferred alias is unreachable.
|
||||
let groups = group_aliases_by_canonical_base(
|
||||
vec![
|
||||
"Qwen3-8B-Q4_K_M".to_string(),
|
||||
"unsloth/Qwen3-8B-GGUF:Q4_K_M".to_string(),
|
||||
],
|
||||
None,
|
||||
);
|
||||
assert_eq!(groups.len(), 1, "both names share a canonical base");
|
||||
assert_eq!(groups[0].len(), 2, "both aliases retained");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_aliases_prefers_locally_served_alias_even_when_longer() {
|
||||
// Without a targets table, length-order wins and the shorter peer
|
||||
// alias would be tried first — forcing an unnecessary QUIC hop
|
||||
// when the model is right here under a different alias.
|
||||
// With targets, the local-served alias must come first.
|
||||
let local = "unsloth/Qwen3-8B-GGUF:Q4_K_M";
|
||||
let peer = "Qwen3-8B-Q4_K_M";
|
||||
let targets = make_targets(&[local]);
|
||||
let groups = group_aliases_by_canonical_base(
|
||||
vec![peer.to_string(), local.to_string()],
|
||||
Some(&targets),
|
||||
);
|
||||
assert_eq!(groups.len(), 1);
|
||||
assert_eq!(
|
||||
groups[0].first().map(String::as_str),
|
||||
Some(local),
|
||||
"locally-served alias must win even though it's longer"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_aliases_falls_back_to_shortest_when_no_local() {
|
||||
// No targets table at all (pure --client --auto node) — shortest
|
||||
// alias should win, but the longer alias is still in the group so
|
||||
// it can be tried if the shortest one is unreachable.
|
||||
let groups = group_aliases_by_canonical_base(
|
||||
vec![
|
||||
"unsloth/Qwen3-8B-GGUF:Q4_K_M".to_string(),
|
||||
"Qwen3-8B-Q4_K_M".to_string(),
|
||||
],
|
||||
None,
|
||||
);
|
||||
assert_eq!(groups.len(), 1);
|
||||
assert_eq!(
|
||||
groups[0].first().map(String::as_str),
|
||||
Some("Qwen3-8B-Q4_K_M")
|
||||
);
|
||||
assert_eq!(groups[0].len(), 2, "longer alias kept as fallback");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_aliases_distinct_models_stay_in_separate_groups() {
|
||||
let groups = group_aliases_by_canonical_base(
|
||||
vec![
|
||||
"unsloth/Qwen3-8B-GGUF:Q4_K_M".to_string(),
|
||||
"unsloth/Qwen3-32B-GGUF:Q4_K_M".to_string(),
|
||||
"unsloth/MiniMax-M2.5-GGUF:Q4_K_M".to_string(),
|
||||
],
|
||||
None,
|
||||
);
|
||||
assert_eq!(groups.len(), 3);
|
||||
}
|
||||
// ── extract_enable_thinking_override ────────────────────────────────
|
||||
//
|
||||
// Mirrors the shapes that `openai_frontend::common::normalize_reasoning_template_options`
|
||||
|
|
@ -758,14 +506,30 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn effective_lets_caller_explicitly_enable_thinking() {
|
||||
// Escape hatch: a caller who really wants reasoning on MoA can
|
||||
// ask for it via any of the recognised knobs.
|
||||
let body = serde_json::json!({
|
||||
"reasoning_effort": "low",
|
||||
"model": "mesh",
|
||||
});
|
||||
assert_eq!(effective_enable_thinking_for_moa(&body), Some(true));
|
||||
fn effective_ignores_caller_request_to_enable_thinking() {
|
||||
// There is deliberately no escape hatch. Thinking-on is a broken
|
||||
// configuration for MoA fan-out: recorded traces show reasoning
|
||||
// models spending their entire worker budget inside `<think>` and
|
||||
// returning `finish_reason=length` with null content, contributing
|
||||
// nothing while still costing a full inference.
|
||||
//
|
||||
// The override is parsed (and logged) but never honoured, so a
|
||||
// caller asking for reasoning gets a working turn instead of a pool
|
||||
// of empty workers. Reasoning output should be requested from a
|
||||
// model directly, not through `model=mesh`.
|
||||
for body in [
|
||||
serde_json::json!({"reasoning_effort": "low", "model": "mesh"}),
|
||||
serde_json::json!({"reasoning_effort": "high", "model": "mesh"}),
|
||||
serde_json::json!({"enable_thinking": true, "model": "mesh"}),
|
||||
serde_json::json!({"reasoning": {"enabled": true}, "model": "mesh"}),
|
||||
serde_json::json!({"chat_template_kwargs": {"enable_thinking": true}}),
|
||||
] {
|
||||
assert_eq!(
|
||||
effective_enable_thinking_for_moa(&body),
|
||||
Some(false),
|
||||
"MoA must force thinking off regardless of caller knobs: {body}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -781,4 +545,46 @@ mod tests {
|
|||
});
|
||||
assert_eq!(effective_enable_thinking_for_moa(&body), Some(false));
|
||||
}
|
||||
|
||||
/// Public meshes are a pathological availability case: unknown peers,
|
||||
/// wider latency spread, more churn. Both patience knobs are "hold a
|
||||
/// usable answer hoping for a better one", which is exactly the wait that
|
||||
/// hurts when the tail is long — so they shrink, and only they.
|
||||
#[test]
|
||||
fn public_mesh_waits_less_for_a_better_answer() {
|
||||
let public = patience_profile(true);
|
||||
let private = patience_profile(false);
|
||||
assert!(
|
||||
public.first_answer_grace < private.first_answer_grace,
|
||||
"public mesh must ship a good answer sooner"
|
||||
);
|
||||
assert!(
|
||||
public.strong_patience < private.strong_patience,
|
||||
"public mesh must not hold a usable answer as long for a slow strong peer"
|
||||
);
|
||||
}
|
||||
|
||||
/// Shrinking patience must not disable the quality paths entirely — a
|
||||
/// prompt strong peer should still get a chance to land.
|
||||
#[test]
|
||||
fn public_mesh_still_gives_strong_peers_a_chance() {
|
||||
let public = patience_profile(true);
|
||||
assert!(!public.first_answer_grace.is_zero());
|
||||
assert!(public.strong_patience >= std::time::Duration::from_secs(5));
|
||||
}
|
||||
|
||||
/// Private-mesh timings are the tuned defaults and must not drift silently.
|
||||
/// Grace is 10s (widened from 3s): at 3s a fast worker landed inside the
|
||||
/// window while a larger peer was still generating, so grace armed and the
|
||||
/// committee never synthesized — measured 80/80 early-exit at capable
|
||||
/// scale. See `evals/moa-openrouter/RESULTS.md`.
|
||||
#[test]
|
||||
fn private_mesh_keeps_the_tuned_defaults() {
|
||||
let private = patience_profile(false);
|
||||
assert_eq!(
|
||||
private.first_answer_grace,
|
||||
std::time::Duration::from_secs(10)
|
||||
);
|
||||
assert_eq!(private.strong_patience, std::time::Duration::from_secs(20));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,39 +1,23 @@
|
|||
//! Deterministic arbitration of worker outputs.
|
||||
//! Deterministic arbitration of worker **answers**.
|
||||
//!
|
||||
//! The arbiter uses code, not models, to decide the outcome.
|
||||
//! Models are only called (via the reducer) when there's genuine ambiguity.
|
||||
//!
|
||||
//! This arbiter handles the text path only. Tool-bearing turns take the
|
||||
//! asymmetric actor path in [`crate::tool_turn`], where the best tool-caller
|
||||
//! acts and references only advise — so no tool proposal ever reaches here.
|
||||
//! Any tool-shaped text on the text path was demoted to `Uncertainty` by
|
||||
//! `enforce_tool_call_contract` (tools disabled ⇒ empty allow-list).
|
||||
//!
|
||||
//! Decision priority:
|
||||
//! 1. Unanimous tool proposal → emit tool call
|
||||
//! 2. High-confidence tool proposal with no dissent → emit tool call
|
||||
//! 3. Unanimous answers → pick highest confidence
|
||||
//! 4. Conflicting outputs → escalate to reducer
|
||||
//! 5. All uncertainty → escalate to reducer
|
||||
//! 1. Agreeing answers (content cluster, majority) → pick highest confidence
|
||||
//! 2. Diverging answers → escalate to reducer (synthesis)
|
||||
//! 3. All uncertainty → escalate to reducer
|
||||
|
||||
use crate::normalize::{OutputKind, WorkerOutput};
|
||||
use crate::worker::WorkerRole;
|
||||
use serde_json::Value;
|
||||
|
||||
/// Pick the best tool proposal: prefer proposals that have arguments,
|
||||
/// then by confidence. A proposal without arguments (e.g. from a fast
|
||||
/// worker that only got tool names in the system prompt) should lose to
|
||||
/// one that has actual arguments.
|
||||
fn best_tool_proposal<'a>(proposals: &[&'a WorkerOutput]) -> &'a WorkerOutput {
|
||||
proposals
|
||||
.iter()
|
||||
.copied()
|
||||
.max_by(|a, b| {
|
||||
let a_has_args = a.tool_arguments.is_some()
|
||||
&& a.tool_arguments.as_ref() != Some(&Value::Object(Default::default()));
|
||||
let b_has_args = b.tool_arguments.is_some()
|
||||
&& b.tool_arguments.as_ref() != Some(&Value::Object(Default::default()));
|
||||
a_has_args
|
||||
.cmp(&b_has_args)
|
||||
.then(a.confidence.total_cmp(&b.confidence))
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// What the arbiter decided.
|
||||
#[derive(Debug)]
|
||||
pub enum Decision {
|
||||
|
|
@ -46,7 +30,7 @@ pub enum Decision {
|
|||
}
|
||||
|
||||
/// Arbitrate worker outputs into a single decision.
|
||||
pub fn arbitrate(outputs: &[WorkerOutput], has_tools: bool) -> Decision {
|
||||
pub fn arbitrate(outputs: &[WorkerOutput]) -> Decision {
|
||||
if outputs.is_empty() {
|
||||
return Decision::NeedsReducer {
|
||||
reason: "no worker outputs".into(),
|
||||
|
|
@ -54,13 +38,9 @@ pub fn arbitrate(outputs: &[WorkerOutput], has_tools: bool) -> Decision {
|
|||
}
|
||||
|
||||
if outputs.len() == 1 {
|
||||
return single_output_decision(&outputs[0], has_tools);
|
||||
return single_output_decision(&outputs[0]);
|
||||
}
|
||||
|
||||
let tool_proposals: Vec<&WorkerOutput> = outputs
|
||||
.iter()
|
||||
.filter(|o| o.kind == OutputKind::ToolProposal)
|
||||
.collect();
|
||||
let answers: Vec<&WorkerOutput> = outputs.iter().filter(|o| is_usable_answer(o)).collect();
|
||||
let critiques: Vec<&WorkerOutput> = outputs
|
||||
.iter()
|
||||
|
|
@ -78,79 +58,12 @@ pub fn arbitrate(outputs: &[WorkerOutput], has_tools: bool) -> Decision {
|
|||
};
|
||||
}
|
||||
|
||||
// ── Tool call arbitration ────────────────────────────────────
|
||||
|
||||
if has_tools && !tool_proposals.is_empty() {
|
||||
// Check if any critique opposes the tool call
|
||||
let has_tool_dissent = critiques.iter().any(|c| {
|
||||
c.payload.to_lowercase().contains("don't")
|
||||
|| c.payload.to_lowercase().contains("should not")
|
||||
|| c.payload.to_lowercase().contains("no tool")
|
||||
});
|
||||
|
||||
if has_tool_dissent {
|
||||
return Decision::NeedsReducer {
|
||||
reason: "tool proposal with dissenting critique".into(),
|
||||
};
|
||||
}
|
||||
|
||||
// All tool proposals agree on the same tool?
|
||||
let tool_names: Vec<&str> = tool_proposals
|
||||
.iter()
|
||||
.filter_map(|o| o.tool_name.as_deref())
|
||||
.collect();
|
||||
|
||||
if !tool_names.is_empty() {
|
||||
// If some workers propose tools and others answer directly, conflict
|
||||
if !answers.is_empty() {
|
||||
return Decision::NeedsReducer {
|
||||
reason: "some workers propose tools, others answer directly".into(),
|
||||
};
|
||||
}
|
||||
|
||||
let first = tool_names[0];
|
||||
let unanimous = tool_names.iter().all(|n| *n == first);
|
||||
|
||||
if unanimous {
|
||||
let best = best_tool_proposal(&tool_proposals);
|
||||
return Decision::ToolCall {
|
||||
name: first.to_string(),
|
||||
arguments: best
|
||||
.tool_arguments
|
||||
.clone()
|
||||
.unwrap_or(Value::Object(Default::default())),
|
||||
};
|
||||
}
|
||||
|
||||
// Different tools proposed — check if one is clearly dominant
|
||||
let max_conf = best_tool_proposal(&tool_proposals);
|
||||
let others_low = tool_proposals
|
||||
.iter()
|
||||
.filter(|o| o.tool_name != max_conf.tool_name)
|
||||
.all(|o| o.confidence < 0.5);
|
||||
|
||||
if max_conf.confidence > 0.7 && others_low {
|
||||
return Decision::ToolCall {
|
||||
name: max_conf.tool_name.clone().unwrap_or_default(),
|
||||
arguments: max_conf
|
||||
.tool_arguments
|
||||
.clone()
|
||||
.unwrap_or(Value::Object(Default::default())),
|
||||
};
|
||||
}
|
||||
|
||||
return Decision::NeedsReducer {
|
||||
reason: format!("conflicting tool proposals: {}", tool_names.join(" vs ")),
|
||||
};
|
||||
}
|
||||
|
||||
// Tool proposals without extractable names — single high-confidence?
|
||||
if tool_proposals.len() == 1 && tool_proposals[0].confidence > 0.6 {
|
||||
return Decision::NeedsReducer {
|
||||
reason: "tool proposal without parseable tool name".into(),
|
||||
};
|
||||
}
|
||||
}
|
||||
// Tool turns never reach here — a fresh tool request takes the asymmetric
|
||||
// actor path in `tool_turn`, where the best tool-caller acts and references
|
||||
// only advise. This arbiter is purely an answer/critique/uncertainty
|
||||
// arbiter: any tool-shaped output on the text path was demoted by
|
||||
// `enforce_tool_call_contract` (tools disabled ⇒ empty allow-list), so it
|
||||
// is treated as ordinary text below.
|
||||
|
||||
// ── Answer arbitration ───────────────────────────────────────
|
||||
|
||||
|
|
@ -168,6 +81,26 @@ pub fn arbitrate(outputs: &[WorkerOutput], has_tools: bool) -> Decision {
|
|||
};
|
||||
}
|
||||
|
||||
// Diverging answers are synthesized, not picked.
|
||||
//
|
||||
// Returning `best.payload` verbatim was close to arbitrary: models
|
||||
// rarely emit our `kind:/confidence:` envelope, so `normalize`
|
||||
// defaults them all to `confidence: 0.5` and `max_by` just returns
|
||||
// the first maximum — i.e. whichever worker happened to finish
|
||||
// first. That discarded every other worker's contribution while
|
||||
// still paying to run them.
|
||||
//
|
||||
// This is the pattern Together's MoA is built on and benchmarks
|
||||
// well with: fan out, then have an aggregator read all responses
|
||||
// and write one. We only take that path when the answers actually
|
||||
// disagree — when they agree, the existing consensus/early-exit
|
||||
// paths are cheaper and already correct.
|
||||
if answers.len() >= 2 && largest_agreeing_cluster(&answers).is_none() {
|
||||
return Decision::NeedsReducer {
|
||||
reason: format!("{} workers answered with no agreement", answers.len()),
|
||||
};
|
||||
}
|
||||
|
||||
return Decision::Answer(best.payload.clone());
|
||||
}
|
||||
|
||||
|
|
@ -208,7 +141,6 @@ pub fn try_early_decision(
|
|||
outputs: &[WorkerOutput],
|
||||
total_workers: usize,
|
||||
total_finished: usize,
|
||||
has_tools: bool,
|
||||
strong_gate: StrongGate,
|
||||
) -> Option<Decision> {
|
||||
if outputs.is_empty() {
|
||||
|
|
@ -231,7 +163,7 @@ pub fn try_early_decision(
|
|||
// If we have 1 successful output and no more workers are coming,
|
||||
// return it immediately — no point waiting.
|
||||
if outputs.len() == 1 && remaining == 0 {
|
||||
return Some(single_output_decision(&outputs[0], has_tools));
|
||||
return Some(single_output_decision(&outputs[0]));
|
||||
}
|
||||
|
||||
// ── Single output with others still pending ─────────────────────
|
||||
|
|
@ -246,9 +178,7 @@ pub fn try_early_decision(
|
|||
}
|
||||
// Tier gate: a small-tier sole-survivor *answer* must not
|
||||
// finalize while the strong worker is still running. The
|
||||
// fan-out loop bounds this wait via `strong_patience`. Tool
|
||||
// proposals are exempt — they are schema-verified and holding
|
||||
// them would slow agent loops.
|
||||
// fan-out loop bounds this wait via `strong_patience`.
|
||||
if strong_pending
|
||||
&& outputs[0].role != WorkerRole::Strong
|
||||
&& outputs[0].kind == OutputKind::Answer
|
||||
|
|
@ -259,23 +189,22 @@ pub fn try_early_decision(
|
|||
tracing::info!(
|
||||
"moa: early exit — sole survivor, {failed_count}/{total_workers} workers failed",
|
||||
);
|
||||
return Some(single_output_decision(&outputs[0], has_tools));
|
||||
return Some(single_output_decision(&outputs[0]));
|
||||
}
|
||||
|
||||
// ── 2+ outputs: check for consensus ─────────────────────────────
|
||||
// ── 2+ outputs: check for answer consensus ──────────────────────
|
||||
//
|
||||
// Tool turns never reach here (they take the asymmetric actor path), so
|
||||
// this is purely answer-consensus detection.
|
||||
|
||||
let answers: Vec<&WorkerOutput> = outputs.iter().filter(|o| is_usable_answer(o)).collect();
|
||||
let tool_proposals: Vec<&WorkerOutput> = outputs
|
||||
.iter()
|
||||
.filter(|o| o.kind == OutputKind::ToolProposal)
|
||||
.collect();
|
||||
|
||||
// Workers agree on an answer — but agreement means the *content*
|
||||
// overlaps, not just that they all produced an Answer-kind output.
|
||||
// Two workers saying "Paris" and "Berlin" must not be treated as
|
||||
// consensus. Find the largest cluster of content-similar answers
|
||||
// and only early-exit if it's ≥2 workers AND a majority of answers.
|
||||
let agreeing_cluster = if answers.len() >= 2 && tool_proposals.is_empty() {
|
||||
let agreeing_cluster = if answers.len() >= 2 {
|
||||
largest_agreeing_cluster(&answers)
|
||||
} else {
|
||||
None
|
||||
|
|
@ -293,48 +222,6 @@ pub fn try_early_decision(
|
|||
}
|
||||
}
|
||||
|
||||
// All agree on the same tool call
|
||||
if has_tools && tool_proposals.len() >= 2 && answers.is_empty() {
|
||||
let tool_names: Vec<&str> = tool_proposals
|
||||
.iter()
|
||||
.filter_map(|o| o.tool_name.as_deref())
|
||||
.collect();
|
||||
if !tool_names.is_empty() {
|
||||
let first = tool_names[0];
|
||||
let unanimous = tool_names.iter().all(|n| *n == first);
|
||||
if unanimous {
|
||||
let best = best_tool_proposal(&tool_proposals);
|
||||
tracing::info!(
|
||||
"moa: early exit — {} workers agree on tool '{}', {} still pending",
|
||||
tool_proposals.len(),
|
||||
first,
|
||||
remaining,
|
||||
);
|
||||
return Some(Decision::ToolCall {
|
||||
name: first.to_string(),
|
||||
arguments: best
|
||||
.tool_arguments
|
||||
.clone()
|
||||
.unwrap_or(serde_json::Value::Object(Default::default())),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Conflict detected early — some say tool, some say answer.
|
||||
// Escalate to reducer now, don't wait for more conflicting opinions.
|
||||
if !tool_proposals.is_empty() && !answers.is_empty() {
|
||||
tracing::info!(
|
||||
"moa: early escalation — {} tool proposals vs {} answers, {} still pending",
|
||||
tool_proposals.len(),
|
||||
answers.len(),
|
||||
remaining,
|
||||
);
|
||||
return Some(Decision::NeedsReducer {
|
||||
reason: "some workers propose tools, others answer directly".into(),
|
||||
});
|
||||
}
|
||||
|
||||
// Not enough signal yet — keep waiting
|
||||
None
|
||||
}
|
||||
|
|
@ -552,27 +439,23 @@ fn largest_agreeing_cluster<'a>(answers: &[&'a WorkerOutput]) -> Option<(usize,
|
|||
best
|
||||
}
|
||||
|
||||
fn single_output_decision(output: &WorkerOutput, has_tools: bool) -> Decision {
|
||||
fn single_output_decision(output: &WorkerOutput) -> Decision {
|
||||
if output.kind == OutputKind::Answer && !is_usable_answer(output) {
|
||||
// Two distinct unusable shapes reach here, and the reason string is
|
||||
// surfaced to the reducer as context, so keep them apart.
|
||||
return Decision::NeedsReducer {
|
||||
reason: "single worker returned silent reply sentinel".into(),
|
||||
reason: if output.truncated {
|
||||
"single worker answer truncated at token limit".into()
|
||||
} else {
|
||||
"single worker returned silent reply sentinel".to_string()
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Tool turns take the asymmetric actor path, so this text-path arbiter
|
||||
// never sees an executable ToolProposal (tool-shaped text is demoted to
|
||||
// Uncertainty by `enforce_tool_call_contract` when tools are disabled).
|
||||
match output.kind {
|
||||
OutputKind::ToolProposal if has_tools => {
|
||||
if let Some(ref name) = output.tool_name {
|
||||
Decision::ToolCall {
|
||||
name: name.clone(),
|
||||
arguments: output
|
||||
.tool_arguments
|
||||
.clone()
|
||||
.unwrap_or(Value::Object(Default::default())),
|
||||
}
|
||||
} else {
|
||||
Decision::Answer(output.payload.clone())
|
||||
}
|
||||
}
|
||||
OutputKind::Uncertainty => Decision::NeedsReducer {
|
||||
reason: "single worker uncertain".into(),
|
||||
},
|
||||
|
|
@ -580,8 +463,19 @@ fn single_output_decision(output: &WorkerOutput, has_tools: bool) -> Decision {
|
|||
}
|
||||
}
|
||||
|
||||
/// Is this output an answer we can return to the caller verbatim?
|
||||
///
|
||||
/// Excludes truncated answers. A response the backend cut off at the token
|
||||
/// limit is a half-finished sentence: it must not win the confidence pick,
|
||||
/// must not anchor consensus, and must not be shipped as-is.
|
||||
///
|
||||
/// Truncated answers are *not* dropped from the turn, though — they stay in
|
||||
/// `outputs` and so are still packed into the reducer's context by
|
||||
/// `pack_for_reducer_selected`, which labels them as incomplete. Partial text
|
||||
/// is usable material for synthesis; it just can't be the final answer.
|
||||
fn is_usable_answer(output: &WorkerOutput) -> bool {
|
||||
output.kind == OutputKind::Answer
|
||||
&& !output.truncated
|
||||
&& !crate::normalize::is_silent_reply_sentinel(&output.payload)
|
||||
}
|
||||
|
||||
|
|
@ -599,21 +493,13 @@ mod tests {
|
|||
model: "test".to_string(),
|
||||
role: WorkerRole::Generalist,
|
||||
elapsed_ms: 0,
|
||||
truncated: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_tool_output(confidence: f32, tool: &str, args: Value) -> WorkerOutput {
|
||||
WorkerOutput {
|
||||
kind: OutputKind::ToolProposal,
|
||||
confidence,
|
||||
tool_name: Some(tool.to_string()),
|
||||
tool_arguments: Some(args),
|
||||
payload: "propose tool".to_string(),
|
||||
model: "test".to_string(),
|
||||
role: WorkerRole::Generalist,
|
||||
elapsed_ms: 0,
|
||||
}
|
||||
}
|
||||
// Tool arbitration is intentionally absent here: tool turns take the
|
||||
// asymmetric actor path in `tool_turn`, so this arbiter only ever sees
|
||||
// answers / critiques / uncertainty. See the module docs.
|
||||
|
||||
#[test]
|
||||
fn unanimous_answer_picks_highest_confidence() {
|
||||
|
|
@ -621,55 +507,18 @@ mod tests {
|
|||
make_output(OutputKind::Answer, 0.7, "Paris"),
|
||||
make_output(OutputKind::Answer, 0.9, "Paris is the capital"),
|
||||
];
|
||||
match arbitrate(&outputs, false) {
|
||||
match arbitrate(&outputs) {
|
||||
Decision::Answer(text) => assert!(text.contains("Paris")),
|
||||
other => panic!("expected Answer, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unanimous_tool_proposal() {
|
||||
let outputs = vec![
|
||||
make_tool_output(0.8, "read_file", serde_json::json!({"path": "a.rs"})),
|
||||
make_tool_output(0.7, "read_file", serde_json::json!({"path": "a.rs"})),
|
||||
];
|
||||
match arbitrate(&outputs, true) {
|
||||
Decision::ToolCall { name, .. } => assert_eq!(name, "read_file"),
|
||||
other => panic!("expected ToolCall, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflicting_tools_needs_reducer() {
|
||||
let outputs = vec![
|
||||
make_tool_output(0.6, "read_file", serde_json::json!({})),
|
||||
make_tool_output(0.6, "web_search", serde_json::json!({})),
|
||||
];
|
||||
match arbitrate(&outputs, true) {
|
||||
Decision::NeedsReducer { reason } => assert!(reason.contains("conflicting")),
|
||||
other => panic!("expected NeedsReducer, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_vs_answer_needs_reducer() {
|
||||
let outputs = vec![
|
||||
make_tool_output(0.7, "read_file", serde_json::json!({})),
|
||||
make_output(OutputKind::Answer, 0.8, "I can answer that directly"),
|
||||
];
|
||||
match arbitrate(&outputs, true) {
|
||||
Decision::NeedsReducer { reason } => assert!(reason.contains("some workers")),
|
||||
other => panic!("expected NeedsReducer, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Early decision tests ────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn early_decision_none_with_one_of_three() {
|
||||
let outputs = vec![make_output(OutputKind::Answer, 0.9, "Paris")];
|
||||
// 1 of 3 — too early to decide
|
||||
assert!(try_early_decision(&outputs, 3, outputs.len(), false, StrongGate::Off).is_none());
|
||||
assert!(try_early_decision(&outputs, 3, outputs.len(), StrongGate::Off).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -678,48 +527,19 @@ mod tests {
|
|||
make_output(OutputKind::Answer, 0.8, "Paris"),
|
||||
make_output(OutputKind::Answer, 0.9, "Paris is the capital"),
|
||||
];
|
||||
// 2 of 3 agree — early exit
|
||||
match try_early_decision(&outputs, 3, outputs.len(), false, StrongGate::Off) {
|
||||
match try_early_decision(&outputs, 3, outputs.len(), StrongGate::Off) {
|
||||
Some(Decision::Answer(text)) => assert!(text.contains("Paris")),
|
||||
other => panic!("expected early Answer, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn early_decision_tool_consensus() {
|
||||
let outputs = vec![
|
||||
make_tool_output(0.8, "read_file", serde_json::json!({"path": "a.rs"})),
|
||||
make_tool_output(0.7, "read_file", serde_json::json!({"path": "a.rs"})),
|
||||
];
|
||||
match try_early_decision(&outputs, 3, outputs.len(), true, StrongGate::Off) {
|
||||
Some(Decision::ToolCall { name, .. }) => assert_eq!(name, "read_file"),
|
||||
other => panic!("expected early ToolCall, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn early_decision_conflict_escalates() {
|
||||
let outputs = vec![
|
||||
make_tool_output(0.7, "read_file", serde_json::json!({})),
|
||||
make_output(OutputKind::Answer, 0.8, "I know the answer"),
|
||||
];
|
||||
match try_early_decision(&outputs, 3, outputs.len(), true, StrongGate::Off) {
|
||||
Some(Decision::NeedsReducer { .. }) => {}
|
||||
other => panic!("expected early NeedsReducer, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn early_decision_requires_content_agreement() {
|
||||
// Two high-confidence answers that disagree on the answer noun.
|
||||
// The old code wrongly early-exited on the highest-confidence
|
||||
// one. The new subset rule sees `{paris}` and `{berlin}` as
|
||||
// distinct leftovers, so neither side is a subset of the other.
|
||||
let outputs = vec![
|
||||
make_output(OutputKind::Answer, 0.9, "The capital of France is Paris"),
|
||||
make_output(OutputKind::Answer, 0.8, "The capital of France is Berlin"),
|
||||
];
|
||||
let res = try_early_decision(&outputs, 3, outputs.len(), false, StrongGate::Off);
|
||||
let res = try_early_decision(&outputs, 3, outputs.len(), StrongGate::Off);
|
||||
assert!(
|
||||
res.is_none(),
|
||||
"disagreeing answers should not trigger early-exit, got {res:?}"
|
||||
|
|
@ -728,15 +548,11 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn early_decision_agrees_on_terse_vs_verbose() {
|
||||
// The most common real-world agreement pattern: one worker is
|
||||
// terse, another is verbose, both correct. Terse content tokens
|
||||
// ⊆ verbose content tokens → cluster.
|
||||
let outputs = vec![
|
||||
make_output(OutputKind::Answer, 0.7, "Paris"),
|
||||
make_output(OutputKind::Answer, 0.9, "Paris is the capital of France"),
|
||||
];
|
||||
match try_early_decision(&outputs, 3, outputs.len(), false, StrongGate::Off) {
|
||||
// Representative picks the most complete (most tokens) member.
|
||||
match try_early_decision(&outputs, 3, outputs.len(), StrongGate::Off) {
|
||||
Some(Decision::Answer(text)) => {
|
||||
let lower = text.to_lowercase();
|
||||
assert!(lower.contains("paris"), "expected Paris, got {text:?}");
|
||||
|
|
@ -751,14 +567,12 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn early_decision_majority_cluster_wins() {
|
||||
// Two agreeing answers + one outlier. Cluster of 2 is a majority
|
||||
// of 3 finished answers → early-exit fires.
|
||||
let outputs = vec![
|
||||
make_output(OutputKind::Answer, 0.9, "Paris"),
|
||||
make_output(OutputKind::Answer, 0.8, "Paris is the capital"),
|
||||
make_output(OutputKind::Answer, 0.6, "I think it's Lyon"),
|
||||
];
|
||||
match try_early_decision(&outputs, 4, outputs.len(), false, StrongGate::Off) {
|
||||
match try_early_decision(&outputs, 4, outputs.len(), StrongGate::Off) {
|
||||
Some(Decision::Answer(text)) => assert!(
|
||||
text.to_lowercase().contains("paris"),
|
||||
"should pick from the agreeing cluster, got {text:?}"
|
||||
|
|
@ -769,16 +583,12 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn early_decision_disagreement_with_shared_scaffolding_still_blocks() {
|
||||
// High token overlap from shared scaffolding ("the capital of
|
||||
// France is X") used to false-positive a similarity check.
|
||||
// With subset containment, each answer has a distinct leftover
|
||||
// (paris, berlin, madrid) so no cluster forms.
|
||||
let outputs = vec![
|
||||
make_output(OutputKind::Answer, 0.9, "The capital of France is Paris"),
|
||||
make_output(OutputKind::Answer, 0.9, "The capital of France is Berlin"),
|
||||
make_output(OutputKind::Answer, 0.5, "The capital of France is Madrid"),
|
||||
];
|
||||
let res = try_early_decision(&outputs, 4, outputs.len(), false, StrongGate::Off);
|
||||
let res = try_early_decision(&outputs, 4, outputs.len(), StrongGate::Off);
|
||||
assert!(
|
||||
res.is_none(),
|
||||
"three disagreeing answers should not early-exit, got {res:?}"
|
||||
|
|
@ -787,14 +597,11 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn early_decision_negation_blocks_agreement() {
|
||||
// The negation guard keeps "not" / "dont" etc. as content tokens,
|
||||
// so an answer with negation is not a subset of the affirmative
|
||||
// version even when all other tokens match.
|
||||
let outputs = vec![
|
||||
make_output(OutputKind::Answer, 0.9, "You should use grep"),
|
||||
make_output(OutputKind::Answer, 0.8, "You should not use grep"),
|
||||
];
|
||||
let res = try_early_decision(&outputs, 3, outputs.len(), false, StrongGate::Off);
|
||||
let res = try_early_decision(&outputs, 3, outputs.len(), StrongGate::Off);
|
||||
assert!(
|
||||
res.is_none(),
|
||||
"affirmative vs negated answer should not cluster, got {res:?}"
|
||||
|
|
@ -803,13 +610,11 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn early_decision_dont_blocks_agreement() {
|
||||
// Same idea with a contraction. After punctuation stripping
|
||||
// "don't" → "dont", which is in NEGATION_TOKENS.
|
||||
let outputs = vec![
|
||||
make_output(OutputKind::Answer, 0.9, "Do that"),
|
||||
make_output(OutputKind::Answer, 0.8, "Don't do that"),
|
||||
];
|
||||
let res = try_early_decision(&outputs, 3, outputs.len(), false, StrongGate::Off);
|
||||
let res = try_early_decision(&outputs, 3, outputs.len(), StrongGate::Off);
|
||||
assert!(
|
||||
res.is_none(),
|
||||
"affirmative vs negated should not cluster, got {res:?}"
|
||||
|
|
@ -818,12 +623,11 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn early_decision_numeric_answers_cluster() {
|
||||
// Numeric answers survive the length filter and cluster cleanly.
|
||||
let outputs = vec![
|
||||
make_output(OutputKind::Answer, 0.9, "42"),
|
||||
make_output(OutputKind::Answer, 0.8, "The answer is 42"),
|
||||
];
|
||||
match try_early_decision(&outputs, 3, outputs.len(), false, StrongGate::Off) {
|
||||
match try_early_decision(&outputs, 3, outputs.len(), StrongGate::Off) {
|
||||
Some(Decision::Answer(text)) => assert!(text.contains("42")),
|
||||
other => panic!("expected numeric agreement, got {other:?}"),
|
||||
}
|
||||
|
|
@ -831,10 +635,8 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn early_decision_single_survivor() {
|
||||
// 1 success out of 3, other 2 failed — should return the single answer
|
||||
let outputs = vec![make_output(OutputKind::Answer, 0.8, "Paris")];
|
||||
// total_workers=3, total_finished=3 (1 success + 2 failures), remaining=0
|
||||
match try_early_decision(&outputs, 3, 3, false, StrongGate::Off) {
|
||||
match try_early_decision(&outputs, 3, 3, StrongGate::Off) {
|
||||
Some(Decision::Answer(text)) => assert!(text.contains("Paris")),
|
||||
other => panic!("expected early Answer for sole survivor, got {other:?}"),
|
||||
}
|
||||
|
|
@ -846,8 +648,7 @@ mod tests {
|
|||
make_output(OutputKind::Answer, 0.3, "maybe Paris"),
|
||||
make_output(OutputKind::Answer, 0.4, "could be Paris"),
|
||||
];
|
||||
// Both answers but low confidence — should wait for more
|
||||
assert!(try_early_decision(&outputs, 3, outputs.len(), false, StrongGate::Off).is_none());
|
||||
assert!(try_early_decision(&outputs, 3, outputs.len(), StrongGate::Off).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -856,7 +657,7 @@ mod tests {
|
|||
make_output(OutputKind::Answer, 0.99, "NO_REPLY"),
|
||||
make_output(OutputKind::Answer, 0.6, "I can help with that."),
|
||||
];
|
||||
match arbitrate(&outputs, false) {
|
||||
match arbitrate(&outputs) {
|
||||
Decision::Answer(text) => assert_eq!(text, "I can help with that."),
|
||||
other => panic!("expected usable answer, got {other:?}"),
|
||||
}
|
||||
|
|
@ -865,7 +666,7 @@ mod tests {
|
|||
#[test]
|
||||
fn no_reply_sentinel_for_single_output_needs_reducer() {
|
||||
let outputs = vec![make_output(OutputKind::Answer, 0.99, "NO_REPLY")];
|
||||
match arbitrate(&outputs, false) {
|
||||
match arbitrate(&outputs) {
|
||||
Decision::NeedsReducer { reason } => {
|
||||
assert!(reason.contains("silent reply sentinel"));
|
||||
}
|
||||
|
|
@ -879,7 +680,7 @@ mod tests {
|
|||
make_output(OutputKind::Uncertainty, 0.2, "not sure"),
|
||||
make_output(OutputKind::Uncertainty, 0.3, "hard to say"),
|
||||
];
|
||||
match arbitrate(&outputs, false) {
|
||||
match arbitrate(&outputs) {
|
||||
Decision::NeedsReducer { reason } => assert!(reason.contains("uncertain")),
|
||||
other => panic!("expected NeedsReducer, got {other:?}"),
|
||||
}
|
||||
|
|
@ -887,10 +688,8 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn early_decision_sole_survivor_majority_failed() {
|
||||
// 1 success, 3 failures, 1 still pending — majority failed, return sole survivor
|
||||
let outputs = vec![make_output(OutputKind::Answer, 0.8, "Paris")];
|
||||
// total_workers=5, total_finished=4 (1 success + 3 failures), remaining=1
|
||||
match try_early_decision(&outputs, 5, 4, false, StrongGate::Off) {
|
||||
match try_early_decision(&outputs, 5, 4, StrongGate::Off) {
|
||||
Some(Decision::Answer(text)) => assert!(text.contains("Paris")),
|
||||
other => {
|
||||
panic!("expected early Answer for sole survivor (majority failed), got {other:?}")
|
||||
|
|
@ -900,66 +699,96 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn early_decision_sole_survivor_minority_failed_waits() {
|
||||
// 1 success, 1 failure, 3 still pending — minority failed, wait for more
|
||||
let outputs = vec![make_output(OutputKind::Answer, 0.8, "Paris")];
|
||||
// total_workers=5, total_finished=2 (1 success + 1 failure), remaining=3
|
||||
assert!(try_early_decision(&outputs, 5, 2, false, StrongGate::Off).is_none());
|
||||
assert!(try_early_decision(&outputs, 5, 2, StrongGate::Off).is_none());
|
||||
}
|
||||
|
||||
// ── Truncation ───────────────────────────────────────────────────
|
||||
//
|
||||
// 39/140 recorded responses came back `finish_reason == "length"`, and 24
|
||||
// of those carry partial text. Such an answer parses as normal prose at
|
||||
// the default 0.5 confidence, so before truncation was tracked it could
|
||||
// win the pick and be returned verbatim as a half-finished sentence.
|
||||
|
||||
#[test]
|
||||
fn truncated_answer_is_not_returned_verbatim() {
|
||||
let mut cut_off = make_output(
|
||||
OutputKind::Answer,
|
||||
0.9,
|
||||
"**Island Magic: My Unforgettable Journey Through the Heart of",
|
||||
);
|
||||
cut_off.truncated = true;
|
||||
let complete = make_output(OutputKind::Answer, 0.5, "Hawaii is worth visiting.");
|
||||
|
||||
match arbitrate(&[cut_off, complete]) {
|
||||
Decision::Answer(text) => assert_eq!(
|
||||
text, "Hawaii is worth visiting.",
|
||||
"a truncated answer must not win even with higher confidence"
|
||||
),
|
||||
other => panic!("expected the complete answer, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn best_tool_proposal_prefers_arguments() {
|
||||
let without_args = WorkerOutput {
|
||||
kind: OutputKind::ToolProposal,
|
||||
confidence: 0.9,
|
||||
tool_name: Some("read_file".into()),
|
||||
tool_arguments: None,
|
||||
payload: "calling read_file".into(),
|
||||
model: "fast-model".into(),
|
||||
role: crate::worker::WorkerRole::Fast,
|
||||
elapsed_ms: 100,
|
||||
};
|
||||
let with_args = WorkerOutput {
|
||||
kind: OutputKind::ToolProposal,
|
||||
confidence: 0.6,
|
||||
tool_name: Some("read_file".into()),
|
||||
tool_arguments: Some(serde_json::json!({"path": "/tmp/test.txt"})),
|
||||
payload: "calling read_file".into(),
|
||||
model: "strong-model".into(),
|
||||
role: crate::worker::WorkerRole::Strong,
|
||||
elapsed_ms: 3000,
|
||||
};
|
||||
let proposals = vec![&without_args, &with_args];
|
||||
let best = best_tool_proposal(&proposals);
|
||||
assert_eq!(best.model, "strong-model");
|
||||
assert!(best.tool_arguments.is_some());
|
||||
fn sole_truncated_answer_goes_to_synthesis() {
|
||||
let mut cut_off = make_output(OutputKind::Answer, 0.9, "The first step is to");
|
||||
cut_off.truncated = true;
|
||||
|
||||
match arbitrate(&[cut_off]) {
|
||||
Decision::NeedsReducer { reason } => assert!(
|
||||
reason.contains("truncated"),
|
||||
"reason should name truncation so the reducer gets useful context; got {reason:?}"
|
||||
),
|
||||
other => panic!("expected NeedsReducer, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn best_tool_proposal_falls_back_to_confidence() {
|
||||
let a = WorkerOutput {
|
||||
kind: OutputKind::ToolProposal,
|
||||
confidence: 0.6,
|
||||
tool_name: Some("read_file".into()),
|
||||
tool_arguments: Some(serde_json::json!({"path": "/a.txt"})),
|
||||
payload: "calling read_file".into(),
|
||||
model: "model-a".into(),
|
||||
role: crate::worker::WorkerRole::Specialist,
|
||||
elapsed_ms: 2000,
|
||||
};
|
||||
let b = WorkerOutput {
|
||||
kind: OutputKind::ToolProposal,
|
||||
confidence: 0.9,
|
||||
tool_name: Some("read_file".into()),
|
||||
tool_arguments: Some(serde_json::json!({"path": "/b.txt"})),
|
||||
payload: "calling read_file".into(),
|
||||
model: "model-b".into(),
|
||||
role: crate::worker::WorkerRole::Strong,
|
||||
elapsed_ms: 3000,
|
||||
};
|
||||
let proposals = vec![&a, &b];
|
||||
let best = best_tool_proposal(&proposals);
|
||||
// Both have args, so confidence wins
|
||||
assert_eq!(best.model, "model-b");
|
||||
fn truncated_answers_do_not_anchor_consensus() {
|
||||
let mut a = make_output(OutputKind::Answer, 0.9, "The capital of Japan is");
|
||||
a.truncated = true;
|
||||
let mut b = make_output(OutputKind::Answer, 0.9, "The capital of Japan is");
|
||||
b.truncated = true;
|
||||
|
||||
assert!(
|
||||
matches!(
|
||||
try_early_decision(&[a, b], 4, 2, StrongGate::Off),
|
||||
None | Some(Decision::NeedsReducer { .. })
|
||||
),
|
||||
"truncated agreement must not short-circuit the turn"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Synthesis on disagreement ────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn diverging_answers_go_to_synthesis_rather_than_an_arbitrary_pick() {
|
||||
let a = make_output(OutputKind::Answer, 0.5, "Use ripgrep for this search task");
|
||||
let b = make_output(OutputKind::Answer, 0.5, "Postgres indexes are B-trees");
|
||||
let c = make_output(
|
||||
OutputKind::Answer,
|
||||
0.5,
|
||||
"Kubernetes schedules pods on nodes",
|
||||
);
|
||||
|
||||
match arbitrate(&[a, b, c]) {
|
||||
Decision::NeedsReducer { reason } => assert!(
|
||||
reason.contains("no agreement"),
|
||||
"reason should explain the escalation; got {reason:?}"
|
||||
),
|
||||
other => panic!("three unrelated answers should be synthesized, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agreeing_answers_still_short_circuit_without_the_reducer() {
|
||||
let a = make_output(OutputKind::Answer, 0.5, "The capital of Japan is Tokyo");
|
||||
let b = make_output(OutputKind::Answer, 0.5, "The capital of Japan is Tokyo");
|
||||
|
||||
assert!(
|
||||
matches!(arbitrate(&[a, b]), Decision::Answer(_)),
|
||||
"agreeing answers must not be sent to the reducer"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tier gate (StrongGate) ───────────────────────────────────────
|
||||
|
|
@ -982,8 +811,6 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn gate_holds_small_tier_consensus_while_strong_pending() {
|
||||
// Two small-tier workers agree — without the gate this would
|
||||
// early-exit. With the strong worker still running, hold.
|
||||
let outputs = vec![
|
||||
make_role_output(OutputKind::Answer, 0.8, "Paris", WorkerRole::Fast),
|
||||
make_role_output(
|
||||
|
|
@ -994,20 +821,17 @@ mod tests {
|
|||
),
|
||||
];
|
||||
assert!(
|
||||
try_early_decision(&outputs, 3, outputs.len(), false, GATE_PENDING).is_none(),
|
||||
try_early_decision(&outputs, 3, outputs.len(), GATE_PENDING).is_none(),
|
||||
"small-tier consensus must be held while the strong worker is pending"
|
||||
);
|
||||
// Same outputs, gate off → previous behavior (early exit).
|
||||
assert!(
|
||||
try_early_decision(&outputs, 3, outputs.len(), false, StrongGate::Off).is_some(),
|
||||
try_early_decision(&outputs, 3, outputs.len(), StrongGate::Off).is_some(),
|
||||
"gate off must preserve pre-gate early-exit behavior"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gate_passes_consensus_that_includes_strong_worker() {
|
||||
// Strong worker has answered and agrees — that's agreement WITH
|
||||
// the strong model. Ship it even though another worker is pending.
|
||||
let outputs = vec![
|
||||
make_role_output(OutputKind::Answer, 0.8, "Paris", WorkerRole::Fast),
|
||||
make_role_output(
|
||||
|
|
@ -1020,7 +844,7 @@ mod tests {
|
|||
let gate = StrongGate::Active {
|
||||
strong_pending: false,
|
||||
};
|
||||
match try_early_decision(&outputs, 3, outputs.len(), false, gate) {
|
||||
match try_early_decision(&outputs, 3, outputs.len(), gate) {
|
||||
Some(Decision::Answer(text)) => assert!(text.contains("Paris")),
|
||||
other => panic!("expected early Answer with strong agreement, got {other:?}"),
|
||||
}
|
||||
|
|
@ -1028,43 +852,21 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn gate_holds_small_tier_sole_survivor_answer() {
|
||||
// Majority failed, sole survivor is a small-tier Answer, strong
|
||||
// still pending → hold (the fan-out patience timer bounds this).
|
||||
let outputs = vec![make_role_output(
|
||||
OutputKind::Answer,
|
||||
0.9,
|
||||
"Tokyo",
|
||||
WorkerRole::Fast,
|
||||
)];
|
||||
// 3 dispatched, 2 finished (1 ok + 1 failed) → majority_failed
|
||||
assert!(
|
||||
try_early_decision(&outputs, 3, 2, false, GATE_PENDING).is_none(),
|
||||
try_early_decision(&outputs, 3, 2, GATE_PENDING).is_none(),
|
||||
"small-tier sole-survivor answer must be held while strong is pending"
|
||||
);
|
||||
// Gate off → pre-gate behavior: sole survivor ships.
|
||||
assert!(try_early_decision(&outputs, 3, 2, false, StrongGate::Off).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gate_does_not_hold_tool_proposals() {
|
||||
// Tool proposals are schema-verified and exempt from the gate —
|
||||
// agent loops must stay snappy.
|
||||
let outputs = vec![WorkerOutput {
|
||||
role: WorkerRole::Fast,
|
||||
..make_tool_output(0.9, "read_file", serde_json::json!({"path": "x"}))
|
||||
}];
|
||||
// Majority failed → sole-survivor path, but it's a ToolProposal.
|
||||
match try_early_decision(&outputs, 3, 2, true, GATE_PENDING) {
|
||||
Some(Decision::ToolCall { name, .. }) => assert_eq!(name, "read_file"),
|
||||
other => panic!("tool proposals must not be gated, got {other:?}"),
|
||||
}
|
||||
assert!(try_early_decision(&outputs, 3, 2, StrongGate::Off).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strong_dissent_wins_over_small_consensus() {
|
||||
// Two small workers agree on "Sydney"; the strong worker landed
|
||||
// with a different answer ("Canberra"). Gate no longer pending.
|
||||
// The strong worker's answer must win, not the small consensus.
|
||||
let outputs = vec![
|
||||
make_role_output(OutputKind::Answer, 0.9, "Sydney", WorkerRole::Fast),
|
||||
make_role_output(OutputKind::Answer, 0.9, "Sydney", WorkerRole::Specialist),
|
||||
|
|
@ -1073,7 +875,7 @@ mod tests {
|
|||
let gate = StrongGate::Active {
|
||||
strong_pending: false,
|
||||
};
|
||||
match try_early_decision(&outputs, 3, outputs.len(), false, gate) {
|
||||
match try_early_decision(&outputs, 3, outputs.len(), gate) {
|
||||
Some(Decision::Answer(text)) => assert!(
|
||||
text.contains("Canberra"),
|
||||
"strong dissent must win over small consensus, got {text:?}"
|
||||
|
|
@ -1084,8 +886,6 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn gate_releases_when_strong_finished() {
|
||||
// Strong worker finished (failed or succeeded without usable
|
||||
// answer) — gate no longer pending, consensus ships.
|
||||
let outputs = vec![
|
||||
make_role_output(OutputKind::Answer, 0.8, "Paris", WorkerRole::Fast),
|
||||
make_role_output(
|
||||
|
|
@ -1099,7 +899,7 @@ mod tests {
|
|||
strong_pending: false,
|
||||
};
|
||||
assert!(
|
||||
try_early_decision(&outputs, 4, outputs.len(), false, gate).is_some(),
|
||||
try_early_decision(&outputs, 4, outputs.len(), gate).is_some(),
|
||||
"consensus must ship once the strong worker has finished"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -138,6 +138,47 @@ impl ModelBackend for HttpBackend {
|
|||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
|
||||
// Some OpenAI-compatible endpoints *require* reasoning and reject
|
||||
// our thinking-disable flags outright. Observed against
|
||||
// minimax-m2.5: `HTTP 400: Reasoning is mandatory for this
|
||||
// endpoint`, which killed that worker on 12/12 recorded requests
|
||||
// until the flags were dropped. A strict endpoint must cost us a
|
||||
// slightly slower worker, not the whole worker.
|
||||
if status.as_u16() == 400
|
||||
&& text.to_ascii_lowercase().contains("reasoning")
|
||||
&& sampling.enable_thinking == Some(false)
|
||||
{
|
||||
tracing::info!(
|
||||
"moa: {model} rejected thinking-disable flags, retrying without them"
|
||||
);
|
||||
let mut retry_body = body.clone();
|
||||
if let Some(obj) = retry_body.as_object_mut() {
|
||||
obj.remove("reasoning_effort");
|
||||
obj.remove("chat_template_kwargs");
|
||||
}
|
||||
let retry = self
|
||||
.http
|
||||
.post(&url)
|
||||
.json(&retry_body)
|
||||
.timeout(timeout)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("request failed: {e}"))?;
|
||||
let retry_status = retry.status();
|
||||
if !retry_status.is_success() {
|
||||
let retry_text = retry.text().await.unwrap_or_default();
|
||||
return Err(format!(
|
||||
"HTTP {retry_status}: {}",
|
||||
crate::worker::truncate_chars(&retry_text, 200)
|
||||
));
|
||||
}
|
||||
return retry
|
||||
.json::<Value>()
|
||||
.await
|
||||
.map_err(|e| format!("response parse: {e}"));
|
||||
}
|
||||
|
||||
return Err(format!(
|
||||
"HTTP {status}: {}",
|
||||
crate::worker::truncate_chars(&text, 200)
|
||||
|
|
@ -164,6 +205,27 @@ pub struct ModelEntry {
|
|||
|
||||
// ─── Backend call + text extraction ──────────────────────────────────
|
||||
|
||||
/// A successful backend call: the extracted assistant text plus the
|
||||
/// transport-level facts the arbiter needs that the text alone can't carry.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BackendReply {
|
||||
pub text: String,
|
||||
/// `choices[0].finish_reason == "length"` — the backend cut this
|
||||
/// response off at the token limit. See [`crate::normalize::WorkerOutput::truncated`].
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
impl BackendReply {
|
||||
/// A complete (non-truncated) reply. Convenience for tests and for
|
||||
/// call sites that synthesize replies rather than receiving them.
|
||||
pub fn complete(text: impl Into<String>) -> Self {
|
||||
Self {
|
||||
text: text.into(),
|
||||
truncated: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Call a backend and extract the assistant text from the response.
|
||||
/// Retries once on HTTP 429 (rate limit) after the server's `retry-after`
|
||||
/// delay (default 1s).
|
||||
|
|
@ -175,7 +237,7 @@ pub(crate) async fn call_backend(
|
|||
max_tokens: u32,
|
||||
timeout: Duration,
|
||||
sampling: SamplingParams,
|
||||
) -> Result<String, String> {
|
||||
) -> Result<BackendReply, String> {
|
||||
match backend
|
||||
.chat_completion(model, messages, tools, max_tokens, timeout, sampling)
|
||||
.await
|
||||
|
|
@ -262,11 +324,21 @@ fn parse_retry_after(err: &str) -> Option<u64> {
|
|||
/// an empty string — or worse, panics in `.unwrap()` chains. Use
|
||||
/// `.pointer()` so a malformed response surfaces as a structured `Err`
|
||||
/// rather than a hidden empty answer.
|
||||
fn extract_text_from_response(resp: &Value) -> Result<String, String> {
|
||||
fn extract_text_from_response(resp: &Value) -> Result<BackendReply, String> {
|
||||
let message = resp
|
||||
.pointer("/choices/0/message")
|
||||
.ok_or_else(|| "malformed response: missing choices[0].message".to_string())?;
|
||||
|
||||
// The backend stopped at the token limit. Recorded open-model traces
|
||||
// show this on 15/140 responses, so it is a normal operating condition
|
||||
// rather than an edge case. Carry it out so the arbiter can refuse to
|
||||
// return a half-finished sentence verbatim.
|
||||
let truncated = resp
|
||||
.pointer("/choices/0/finish_reason")
|
||||
.and_then(Value::as_str)
|
||||
== Some("length");
|
||||
let reply = |text: String| BackendReply { text, truncated };
|
||||
|
||||
// Native tool_calls → KV format for normalizer
|
||||
let first_tool_call = message
|
||||
.get("tool_calls")
|
||||
|
|
@ -281,9 +353,12 @@ fn extract_text_from_response(resp: &Value) -> Result<String, String> {
|
|||
.pointer("/function/arguments")
|
||||
.and_then(|a| a.as_str())
|
||||
.unwrap_or("{}");
|
||||
return Ok(format!(
|
||||
// A tool call that parsed into name + arguments is structurally
|
||||
// complete even if the backend hit the token limit emitting it, so
|
||||
// this path is never marked truncated.
|
||||
return Ok(BackendReply::complete(format!(
|
||||
"kind: tool_proposal\ntool: {name}\narguments: {args}\nconfidence: 0.9\npayload: calling {name}",
|
||||
));
|
||||
)));
|
||||
}
|
||||
|
||||
let content = message
|
||||
|
|
@ -294,12 +369,12 @@ fn extract_text_from_response(resp: &Value) -> Result<String, String> {
|
|||
|
||||
let stripped = worker::strip_thinking(&content);
|
||||
if !stripped.is_empty() {
|
||||
return Ok(stripped);
|
||||
return Ok(reply(stripped));
|
||||
}
|
||||
|
||||
let thinking = worker::extract_thinking(&content);
|
||||
if !thinking.is_empty() {
|
||||
return Ok(thinking);
|
||||
return Ok(reply(thinking));
|
||||
}
|
||||
|
||||
let reasoning = message
|
||||
|
|
@ -307,7 +382,7 @@ fn extract_text_from_response(resp: &Value) -> Result<String, String> {
|
|||
.and_then(|r| r.as_str())
|
||||
.unwrap_or("");
|
||||
if !reasoning.is_empty() {
|
||||
return Ok(reasoning.to_string());
|
||||
return Ok(reply(reasoning.to_string()));
|
||||
}
|
||||
|
||||
Err("empty response".into())
|
||||
|
|
|
|||
|
|
@ -67,7 +67,24 @@ const MOA_PREAMBLE: &str = "\
|
|||
[Multiple models are analyzing this request in parallel. \
|
||||
Respond with your best answer or tool call. Be direct.]";
|
||||
|
||||
/// Text-turn preamble.
|
||||
///
|
||||
/// The tool-turn wording ("your best answer **or tool call**. Be direct.") is
|
||||
/// wrong on a text turn twice over: there is no tool to call, and "be direct"
|
||||
/// pushes workers toward stubs. Since these drafts are the *input* to the
|
||||
/// refinement round, brevity here compounds — measured end-to-end, MoA answers
|
||||
/// ran ~3.3k chars against a ~4.1k-char solo baseline and lost on judged
|
||||
/// quality. The study that showed the gain gave workers no such instruction.
|
||||
const MOA_PREAMBLE_TEXT: &str = "\
|
||||
[Multiple models are answering this request in parallel; the best parts of each \
|
||||
will be combined. Give your most accurate and complete answer.]";
|
||||
|
||||
fn augmented_system_prompt_for_mode(session: &Session, include_tool_guidance: bool) -> String {
|
||||
let preamble = if include_tool_guidance {
|
||||
MOA_PREAMBLE
|
||||
} else {
|
||||
MOA_PREAMBLE_TEXT
|
||||
};
|
||||
match session.system_prompt() {
|
||||
Some(sp) => {
|
||||
let prompt = if include_tool_guidance {
|
||||
|
|
@ -75,9 +92,9 @@ fn augmented_system_prompt_for_mode(session: &Session, include_tool_guidance: bo
|
|||
} else {
|
||||
strip_tool_guidance_sections(&sp)
|
||||
};
|
||||
format!("{MOA_PREAMBLE}\n\n{prompt}")
|
||||
format!("{preamble}\n\n{prompt}")
|
||||
}
|
||||
None => MOA_PREAMBLE.to_string(),
|
||||
None => preamble.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -263,26 +280,64 @@ pub fn pack_for_reducer_selected(
|
|||
) -> (Vec<Value>, Option<Value>) {
|
||||
let user_text = session.last_user_text();
|
||||
|
||||
let mut system_parts = vec![
|
||||
augmented_system_prompt_for_mode(session, has_tools),
|
||||
String::new(),
|
||||
format!("Multiple models analyzed this request and disagreed. Reason: {reason}"),
|
||||
"Review their outputs below and produce ONE final response — either a direct answer \
|
||||
or a tool call. Be concise."
|
||||
.to_string(),
|
||||
];
|
||||
// Synthesis framing adapted from Together's MoA aggregator prompt: tell the
|
||||
// model to synthesize (not relay) and warn that inputs may be wrong — the
|
||||
// second clause stops it averaging in confidently-wrong inputs. We add
|
||||
// per-worker attribution and per-payload length bounds (below), which
|
||||
// Together omits.
|
||||
// The reducer is the synthesizer, not one of the parallel answerers. Giving
|
||||
// it the *worker* preamble ("multiple models are answering in parallel;
|
||||
// give your most complete answer") on top of the synthesis instruction is
|
||||
// contradictory framing: it tells the model to both draft and aggregate. A
|
||||
// 32B reconciles it; an 8B reducer does not. So the reducer gets the
|
||||
// agent's own system prompt (tool guidance only when this is a tool turn)
|
||||
// plus the synthesis instruction — matching the harness configuration that
|
||||
// measured 12W/2L on a 6x8B pool.
|
||||
let mut system_parts: Vec<String> = Vec::new();
|
||||
if let Some(sp) = session.system_prompt() {
|
||||
system_parts.push(sp);
|
||||
system_parts.push(String::new());
|
||||
}
|
||||
if has_tools {
|
||||
system_parts.push(format!(
|
||||
"Multiple models analyzed this request. Reason for synthesis: {reason}"
|
||||
));
|
||||
}
|
||||
system_parts.push(synthesis_instruction(has_tools));
|
||||
|
||||
// Worker outputs
|
||||
system_parts.push(String::new());
|
||||
system_parts.push("## Worker outputs".to_string());
|
||||
let payload_budget = reducer_payload_budget(has_tools);
|
||||
for (i, output) in outputs.iter().enumerate() {
|
||||
system_parts.push(format!("\n[Worker {} — {}]:", i + 1, output.model,));
|
||||
let payload = if output.payload.len() > 500 {
|
||||
format!("{}...", crate::worker::truncate_chars(&output.payload, 497))
|
||||
// Anonymous on text turns, matching the measured configuration and
|
||||
// Hermes, which anonymizes reference outputs "to prevent aggregator
|
||||
// bias" — a named model invites deference to the name rather than the
|
||||
// content. Tool turns keep attribution: the reducer is arbitrating
|
||||
// between proposals and provenance is genuinely useful there, and it
|
||||
// is what the tool-path tests pin.
|
||||
if has_tools {
|
||||
system_parts.push(format!("\n[Worker {} — {}]:", i + 1, output.model));
|
||||
} else {
|
||||
system_parts.push(format!("\n[Response {}]:", i + 1));
|
||||
}
|
||||
let payload = if output.payload.len() > payload_budget {
|
||||
format!(
|
||||
"{}...",
|
||||
crate::worker::truncate_chars(&output.payload, payload_budget - 3)
|
||||
)
|
||||
} else {
|
||||
output.payload.clone()
|
||||
};
|
||||
system_parts.push(payload);
|
||||
// Truncated inputs are labelled so the reducer treats them as partial
|
||||
// material rather than copying a dangling sentence as a finished
|
||||
// answer. `is_usable_answer` already bars them from winning verbatim;
|
||||
// this is what lets them still contribute here.
|
||||
if output.truncated {
|
||||
system_parts
|
||||
.push(" → NOTE: cut off at the token limit — incomplete, do not copy".to_string());
|
||||
}
|
||||
if let Some(ref tool) = output.tool_name {
|
||||
system_parts.push(format!(" → Proposed tool: {tool}"));
|
||||
if let Some(ref args) = output.tool_arguments {
|
||||
|
|
@ -302,6 +357,218 @@ pub fn pack_for_reducer_selected(
|
|||
)
|
||||
}
|
||||
|
||||
/// Advisor framing for [`pack_for_reference`]. Deliberately does NOT ask for a
|
||||
/// tool call: references hold no schemas, so requesting one yields tool-shaped
|
||||
/// prose that can pull the actor off its own (better) choice.
|
||||
const REFERENCE_PREAMBLE: &str = "\
|
||||
You are advising another model that will decide and act on this request. \
|
||||
You do not have tools and must not emit a tool call. Give a short, direct \
|
||||
analysis: what the request is really asking, and what you would do. Be concise.";
|
||||
|
||||
/// Pack context for a **reference** (advisor), Hermes-style: only the
|
||||
/// conversation's user/assistant text.
|
||||
///
|
||||
/// Three things are withheld on purpose:
|
||||
/// * the agent's system prompt — an advisor told "you are a coding agent, run
|
||||
/// the tests" role-plays the actor instead of advising it;
|
||||
/// * the tool-call transcript — it anchors every advisor on the trajectory
|
||||
/// already taken, collapsing the error-independence aggregation depends on;
|
||||
/// * any instruction to emit a tool call (see [`REFERENCE_PREAMBLE`]).
|
||||
///
|
||||
/// The view is uniform across advisors (no per-role trimming) and is a stable
|
||||
/// function of the history, so it caches across iterations.
|
||||
pub fn pack_for_reference(session: &Session, max_messages: usize) -> PackedContext {
|
||||
let mut messages = vec![json!({"role": "system", "content": REFERENCE_PREAMBLE})];
|
||||
|
||||
// User/assistant prose only: no system turn, no tool_calls, no tool results.
|
||||
let history: Vec<Value> = session
|
||||
.messages()
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
let role = m.get("role").and_then(Value::as_str).unwrap_or("");
|
||||
let is_prose = matches!(role, "user" | "assistant");
|
||||
let carries_tool_call = m.get("tool_calls").is_some();
|
||||
let has_text = m
|
||||
.get("content")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|s| !s.trim().is_empty());
|
||||
is_prose && !carries_tool_call && has_text
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
let start = history.len().saturating_sub(max_messages);
|
||||
messages.extend_from_slice(&history[start..]);
|
||||
|
||||
// Guarantee the current request is present even if it was filtered above.
|
||||
let user_text = session.last_user_text();
|
||||
let last_is_current = messages
|
||||
.last()
|
||||
.and_then(|m| m.get("content").and_then(Value::as_str))
|
||||
== Some(user_text.as_str());
|
||||
if !last_is_current && !user_text.is_empty() {
|
||||
messages.push(json!({"role": "user", "content": user_text}));
|
||||
}
|
||||
|
||||
PackedContext {
|
||||
messages,
|
||||
max_tokens: 600, // Hermes caps advisors; the slowest advisor sets turn latency.
|
||||
tools: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// How much of each peer draft a refiner may see.
|
||||
const REFINEMENT_DRAFT_BUDGET: usize = 4000;
|
||||
|
||||
/// What the reducer is asked to do with the worker outputs.
|
||||
///
|
||||
/// Text turns use the wording the committee study measured
|
||||
/// (`evals/moa-openrouter/RESULTS.md`), which asks for a *well-structured*
|
||||
/// synthesis. The previous text wording framed the turn as reconciling a
|
||||
/// disagreement and ended with "Be concise" — measured end-to-end through
|
||||
/// `handle_turn`, that produced ~2.0k-char answers against a ~4.1k-char solo
|
||||
/// baseline and lost to it on judged quality. Terseness is not the goal on a
|
||||
/// reasoning turn; accuracy and completeness are.
|
||||
///
|
||||
/// Tool turns keep the tight framing: the output there is an action, and the
|
||||
/// reducer must be free to emit a tool call rather than prose.
|
||||
fn synthesis_instruction(has_tools: bool) -> String {
|
||||
if has_tools {
|
||||
"You have been provided with their responses below. Synthesize them into ONE \
|
||||
final response — either a direct answer or a tool call. Critically evaluate \
|
||||
what they say: some of it may be biased or incorrect, and agreement between \
|
||||
workers is not proof of correctness. Do not simply copy the longest or most \
|
||||
confident response; produce the most accurate reply to the request. Be concise."
|
||||
.to_string()
|
||||
} else {
|
||||
"You have been given a user request and several candidate responses from other \
|
||||
models. Synthesize them into one high-quality response. Critically evaluate them — \
|
||||
some may be biased or incorrect, and agreement is not proof of correctness. Do not \
|
||||
merely copy the longest or most confident; produce the most accurate, \
|
||||
well-structured reply. Be direct."
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// How much of each worker payload the reducer may see.
|
||||
///
|
||||
/// Tool turns keep the tight bound: the reducer is choosing an action, the
|
||||
/// signal is the proposal itself, and long prose crowds out the tool schemas.
|
||||
///
|
||||
/// Text turns need far more. Measured refined answers average ~3.8k chars
|
||||
/// (`evals/moa-openrouter/RESULTS.md`), so a 500-char bound would hand the
|
||||
/// reducer ~13% of each answer and discard exactly the content refinement just
|
||||
/// produced — the measured gain could not survive it. Together's aggregator
|
||||
/// passes references unbounded; we keep a bound so a pathological worker can't
|
||||
/// blow the context, just a realistic one.
|
||||
fn reducer_payload_budget(has_tools: bool) -> usize {
|
||||
if has_tools { 500 } else { 4000 }
|
||||
}
|
||||
|
||||
/// Pack context for a worker in the cross-peer refinement round.
|
||||
///
|
||||
/// The worker sees every round-1 draft (its own included, anonymized) and
|
||||
/// rewrites its answer. Anonymizing keeps the worker from deferring to a name
|
||||
/// it recognizes, and the framing asks for an improved answer rather than a
|
||||
/// critique — the reducer still does the final synthesis.
|
||||
pub fn pack_for_refinement(session: &Session, drafts: &[String]) -> PackedContext {
|
||||
// Wording deliberately matches the eval that measured the +0.250 gain
|
||||
// (`evals/moa-openrouter/RESULTS.md`), which in turn matches Together's
|
||||
// `advanced-moa.py` — it reuses the aggregator prompt for refinement
|
||||
// layers. A refinement-specific wording may well read better, but this is
|
||||
// the configuration with evidence behind it; changing it should be a
|
||||
// measured change, not an assumed improvement.
|
||||
let mut system = String::from(
|
||||
"You have been given a user request and several candidate responses from \
|
||||
other models. Synthesize them into one high-quality response. Critically \
|
||||
evaluate them — some may be biased or incorrect, and agreement is not proof \
|
||||
of correctness. Do not merely copy the longest or most confident; produce \
|
||||
the most accurate, well-structured reply. Be direct.\n\nCandidate responses:",
|
||||
);
|
||||
for (i, d) in drafts.iter().enumerate() {
|
||||
// Same reasoning as `reducer_payload_budget`: measured drafts average
|
||||
// ~3.8k chars, so a tight bound would hand each refiner a fraction of
|
||||
// what its peers actually said — the input the round exists to use.
|
||||
let bounded = if d.len() > REFINEMENT_DRAFT_BUDGET {
|
||||
format!(
|
||||
"{}...",
|
||||
crate::worker::truncate_chars(d, REFINEMENT_DRAFT_BUDGET - 3)
|
||||
)
|
||||
} else {
|
||||
d.clone()
|
||||
};
|
||||
system.push_str(&format!("\n[Response {}]:\n{bounded}\n", i + 1));
|
||||
}
|
||||
|
||||
PackedContext {
|
||||
messages: vec![
|
||||
json!({"role": "system", "content": system}),
|
||||
json!({"role": "user", "content": session.last_user_text()}),
|
||||
],
|
||||
max_tokens: 1024,
|
||||
tools: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Pack context for the actor in the asymmetric tool path: "here is advice, now
|
||||
/// you act" (not the reducer's "you disagreed, reconcile"). Advice is prose,
|
||||
/// per-model length-bounded and truncation-labelled; `has_tools` /
|
||||
/// `selected_tool_names` attach the real tools the advisors never saw.
|
||||
pub fn pack_for_actor(
|
||||
session: &Session,
|
||||
references: &[WorkerOutput],
|
||||
has_tools: bool,
|
||||
selected_tool_names: &[String],
|
||||
) -> (Vec<Value>, Option<Value>) {
|
||||
let user_text = session.last_user_text();
|
||||
|
||||
let mut system_parts = vec![
|
||||
augmented_system_prompt_for_mode(session, has_tools),
|
||||
String::new(),
|
||||
"Other models were asked to advise on this request. They did not have \
|
||||
access to tools; you do. Use their advice as input, but you decide the \
|
||||
action. Critically evaluate what they say — some of it may be biased or \
|
||||
incorrect, and agreement between them is not proof of correctness. \
|
||||
Respond with the single best action: a direct answer, or the appropriate \
|
||||
tool call. Be concise."
|
||||
.to_string(),
|
||||
];
|
||||
|
||||
if references.is_empty() {
|
||||
// No advice in time (slow/absent peers): actor proceeds alone.
|
||||
system_parts.push(String::new());
|
||||
system_parts
|
||||
.push("(No advice from other models arrived in time — proceed on your own.)".into());
|
||||
} else {
|
||||
system_parts.push(String::new());
|
||||
system_parts.push("## Advice from other models".to_string());
|
||||
for (i, r) in references.iter().enumerate() {
|
||||
system_parts.push(format!("\n[Advisor {} — {}]:", i + 1, r.model));
|
||||
let payload = if r.payload.len() > 500 {
|
||||
format!("{}...", crate::worker::truncate_chars(&r.payload, 497))
|
||||
} else {
|
||||
r.payload.clone()
|
||||
};
|
||||
system_parts.push(payload);
|
||||
if r.truncated {
|
||||
system_parts.push(
|
||||
" → NOTE: cut off at the token limit — incomplete, treat as partial".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let tools = selected_tools(session, has_tools, selected_tool_names);
|
||||
|
||||
(
|
||||
vec![
|
||||
json!({"role": "system", "content": system_parts.join("\n")}),
|
||||
json!({"role": "user", "content": user_text}),
|
||||
],
|
||||
tools,
|
||||
)
|
||||
}
|
||||
|
||||
fn selected_tools(
|
||||
session: &Session,
|
||||
has_tools: bool,
|
||||
|
|
@ -793,6 +1060,165 @@ mod tests {
|
|||
s
|
||||
}
|
||||
|
||||
/// The reducer must actually see the answers it is synthesizing.
|
||||
///
|
||||
/// Measured refined answers average ~3.8k chars. The old flat 500-char
|
||||
/// bound handed the reducer ~13% of each one, discarding exactly the
|
||||
/// content the refinement round produces — the measured gain could not
|
||||
/// have survived it. Tool turns keep the tight bound (the signal is the
|
||||
/// proposal, and prose crowds out schemas).
|
||||
#[test]
|
||||
fn text_reducer_sees_realistic_answer_lengths() {
|
||||
let long_answer = "x".repeat(3800);
|
||||
let outputs = vec![
|
||||
WorkerOutput {
|
||||
kind: OutputKind::Answer,
|
||||
confidence: 0.5,
|
||||
tool_name: None,
|
||||
tool_arguments: None,
|
||||
payload: long_answer.clone(),
|
||||
model: "a".into(),
|
||||
role: WorkerRole::Generalist,
|
||||
elapsed_ms: 0,
|
||||
truncated: false,
|
||||
},
|
||||
WorkerOutput {
|
||||
kind: OutputKind::Answer,
|
||||
confidence: 0.5,
|
||||
tool_name: None,
|
||||
tool_arguments: None,
|
||||
payload: long_answer,
|
||||
model: "b".into(),
|
||||
role: WorkerRole::Generalist,
|
||||
elapsed_ms: 0,
|
||||
truncated: false,
|
||||
},
|
||||
];
|
||||
let session = session_with(&[json!({"role": "user", "content": "explain"})], None);
|
||||
|
||||
let (messages, _) =
|
||||
pack_for_reducer_selected(&session, &outputs, "no agreement", false, &[]);
|
||||
let sys = system_text(&messages);
|
||||
// Each 3800-char answer must survive largely intact (2 answers).
|
||||
assert!(
|
||||
sys.len() > 7000,
|
||||
"text reducer truncated the answers it must synthesize: {} chars",
|
||||
sys.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// Tool turns keep the tight payload bound.
|
||||
#[test]
|
||||
fn tool_reducer_keeps_the_tight_payload_bound() {
|
||||
let outputs = vec![WorkerOutput {
|
||||
kind: OutputKind::Answer,
|
||||
confidence: 0.5,
|
||||
tool_name: None,
|
||||
tool_arguments: None,
|
||||
payload: "x".repeat(3800),
|
||||
model: "a".into(),
|
||||
role: WorkerRole::Generalist,
|
||||
elapsed_ms: 0,
|
||||
truncated: false,
|
||||
}];
|
||||
let session = session_with(&[json!({"role": "user", "content": "read a file"})], None);
|
||||
|
||||
let (messages, _) = pack_for_reducer_selected(&session, &outputs, "conflict", true, &[]);
|
||||
let sys = system_text(&messages);
|
||||
assert!(
|
||||
!sys.contains(&"x".repeat(1000)),
|
||||
"tool reducer must keep payloads tight so schemas aren't crowded out"
|
||||
);
|
||||
}
|
||||
|
||||
/// Refiners must see what their peers actually said, for the same reason.
|
||||
#[test]
|
||||
fn refiners_see_realistic_peer_draft_lengths() {
|
||||
let session = session_with(&[json!({"role": "user", "content": "explain"})], None);
|
||||
let drafts = vec!["y".repeat(3800), "z".repeat(3800)];
|
||||
|
||||
let packed = pack_for_refinement(&session, &drafts);
|
||||
let sys = system_text(&packed.messages);
|
||||
assert!(
|
||||
sys.len() > 7000,
|
||||
"refiners were handed a fraction of their peers' drafts: {} chars",
|
||||
sys.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// Advisors must not be told to emit a tool call. Asking a schema-less
|
||||
/// model for one yields tool-shaped prose, which measurably pulled the
|
||||
/// actor off its own better choice.
|
||||
#[test]
|
||||
fn reference_packing_never_requests_a_tool_call() {
|
||||
let s = session_with(&[json!({"role": "user", "content": "list src"})], None);
|
||||
let packed = pack_for_reference(&s, 6);
|
||||
let sys = system_text(&packed.messages).to_lowercase();
|
||||
assert!(sys.contains("must not emit a tool call"));
|
||||
assert!(packed.tools.is_none(), "advisors never receive schemas");
|
||||
}
|
||||
|
||||
/// The agent's system prompt is withheld: an advisor handed "you are a
|
||||
/// coding agent, run the tests" role-plays the actor instead of advising.
|
||||
#[test]
|
||||
fn reference_packing_strips_the_agent_system_prompt() {
|
||||
let s = session_with(
|
||||
&[
|
||||
json!({"role": "system", "content": "You are a coding agent. SECRET_MARKER."}),
|
||||
json!({"role": "user", "content": "what is failing?"}),
|
||||
],
|
||||
None,
|
||||
);
|
||||
let packed = pack_for_reference(&s, 6);
|
||||
let all = serde_json::to_string(&packed.messages).unwrap();
|
||||
assert!(
|
||||
!all.contains("SECRET_MARKER"),
|
||||
"agent system prompt must not reach advisors: {all}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The tool transcript is withheld so advisors stay independent of the
|
||||
/// trajectory already taken — error independence is what makes
|
||||
/// aggregation worth anything.
|
||||
#[test]
|
||||
fn reference_packing_strips_the_tool_transcript() {
|
||||
let s = session_with(
|
||||
&[
|
||||
json!({"role": "user", "content": "find the bug"}),
|
||||
json!({"role": "assistant", "content": Value::Null,
|
||||
"tool_calls": [{"id": "1", "type": "function",
|
||||
"function": {"name": "list_dir", "arguments": "{\"path\":\"TRAJECTORY\"}"}}]}),
|
||||
json!({"role": "tool", "tool_call_id": "1", "content": "TOOL_RESULT_MARKER"}),
|
||||
json!({"role": "user", "content": "and now?"}),
|
||||
],
|
||||
None,
|
||||
);
|
||||
let packed = pack_for_reference(&s, 6);
|
||||
let all = serde_json::to_string(&packed.messages).unwrap();
|
||||
assert!(!all.contains("TOOL_RESULT_MARKER"), "tool results leaked");
|
||||
assert!(!all.contains("TRAJECTORY"), "prior tool_calls leaked");
|
||||
assert!(all.contains("and now?"), "current request must survive");
|
||||
}
|
||||
|
||||
/// Uniform view regardless of role: every advisor sees the same prose,
|
||||
/// so the packing is a stable function of history (and caches).
|
||||
#[test]
|
||||
fn reference_packing_keeps_user_assistant_prose() {
|
||||
let s = session_with(
|
||||
&[
|
||||
json!({"role": "user", "content": "first question"}),
|
||||
json!({"role": "assistant", "content": "first answer"}),
|
||||
json!({"role": "user", "content": "second question"}),
|
||||
],
|
||||
None,
|
||||
);
|
||||
let packed = pack_for_reference(&s, 6);
|
||||
let all = serde_json::to_string(&packed.messages).unwrap();
|
||||
assert!(all.contains("first question"));
|
||||
assert!(all.contains("first answer"));
|
||||
assert!(all.contains("second question"));
|
||||
}
|
||||
|
||||
/// Helper: extract the system message content from a packed message vec.
|
||||
fn system_text(messages: &[Value]) -> String {
|
||||
messages
|
||||
|
|
@ -1266,6 +1692,7 @@ keep this";
|
|||
model: model.to_string(),
|
||||
role: WorkerRole::Strong,
|
||||
elapsed_ms: 0,
|
||||
truncated: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1324,17 +1751,18 @@ keep this";
|
|||
#[test]
|
||||
fn reducer_truncates_long_worker_payloads() {
|
||||
let s = session_with(&[user_msg("go")], None);
|
||||
let big = "x".repeat(2000);
|
||||
// Above the text-turn budget: realistic answers (~3.8k chars) must pass
|
||||
// through intact — see `text_reducer_sees_realistic_answer_lengths` —
|
||||
// but a pathological payload still gets bounded.
|
||||
let big = "x".repeat(9000);
|
||||
let outputs = vec![worker_out("alpha", &big)];
|
||||
|
||||
let (messages, _tools) = pack_for_reducer(&s, &outputs, "conflict", false);
|
||||
let sys = system_text(&messages);
|
||||
|
||||
// Long payloads must be truncated (cap is ~500 chars + ellipsis).
|
||||
// The full 2000-char string must NOT appear verbatim.
|
||||
assert!(
|
||||
!sys.contains(&big),
|
||||
"reducer must truncate long worker payloads to keep context bounded",
|
||||
"reducer must bound pathological worker payloads to keep context sane",
|
||||
);
|
||||
assert!(
|
||||
sys.contains("..."),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::backend::BackendReply;
|
||||
use crate::enforce_tool_call_contract;
|
||||
use crate::worker::WorkerRole;
|
||||
use crate::{WorkerSummary, arbiter, normalize};
|
||||
|
|
@ -36,6 +37,25 @@ pub(crate) struct GatherPolicy {
|
|||
pub grace_mode: GraceMode,
|
||||
/// See [`crate::GatewayConfig::strong_patience`].
|
||||
pub strong_patience: Duration,
|
||||
/// How many qualifying answers must be in hand before the answer grace may
|
||||
/// fire. Normally 1 — ship the good answer, stop waiting for the tail.
|
||||
///
|
||||
/// Raised when a refinement round is expected, so grace cannot trip before
|
||||
/// there is enough material to refine with.
|
||||
pub min_grace_answers: usize,
|
||||
/// Whether grace expiry *finalizes* the turn or merely *stops collecting*.
|
||||
///
|
||||
/// Normally true: grace ships the best answer in hand and the turn is done.
|
||||
///
|
||||
/// False when a refinement round is expected. Grace is a deadline on
|
||||
/// waiting, not a quality signal — but finalizing there skips refinement,
|
||||
/// which on a small pool is the only step that beats the best member.
|
||||
/// Measured end-to-end through `handle_turn`, finalizing grace made MoA
|
||||
/// *lose* 7/8/65 to a single small model: 79 of 80 turns exited at grace
|
||||
/// with a lone 8B answer and never refined. With this false, grace still
|
||||
/// bounds the round-1 wait, then the turn proceeds to refine and
|
||||
/// synthesize what arrived.
|
||||
pub grace_finalizes: bool,
|
||||
}
|
||||
|
||||
/// Identifier for a worker we dispatched. Used to reconcile the
|
||||
|
|
@ -47,10 +67,17 @@ pub(crate) struct DispatchedWorker {
|
|||
pub role: WorkerRole,
|
||||
}
|
||||
|
||||
/// What a spawned worker task yields: `(model, role, reply, elapsed_ms)`.
|
||||
///
|
||||
/// The reply carries [`BackendReply::truncated`] alongside the text so the
|
||||
/// arbiter can tell a complete answer from one the backend cut off at the
|
||||
/// token limit. Flattening this to a bare `String` is what previously let
|
||||
/// a half-finished sentence compete as a normal answer.
|
||||
pub(crate) type WorkerTaskResult = (String, WorkerRole, Result<BackendReply, String>, u64);
|
||||
|
||||
pub(crate) async fn gather_workers_incremental(
|
||||
join_set: &mut tokio::task::JoinSet<(String, WorkerRole, Result<String, String>, u64)>,
|
||||
join_set: &mut tokio::task::JoinSet<WorkerTaskResult>,
|
||||
dispatched: &[DispatchedWorker],
|
||||
has_tools: bool,
|
||||
allowed_tools: &[String],
|
||||
tools: Option<&Value>,
|
||||
policy: GatherPolicy,
|
||||
|
|
@ -63,6 +90,8 @@ pub(crate) async fn gather_workers_incremental(
|
|||
first_answer_grace,
|
||||
grace_mode,
|
||||
strong_patience,
|
||||
min_grace_answers,
|
||||
grace_finalizes,
|
||||
} = policy;
|
||||
let total_workers = dispatched.len();
|
||||
let mut outputs = Vec::new();
|
||||
|
|
@ -101,9 +130,15 @@ pub(crate) async fn gather_workers_incremental(
|
|||
}
|
||||
match grace_mode {
|
||||
GraceMode::Disabled => false,
|
||||
GraceMode::Answer => outs.iter().any(|o| {
|
||||
o.kind == normalize::OutputKind::Answer && o.confidence >= GRACE_MIN_CONFIDENCE
|
||||
}),
|
||||
GraceMode::Answer => {
|
||||
outs.iter()
|
||||
.filter(|o| {
|
||||
o.kind == normalize::OutputKind::Answer
|
||||
&& o.confidence >= GRACE_MIN_CONFIDENCE
|
||||
})
|
||||
.count()
|
||||
>= min_grace_answers.max(1)
|
||||
}
|
||||
GraceMode::Tool => outs.iter().any(|o| {
|
||||
o.kind == normalize::OutputKind::ToolProposal
|
||||
&& o.tool_name.is_some()
|
||||
|
|
@ -141,18 +176,26 @@ pub(crate) async fn gather_workers_incremental(
|
|||
join = join_set.join_next() => join,
|
||||
_ = tokio::time::sleep(grace_remaining), if armed => {
|
||||
tracing::info!(
|
||||
"moa: grace early-exit after {}ms (grace={}ms), {} pending",
|
||||
"moa: grace expiry after {}ms (grace={}ms), {} pending, finalize={}",
|
||||
dispatched_at.elapsed().as_millis(),
|
||||
first_answer_grace.as_millis(),
|
||||
total_workers.saturating_sub(total_finished),
|
||||
grace_finalizes,
|
||||
);
|
||||
drain_after_early_exit(join_set, &mut summaries).await;
|
||||
reconcile_dispatched(dispatched, &mut summaries);
|
||||
// When a refinement round is expected, grace is only a deadline
|
||||
// on *collecting*: stop waiting for the tail, but let the turn
|
||||
// refine and synthesize what arrived instead of shipping one
|
||||
// worker's answer.
|
||||
if !grace_finalizes {
|
||||
return (outputs, summaries, None);
|
||||
}
|
||||
let decision = match grace_mode {
|
||||
GraceMode::Answer => grace_answer_decision(&outputs),
|
||||
GraceMode::Tool => grace_tool_decision(&outputs),
|
||||
GraceMode::Disabled => unreachable!("disabled grace cannot be armed"),
|
||||
};
|
||||
drain_after_early_exit(join_set, &mut summaries).await;
|
||||
reconcile_dispatched(dispatched, &mut summaries);
|
||||
return (outputs, summaries, Some(decision));
|
||||
}
|
||||
_ = tokio::time::sleep(patience_remaining), if gate_holding => {
|
||||
|
|
@ -166,7 +209,6 @@ pub(crate) async fn gather_workers_incremental(
|
|||
&outputs,
|
||||
total_workers,
|
||||
total_finished,
|
||||
has_tools,
|
||||
arbiter::StrongGate::Off,
|
||||
) {
|
||||
drain_after_early_exit(join_set, &mut summaries).await;
|
||||
|
|
@ -182,22 +224,32 @@ pub(crate) async fn gather_workers_incremental(
|
|||
};
|
||||
|
||||
match join_result {
|
||||
Ok((model, role, Ok(text), elapsed)) => {
|
||||
Ok((model, role, Ok(reply), elapsed)) => {
|
||||
total_finished += 1;
|
||||
if role == WorkerRole::Strong {
|
||||
strong_finished = true;
|
||||
}
|
||||
let mut normalized =
|
||||
normalize::normalize_worker_output(&text, &model, role, elapsed);
|
||||
normalize::normalize_worker_output(&reply.text, &model, role, elapsed);
|
||||
// Truncation is a transport fact the text can't carry: a
|
||||
// response cut off at the token limit looks like a normal
|
||||
// answer to the parser. Stamp it so the arbiter can keep it
|
||||
// out of consensus and out of verbatim responses.
|
||||
normalized.truncated = reply.truncated;
|
||||
enforce_tool_call_contract(&mut normalized, allowed_tools, tools, &model);
|
||||
tracing::info!(
|
||||
"moa: worker {} ({}) → {:?} conf={:.2} ({}ms, {} chars)",
|
||||
"moa: worker {} ({}) → {:?} conf={:.2} ({}ms, {} chars{})",
|
||||
model,
|
||||
role.label(),
|
||||
normalized.kind,
|
||||
normalized.confidence,
|
||||
elapsed,
|
||||
text.len(),
|
||||
reply.text.len(),
|
||||
if normalized.truncated {
|
||||
", TRUNCATED"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
);
|
||||
summaries.push(WorkerSummary {
|
||||
model: model.clone(),
|
||||
|
|
@ -213,7 +265,6 @@ pub(crate) async fn gather_workers_incremental(
|
|||
&outputs,
|
||||
total_workers,
|
||||
total_finished,
|
||||
has_tools,
|
||||
strong_gate(strong_finished, dispatched_at.elapsed()),
|
||||
) {
|
||||
drain_after_early_exit(join_set, &mut summaries).await;
|
||||
|
|
@ -246,7 +297,6 @@ pub(crate) async fn gather_workers_incremental(
|
|||
&outputs,
|
||||
total_workers,
|
||||
total_finished,
|
||||
has_tools,
|
||||
strong_gate(strong_finished, dispatched_at.elapsed()),
|
||||
) {
|
||||
drain_after_early_exit(join_set, &mut summaries).await;
|
||||
|
|
@ -275,6 +325,99 @@ pub(crate) async fn gather_workers_incremental(
|
|||
(outputs, summaries, None)
|
||||
}
|
||||
|
||||
/// Gather **references** for the asymmetric (Hermes-style) tool path.
|
||||
///
|
||||
/// References run tool-free and only advise; the actor acts afterwards. So
|
||||
/// unlike [`gather_workers_incremental`] this does no arbitration, no
|
||||
/// consensus, no early-exit — it just collects whatever advice arrives within
|
||||
/// a bounded window and returns it.
|
||||
///
|
||||
/// The bound is the whole point on a mixed/public mesh: we must not wait for
|
||||
/// perfect when good-enough advice is already in hand. We stop as soon as
|
||||
/// *either* `min_references` usable outputs have arrived *or* `deadline`
|
||||
/// elapses (or every reference finishes), then abort the stragglers. An empty
|
||||
/// result is legal — the actor proceeds on the user request alone.
|
||||
pub(crate) async fn gather_references(
|
||||
join_set: &mut tokio::task::JoinSet<WorkerTaskResult>,
|
||||
dispatched: &[DispatchedWorker],
|
||||
deadline: Duration,
|
||||
min_references: usize,
|
||||
) -> (Vec<WorkerOutput>, Vec<WorkerSummary>) {
|
||||
let mut outputs = Vec::new();
|
||||
let mut summaries = Vec::new();
|
||||
let started = Instant::now();
|
||||
|
||||
loop {
|
||||
// Enough advice already, or we've waited long enough: stop.
|
||||
if !outputs.is_empty() && outputs.len() >= min_references {
|
||||
break;
|
||||
}
|
||||
let remaining = deadline.saturating_sub(started.elapsed());
|
||||
if remaining.is_zero() {
|
||||
tracing::info!(
|
||||
"moa: reference deadline reached after {}ms with {} advisor(s)",
|
||||
started.elapsed().as_millis(),
|
||||
outputs.len(),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
let join_result = tokio::select! {
|
||||
biased;
|
||||
join = join_set.join_next() => join,
|
||||
_ = tokio::time::sleep(remaining) => {
|
||||
tracing::info!(
|
||||
"moa: reference deadline ({}ms) elapsed with {} advisor(s)",
|
||||
deadline.as_millis(),
|
||||
outputs.len(),
|
||||
);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
let Some(join_result) = join_result else {
|
||||
break; // all references finished
|
||||
};
|
||||
|
||||
match join_result {
|
||||
Ok((model, role, Ok(reply), elapsed)) => {
|
||||
let mut normalized =
|
||||
normalize::normalize_worker_output(&reply.text, &model, role, elapsed);
|
||||
normalized.truncated = reply.truncated;
|
||||
// No `enforce_tool_call_contract`: references ran tool-free, so
|
||||
// any tool-shaped text is advice, not an executable proposal.
|
||||
summaries.push(WorkerSummary {
|
||||
model,
|
||||
role,
|
||||
succeeded: true,
|
||||
elapsed_ms: elapsed,
|
||||
output_kind: Some(normalized.kind),
|
||||
confidence: Some(normalized.confidence),
|
||||
});
|
||||
outputs.push(normalized);
|
||||
}
|
||||
Ok((model, role, Err(e), elapsed)) => {
|
||||
tracing::warn!("moa: reference {model} ({}) failed: {e}", role.label());
|
||||
summaries.push(WorkerSummary {
|
||||
model,
|
||||
role,
|
||||
succeeded: false,
|
||||
elapsed_ms: elapsed,
|
||||
output_kind: None,
|
||||
confidence: None,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("moa: reference task panicked or was cancelled: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drain_after_early_exit(join_set, &mut summaries).await;
|
||||
reconcile_dispatched(dispatched, &mut summaries);
|
||||
(outputs, summaries)
|
||||
}
|
||||
|
||||
fn grace_answer_decision(outputs: &[WorkerOutput]) -> arbiter::Decision {
|
||||
// Prefer the Strong worker's qualifying answer when it has landed:
|
||||
// if the biggest model already answered, shipping a smaller model's
|
||||
|
|
@ -347,7 +490,7 @@ fn grace_tool_decision(outputs: &[WorkerOutput]) -> arbiter::Decision {
|
|||
/// those are reconciled by [`reconcile_dispatched`] using the dispatch
|
||||
/// list.
|
||||
async fn drain_after_early_exit(
|
||||
join_set: &mut tokio::task::JoinSet<(String, WorkerRole, Result<String, String>, u64)>,
|
||||
join_set: &mut tokio::task::JoinSet<WorkerTaskResult>,
|
||||
summaries: &mut Vec<WorkerSummary>,
|
||||
) {
|
||||
join_set.abort_all();
|
||||
|
|
@ -417,12 +560,33 @@ mod tests {
|
|||
.to_string()
|
||||
}
|
||||
|
||||
/// Spawn a worker that yields complete (non-truncated) text. Truncation
|
||||
/// behavior has its own helper below so existing tests keep asserting
|
||||
/// the same thing they always did.
|
||||
fn spawn_worker(
|
||||
join_set: &mut tokio::task::JoinSet<(String, WorkerRole, Result<String, String>, u64)>,
|
||||
join_set: &mut tokio::task::JoinSet<WorkerTaskResult>,
|
||||
model: &str,
|
||||
role: WorkerRole,
|
||||
delay_ms: u64,
|
||||
result: Result<String, String>,
|
||||
) -> DispatchedWorker {
|
||||
spawn_worker_reply(
|
||||
join_set,
|
||||
model,
|
||||
role,
|
||||
delay_ms,
|
||||
result.map(BackendReply::complete),
|
||||
)
|
||||
}
|
||||
|
||||
/// Spawn a worker yielding a full [`BackendReply`], so a test can set
|
||||
/// `truncated`.
|
||||
fn spawn_worker_reply(
|
||||
join_set: &mut tokio::task::JoinSet<WorkerTaskResult>,
|
||||
model: &str,
|
||||
role: WorkerRole,
|
||||
delay_ms: u64,
|
||||
result: Result<BackendReply, String>,
|
||||
) -> DispatchedWorker {
|
||||
let model_owned = model.to_string();
|
||||
let result_clone = result.clone();
|
||||
|
|
@ -470,13 +634,14 @@ mod tests {
|
|||
let (outputs, summaries, decision) = gather_workers_incremental(
|
||||
&mut js,
|
||||
&dispatched,
|
||||
false, // has_tools
|
||||
&[],
|
||||
None,
|
||||
GatherPolicy {
|
||||
first_answer_grace: Duration::from_millis(50),
|
||||
grace_mode: GraceMode::Answer,
|
||||
strong_patience: Duration::ZERO,
|
||||
min_grace_answers: 1,
|
||||
grace_finalizes: true,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
|
@ -526,13 +691,14 @@ mod tests {
|
|||
let (outputs, _summaries, decision) = gather_workers_incremental(
|
||||
&mut js,
|
||||
&dispatched,
|
||||
true, // has_tools
|
||||
&[],
|
||||
None,
|
||||
GatherPolicy {
|
||||
first_answer_grace: Duration::from_millis(50),
|
||||
grace_mode: GraceMode::Answer,
|
||||
strong_patience: Duration::ZERO,
|
||||
min_grace_answers: 1,
|
||||
grace_finalizes: true,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
|
@ -577,13 +743,14 @@ mod tests {
|
|||
let (outputs, _summaries, _decision) = gather_workers_incremental(
|
||||
&mut js,
|
||||
&dispatched,
|
||||
true,
|
||||
&[],
|
||||
None,
|
||||
GatherPolicy {
|
||||
first_answer_grace: Duration::from_millis(50),
|
||||
grace_mode: GraceMode::Disabled,
|
||||
strong_patience: Duration::ZERO,
|
||||
min_grace_answers: 1,
|
||||
grace_finalizes: true,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
|
@ -620,13 +787,14 @@ mod tests {
|
|||
let (_outputs, _summaries, decision) = gather_workers_incremental(
|
||||
&mut js,
|
||||
&dispatched,
|
||||
true,
|
||||
&["read".to_string()],
|
||||
None,
|
||||
GatherPolicy {
|
||||
first_answer_grace: Duration::from_millis(50),
|
||||
grace_mode: GraceMode::Tool,
|
||||
strong_patience: Duration::ZERO,
|
||||
min_grace_answers: 1,
|
||||
grace_finalizes: true,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
|
@ -678,13 +846,14 @@ mod tests {
|
|||
let (outputs, _summaries, _decision) = gather_workers_incremental(
|
||||
&mut js,
|
||||
&dispatched,
|
||||
false, // has_tools
|
||||
&[],
|
||||
None,
|
||||
GatherPolicy {
|
||||
first_answer_grace: Duration::ZERO,
|
||||
grace_mode: GraceMode::Answer,
|
||||
strong_patience: Duration::ZERO,
|
||||
min_grace_answers: 1,
|
||||
grace_finalizes: true,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
|
@ -729,13 +898,14 @@ mod tests {
|
|||
let (outputs, _summaries, _decision) = gather_workers_incremental(
|
||||
&mut js,
|
||||
&dispatched,
|
||||
false,
|
||||
&[],
|
||||
None,
|
||||
GatherPolicy {
|
||||
first_answer_grace: Duration::from_millis(50),
|
||||
grace_mode: GraceMode::Answer,
|
||||
strong_patience: Duration::ZERO,
|
||||
min_grace_answers: 1,
|
||||
grace_finalizes: true,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
|
@ -785,13 +955,14 @@ mod tests {
|
|||
let (outputs, _summaries, decision) = gather_workers_incremental(
|
||||
&mut js,
|
||||
&dispatched,
|
||||
false,
|
||||
&[],
|
||||
None,
|
||||
GatherPolicy {
|
||||
first_answer_grace: Duration::from_millis(50),
|
||||
grace_mode: GraceMode::Answer,
|
||||
strong_patience: Duration::ZERO,
|
||||
min_grace_answers: 1,
|
||||
grace_finalizes: true,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
|
@ -849,13 +1020,14 @@ mod tests {
|
|||
let (_outputs, _summaries, decision) = gather_workers_incremental(
|
||||
&mut js,
|
||||
&dispatched,
|
||||
false,
|
||||
&[],
|
||||
None,
|
||||
GatherPolicy {
|
||||
first_answer_grace: Duration::from_millis(100),
|
||||
grace_mode: GraceMode::Answer,
|
||||
strong_patience: Duration::ZERO,
|
||||
min_grace_answers: 1,
|
||||
grace_finalizes: true,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
|
|
|||
|
|
@ -41,8 +41,10 @@ pub mod context;
|
|||
mod fanout;
|
||||
pub mod normalize;
|
||||
mod reducer;
|
||||
mod refinement;
|
||||
pub mod session;
|
||||
mod tool_guard;
|
||||
mod tool_turn;
|
||||
pub mod worker;
|
||||
|
||||
pub use backend::{HttpBackend, ModelBackend, ModelEntry, SamplingParams, apply_enable_thinking};
|
||||
|
|
@ -57,7 +59,7 @@ use serde_json::{Value, json};
|
|||
use session::Session;
|
||||
use std::time::{Duration, Instant};
|
||||
use worker::WorkerRole;
|
||||
pub use worker::{strip_thinking, truncate_chars};
|
||||
pub use worker::{model_name_is_small_tier, strip_thinking, truncate_chars};
|
||||
|
||||
const SAME_TOOL_FORCE_ANSWER_THRESHOLD: usize = 3;
|
||||
|
||||
|
|
@ -104,6 +106,66 @@ pub struct GatewayConfig {
|
|||
/// populates this from the caller's `reasoning_effort` / `enable_thinking`
|
||||
/// / `reasoning.enabled` knobs so MoA users get a single switch.
|
||||
pub enable_thinking: Option<bool>,
|
||||
/// Actor priority order for tool turns and synthesis, as indices into
|
||||
/// [`Self::models`], best actor first.
|
||||
///
|
||||
/// In the asymmetric (Hermes-style) tool path the *actor* is the model
|
||||
/// that actually emits the tool call; references only advise. The actor
|
||||
/// should be the best available tool-caller, which is a host-side judgement
|
||||
/// combining gossiped `tool_use` capability, model size, and recent peer
|
||||
/// health — signals the engine crate cannot see. The host computes the
|
||||
/// ordering and passes it here.
|
||||
///
|
||||
/// Empty (the default) means "no host guidance": the engine falls back to
|
||||
/// its name-derived size tier (big-tier first), preserving prior behaviour
|
||||
/// for tests and any caller that doesn't populate it.
|
||||
pub actor_candidates: Vec<usize>,
|
||||
/// Whether tool turns gather advisory references before the actor acts.
|
||||
pub reference_policy: ReferencePolicy,
|
||||
/// Whether text turns run a cross-peer refinement round before synthesis.
|
||||
pub refinement_policy: RefinementPolicy,
|
||||
}
|
||||
|
||||
/// When a text turn should run a cross-peer refinement round (Together's
|
||||
/// `layers`): every worker sees all round-1 drafts and rewrites its answer
|
||||
/// before the reducer synthesizes.
|
||||
///
|
||||
/// Measured over 40 preregistered reasoning prompts x 3 draws
|
||||
/// (`evals/moa-openrouter/RESULTS.md`): a pool of four 8B-class models beat its
|
||||
/// own best member **only** with this round — single-round synthesis was
|
||||
/// indistinguishable from the aggregator acting solo (26/75/19, p=0.37) while
|
||||
/// refine-then-synthesize won (42/66/12, p=5.2e-05). With a 32B aggregator the
|
||||
/// round adds much less over single-round synthesis (p=0.015), so it is not
|
||||
/// worth a second fan-out there.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub enum RefinementPolicy {
|
||||
/// Refine when the pool is all small-tier — the case where the round is
|
||||
/// what makes the collective beat its best member.
|
||||
#[default]
|
||||
Auto,
|
||||
/// Always run the refinement round.
|
||||
Always,
|
||||
/// Never refine: synthesize the round-1 drafts directly.
|
||||
Never,
|
||||
}
|
||||
|
||||
/// When the asymmetric tool path should gather advisory references.
|
||||
///
|
||||
/// Measured on 40 preregistered tool tasks x 10 draws (see
|
||||
/// `evals/moa-openrouter/RESULTS.md`): with correct advisor packing, references
|
||||
/// are worth +0.017 net uplift to a weak actor but -0.037 to a strong one. They
|
||||
/// help where the actor has headroom and cost where it is already reliable, so
|
||||
/// the useful default is to gate on actor strength rather than always or never.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub enum ReferencePolicy {
|
||||
/// Gather references only when the acting model looks weak enough to
|
||||
/// benefit. Cheapest correct default.
|
||||
#[default]
|
||||
Auto,
|
||||
/// Always gather references, regardless of actor strength.
|
||||
Always,
|
||||
/// Never gather references: the actor acts alone (Hermes' `enabled: false`).
|
||||
Never,
|
||||
}
|
||||
|
||||
// ─── Turn result ─────────────────────────────────────────────────────
|
||||
|
|
@ -163,9 +225,9 @@ pub struct WorkerSummary {
|
|||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ForcedToolChoice {
|
||||
name: String,
|
||||
fallback_arguments: Value,
|
||||
pub(crate) struct ForcedToolChoice {
|
||||
pub(crate) name: String,
|
||||
pub(crate) fallback_arguments: Value,
|
||||
}
|
||||
|
||||
struct DecisionResolution<'a> {
|
||||
|
|
@ -241,9 +303,44 @@ async fn handle_query(
|
|||
forced_tool: Option<&ForcedToolChoice>,
|
||||
start: Instant,
|
||||
) -> TurnResult {
|
||||
// Tool-bearing turns take the asymmetric (Hermes-style) path: references
|
||||
// advise tool-free, the best tool-caller acts. Tool authority tracks
|
||||
// capability, not a majority vote — see `tool_turn`. Text-only turns keep
|
||||
// the symmetric fan-out + synthesis-on-divergence path below.
|
||||
if has_tools || forced_tool.is_some() {
|
||||
return tool_turn::handle_tool_query(config, session, allowed_tools, forced_tool, start)
|
||||
.await;
|
||||
}
|
||||
|
||||
let assignments = worker::assign_roles(&config.models);
|
||||
let grace_mode = grace_mode_for_turn(session, has_tools);
|
||||
let query_uses_tools = forced_tool.is_some() || matches!(grace_mode, GraceMode::Tool);
|
||||
|
||||
// If the caller gave us tools, the workers get tools. Full stop.
|
||||
//
|
||||
// This used to be `matches!(grace_mode, GraceMode::Tool)`, which routed
|
||||
// the decision through `looks_like_tool_intent` — a keyword match against
|
||||
// the user's text ("read ", "search ", "file", "directory", ...). Two
|
||||
// separate concerns were riding on one flag: whether tools are *available*
|
||||
// and whether the chat-only answer grace applies.
|
||||
//
|
||||
// Recorded agentic traces show how badly that misfires
|
||||
// (`evals/moa-openrouter/agentic.jsonl`). "The test suite is failing. Find
|
||||
// out which test fails and why" matches no phrase, so every worker was
|
||||
// dispatched without tool schemas — while the same prompt, given tools,
|
||||
// produced 53 tool calls across 9 models. Same for "Is this project's test
|
||||
// suite passing?" (32) and "Find every place MeshError::Timeout is
|
||||
// constructed in this repo." (20). Five of ten recorded tool scenarios had
|
||||
// tools silently withheld.
|
||||
//
|
||||
// Worse, this flag is also passed to the arbiter as its `has_tools`, so a
|
||||
// pool that unanimously proposed a tool fell through to the answer path and
|
||||
// leaked the proposal's payload text — an agent harness received the prose
|
||||
// "calling search" instead of a `search` tool call.
|
||||
//
|
||||
// Tool availability is now the caller's declaration. `grace_mode` keeps
|
||||
// using the heuristic, which is where a guess is actually appropriate: it
|
||||
// only tunes how long we wait before shipping a partial answer.
|
||||
let query_uses_tools = forced_tool.is_some() || has_tools;
|
||||
let selected_tool_names = if let Some(tool) = forced_tool {
|
||||
vec![tool.name.clone()]
|
||||
} else if query_uses_tools {
|
||||
|
|
@ -266,10 +363,34 @@ async fn handle_query(
|
|||
let mut dispatched: Vec<fanout::DispatchedWorker> = Vec::with_capacity(assignments.len());
|
||||
|
||||
let enable_thinking = config.enable_thinking;
|
||||
// Role tiers (Fast 256 tokens, Specialist 512, Strong 1024) encode a
|
||||
// capability spread so the cheap worker can answer the grace path quickly.
|
||||
// A homogeneous pool has no such spread, and when refinement is expected
|
||||
// every draft is an *input* to the round — a 256-token draft is a ~1000-char
|
||||
// stub that drags the refined answer down. Measured end-to-end, production's
|
||||
// tiered budgets produced ~3.1k-char answers against a ~4.1k-char solo
|
||||
// baseline; the study that showed the gain gave every peer the full budget.
|
||||
// Full-budget drafts for every answer-turn worker. Role tiers (Fast 256 /
|
||||
// Specialist 512 / Strong 1024) existed only so the cheap worker could
|
||||
// answer the grace fast-path quickly with a short reply — but grace no
|
||||
// longer finalizes answer turns (it collects, then synthesizes), so a
|
||||
// truncated draft is now pure downside: it is an *input* to synthesis, and
|
||||
// a 256-token stub drags the aggregated answer down. Measured: an all-small
|
||||
// pool lost 5W/31L through the shipped path with role-tiered drafts
|
||||
// (3399-char MoA vs 4064 solo) while the full-budget harness won 12W/2L on
|
||||
// the same pool. A big pool tolerated tiering only because its aggregator
|
||||
// is strong enough to rebuild a full answer from stubs. See
|
||||
// `evals/moa-openrouter/RESULTS.md`.
|
||||
let uniform_packing = grace_mode == GraceMode::Answer;
|
||||
for assignment in &assignments {
|
||||
let pack_role = if uniform_packing {
|
||||
worker::WorkerRole::Generalist
|
||||
} else {
|
||||
assignment.role
|
||||
};
|
||||
let packed = context::pack_for_worker_selected(
|
||||
session,
|
||||
assignment.role,
|
||||
pack_role,
|
||||
query_uses_tools,
|
||||
&selected_tool_names,
|
||||
);
|
||||
|
|
@ -300,20 +421,87 @@ async fn handle_query(
|
|||
});
|
||||
}
|
||||
|
||||
let (outputs, summaries, early_decision) = gather_workers_incremental(
|
||||
let (mut outputs, mut summaries, early_decision) = gather_workers_incremental(
|
||||
&mut join_set,
|
||||
&dispatched,
|
||||
query_uses_tools,
|
||||
allowed_tools,
|
||||
session.tools(),
|
||||
fanout::GatherPolicy {
|
||||
first_answer_grace: config.first_answer_grace,
|
||||
grace_mode,
|
||||
strong_patience: config.strong_patience,
|
||||
// Refinement quality scales with how many perspectives it gets.
|
||||
// MIN_DRAFTS (2) is the minimum for the round to *run*, not a good
|
||||
// target to collect: on a 4-model pool it let grace stop gathering
|
||||
// at 2 of 4, while the study that measured the gain refined over
|
||||
// every draft. Wait for all but one, so a single straggler still
|
||||
// cannot hold the turn and `worker_timeout` still bounds the wait.
|
||||
// Synthesis quality scales with WIDTH — that is the whole small-pool
|
||||
// finding (6x 8B beats its best member, 4x is marginal, 2x is null).
|
||||
// Arming grace on the first answer let it stop collecting at ~2 of 6,
|
||||
// synthesizing a committee too narrow to win. Wait for all but one
|
||||
// draft on any multi-worker answer turn, so a single straggler still
|
||||
// cannot hold the turn and `worker_timeout` remains the hard bound.
|
||||
// Tool turns keep the fast path (first valid proposal wins).
|
||||
min_grace_answers: if refinement::refinement_expected(config) {
|
||||
config
|
||||
.models
|
||||
.len()
|
||||
.saturating_sub(1)
|
||||
.max(refinement::MIN_DRAFTS)
|
||||
} else {
|
||||
// 1, deliberately: `min_grace_answers` gates whether grace can
|
||||
// ARM at all, so any higher value is a liveness hazard. Setting
|
||||
// it to N-1 on an answer turn meant a 6-worker pool where two
|
||||
// public-mesh peers never returned could never arm grace (only
|
||||
// 4 answers ever arrive), so the turn rode `worker_timeout`
|
||||
// instead — measured 61s on a public mesh against 3-11s for the
|
||||
// released build, for a SHORTER answer.
|
||||
//
|
||||
// Width comes from the grace WINDOW (10s), not from a count
|
||||
// gate: healthy peers all land inside it, and a dead peer costs
|
||||
// 10s rather than 60s. This is also the configuration the
|
||||
// capable-pool win was measured under (71W/8T/1L).
|
||||
1
|
||||
},
|
||||
// Grace finalizes (ships one worker's answer and stops) ONLY on
|
||||
// tool turns, where a fast validated tool call keeps agent loops
|
||||
// snappy. On answer turns grace is a *collection deadline*: stop
|
||||
// waiting for the slow tail, but still synthesize what arrived
|
||||
// instead of shipping one worker's answer. Finalizing answer turns
|
||||
// shipped a single (often role-truncated) answer and skipped
|
||||
// synthesis — measured 80/80 early-exit at capable scale, turning a
|
||||
// 61W/3L committee win into a 26W/40L loss. See
|
||||
// `evals/moa-openrouter/RESULTS.md`.
|
||||
grace_finalizes: grace_mode == GraceMode::Tool,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
// Cross-peer refinement (Together's `layers`): each worker rewrites its
|
||||
// answer after seeing the others'. Runs only for pool shapes where it
|
||||
// measurably pays (`evals/moa-openrouter/RESULTS.md`), and is best-effort —
|
||||
// on shortfall we keep the round-1 outputs.
|
||||
//
|
||||
// An `early_decision` here means the workers actually *agreed* — that is a
|
||||
// real signal and the cheap path, so it still short-circuits. (The other
|
||||
// early-exit source, the answer grace, is a timeout rather than a quality
|
||||
// signal; when refinement is expected it is configured above to bound the
|
||||
// gather without finalizing, so it no longer pre-empts this round.)
|
||||
if early_decision.is_none()
|
||||
&& refinement::should_refine(config, outputs.len())
|
||||
&& let Some((refined, refine_summaries)) =
|
||||
refinement::refine_round(config, session, &outputs).await
|
||||
{
|
||||
tracing::info!(
|
||||
"moa: refinement round produced {} draft(s) from {}",
|
||||
refined.len(),
|
||||
outputs.len(),
|
||||
);
|
||||
outputs = refined;
|
||||
summaries.extend(refine_summaries);
|
||||
}
|
||||
|
||||
if outputs.is_empty() {
|
||||
return TurnResult {
|
||||
response_body: error_response("All MoA workers failed", MOA_ERR_ALL_WORKERS_FAILED),
|
||||
|
|
@ -328,7 +516,33 @@ async fn handle_query(
|
|||
// Capture whether we took the early-exit path BEFORE we resolve the
|
||||
// decision: the arbiter never runs when early_decision is Some.
|
||||
let took_early_exit = early_decision.is_some();
|
||||
let decision = early_decision.unwrap_or_else(|| arbiter::arbitrate(&outputs, query_uses_tools));
|
||||
let decision = early_decision.unwrap_or_else(|| arbiter::arbitrate(&outputs));
|
||||
|
||||
// Always synthesize a multi-worker answer turn — never ship one worker's
|
||||
// text verbatim.
|
||||
//
|
||||
// `arbitrate` returns `Answer(payload)` when the drafts agree, which ships
|
||||
// whichever single worker represented the cluster. That is the whole
|
||||
// harness-vs-shipped gap: the eval rig always synthesizes (draft ->
|
||||
// aggregate) and a 6x8B pool wins 12W/2L, while the shipped path shipped one
|
||||
// 8B verbatim on agreement and lost (6W/19L on the same pool). A weak 8B
|
||||
// answer alone loses to a full-budget solo; an aggregation of six beats it.
|
||||
// The synthesizer sees every draft and produces the fuller, better answer —
|
||||
// agreeing drafts are the best possible input to it, not a reason to skip.
|
||||
//
|
||||
// Applies to answer turns with >=2 successful workers. Tool turns keep
|
||||
// their own routing (single best actor). A single surviving worker has
|
||||
// nothing to synthesize, so it still ships directly.
|
||||
let is_answer_turn = grace_mode == GraceMode::Answer;
|
||||
let decision =
|
||||
if is_answer_turn && outputs.len() >= 2 && matches!(decision, arbiter::Decision::Answer(_))
|
||||
{
|
||||
arbiter::Decision::NeedsReducer {
|
||||
reason: format!("{} drafts to synthesize", outputs.len()),
|
||||
}
|
||||
} else {
|
||||
decision
|
||||
};
|
||||
let (response_body, reducer_used, reducer_attempts) = resolve_decision(
|
||||
config,
|
||||
DecisionResolution {
|
||||
|
|
@ -427,7 +641,10 @@ fn looks_like_tool_intent(text: &str) -> bool {
|
|||
.any(|phrase| text.contains(phrase))
|
||||
}
|
||||
|
||||
fn selected_tool_names_for_turn(session: &Session, allowed_tools: &[String]) -> Vec<String> {
|
||||
pub(crate) fn selected_tool_names_for_turn(
|
||||
session: &Session,
|
||||
allowed_tools: &[String],
|
||||
) -> Vec<String> {
|
||||
let available = if allowed_tools.is_empty() {
|
||||
session.tool_names()
|
||||
} else {
|
||||
|
|
@ -1129,7 +1346,7 @@ async fn resolve_decision(
|
|||
|
||||
// ─── Response builders ───────────────────────────────────────────────
|
||||
|
||||
fn best_answer(outputs: &[WorkerOutput]) -> String {
|
||||
pub(crate) fn best_answer(outputs: &[WorkerOutput]) -> String {
|
||||
outputs
|
||||
.iter()
|
||||
.filter(|o| {
|
||||
|
|
@ -1146,7 +1363,7 @@ fn best_answer(outputs: &[WorkerOutput]) -> String {
|
|||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn fallback_worker_response(outputs: &[WorkerOutput]) -> Value {
|
||||
pub(crate) fn fallback_worker_response(outputs: &[WorkerOutput]) -> Value {
|
||||
let answer = best_answer(outputs);
|
||||
if answer.is_empty() {
|
||||
error_response(
|
||||
|
|
@ -1158,7 +1375,7 @@ fn fallback_worker_response(outputs: &[WorkerOutput]) -> Value {
|
|||
}
|
||||
}
|
||||
|
||||
fn tool_proposal_response(output: &WorkerOutput, has_tools: bool) -> Value {
|
||||
pub(crate) fn tool_proposal_response(output: &WorkerOutput, has_tools: bool) -> Value {
|
||||
if let (true, Some(name)) = (has_tools, output.tool_name.as_ref()) {
|
||||
let args = output.tool_arguments.as_ref().unwrap_or(&Value::Null);
|
||||
return tool_call_response(name, args);
|
||||
|
|
@ -1194,7 +1411,7 @@ fn tool_proposal_response(output: &WorkerOutput, has_tools: bool) -> Value {
|
|||
///
|
||||
/// The ingress layer is responsible for choosing the HTTP status; this
|
||||
/// body is the in-band signal.
|
||||
fn error_response(message: &str, code: &str) -> Value {
|
||||
pub(crate) fn error_response(message: &str, code: &str) -> Value {
|
||||
json!({
|
||||
"id": format!("chatcmpl-moa-{}", short_id()),
|
||||
"object": "chat.completion",
|
||||
|
|
@ -1240,7 +1457,7 @@ pub const MOA_ERR_ALL_REDUCERS_FAILED: &str = "all_reducers_failed";
|
|||
/// MoA only received silence directives or uncertainty after reduction.
|
||||
pub const MOA_ERR_NO_USABLE_ANSWER: &str = "no_usable_answer";
|
||||
|
||||
fn chat_response(content: &str) -> Value {
|
||||
pub(crate) fn chat_response(content: &str) -> Value {
|
||||
json!({
|
||||
"id": format!("chatcmpl-moa-{}", short_id()),
|
||||
"object": "chat.completion",
|
||||
|
|
@ -1254,7 +1471,7 @@ fn chat_response(content: &str) -> Value {
|
|||
})
|
||||
}
|
||||
|
||||
fn tool_call_response(name: &str, arguments: &Value) -> Value {
|
||||
pub(crate) fn tool_call_response(name: &str, arguments: &Value) -> Value {
|
||||
// OpenAI tool-call `arguments` is a JSON-object *string*. Three input
|
||||
// shapes have to collapse to a valid object string here:
|
||||
//
|
||||
|
|
@ -1316,6 +1533,7 @@ mod response_builder_tests {
|
|||
model: model.to_string(),
|
||||
role: WorkerRole::Fast,
|
||||
elapsed_ms: 1,
|
||||
truncated: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1370,6 +1588,7 @@ mod response_builder_tests {
|
|||
model: "reducer".to_string(),
|
||||
role: WorkerRole::Reducer,
|
||||
elapsed_ms: 1,
|
||||
truncated: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1574,6 +1793,9 @@ mod response_builder_tests {
|
|||
first_answer_grace: Duration::from_millis(10),
|
||||
strong_patience: Duration::ZERO,
|
||||
enable_thinking: Some(false),
|
||||
actor_candidates: Vec::new(),
|
||||
reference_policy: Default::default(),
|
||||
refinement_policy: Default::default(),
|
||||
};
|
||||
let forced_tool = ForcedToolChoice {
|
||||
name: "lookup_probe_fact".to_string(),
|
||||
|
|
|
|||
|
|
@ -31,6 +31,21 @@ pub struct WorkerOutput {
|
|||
pub model: String,
|
||||
pub role: WorkerRole,
|
||||
pub elapsed_ms: u64,
|
||||
/// The backend stopped this response at the token limit
|
||||
/// (`finish_reason == "length"`) rather than letting the model finish.
|
||||
///
|
||||
/// Recorded traces show this is not rare: 15 of 140 responses from
|
||||
/// open-weight models came back `length` (see
|
||||
/// `evals/moa-openrouter/corpus.jsonl`). Two shapes matter:
|
||||
///
|
||||
/// * empty content — already surfaces as a worker error, and
|
||||
/// * **partial text** — a half-finished sentence that previously
|
||||
/// entered arbitration as a normal answer at the default 0.5
|
||||
/// confidence, making it eligible to win the pick outright.
|
||||
///
|
||||
/// Truncated answers are excluded from consensus and never returned
|
||||
/// verbatim; they are still handed to synthesis as partial material.
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
/// Normalize raw worker text into a structured output.
|
||||
|
|
@ -210,6 +225,7 @@ fn try_json_parse(
|
|||
model: model.to_string(),
|
||||
role,
|
||||
elapsed_ms,
|
||||
truncated: false,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -331,6 +347,7 @@ fn try_kv_parse(raw: &str, model: &str, role: WorkerRole, elapsed_ms: u64) -> Op
|
|||
model: model.to_string(),
|
||||
role,
|
||||
elapsed_ms,
|
||||
truncated: false,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -349,6 +366,7 @@ fn heuristic_classify(raw: &str, model: &str, role: WorkerRole, elapsed_ms: u64)
|
|||
model: model.to_string(),
|
||||
role,
|
||||
elapsed_ms,
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -363,6 +381,7 @@ fn heuristic_classify(raw: &str, model: &str, role: WorkerRole, elapsed_ms: u64)
|
|||
model: model.to_string(),
|
||||
role,
|
||||
elapsed_ms,
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -376,6 +395,7 @@ fn heuristic_classify(raw: &str, model: &str, role: WorkerRole, elapsed_ms: u64)
|
|||
model: model.to_string(),
|
||||
role,
|
||||
elapsed_ms,
|
||||
truncated: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,30 @@ use std::time::Duration;
|
|||
/// running a stale binary that 502s on tool calls) doesn't take down
|
||||
/// the whole reducer step.
|
||||
pub(crate) fn reducer_candidates(config: &GatewayConfig) -> Vec<(String, usize)> {
|
||||
// Host-provided actor priority wins. In the asymmetric tool path the actor
|
||||
// is the model that actually emits the tool call, so it must be the best
|
||||
// available tool-caller — a judgement the host makes from gossiped
|
||||
// `tool_use` capability, model size, and peer health, none of which this
|
||||
// crate can see. `actor_candidates` are indices into `config.models`,
|
||||
// best-first; we translate them to `(name, backend_index)` and skip any
|
||||
// stale/out-of-range index defensively.
|
||||
if !config.actor_candidates.is_empty() {
|
||||
let ordered: Vec<(String, usize)> = config
|
||||
.actor_candidates
|
||||
.iter()
|
||||
.filter_map(|&i| config.models.get(i))
|
||||
.map(|m| (m.name.clone(), m.backend_index))
|
||||
.collect();
|
||||
if !ordered.is_empty() {
|
||||
return ordered;
|
||||
}
|
||||
// Every provided index was stale — fall through to the size heuristic
|
||||
// rather than return empty and fail the turn.
|
||||
}
|
||||
|
||||
// No host guidance (or all indices stale): fall back to name-derived size
|
||||
// tier, big-tier first. Preserves prior behaviour for tests and callers
|
||||
// that don't populate `actor_candidates`.
|
||||
let mut big = Vec::new();
|
||||
let mut small = Vec::new();
|
||||
for m in &config.models {
|
||||
|
|
@ -127,7 +151,11 @@ pub(crate) async fn hedged_reducer_call(
|
|||
SamplingParams::reducer().with_thinking(enable_thinking),
|
||||
)
|
||||
.await;
|
||||
(name, result)
|
||||
// The reducer's output *is* the final response, so there is no
|
||||
// later arbitration step that could act on truncation. Keep the
|
||||
// text; the 2048-token reducer budget is well clear of the
|
||||
// worker budget where truncation actually bites.
|
||||
(name, result.map(|reply| reply.text))
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
296
crates/mesh-mixture-of-agents/src/refinement.rs
Normal file
296
crates/mesh-mixture-of-agents/src/refinement.rs
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
//! Cross-peer refinement round (Together's `layers`) for text turns.
|
||||
//!
|
||||
//! Round 1 workers answer independently. In the refinement round every worker
|
||||
//! sees *all* round-1 drafts and rewrites its own answer, after which the
|
||||
//! reducer synthesizes the refined set.
|
||||
//!
|
||||
//! Why it exists: measured over 40 preregistered reasoning prompts x 3 draws
|
||||
//! (`evals/moa-openrouter/RESULTS.md`), a pool of four 8B-class models beat its
|
||||
//! own best member only when the refinement round was present —
|
||||
//! single-round synthesis alone was indistinguishable from the aggregator
|
||||
//! acting solo (p=0.37), while refine-then-synthesize won (p=5.2e-05). For a
|
||||
//! strong aggregator the extra round adds much less, so the round is gated.
|
||||
//!
|
||||
//! Mesh flavour: the round is best-effort. It refines with whichever drafts
|
||||
//! arrived, bounds its own wait, and on any shortfall returns the round-1
|
||||
//! outputs unchanged rather than failing the turn.
|
||||
|
||||
use crate::backend::{SamplingParams, call_backend};
|
||||
use crate::context;
|
||||
use crate::normalize::{self, WorkerOutput};
|
||||
use crate::session::Session;
|
||||
use crate::worker;
|
||||
use crate::{GatewayConfig, RefinementPolicy, WorkerSummary};
|
||||
use std::time::Instant;
|
||||
|
||||
/// Minimum round-1 drafts needed for refinement to be meaningful.
|
||||
pub(crate) const MIN_DRAFTS: usize = 2;
|
||||
|
||||
/// Share of the worker budget the refinement round may spend.
|
||||
///
|
||||
/// Refinement sits between round 1 and the reducer, so an unbounded round would
|
||||
/// let one turn pay three sequential worker budgets. Half a budget is enough
|
||||
/// for a pool that answered round 1 promptly, and caps the worst case at ~2.5x
|
||||
/// a plain turn instead of 3x.
|
||||
const REFINEMENT_BUDGET_NUMERATOR: u32 = 1;
|
||||
const REFINEMENT_BUDGET_DENOMINATOR: u32 = 2;
|
||||
|
||||
fn refinement_budget(worker_timeout: std::time::Duration) -> std::time::Duration {
|
||||
worker_timeout / REFINEMENT_BUDGET_DENOMINATOR * REFINEMENT_BUDGET_NUMERATOR
|
||||
}
|
||||
|
||||
/// Should this text turn run a refinement round?
|
||||
///
|
||||
/// `Auto` follows the evidence: refine when the pool is dominated by
|
||||
/// small-tier models (where the round is what makes the collective beat its
|
||||
/// best member) and skip it when a big-tier model is present to synthesize
|
||||
/// directly, since there the extra round buys much less than it costs.
|
||||
pub(crate) fn should_refine(config: &GatewayConfig, drafts: usize) -> bool {
|
||||
drafts >= MIN_DRAFTS && refinement_expected(config)
|
||||
}
|
||||
|
||||
/// Will this config refine, given enough drafts?
|
||||
///
|
||||
/// Depends only on policy and pool shape, so it can be answered *before*
|
||||
/// dispatch — which the text path needs in order to decide whether the answer
|
||||
/// grace may pre-empt the round.
|
||||
pub(crate) fn refinement_expected(config: &GatewayConfig) -> bool {
|
||||
match config.refinement_policy {
|
||||
RefinementPolicy::Never => false,
|
||||
RefinementPolicy::Always => config.models.len() >= MIN_DRAFTS,
|
||||
RefinementPolicy::Auto => {
|
||||
if config.models.len() < MIN_DRAFTS {
|
||||
return false;
|
||||
}
|
||||
// The cross-peer refine round is an extra *serial* fan-out pass
|
||||
// (draft -> synth -> refine -> synth). It only earns that cost in
|
||||
// one measured case: a homogeneous pool at real scale, where the
|
||||
// repeated same-model drafts are correlated enough that a round of
|
||||
// cross-pollination helps (same-model 32B ×2: 48/2 with refine vs
|
||||
// 35/10 without).
|
||||
//
|
||||
// It does NOT pay for small pools. The width sprint measured
|
||||
// refine-vs-single-aggregation as null in every 8B cell (2/4/6,
|
||||
// diverse and same); single aggregation alone is what wins there
|
||||
// (6× diverse 8B, 12W/2L). And a diverse big pool gains ~nothing
|
||||
// either (mid diverse 49/6 layered vs 47/4 single-round). So skip
|
||||
// the round for any all-small pool and for diverse pools —
|
||||
// matching Hermes' cheaper single-synth cadence where refine buys
|
||||
// nothing. See `evals/moa-openrouter/RESULTS.md`.
|
||||
let all_small = config
|
||||
.models
|
||||
.iter()
|
||||
.all(|m| worker::model_name_is_small_tier(&m.name));
|
||||
!all_small && worker::pool_is_homogeneous(&config.models)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run one refinement round over `drafts`.
|
||||
///
|
||||
/// Returns the refined outputs plus a summary per refining worker. Any worker
|
||||
/// that fails or times out simply doesn't contribute; if fewer than
|
||||
/// [`MIN_DRAFTS`] refinements land we return `None` so the caller keeps the
|
||||
/// round-1 outputs.
|
||||
pub(crate) async fn refine_round(
|
||||
config: &GatewayConfig,
|
||||
session: &Session,
|
||||
drafts: &[WorkerOutput],
|
||||
) -> Option<(Vec<WorkerOutput>, Vec<WorkerSummary>)> {
|
||||
let assignments = worker::assign_roles(&config.models);
|
||||
let texts: Vec<String> = drafts.iter().map(|d| d.payload.clone()).collect();
|
||||
|
||||
let mut join_set = tokio::task::JoinSet::new();
|
||||
for a in &assignments {
|
||||
let packed = context::pack_for_refinement(session, &texts);
|
||||
let model = a.model_name.clone();
|
||||
let role = a.role;
|
||||
let backend = config.backends[a.backend_index].clone();
|
||||
let timeout = config.worker_timeout;
|
||||
let thinking = config.enable_thinking;
|
||||
join_set.spawn(async move {
|
||||
let t0 = Instant::now();
|
||||
let result = call_backend(
|
||||
&*backend,
|
||||
&model,
|
||||
&packed.messages,
|
||||
None, // text path: refinement never carries tools
|
||||
packed.max_tokens,
|
||||
timeout,
|
||||
SamplingParams::worker().with_thinking(thinking),
|
||||
)
|
||||
.await;
|
||||
(model, role, result, t0.elapsed().as_millis() as u64)
|
||||
});
|
||||
}
|
||||
|
||||
// Bounded well inside the worker budget. Refinement is an *optional*
|
||||
// improvement inserted between round 1 and the reducer, so at full
|
||||
// `worker_timeout` a slow pool could pay three sequential budgets for one
|
||||
// turn — the worst outcome on exactly the high-latency meshes this feature
|
||||
// targets. Give the round a fraction of the budget and fall back to the
|
||||
// round-1 drafts if the pool can't refine in that time.
|
||||
let deadline = tokio::time::sleep(refinement_budget(config.worker_timeout));
|
||||
tokio::pin!(deadline);
|
||||
|
||||
let mut refined = Vec::new();
|
||||
let mut summaries = Vec::new();
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
joined = join_set.join_next() => {
|
||||
let Some(joined) = joined else { break };
|
||||
match joined {
|
||||
Ok((model, role, Ok(reply), elapsed)) => {
|
||||
if reply.text.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut out = normalize::normalize_worker_output(
|
||||
&reply.text, &model, role, elapsed,
|
||||
);
|
||||
out.truncated = reply.truncated;
|
||||
summaries.push(WorkerSummary {
|
||||
model,
|
||||
role,
|
||||
succeeded: true,
|
||||
elapsed_ms: elapsed,
|
||||
output_kind: Some(out.kind),
|
||||
confidence: Some(out.confidence),
|
||||
});
|
||||
refined.push(out);
|
||||
}
|
||||
Ok((model, role, Err(e), elapsed)) => {
|
||||
tracing::warn!("moa: refinement worker {model} failed: {e}");
|
||||
summaries.push(WorkerSummary {
|
||||
model,
|
||||
role,
|
||||
succeeded: false,
|
||||
elapsed_ms: elapsed,
|
||||
output_kind: None,
|
||||
confidence: None,
|
||||
});
|
||||
}
|
||||
Err(e) => tracing::warn!("moa: refinement task cancelled: {e}"),
|
||||
}
|
||||
}
|
||||
_ = &mut deadline => {
|
||||
tracing::info!(
|
||||
"moa: refinement deadline reached with {} refined draft(s)",
|
||||
refined.len(),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
join_set.abort_all();
|
||||
|
||||
if refined.len() < MIN_DRAFTS {
|
||||
tracing::info!(
|
||||
"moa: refinement produced {} draft(s), keeping round-1 outputs",
|
||||
refined.len()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Some((refined, summaries))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::backend::ModelEntry;
|
||||
use std::time::Duration;
|
||||
|
||||
fn config(models: &[&str], policy: RefinementPolicy) -> GatewayConfig {
|
||||
GatewayConfig {
|
||||
backends: Vec::new(),
|
||||
models: models
|
||||
.iter()
|
||||
.map(|n| ModelEntry {
|
||||
name: (*n).to_string(),
|
||||
backend_index: 0,
|
||||
})
|
||||
.collect(),
|
||||
worker_timeout: Duration::from_secs(60),
|
||||
hedge_delay: Duration::from_secs(5),
|
||||
reducer_timeout: Duration::from_secs(60),
|
||||
first_answer_grace: Duration::ZERO,
|
||||
strong_patience: Duration::ZERO,
|
||||
enable_thinking: Some(false),
|
||||
actor_candidates: Vec::new(),
|
||||
reference_policy: Default::default(),
|
||||
refinement_policy: policy,
|
||||
}
|
||||
}
|
||||
|
||||
/// An all-small pool wins by WIDTH + single aggregation, not the refine
|
||||
/// round: the width sprint measured refine-vs-single-aggregation null in
|
||||
/// every 8B cell (2/4/6, diverse and same). So Auto skips the extra serial
|
||||
/// pass here — single aggregation over a wide pool is what wins.
|
||||
#[test]
|
||||
fn auto_skips_an_all_small_pool() {
|
||||
let c = config(
|
||||
&["Qwen3-8B", "Llama-3.1-8B", "Ministral-8B"],
|
||||
RefinementPolicy::Auto,
|
||||
);
|
||||
assert!(!should_refine(&c, 3));
|
||||
}
|
||||
|
||||
/// A *diverse* pool with a big-tier synthesizer gains ~nothing from the
|
||||
/// extra round (measured 49/6 layered vs 47/4 single-round), so Auto skips
|
||||
/// it to save the round-trip.
|
||||
#[test]
|
||||
fn auto_skips_for_a_diverse_big_tier_pool() {
|
||||
let c = config(&["Qwen3-32B", "Qwen3-8B"], RefinementPolicy::Auto);
|
||||
assert!(!should_refine(&c, 2));
|
||||
}
|
||||
|
||||
/// A *homogeneous* big-tier pool (same model, incl. repeated instances)
|
||||
/// produces correlated drafts, and the round is what pulls them apart —
|
||||
/// same-model 32B ×2 wins 48/2 with refinement vs 35/10 without. Auto must
|
||||
/// refine here even though the members are big-tier.
|
||||
#[test]
|
||||
fn auto_refines_a_homogeneous_big_tier_pool() {
|
||||
let c = config(&["Qwen3-32B", "Qwen3-32B"], RefinementPolicy::Auto);
|
||||
assert!(should_refine(&c, 2));
|
||||
}
|
||||
|
||||
/// Repeated instances of one model (same alias) are homogeneous.
|
||||
#[test]
|
||||
fn auto_refines_repeated_instances_of_one_model() {
|
||||
let c = config(
|
||||
&["Qwen3-32B", "Qwen3-32B", "Qwen3-32B"],
|
||||
RefinementPolicy::Auto,
|
||||
);
|
||||
assert!(should_refine(&c, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refinement_needs_at_least_two_drafts() {
|
||||
let c = config(&["Qwen3-8B", "Llama-3.1-8B"], RefinementPolicy::Always);
|
||||
assert!(!should_refine(&c, 1));
|
||||
assert!(should_refine(&c, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_policies_override_pool_shape() {
|
||||
let never = config(&["Qwen3-8B", "Llama-3.1-8B"], RefinementPolicy::Never);
|
||||
assert!(!should_refine(&never, 3));
|
||||
let always = config(&["Qwen3-32B", "Qwen3-8B"], RefinementPolicy::Always);
|
||||
assert!(should_refine(&always, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_is_the_default() {
|
||||
assert_eq!(RefinementPolicy::default(), RefinementPolicy::Auto);
|
||||
}
|
||||
|
||||
/// Refinement must not spend a full worker budget: it sits between round 1
|
||||
/// and the reducer, so an unbounded round would make one turn pay three
|
||||
/// sequential budgets on exactly the slow meshes this feature targets.
|
||||
#[test]
|
||||
fn refinement_budget_is_a_fraction_of_the_worker_timeout() {
|
||||
let budget = refinement_budget(Duration::from_secs(60));
|
||||
assert_eq!(budget, Duration::from_secs(30));
|
||||
assert!(budget < Duration::from_secs(60));
|
||||
}
|
||||
}
|
||||
|
|
@ -76,6 +76,7 @@ mod tests {
|
|||
model: "alpha".into(),
|
||||
role: WorkerRole::Strong,
|
||||
elapsed_ms: 0,
|
||||
truncated: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -164,6 +165,7 @@ mod tests {
|
|||
model: "beta".into(),
|
||||
role: WorkerRole::Fast,
|
||||
elapsed_ms: 0,
|
||||
truncated: false,
|
||||
};
|
||||
enforce_tool_call_contract(&mut out, &["read_file".into()], None, "beta");
|
||||
assert_eq!(out.kind, OutputKind::Answer);
|
||||
|
|
|
|||
336
crates/mesh-mixture-of-agents/src/tool_turn.rs
Normal file
336
crates/mesh-mixture-of-agents/src/tool_turn.rs
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
//! Asymmetric tool turn: the best tool-caller (the "actor") acts with the real
|
||||
//! tools; every other model advises tool-free. Tool authority tracks capability,
|
||||
//! not a majority vote — which removes the majority-of-weakness failure class
|
||||
//! (weak models outvoting the one strong tool-caller) by construction.
|
||||
//!
|
||||
//! Stays a stateless `/v1/chat/completions` turn: references are rebuilt from
|
||||
//! the transcript each request, and the client still owns tool execution.
|
||||
|
||||
use crate::backend::{SamplingParams, call_backend};
|
||||
use crate::context;
|
||||
use crate::fanout::{DispatchedWorker, gather_references};
|
||||
use crate::normalize::{self, WorkerOutput};
|
||||
use crate::reducer::{self, hedged_reducer_call, reducer_candidates};
|
||||
use crate::session::Session;
|
||||
use crate::worker::{self, WorkerRole};
|
||||
use crate::{
|
||||
ForcedToolChoice, GatewayConfig, MOA_ERR_ALL_REDUCERS_FAILED, ReferencePolicy, TurnKind,
|
||||
TurnResult, WorkerSummary, chat_response, enforce_tool_call_contract, error_response,
|
||||
fallback_worker_response, selected_tool_names_for_turn, tool_call_response,
|
||||
tool_proposal_response,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::time::Instant;
|
||||
|
||||
/// How much conversation prose advisors see. Enough for continuity on a
|
||||
/// multi-turn session, short enough to stay cheap and cacheable.
|
||||
const REFERENCE_HISTORY_MESSAGES: usize = 6;
|
||||
|
||||
/// Handle a fresh, tool-bearing query with the asymmetric actor design.
|
||||
pub(crate) async fn handle_tool_query(
|
||||
config: &GatewayConfig,
|
||||
session: &Session,
|
||||
allowed_tools: &[String],
|
||||
forced_tool: Option<&ForcedToolChoice>,
|
||||
start: Instant,
|
||||
) -> TurnResult {
|
||||
// Best tool-caller first (host `actor_candidates`, else name-derived tier).
|
||||
let candidates = reducer_candidates(config);
|
||||
let actor_top = candidates.first().map(|(name, _)| name.clone());
|
||||
|
||||
// Advisors are gathered only when they're likely to pay for themselves.
|
||||
// Actor excluded from advisors so it doesn't pay a redundant advisory pass.
|
||||
let (references, mut summaries) = if should_gather_references(config, actor_top.as_deref()) {
|
||||
dispatch_and_gather_references(config, session, actor_top.as_deref()).await
|
||||
} else {
|
||||
tracing::debug!(
|
||||
"moa: skipping advisory references (policy={:?}, actor={:?})",
|
||||
config.reference_policy,
|
||||
actor_top,
|
||||
);
|
||||
(Vec::new(), Vec::new())
|
||||
};
|
||||
|
||||
let selected = selected_tool_names_for_turn(session, allowed_tools);
|
||||
let (messages, tools) = context::pack_for_actor(session, &references, true, &selected);
|
||||
|
||||
let hedge = hedged_reducer_call(
|
||||
&config.backends,
|
||||
candidates.clone(),
|
||||
messages,
|
||||
tools,
|
||||
config.reducer_timeout,
|
||||
config.hedge_delay,
|
||||
config.enable_thinking,
|
||||
)
|
||||
.await;
|
||||
|
||||
let fallback_name = actor_top.unwrap_or_default();
|
||||
let (response_body, actor_name, actor_ok, attempts) = finalize_actor_output(
|
||||
session,
|
||||
allowed_tools,
|
||||
forced_tool,
|
||||
&references,
|
||||
fallback_name,
|
||||
hedge,
|
||||
);
|
||||
|
||||
// Reducer role marks the acting pass, distinct from advisory summaries.
|
||||
summaries.push(WorkerSummary {
|
||||
model: actor_name,
|
||||
role: WorkerRole::Reducer,
|
||||
succeeded: actor_ok,
|
||||
elapsed_ms: start.elapsed().as_millis() as u64,
|
||||
output_kind: None,
|
||||
confidence: None,
|
||||
});
|
||||
|
||||
TurnResult {
|
||||
response_body,
|
||||
worker_summaries: summaries,
|
||||
reducer_used: true,
|
||||
reducer_attempts: attempts,
|
||||
turn_kind: TurnKind::Fanout,
|
||||
elapsed_ms: start.elapsed().as_millis() as u64,
|
||||
}
|
||||
}
|
||||
|
||||
/// Should this tool turn gather advisory references?
|
||||
///
|
||||
/// Under [`ReferencePolicy::Auto`] the answer tracks *actor headroom*. Measured
|
||||
/// over 40 preregistered tool tasks x 10 draws with correct advisor packing
|
||||
/// (`evals/moa-openrouter/RESULTS.md`):
|
||||
///
|
||||
/// * weak actor (qwen3-8b): +0.017 net uplift — references help
|
||||
/// * strong actor (qwen3-32b): -0.037 net uplift — references cost
|
||||
///
|
||||
/// Per-stratum the split is sharper still: references gained where the actor
|
||||
/// had headroom (search +10, execute +4) and lost where it was already perfect
|
||||
/// (inspect -7). So we advise a small-tier actor and let a big-tier one act
|
||||
/// alone.
|
||||
///
|
||||
/// Size tier is a coarse proxy for tool-calling strength; it is the same signal
|
||||
/// the host already ranks actors by, and it needs no extra round trip. If a
|
||||
/// pool has only one model there is nobody to advise, so we skip regardless.
|
||||
fn should_gather_references(config: &GatewayConfig, actor: Option<&str>) -> bool {
|
||||
if config.models.len() < 2 {
|
||||
return false;
|
||||
}
|
||||
match config.reference_policy {
|
||||
ReferencePolicy::Never => false,
|
||||
ReferencePolicy::Always => true,
|
||||
// Unknown actor: fall back to advising, which is the prior behaviour.
|
||||
ReferencePolicy::Auto => actor.is_none_or(worker::model_name_is_small_tier),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fan out every non-actor model as a tool-free advisor and collect their
|
||||
/// advice within a bounded window.
|
||||
async fn dispatch_and_gather_references(
|
||||
config: &GatewayConfig,
|
||||
session: &Session,
|
||||
exclude: Option<&str>,
|
||||
) -> (Vec<WorkerOutput>, Vec<WorkerSummary>) {
|
||||
let assignments = worker::assign_roles(&config.models);
|
||||
let mut join_set = tokio::task::JoinSet::new();
|
||||
let mut dispatched: Vec<DispatchedWorker> = Vec::new();
|
||||
let enable_thinking = config.enable_thinking;
|
||||
|
||||
for a in &assignments {
|
||||
if Some(a.model_name.as_str()) == exclude {
|
||||
continue; // the actor advises itself when it acts
|
||||
}
|
||||
// Advisor packing: conversation prose only, no agent system prompt, no
|
||||
// tool transcript, no request for a tool call. Measured: the old
|
||||
// worker packing cost -0.102 net uplift vs -0.037 for this one (same
|
||||
// actor, same 40 tasks) — see evals/moa-openrouter/RESULTS.md.
|
||||
let packed = context::pack_for_reference(session, REFERENCE_HISTORY_MESSAGES);
|
||||
let model_name = a.model_name.clone();
|
||||
let role = a.role;
|
||||
let backend = config.backends[a.backend_index].clone();
|
||||
let timeout = config.worker_timeout;
|
||||
|
||||
dispatched.push(DispatchedWorker {
|
||||
model: model_name.clone(),
|
||||
role,
|
||||
});
|
||||
|
||||
join_set.spawn(async move {
|
||||
let t0 = Instant::now();
|
||||
let result = call_backend(
|
||||
&*backend,
|
||||
&model_name,
|
||||
&packed.messages,
|
||||
packed.tools.as_ref(),
|
||||
packed.max_tokens,
|
||||
timeout,
|
||||
SamplingParams::worker().with_thinking(enable_thinking),
|
||||
)
|
||||
.await;
|
||||
(model_name, role, result, t0.elapsed().as_millis() as u64)
|
||||
});
|
||||
}
|
||||
|
||||
if dispatched.is_empty() {
|
||||
return (Vec::new(), Vec::new());
|
||||
}
|
||||
|
||||
// Bounded wait: proceed at a majority of advisors so slow/absent peers on a
|
||||
// mixed mesh can't hold up the actor.
|
||||
let min_references = dispatched.len().div_ceil(2).max(1);
|
||||
gather_references(
|
||||
&mut join_set,
|
||||
&dispatched,
|
||||
config.worker_timeout,
|
||||
min_references,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Turn the actor's hedged result into a response body + accounting.
|
||||
fn finalize_actor_output(
|
||||
session: &Session,
|
||||
allowed_tools: &[String],
|
||||
forced_tool: Option<&ForcedToolChoice>,
|
||||
references: &[WorkerOutput],
|
||||
fallback_actor_name: String,
|
||||
hedge: Result<reducer::HedgedReducerOk, reducer::HedgedReducerErr>,
|
||||
) -> (Value, String, bool, u32) {
|
||||
match hedge {
|
||||
Ok(reducer::HedgedReducerOk {
|
||||
winner,
|
||||
text,
|
||||
attempts,
|
||||
}) => {
|
||||
let mut acted =
|
||||
normalize::normalize_worker_output(&text, &winner, WorkerRole::Reducer, 0);
|
||||
enforce_tool_call_contract(&mut acted, allowed_tools, session.tools(), &winner);
|
||||
(
|
||||
actor_body(&acted, forced_tool, references),
|
||||
winner,
|
||||
true,
|
||||
attempts,
|
||||
)
|
||||
}
|
||||
Err(reducer::HedgedReducerErr { err, attempts }) => {
|
||||
tracing::warn!("moa: all {attempts} actor candidate(s) failed: {err}");
|
||||
let body = if let Some(t) = forced_tool {
|
||||
// A forced tool call is honoured even if the actor died.
|
||||
tool_call_response(&t.name, &t.fallback_arguments)
|
||||
} else if !references.is_empty() {
|
||||
// Degrade to the best advisory answer rather than fail outright.
|
||||
fallback_worker_response(references)
|
||||
} else {
|
||||
error_response(
|
||||
&format!("Actor failed (tried {attempts}): {err}"),
|
||||
MOA_ERR_ALL_REDUCERS_FAILED,
|
||||
)
|
||||
};
|
||||
(body, fallback_actor_name, false, attempts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Map the actor's classified output to an OpenAI response body.
|
||||
fn actor_body(
|
||||
acted: &WorkerOutput,
|
||||
forced_tool: Option<&ForcedToolChoice>,
|
||||
references: &[WorkerOutput],
|
||||
) -> Value {
|
||||
match acted.kind {
|
||||
// The whole point: the actor emits the executable tool call.
|
||||
normalize::OutputKind::ToolProposal => tool_proposal_response(acted, true),
|
||||
normalize::OutputKind::Uncertainty => match forced_tool {
|
||||
Some(t) => tool_call_response(&t.name, &t.fallback_arguments),
|
||||
None => fallback_worker_response(references),
|
||||
},
|
||||
// Actor chose to answer directly (tool available but not needed).
|
||||
_ => match forced_tool {
|
||||
Some(t) => tool_call_response(&t.name, &t.fallback_arguments),
|
||||
None => chat_response(&acted.payload),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::backend::ModelEntry;
|
||||
use std::time::Duration;
|
||||
|
||||
fn config_with(models: &[&str], policy: ReferencePolicy) -> GatewayConfig {
|
||||
GatewayConfig {
|
||||
backends: Vec::new(),
|
||||
models: models
|
||||
.iter()
|
||||
.map(|n| ModelEntry {
|
||||
name: (*n).to_string(),
|
||||
backend_index: 0,
|
||||
})
|
||||
.collect(),
|
||||
worker_timeout: Duration::from_secs(60),
|
||||
hedge_delay: Duration::from_secs(5),
|
||||
reducer_timeout: Duration::from_secs(60),
|
||||
first_answer_grace: Duration::ZERO,
|
||||
strong_patience: Duration::ZERO,
|
||||
enable_thinking: Some(false),
|
||||
actor_candidates: Vec::new(),
|
||||
reference_policy: policy,
|
||||
refinement_policy: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
const POOL: &[&str] = &["Qwen3-32B", "Qwen3-8B", "Ministral-8B"];
|
||||
|
||||
/// A strong actor is measurably worse with advice (-0.037 net uplift), so
|
||||
/// Auto must let it act alone.
|
||||
#[test]
|
||||
fn auto_skips_references_for_a_big_tier_actor() {
|
||||
let config = config_with(POOL, ReferencePolicy::Auto);
|
||||
assert!(!should_gather_references(&config, Some("Qwen3-32B")));
|
||||
}
|
||||
|
||||
/// A weak actor has headroom advice can fill (+0.017), so Auto advises it.
|
||||
#[test]
|
||||
fn auto_gathers_references_for_a_small_tier_actor() {
|
||||
let config = config_with(POOL, ReferencePolicy::Auto);
|
||||
assert!(should_gather_references(&config, Some("Qwen3-8B")));
|
||||
}
|
||||
|
||||
/// Unknown actor keeps the prior behaviour rather than silently degrading.
|
||||
#[test]
|
||||
fn auto_advises_when_the_actor_is_unknown() {
|
||||
let config = config_with(POOL, ReferencePolicy::Auto);
|
||||
assert!(should_gather_references(&config, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_policies_override_actor_strength() {
|
||||
let never = config_with(POOL, ReferencePolicy::Never);
|
||||
assert!(!should_gather_references(&never, Some("Qwen3-8B")));
|
||||
|
||||
let always = config_with(POOL, ReferencePolicy::Always);
|
||||
assert!(should_gather_references(&always, Some("Qwen3-32B")));
|
||||
}
|
||||
|
||||
/// Nobody left to advise once the actor is excluded.
|
||||
#[test]
|
||||
fn a_single_model_pool_never_gathers_references() {
|
||||
for policy in [
|
||||
ReferencePolicy::Auto,
|
||||
ReferencePolicy::Always,
|
||||
ReferencePolicy::Never,
|
||||
] {
|
||||
let config = config_with(&["Qwen3-8B"], policy);
|
||||
assert!(
|
||||
!should_gather_references(&config, Some("Qwen3-8B")),
|
||||
"{policy:?} must not advise a one-model pool"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_is_the_default_policy() {
|
||||
assert_eq!(ReferencePolicy::default(), ReferencePolicy::Auto);
|
||||
}
|
||||
}
|
||||
|
|
@ -130,6 +130,56 @@ pub fn has_quality_gap<'a>(workers: impl IntoIterator<Item = (&'a str, WorkerRol
|
|||
///
|
||||
/// Mirrors `pick_model_classified`'s sizing heuristic in the main
|
||||
/// router so MoA picks the same "strong" model as `auto` would.
|
||||
/// Public size-tier classifier for out-of-crate callers (the host uses it to
|
||||
/// break ties when ranking actor candidates by capability).
|
||||
///
|
||||
/// True when the model name advertises a single-digit billion-parameter count
|
||||
/// (1B–9B) — the "small tier". Multi-digit sizes (31B, 70B) and names that
|
||||
/// encode no size (MiniMax-M2.5) are big-tier. Same heuristic MoA uses
|
||||
/// internally for role assignment, exposed so the host doesn't re-implement it.
|
||||
pub fn model_name_is_small_tier(name: &str) -> bool {
|
||||
is_single_digit_b_name(name)
|
||||
}
|
||||
|
||||
/// Canonical base of a model name, mirroring the host's dedup logic: lowercase,
|
||||
/// drop an `@branch` segment (keeping any `:quant` tag), strip common
|
||||
/// prefixes/suffixes, keep only alphanumerics. Two aliases of the same model
|
||||
/// map to the same base.
|
||||
pub fn canonical_base_name(name: &str) -> String {
|
||||
let lower = name.to_lowercase();
|
||||
let no_branch = match lower.find('@') {
|
||||
Some(at) => {
|
||||
let after = &lower[at + 1..];
|
||||
let rest = after.find(':').map(|c| &after[c..]).unwrap_or("");
|
||||
format!("{}{}", &lower[..at], rest)
|
||||
}
|
||||
None => lower,
|
||||
};
|
||||
no_branch
|
||||
.replace("-gguf", "")
|
||||
.replace("unsloth/", "")
|
||||
.replace("meshllm/", "")
|
||||
.chars()
|
||||
.filter(char::is_ascii_alphanumeric)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A pool is homogeneous when every member shares one canonical base — i.e. it
|
||||
/// is the same model, possibly as repeated instances or quant variants.
|
||||
///
|
||||
/// Refinement is most valuable exactly here: identical/near-identical members
|
||||
/// produce correlated drafts, and the cross-peer round is what pulls them apart
|
||||
/// (measured: same-model 32B ×2 wins 48/2 with refinement vs 35/10 without,
|
||||
/// while a diverse strong pool is ~unchanged). See
|
||||
/// `evals/moa-openrouter/RESULTS.md`.
|
||||
pub fn pool_is_homogeneous(models: &[crate::backend::ModelEntry]) -> bool {
|
||||
let mut bases = models.iter().map(|m| canonical_base_name(&m.name));
|
||||
match bases.next() {
|
||||
Some(first) => bases.all(|b| b == first),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_single_digit_b_name(name: &str) -> bool {
|
||||
let bytes = name.as_bytes();
|
||||
for i in 0..bytes.len() {
|
||||
|
|
@ -230,6 +280,45 @@ pub fn extract_thinking(text: &str) -> String {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::backend::ModelEntry;
|
||||
|
||||
fn entries(names: &[&str]) -> Vec<ModelEntry> {
|
||||
names
|
||||
.iter()
|
||||
.map(|n| ModelEntry {
|
||||
name: (*n).to_string(),
|
||||
backend_index: 0,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn homogeneous_pool_detects_repeated_instances() {
|
||||
assert!(pool_is_homogeneous(&entries(&["Qwen3-32B", "Qwen3-32B"])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn homogeneous_pool_matches_aliases_of_one_model() {
|
||||
// Same base once prefixes/-gguf/@branch are normalised away.
|
||||
assert!(pool_is_homogeneous(&entries(&[
|
||||
"Qwen3-8B",
|
||||
"unsloth/Qwen3-8B",
|
||||
"Qwen3-8B@main",
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_models_are_not_homogeneous() {
|
||||
assert!(!pool_is_homogeneous(&entries(&[
|
||||
"Qwen3-8B",
|
||||
"Llama-3.1-8B"
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_pool_is_not_homogeneous() {
|
||||
assert!(!pool_is_homogeneous(&[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_chars_shorter_than_limit_is_passthrough() {
|
||||
|
|
|
|||
174
crates/mesh-mixture-of-agents/tests/diag_small_pool.rs
Normal file
174
crates/mesh-mixture-of-agents/tests/diag_small_pool.rs
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
//! Diagnostic: why does an all-small pool win in the eval rig but not through
|
||||
//! `handle_turn`? Dumps every request the shipped path sends (worker prompts +
|
||||
//! reducer prompt), plus how many drafts actually reached synthesis, so the
|
||||
//! difference can be *seen* rather than hypothesised.
|
||||
//!
|
||||
//! Not an assertion test — run with --nocapture and read the output.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use mesh_mixture_of_agents as moa;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Records every request body it is handed, then returns a canned answer.
|
||||
struct RecordingBackend {
|
||||
name: String,
|
||||
reply: String,
|
||||
delay: Duration,
|
||||
log: Arc<Mutex<Vec<(String, Value)>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl moa::ModelBackend for RecordingBackend {
|
||||
async fn chat_completion(
|
||||
&self,
|
||||
model: &str,
|
||||
messages: &[Value],
|
||||
tools: Option<&Value>,
|
||||
max_tokens: u32,
|
||||
_timeout: Duration,
|
||||
params: moa::SamplingParams,
|
||||
) -> Result<Value, String> {
|
||||
self.log.lock().unwrap().push((
|
||||
self.name.clone(),
|
||||
json!({
|
||||
"model": model,
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": params.temperature,
|
||||
"top_p": params.top_p,
|
||||
"enable_thinking": params.enable_thinking,
|
||||
"has_tools": tools.is_some(),
|
||||
"messages": messages,
|
||||
}),
|
||||
));
|
||||
if !self.delay.is_zero() {
|
||||
tokio::time::sleep(self.delay).await;
|
||||
}
|
||||
Ok(json!({
|
||||
"choices": [{"message": {"content": self.reply}, "finish_reason": "stop"}]
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn user_turn(content: &str) -> Value {
|
||||
json!({
|
||||
"model": "mesh",
|
||||
"messages": [{"role": "user", "content": content}],
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn dump_all_small_pool_requests() {
|
||||
let log: Arc<Mutex<Vec<(String, Value)>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
// Six same-tier 8B-class peers, distinct answers so synthesis has real
|
||||
// material and nothing can be mistaken for consensus.
|
||||
let names = [
|
||||
"qwen3-8b",
|
||||
"llama-3.1-8b",
|
||||
"granite-4.1-8b",
|
||||
"ministral-8b",
|
||||
"qwen2.5-7b",
|
||||
"qwen3.5-9b",
|
||||
];
|
||||
let mut backends: Vec<Arc<dyn moa::ModelBackend>> = Vec::new();
|
||||
let mut models = Vec::new();
|
||||
for (i, n) in names.iter().enumerate() {
|
||||
models.push(moa::ModelEntry {
|
||||
name: (*n).to_string(),
|
||||
backend_index: i,
|
||||
});
|
||||
backends.push(Arc::new(RecordingBackend {
|
||||
name: (*n).to_string(),
|
||||
reply: format!("DRAFT-FROM-{n}: backpressure means slowing the producer."),
|
||||
delay: Duration::ZERO,
|
||||
log: log.clone(),
|
||||
}));
|
||||
}
|
||||
|
||||
let config = moa::GatewayConfig {
|
||||
backends,
|
||||
models,
|
||||
worker_timeout: Duration::from_secs(90),
|
||||
hedge_delay: Duration::from_secs(5),
|
||||
reducer_timeout: Duration::from_secs(60),
|
||||
first_answer_grace: Duration::from_secs(10),
|
||||
strong_patience: Duration::from_secs(20),
|
||||
enable_thinking: None,
|
||||
actor_candidates: Vec::new(),
|
||||
reference_policy: Default::default(),
|
||||
refinement_policy: Default::default(),
|
||||
};
|
||||
|
||||
let prompt = "Explain backpressure in distributed systems.";
|
||||
let result = moa::handle_turn(&config, &user_turn(prompt)).await;
|
||||
|
||||
let entries = log.lock().unwrap().clone();
|
||||
println!("\n================ SHIPPED handle_turn ================");
|
||||
println!(
|
||||
"turn_kind={:?} reducer_used={} reducer_attempts={} workers_dispatched={}",
|
||||
result.turn_kind,
|
||||
result.reducer_used,
|
||||
result.reducer_attempts,
|
||||
result.worker_summaries.len()
|
||||
);
|
||||
println!("total backend calls: {}", entries.len());
|
||||
|
||||
for (i, (who, body)) in entries.iter().enumerate() {
|
||||
let msgs = body["messages"].as_array().cloned().unwrap_or_default();
|
||||
let is_reducer = msgs.iter().any(|m| {
|
||||
m["content"]
|
||||
.as_str()
|
||||
.map(|s| s.contains("Worker outputs") || s.contains("Synthesize"))
|
||||
.unwrap_or(false)
|
||||
});
|
||||
println!(
|
||||
"\n--- call {i}: {who} {} | max_tokens={} temp={} thinking={:?} ---",
|
||||
if is_reducer { "[REDUCER]" } else { "[worker]" },
|
||||
body["max_tokens"],
|
||||
body["temperature"],
|
||||
body["enable_thinking"],
|
||||
);
|
||||
for m in &msgs {
|
||||
let role = m["role"].as_str().unwrap_or("?");
|
||||
let content = m["content"].as_str().unwrap_or("");
|
||||
println!(" [{role}] {content}");
|
||||
}
|
||||
}
|
||||
|
||||
// How many drafts actually reached synthesis?
|
||||
let reducer_call = entries.iter().find(|(_, b)| {
|
||||
b["messages"]
|
||||
.as_array()
|
||||
.map(|ms| {
|
||||
ms.iter().any(|m| {
|
||||
m["content"]
|
||||
.as_str()
|
||||
.map(|s| s.contains("Worker outputs"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
})
|
||||
.unwrap_or(false)
|
||||
});
|
||||
match reducer_call {
|
||||
Some((_, b)) => {
|
||||
let sys = b["messages"][0]["content"].as_str().unwrap_or("");
|
||||
let n = sys.matches("[Response").count();
|
||||
println!(
|
||||
"\n>>> DRAFTS THAT REACHED SYNTHESIS: {n} of {}",
|
||||
names.len()
|
||||
);
|
||||
}
|
||||
None => println!("\n>>> NO REDUCER CALL — synthesis never ran"),
|
||||
}
|
||||
|
||||
println!("\n================ RIG (what won 12W/2L) ================");
|
||||
println!("peer draft call : messages=[user: <prompt>] max_tokens=1024 temp=0.8");
|
||||
println!(
|
||||
"synthesis call : messages=[system: COMMITTEE_SYNTH_PROMPT + \"Candidate responses:\" \
|
||||
+ [Response N] x6, user: <prompt>] max_tokens=1024 temp=0.3"
|
||||
);
|
||||
println!("(no MoA preamble on peers, no agent system prompt, no truncation)\n");
|
||||
}
|
||||
2745
crates/mesh-mixture-of-agents/tests/eval_openrouter.rs
Normal file
2745
crates/mesh-mixture-of-agents/tests/eval_openrouter.rs
Normal file
File diff suppressed because it is too large
Load diff
49
crates/mesh-mixture-of-agents/tests/fixtures/ablation_tasks.json
vendored
Normal file
49
crates/mesh-mixture-of-agents/tests/fixtures/ablation_tasks.json
vendored
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
{
|
||||
"_preregistration": "Authored before running the scaled study. Do not edit labels in response to results. 40 tasks, 4 strata x 10. This measures TOOL-SELECTION COHERENCE (did the actor pick a reasonable tool + arguments), not end-to-end agent task success. accept_tools is a SET: pass iff the emitted tool is in the set (empty set = pass iff NO tool call). arg_must_contain / arg_must_not_contain apply to the chosen tool's argument JSON when present.",
|
||||
"tools": ["list_dir", "read_file", "search", "run_command"],
|
||||
"tasks": [
|
||||
{"id": "inspect_src", "category": "inspect", "prompt": "I'm new to this Rust project. Start by looking at what's in the src directory.", "accept_tools": ["list_dir"], "arg_must_contain": "src"},
|
||||
{"id": "inspect_read_cargo", "category": "inspect", "prompt": "Read the project's Cargo.toml so we can see its dependencies.", "accept_tools": ["read_file"], "arg_must_contain": "Cargo.toml"},
|
||||
{"id": "inspect_tests_dir", "category": "inspect", "prompt": "What test files are in the tests directory?", "accept_tools": ["list_dir"], "arg_must_contain": "test"},
|
||||
{"id": "inspect_readme", "category": "inspect", "prompt": "Open the README so I can see the project overview.", "accept_tools": ["read_file"], "arg_must_contain": "README"},
|
||||
{"id": "inspect_network_mod", "category": "inspect", "prompt": "List what's inside the src/network directory.", "accept_tools": ["list_dir"], "arg_must_contain": "network"},
|
||||
{"id": "inspect_main_entry", "category": "inspect", "prompt": "Read the main entry point file, src/main.rs.", "accept_tools": ["read_file"], "arg_must_contain": "main.rs"},
|
||||
{"id": "inspect_toplevel", "category": "inspect", "prompt": "Show me the top-level files and folders in this repository.", "accept_tools": ["list_dir"], "arg_must_contain": null},
|
||||
{"id": "inspect_workflows", "category": "inspect", "prompt": "What GitHub Actions workflows exist? Look in .github/workflows.", "accept_tools": ["list_dir"], "arg_must_contain": "workflows"},
|
||||
{"id": "inspect_read_config", "category": "inspect", "prompt": "Read the config file at src/config.rs.", "accept_tools": ["read_file"], "arg_must_contain": "config"},
|
||||
{"id": "inspect_docs", "category": "inspect", "prompt": "What documents are in the docs folder?", "accept_tools": ["list_dir"], "arg_must_contain": "docs"},
|
||||
|
||||
{"id": "search_error_ctor", "category": "search", "prompt": "Find every place MeshError::Timeout is constructed in this repo.", "accept_tools": ["search"], "arg_must_contain": "Timeout"},
|
||||
{"id": "search_todos", "category": "search", "prompt": "Find all TODO comments in the source.", "accept_tools": ["search"], "arg_must_contain": "TODO"},
|
||||
{"id": "search_fn_def", "category": "search", "prompt": "Where is the function handle_turn defined?", "accept_tools": ["search"], "arg_must_contain": "handle_turn"},
|
||||
{"id": "search_spawn", "category": "search", "prompt": "Find all the places that call tokio::spawn.", "accept_tools": ["search"], "arg_must_contain": "spawn"},
|
||||
{"id": "search_literal", "category": "search", "prompt": "Search the codebase for the string actor_candidates.", "accept_tools": ["search"], "arg_must_contain": "actor_candidates"},
|
||||
{"id": "search_tests_matching", "category": "search", "prompt": "Find all test functions whose name contains early_decision.", "accept_tools": ["search"], "arg_must_contain": "early_decision"},
|
||||
{"id": "search_type_def", "category": "search", "prompt": "Where is the enum CapabilityLevel defined?", "accept_tools": ["search"], "arg_must_contain": "CapabilityLevel"},
|
||||
{"id": "search_unwrap", "category": "search", "prompt": "Find all uses of .unwrap() so we can audit them.", "accept_tools": ["search"], "arg_must_contain": "unwrap"},
|
||||
{"id": "search_struct", "category": "search", "prompt": "Find where the struct GatewayConfig is declared.", "accept_tools": ["search"], "arg_must_contain": "GatewayConfig"},
|
||||
{"id": "search_log_error", "category": "search", "prompt": "Find every place we emit an error-level log line.", "accept_tools": ["search"], "arg_must_contain": null},
|
||||
|
||||
{"id": "exec_run_tests", "category": "execute", "prompt": "Run the test suite for this project.", "accept_tools": ["run_command"], "arg_must_contain": "test"},
|
||||
{"id": "exec_triage_tests", "category": "execute", "prompt": "The test suite is failing. Run the tests to find out which test fails and why.", "accept_tools": ["run_command"], "arg_must_contain": "test"},
|
||||
{"id": "exec_build", "category": "execute", "prompt": "Check whether the project compiles right now.", "accept_tools": ["run_command"], "arg_must_contain": null},
|
||||
{"id": "exec_clippy", "category": "execute", "prompt": "Run the linter (cargo clippy) and report any warnings.", "accept_tools": ["run_command"], "arg_must_contain": "clippy"},
|
||||
{"id": "exec_git_status", "category": "execute", "prompt": "What's the current git status of the working tree?", "accept_tools": ["run_command"], "arg_must_contain": "git"},
|
||||
{"id": "exec_bench", "category": "execute", "prompt": "Run the benchmark suite so we can see current throughput.", "accept_tools": ["run_command"], "arg_must_contain": null},
|
||||
{"id": "exec_rust_version", "category": "execute", "prompt": "What version of the Rust toolchain is installed here?", "accept_tools": ["run_command"], "arg_must_contain": null},
|
||||
{"id": "exec_fmt", "category": "execute", "prompt": "Format all the code with the standard formatter.", "accept_tools": ["run_command"], "arg_must_contain": "fmt"},
|
||||
{"id": "exec_named_test", "category": "execute", "prompt": "Run just the test named early_decision_single_survivor.", "accept_tools": ["run_command"], "arg_must_contain": null},
|
||||
{"id": "exec_git_log", "category": "execute", "prompt": "Show the last few git commits.", "accept_tools": ["run_command"], "arg_must_contain": "git"},
|
||||
|
||||
{"id": "notool_question_op", "category": "no_tool", "prompt": "Answer from your own knowledge, do not look at any files: what does the Rust ? operator do?", "accept_tools": [], "arg_must_contain": null},
|
||||
{"id": "notool_vec_vecdeque", "category": "no_tool", "prompt": "From your own knowledge only: what's the difference between Vec and VecDeque in Rust?", "accept_tools": [], "arg_must_contain": null},
|
||||
{"id": "notool_merkle", "category": "no_tool", "prompt": "Explain what a Merkle tree is, in two sentences. Do not use any tools.", "accept_tools": [], "arg_must_contain": null},
|
||||
{"id": "notool_async", "category": "no_tool", "prompt": "Explain async/await in Rust conceptually. Answer directly without tools.", "accept_tools": [], "arg_must_contain": null},
|
||||
{"id": "notool_capital", "category": "no_tool", "prompt": "What is the capital of Australia? Just answer.", "accept_tools": [], "arg_must_contain": null},
|
||||
{"id": "notool_mutex", "category": "no_tool", "prompt": "Explain what a mutex is and why you'd use one. No tools needed.", "accept_tools": [], "arg_must_contain": null},
|
||||
{"id": "notool_static", "category": "no_tool", "prompt": "From your own knowledge: what does the 'static lifetime mean in Rust?", "accept_tools": [], "arg_must_contain": null},
|
||||
{"id": "notool_actor_model", "category": "no_tool", "prompt": "Summarize the actor model of concurrency in a short paragraph. Do not look at files.", "accept_tools": [], "arg_must_contain": null},
|
||||
{"id": "notool_arithmetic", "category": "no_tool", "prompt": "What is 17 multiplied by 23? Just give the number.", "accept_tools": [], "arg_must_contain": null},
|
||||
{"id": "notool_borrow", "category": "no_tool", "prompt": "Explain the Rust borrow checker in one paragraph, from your own knowledge.", "accept_tools": [], "arg_must_contain": null}
|
||||
]
|
||||
}
|
||||
48
crates/mesh-mixture-of-agents/tests/fixtures/committee_tasks.json
vendored
Normal file
48
crates/mesh-mixture-of-agents/tests/fixtures/committee_tasks.json
vendored
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
"_note": "Realistic agent-session REASONING/ANSWER turns (not tool selection) — the open-ended, quality-varying turns where a committee of diverse peers should help per Together's MoA. Some prompts embed simulated prior context (tool output / code) as an agent loop would. Judged for answer quality by an out-of-pool judge, not label-match. 40 prompts, 4 strata x 10. Preregistered: authored before the scaled run.",
|
||||
"tasks": [
|
||||
{"id": "reason_root_cause", "category": "reason_over_output", "prompt": "A test failed with this output:\n\n```\nthread 'mesh::gossip::tests::peer_merge' panicked at crates/mesh-llm-host-runtime/src/mesh/gossip.rs:412:\nassertion `left == right` failed\n left: 3\n right: 2\n```\nThe test merges two peer sets and asserts the count. What are the most likely root causes, and what would you check first?"},
|
||||
{"id": "reason_diff_review", "category": "reason_over_output", "prompt": "Review this change for correctness and risks:\n\n```rust\npub fn dedup_peers(mut peers: Vec<Peer>) -> Vec<Peer> {\n peers.sort_by_key(|p| p.id);\n peers.dedup_by_key(|p| p.id);\n peers\n}\n```\nThe caller expects the most recently-seen peer to win on duplicate ids. Is that guaranteed here?"},
|
||||
{"id": "reason_log_triage", "category": "reason_over_output", "prompt": "A node logs this repeatedly then stops serving:\n\n```\nWARN moa: reducer hedge -> qwen3-32b\nWARN moa: all 3 reducer candidates failed: HTTP 504\nWARN target unhealthy, cooldown 30s\n```\nWhat is most likely happening across the mesh, and what's the first thing to rule out?"},
|
||||
{"id": "reason_perf", "category": "reason_over_output", "prompt": "Inference throughput dropped from 40 tok/s to 12 tok/s after enabling a second model on the same GPU. No OOM. What are the likely causes in priority order?"},
|
||||
{"id": "reason_data", "category": "reason_over_output", "prompt": "A/B on two routing strategies:\n\n```\nstrategy A: p50=180ms p99=2100ms error=0.4%\nstrategy B: p50=150ms p99=5200ms error=0.2%\n```\nWhich would you ship for an interactive agent, and what would change your mind?"},
|
||||
{"id": "reason_flaky", "category": "reason_over_output", "prompt": "A test passes locally 20/20 times but fails ~15% of the time in CI. The test spawns two async tasks and asserts on a shared counter. What classes of bug produce exactly this signature, and how would you confirm which one it is?"},
|
||||
{"id": "reason_memory", "category": "reason_over_output", "prompt": "RSS grows steadily from 400MB to 6GB over 12 hours of serving, then the process is OOM-killed. Heap profiler shows no single dominant allocation site. What explanations fit this shape, and what would you measure next?"},
|
||||
{"id": "reason_regression", "category": "reason_over_output", "prompt": "After a dependency bump, one integration test now fails with `connection reset by peer` but only on Linux CI, never on macOS. Walk through how you'd narrow this down without bisecting the whole dependency tree."},
|
||||
{"id": "reason_metric_conflict", "category": "reason_over_output", "prompt": "Dashboards show request success rate at 99.8%, but users report frequent failures. Both measurements are probably correct. Explain how that can happen and what to instrument to reconcile them."},
|
||||
{"id": "reason_race_output", "category": "reason_over_output", "prompt": "This appears in logs during shutdown:\n\n```\nINFO worker 3 finished\nINFO shutdown complete\nERROR worker 3 write after close\n```\nThe messages are out of causal order. What does that tell you about the shutdown path?"},
|
||||
|
||||
{"id": "plan_fix", "category": "planning", "prompt": "We need to add per-request timeouts to an existing HTTP proxy that currently has none, without breaking streaming responses. Outline a concrete implementation plan with the main risks."},
|
||||
{"id": "plan_migration", "category": "planning", "prompt": "We're adding a new optional field to a gossip protocol message that older nodes must still parse. Describe the safe rollout plan and what to verify for mixed-version meshes."},
|
||||
{"id": "plan_debug", "category": "planning", "prompt": "Intermittent 5% of inference requests return empty responses under load, not reproducible locally. Lay out a debugging plan that would isolate the cause without disrupting production."},
|
||||
{"id": "reason_ambiguous_req", "category": "planning", "prompt": "A user says 'make the mesh faster'. Before writing any code, what clarifying questions and measurements would you establish, and what are the top candidate levers?"},
|
||||
{"id": "plan_test", "category": "planning", "prompt": "Design a test strategy to validate that a change to request routing doesn't break mixed-version meshes, given you have two machines with different hardware."},
|
||||
{"id": "plan_rollback", "category": "planning", "prompt": "A release went out 40 minutes ago. Error rate is up 3x but not catastrophic, and the change touched both a schema migration and the request path. Plan the next 30 minutes."},
|
||||
{"id": "plan_refactor", "category": "planning", "prompt": "A 3,000-line module owns request routing, retry policy, and metrics. We want to split it without a big-bang rewrite while other people keep shipping features in it. Plan the sequence."},
|
||||
{"id": "plan_cache", "category": "planning", "prompt": "We want to add a response cache in front of an expensive model call. Plan it: what to key on, what must never be cached, how to invalidate, and how you'd prove it isn't serving stale answers."},
|
||||
{"id": "plan_capacity", "category": "planning", "prompt": "Traffic is expected to 5x in six weeks. Current bottleneck is unknown. Plan the work to be ready, in priority order, assuming you can't just buy 5x the hardware."},
|
||||
{"id": "plan_observability", "category": "planning", "prompt": "A distributed system has logs but no tracing, and debugging cross-node issues takes days. Plan an incremental path to useful tracing without instrumenting everything at once."},
|
||||
|
||||
{"id": "explain_concept", "category": "explain", "prompt": "Explain the difference between hedged requests and retries in a distributed system, and when each is the wrong choice."},
|
||||
{"id": "explain_tradeoff", "category": "explain", "prompt": "Explain the tradeoffs between synthesizing multiple model answers versus routing to the single best model, for an interactive coding agent."},
|
||||
{"id": "explain_code", "category": "explain", "prompt": "Explain what this does and any subtle behavior:\n\n```rust\nlet grace = tokio::select! {\n biased;\n r = js.join_next() => r,\n _ = tokio::time::sleep(remaining), if armed => return decide(&outputs),\n};\n```"},
|
||||
{"id": "compare_approaches", "category": "explain", "prompt": "For deduplicating model names advertised by different peers (e.g. 'unsloth/Qwen3-8B-GGUF:Q4_K_M' vs 'Qwen3-8B-Q4_K_M'), compare normalization-by-canonical-name against embedding similarity. Which is more appropriate and why?"},
|
||||
{"id": "explain_failure_mode", "category": "explain", "prompt": "In a mixture-of-agents system, explain the 'majority of weakness' failure mode and two distinct ways to prevent it."},
|
||||
{"id": "explain_backpressure", "category": "explain", "prompt": "Explain backpressure to someone who has only used unbounded queues. Include what actually goes wrong without it, and why 'just add a bigger queue' is usually the wrong fix."},
|
||||
{"id": "explain_idempotency", "category": "explain", "prompt": "Explain why retries require idempotency, what an idempotency key actually buys you, and a concrete case where retries silently corrupt state without one."},
|
||||
{"id": "explain_quantization", "category": "explain", "prompt": "Explain what quantizing a model to 4 bits actually does, what you give up, and why two 4-bit quantizations of the same model can behave noticeably differently."},
|
||||
{"id": "explain_consistency", "category": "explain", "prompt": "Explain eventual consistency using a gossip-based peer membership system as the example. Be concrete about what a client can and cannot rely on."},
|
||||
{"id": "explain_tail_latency", "category": "explain", "prompt": "Explain why average latency is a misleading metric for interactive systems, and what you'd track instead. Include why tail latency gets worse as you add more parallel dependencies."},
|
||||
|
||||
{"id": "review_error_handling", "category": "code_review", "prompt": "Review this for production readiness:\n\n```rust\npub async fn fetch(url: &str) -> String {\n let r = reqwest::get(url).await.unwrap();\n r.text().await.unwrap()\n}\n```\nBe specific about what fails and what you'd change first."},
|
||||
{"id": "review_lock_scope", "category": "code_review", "prompt": "Review for concurrency problems:\n\n```rust\nlet mut state = self.state.lock().await;\nlet peers = state.peers.clone();\nfor p in peers {\n let resp = self.client.get(&p.url).send().await?;\n state.record(p.id, resp.status());\n}\n```"},
|
||||
{"id": "review_unbounded", "category": "code_review", "prompt": "Review this worker pattern:\n\n```rust\nloop {\n let msg = rx.recv().await.unwrap();\n tokio::spawn(async move { handle(msg).await });\n}\n```\nWhat breaks under load, and what's the minimal change that fixes it?"},
|
||||
{"id": "review_error_swallow", "category": "code_review", "prompt": "Review:\n\n```rust\nfor peer in peers {\n if let Err(e) = sync(peer).await {\n tracing::debug!(\"sync failed: {e}\");\n }\n}\n```\nThe team reports that sync failures go unnoticed for days. Explain why and what you'd change."},
|
||||
{"id": "review_timeout_math", "category": "code_review", "prompt": "A client has a 30s timeout. It calls service A (25s timeout), which calls service B (25s timeout), which retries B' up to 3 times with 10s timeouts. Review this timeout budget and say what actually happens under partial failure."},
|
||||
{"id": "review_partial_write", "category": "code_review", "prompt": "Review:\n\n```rust\nlet mut f = File::create(path)?;\nf.write_all(&data)?;\n```\nThis is used to persist config that must survive a crash. What's wrong and what's the correct pattern?"},
|
||||
{"id": "review_retry_storm", "category": "code_review", "prompt": "Review this retry policy: on any error, retry immediately up to 5 times, then return the error. All 200 clients share this policy against one service. Explain the failure mode and give a concrete better policy."},
|
||||
{"id": "review_api_shape", "category": "code_review", "prompt": "Review this API:\n\n```rust\npub fn configure(a: bool, b: bool, c: bool, timeout: u64) -> Result<(), String>\n```\nCall sites look like `configure(true, false, true, 30)`. Suggest a better shape and justify it."},
|
||||
{"id": "review_test_quality", "category": "code_review", "prompt": "Review this test:\n\n```rust\n#[test]\nfn test_routing() {\n let r = route(\"model-a\");\n assert!(r.is_ok());\n}\n```\nWhat does it actually guarantee, and what would you replace it with?"},
|
||||
{"id": "review_cancellation", "category": "code_review", "prompt": "Review for cancellation-safety:\n\n```rust\ntokio::select! {\n _ = shutdown.recv() => return,\n result = write_batch(&mut buffer) => result?,\n}\n```\n`write_batch` drains `buffer` as it writes. What can go wrong on shutdown?"}
|
||||
]
|
||||
}
|
||||
6988
crates/mesh-mixture-of-agents/tests/fixtures/real_traces.json
vendored
Normal file
6988
crates/mesh-mixture-of-agents/tests/fixtures/real_traces.json
vendored
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -94,6 +94,9 @@ fn three_failing_backends() -> moa::GatewayConfig {
|
|||
first_answer_grace: Duration::ZERO,
|
||||
strong_patience: Duration::ZERO,
|
||||
enable_thinking: None,
|
||||
actor_candidates: Vec::new(),
|
||||
reference_policy: Default::default(),
|
||||
refinement_policy: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -102,6 +102,9 @@ fn build_config(
|
|||
first_answer_grace: Duration::ZERO,
|
||||
strong_patience: Duration::ZERO,
|
||||
enable_thinking,
|
||||
actor_candidates: Vec::new(),
|
||||
reference_policy: Default::default(),
|
||||
refinement_policy: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,204 @@
|
|||
//! Pin the partial-survival robustness contract: when *some* dispatched
|
||||
//! workers fail mid-turn but at least one answers, the turn must still complete
|
||||
//! with the survivor(s) — never hang, never fail.
|
||||
//!
|
||||
//! `sim_all_workers_fail` covers total failure (clean structured error). This
|
||||
//! covers the far more common mesh reality: nodes flicker, so a subset of the
|
||||
//! committee dies mid-turn while the rest answer. MoA must degrade to the
|
||||
//! survivors, not the error path.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use mesh_mixture_of_agents as moa;
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Answers with fixed text after a delay.
|
||||
struct AnswerBackend {
|
||||
text: String,
|
||||
delay: Duration,
|
||||
}
|
||||
|
||||
/// Always fails, simulating a peer that dropped mid-turn.
|
||||
struct DeadBackend;
|
||||
|
||||
impl AnswerBackend {
|
||||
fn new(text: impl Into<String>, delay: Duration) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
text: text.into(),
|
||||
delay,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl moa::ModelBackend for AnswerBackend {
|
||||
async fn chat_completion(
|
||||
&self,
|
||||
_model: &str,
|
||||
_messages: &[Value],
|
||||
_tools: Option<&Value>,
|
||||
_max_tokens: u32,
|
||||
_timeout: Duration,
|
||||
_sampling: moa::SamplingParams,
|
||||
) -> Result<Value, String> {
|
||||
tokio::time::sleep(self.delay).await;
|
||||
Ok(json!({"choices": [{"message": {"content": self.text}, "finish_reason": "stop"}]}))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl moa::ModelBackend for DeadBackend {
|
||||
async fn chat_completion(
|
||||
&self,
|
||||
_model: &str,
|
||||
_messages: &[Value],
|
||||
_tools: Option<&Value>,
|
||||
_max_tokens: u32,
|
||||
_timeout: Duration,
|
||||
_sampling: moa::SamplingParams,
|
||||
) -> Result<Value, String> {
|
||||
Err("peer dropped mid-turn".into())
|
||||
}
|
||||
}
|
||||
|
||||
fn config(backends: Vec<Arc<dyn moa::ModelBackend>>, names: &[&str]) -> moa::GatewayConfig {
|
||||
let models = names
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, n)| moa::ModelEntry {
|
||||
name: (*n).to_string(),
|
||||
backend_index: i,
|
||||
})
|
||||
.collect();
|
||||
moa::GatewayConfig {
|
||||
backends,
|
||||
models,
|
||||
worker_timeout: Duration::from_secs(5),
|
||||
hedge_delay: Duration::from_millis(50),
|
||||
reducer_timeout: Duration::from_secs(5),
|
||||
first_answer_grace: Duration::ZERO,
|
||||
strong_patience: Duration::ZERO,
|
||||
enable_thinking: Some(false),
|
||||
actor_candidates: Vec::new(),
|
||||
reference_policy: Default::default(),
|
||||
refinement_policy: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn request() -> Value {
|
||||
json!({
|
||||
"model": "mesh",
|
||||
"messages": [{"role": "user", "content": "explain backpressure"}],
|
||||
"max_tokens": 256,
|
||||
})
|
||||
}
|
||||
|
||||
/// Half the committee dies, the rest answer: the turn completes with the
|
||||
/// survivors, and their failure is accounted rather than hanging.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn turn_completes_when_some_workers_die() {
|
||||
let backends: Vec<Arc<dyn moa::ModelBackend>> = vec![
|
||||
AnswerBackend::new("queues fill up when the consumer is slow", Duration::ZERO),
|
||||
Arc::new(DeadBackend),
|
||||
AnswerBackend::new("it is a flow-control signal upstream", Duration::ZERO),
|
||||
Arc::new(DeadBackend),
|
||||
];
|
||||
let cfg = config(
|
||||
backends,
|
||||
&["Qwen3-8B", "Llama-3.1-8B", "Ministral-8B", "Granite-4.1-8B"],
|
||||
);
|
||||
|
||||
let result = moa::handle_turn(&cfg, &request()).await;
|
||||
|
||||
assert_ne!(
|
||||
result.turn_kind,
|
||||
moa::TurnKind::Failed,
|
||||
"turn must complete with survivors when only some workers fail"
|
||||
);
|
||||
let body = serde_json::to_string(&result.response_body).unwrap();
|
||||
assert!(
|
||||
body.contains("choices"),
|
||||
"a well-formed response must be returned, got: {body}"
|
||||
);
|
||||
// Every dispatched worker is accounted for, dead ones included — none is
|
||||
// silently dropped. We deliberately do NOT assert exact succeeded/failed
|
||||
// counts: early-exit consensus may abort a live worker once a usable answer
|
||||
// is in hand, so the split between "succeeded" and "aborted" is timing
|
||||
// dependent. The contract is that all four appear and the two dead ones are
|
||||
// recorded as not-succeeded.
|
||||
assert_eq!(
|
||||
result.worker_summaries.len(),
|
||||
4,
|
||||
"all four dispatched workers must appear in summaries"
|
||||
);
|
||||
let failed = result
|
||||
.worker_summaries
|
||||
.iter()
|
||||
.filter(|s| !s.succeeded)
|
||||
.count();
|
||||
assert!(
|
||||
failed >= 2,
|
||||
"the two dead workers must be recorded as failed, not dropped (got {failed})"
|
||||
);
|
||||
}
|
||||
|
||||
/// A single survivor is still enough: three of four die, the turn answers.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn turn_completes_with_a_lone_survivor() {
|
||||
let backends: Vec<Arc<dyn moa::ModelBackend>> = vec![
|
||||
Arc::new(DeadBackend),
|
||||
Arc::new(DeadBackend),
|
||||
AnswerBackend::new("the one worker that lived", Duration::ZERO),
|
||||
Arc::new(DeadBackend),
|
||||
];
|
||||
let cfg = config(
|
||||
backends,
|
||||
&["Qwen3-8B", "Llama-3.1-8B", "Ministral-8B", "Granite-4.1-8B"],
|
||||
);
|
||||
|
||||
let result = moa::handle_turn(&cfg, &request()).await;
|
||||
|
||||
assert_ne!(result.turn_kind, moa::TurnKind::Failed);
|
||||
let body = serde_json::to_string(&result.response_body).unwrap();
|
||||
assert!(body.contains("choices"), "got: {body}");
|
||||
}
|
||||
|
||||
/// A slow-dying worker must not hold the turn hostage: the survivor answers
|
||||
/// immediately and the turn does not wait out a long tail on the failing one.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_slow_failing_worker_does_not_stall_the_turn() {
|
||||
struct SlowDead;
|
||||
#[async_trait]
|
||||
impl moa::ModelBackend for SlowDead {
|
||||
async fn chat_completion(
|
||||
&self,
|
||||
_m: &str,
|
||||
_msg: &[Value],
|
||||
_t: Option<&Value>,
|
||||
_mt: u32,
|
||||
timeout: Duration,
|
||||
_s: moa::SamplingParams,
|
||||
) -> Result<Value, String> {
|
||||
// Fail near the worker timeout, not instantly.
|
||||
tokio::time::sleep(timeout.min(Duration::from_millis(400))).await;
|
||||
Err("slow drop".into())
|
||||
}
|
||||
}
|
||||
let backends: Vec<Arc<dyn moa::ModelBackend>> = vec![
|
||||
AnswerBackend::new("fast survivor A", Duration::ZERO),
|
||||
AnswerBackend::new("fast survivor B", Duration::ZERO),
|
||||
Arc::new(SlowDead),
|
||||
];
|
||||
let cfg = config(backends, &["Qwen3-8B", "Llama-3.1-8B", "Ministral-8B"]);
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
let result = moa::handle_turn(&cfg, &request()).await;
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert_ne!(result.turn_kind, moa::TurnKind::Failed);
|
||||
assert!(
|
||||
elapsed < Duration::from_secs(4),
|
||||
"a slow-failing worker must not stall the turn (took {elapsed:?})"
|
||||
);
|
||||
}
|
||||
642
crates/mesh-mixture-of-agents/tests/sim_real_traces.rs
Normal file
642
crates/mesh-mixture-of-agents/tests/sim_real_traces.rs
Normal file
|
|
@ -0,0 +1,642 @@
|
|||
//! Replay real recorded open-model traces through the MoA gateway.
|
||||
//!
|
||||
//! The fixture in `tests/fixtures/real_traces.json` was recorded from 9
|
||||
//! open-weight models on OpenRouter (see `evals/moa-openrouter/`). Each case
|
||||
//! holds every worker's **raw OpenAI-shaped response** — structured
|
||||
//! `tool_calls`, `content`, and `finish_reason` — plus its observed latency.
|
||||
//!
|
||||
//! That matters because the two things this suite is here to protect are
|
||||
//! exactly the two things you lose if you flatten worker responses to text:
|
||||
//!
|
||||
//! 1. `tool_calls` — Together's MoA aggregator reads `.content`, which is
|
||||
//! empty when a model returns a tool call, so its synthesis step receives
|
||||
//! a list of blank strings on every agentic turn.
|
||||
//! 2. `finish_reason` — 39 of the recorded responses came back `"length"`,
|
||||
//! and 24 of those carry *partial text*. Before truncation was plumbed
|
||||
//! through, a half-finished sentence entered arbitration as a normal
|
||||
//! answer at the default 0.5 confidence and could be returned verbatim.
|
||||
//!
|
||||
//! These replay through the real `HttpBackend`-shaped path: the fixture is
|
||||
//! served as a JSON body, so `extract_text_from_response` does the parsing
|
||||
//! under test rather than the test reconstructing its output.
|
||||
//!
|
||||
//! Not covered here: the HTTP-400 "Reasoning is mandatory" retry in
|
||||
//! `HttpBackend`. That needs a live endpoint (verified against
|
||||
//! minimax-m2.5, which failed 12/12 requests until the thinking flags were
|
||||
//! dropped) and is not reachable through a `ModelBackend` fake.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use mesh_mixture_of_agents as moa;
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Recorded latencies span 0.5s–30s. Replaying them literally would make
|
||||
/// this suite take minutes, so compress the timeline while preserving
|
||||
/// *ordering* — which is what early exit, first-answer grace, and strong
|
||||
/// patience actually key off.
|
||||
const LATENCY_DIVISOR: u64 = 40;
|
||||
|
||||
// ─── Fixture model ───────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
struct RecordedWorker {
|
||||
model: String,
|
||||
#[allow(dead_code)]
|
||||
tier: String,
|
||||
elapsed_ms: u64,
|
||||
finish_reason: Option<String>,
|
||||
content: Option<String>,
|
||||
tool_calls: Option<Value>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
impl RecordedWorker {
|
||||
/// Rebuild the exact OpenAI-shaped body this model returned.
|
||||
fn response_body(&self) -> Value {
|
||||
let mut message = json!({ "role": "assistant" });
|
||||
let obj = message.as_object_mut().unwrap();
|
||||
obj.insert(
|
||||
"content".to_string(),
|
||||
match &self.content {
|
||||
Some(c) => json!(c),
|
||||
None => Value::Null,
|
||||
},
|
||||
);
|
||||
if let Some(tcs) = &self.tool_calls {
|
||||
obj.insert("tool_calls".to_string(), tcs.clone());
|
||||
}
|
||||
json!({
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": message,
|
||||
"finish_reason": self.finish_reason.clone().unwrap_or_else(|| "stop".into()),
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
fn truncated(&self) -> bool {
|
||||
self.finish_reason.as_deref() == Some("length")
|
||||
}
|
||||
|
||||
/// Text a truncated worker would have contributed, if any. Used to assert
|
||||
/// it is never echoed back to the caller verbatim.
|
||||
fn partial_text(&self) -> Option<&str> {
|
||||
if !self.truncated() || self.tool_calls.is_some() {
|
||||
return None;
|
||||
}
|
||||
self.content
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|t| !t.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
struct RecordedCase {
|
||||
id: String,
|
||||
scenario: String,
|
||||
step: u32,
|
||||
has_tools: bool,
|
||||
messages: Vec<Value>,
|
||||
workers: Vec<RecordedWorker>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct Fixture {
|
||||
cases: Vec<RecordedCase>,
|
||||
}
|
||||
|
||||
fn load_fixture() -> Fixture {
|
||||
let raw = include_str!("fixtures/real_traces.json");
|
||||
serde_json::from_str(raw).expect("real_traces.json should parse")
|
||||
}
|
||||
|
||||
// ─── Replay backend ──────────────────────────────────────────────────
|
||||
|
||||
/// Text every replayed reducer returns. Distinct from any recorded worker
|
||||
/// payload so a test can tell synthesis apart from a verbatim relay.
|
||||
const SYNTHESIZED: &str = "SYNTHESIZED-BY-REDUCER";
|
||||
|
||||
/// Serves one recorded worker response after its recorded (compressed)
|
||||
/// latency. An `error` row fails the same way a dead peer would, so the
|
||||
/// durability paths get exercised rather than mocked away.
|
||||
///
|
||||
/// A reducer call is a *different* call with different input, so replaying the
|
||||
/// recorded worker body for it would be wrong — it would hand the reducer's
|
||||
/// slot back the same truncated text the worker produced, and any assertion
|
||||
/// about "what the caller received" would be measuring the fixture rather than
|
||||
/// the gateway. Reducer calls are detected by their packed context and answered
|
||||
/// with [`SYNTHESIZED`].
|
||||
struct ReplayBackend {
|
||||
worker: RecordedWorker,
|
||||
calls: AtomicUsize,
|
||||
}
|
||||
|
||||
impl ReplayBackend {
|
||||
fn new(worker: RecordedWorker) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
worker,
|
||||
calls: AtomicUsize::new(0),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Does this call carry reducer context? `pack_for_reducer_selected` always
|
||||
/// emits a `## Worker outputs` section; worker prompts never do.
|
||||
fn is_reducer_call(messages: &[Value]) -> bool {
|
||||
messages
|
||||
.first()
|
||||
.and_then(|m| m.get("content"))
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|s| s.contains("## Worker outputs"))
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl moa::ModelBackend for ReplayBackend {
|
||||
async fn chat_completion(
|
||||
&self,
|
||||
_model: &str,
|
||||
messages: &[Value],
|
||||
_tools: Option<&Value>,
|
||||
_max_tokens: u32,
|
||||
_timeout: Duration,
|
||||
sampling: moa::SamplingParams,
|
||||
) -> Result<Value, String> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
|
||||
// MoA policy: workers never think. If this ever regresses, the
|
||||
// recorded traces stop being representative of what we replay.
|
||||
assert_ne!(
|
||||
sampling.enable_thinking,
|
||||
Some(true),
|
||||
"MoA must not ask a worker to enable thinking"
|
||||
);
|
||||
|
||||
// The reducer is a fresh call, not a replay of this model's worker
|
||||
// turn. Answer it with synthesized text.
|
||||
if is_reducer_call(messages) {
|
||||
return Ok(json!({
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": SYNTHESIZED},
|
||||
"finish_reason": "stop",
|
||||
}]
|
||||
}));
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(
|
||||
(self.worker.elapsed_ms / LATENCY_DIVISOR).max(1),
|
||||
))
|
||||
.await;
|
||||
|
||||
match &self.worker.error {
|
||||
Some(e) => Err(e.clone()),
|
||||
None => Ok(self.worker.response_body()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn config_for(case: &RecordedCase) -> moa::GatewayConfig {
|
||||
let mut backends: Vec<Arc<dyn moa::ModelBackend>> = Vec::new();
|
||||
let mut models = Vec::new();
|
||||
for w in &case.workers {
|
||||
models.push(moa::ModelEntry {
|
||||
name: w.model.clone(),
|
||||
backend_index: backends.len(),
|
||||
});
|
||||
backends.push(ReplayBackend::new(w.clone()));
|
||||
}
|
||||
|
||||
moa::GatewayConfig {
|
||||
backends,
|
||||
models,
|
||||
worker_timeout: Duration::from_secs(10),
|
||||
hedge_delay: Duration::from_millis(200),
|
||||
reducer_timeout: Duration::from_secs(5),
|
||||
first_answer_grace: Duration::ZERO,
|
||||
strong_patience: Duration::ZERO,
|
||||
// Mirrors `effective_enable_thinking_for_moa`, which is now
|
||||
// unconditionally off.
|
||||
enable_thinking: Some(false),
|
||||
actor_candidates: Vec::new(),
|
||||
// This suite exists to verify fan-out accounting (every dispatched
|
||||
// worker attributed, aborted stragglers reconciled), so it pins the
|
||||
// fan-out on. Production defaults to `Auto`, which skips advisors for
|
||||
// a strong actor — covered by `tool_turn`'s gate tests.
|
||||
reference_policy: moa::ReferencePolicy::Always,
|
||||
refinement_policy: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn request_body(case: &RecordedCase) -> Value {
|
||||
let mut body = json!({
|
||||
"model": "mesh",
|
||||
"messages": case.messages,
|
||||
"max_tokens": 512,
|
||||
});
|
||||
if case.has_tools {
|
||||
// Same schemas the traces were recorded against.
|
||||
body.as_object_mut().unwrap().insert(
|
||||
"tools".to_string(),
|
||||
json!([
|
||||
tool_schema("list_dir", &[("path", "string")]),
|
||||
tool_schema("read_file", &[("path", "string")]),
|
||||
tool_schema("search", &[("pattern", "string"), ("path", "string")]),
|
||||
tool_schema("run_command", &[("cmd", "string")]),
|
||||
tool_schema(
|
||||
"edit_file",
|
||||
&[
|
||||
("path", "string"),
|
||||
("before", "string"),
|
||||
("after", "string")
|
||||
],
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
fn tool_schema(name: &str, params: &[(&str, &str)]) -> Value {
|
||||
let props: serde_json::Map<String, Value> = params
|
||||
.iter()
|
||||
.map(|(p, ty)| (p.to_string(), json!({"type": ty})))
|
||||
.collect();
|
||||
json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": name,
|
||||
"description": format!("{name} tool"),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": props,
|
||||
"required": params.iter().map(|(p, _)| *p).collect::<Vec<_>>(),
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn response_text(body: &Value) -> String {
|
||||
body.pointer("/choices/0/message/content")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn response_tool_calls(body: &Value) -> Vec<(String, String)> {
|
||||
body.pointer("/choices/0/message/tool_calls")
|
||||
.and_then(Value::as_array)
|
||||
.map(|tcs| {
|
||||
tcs.iter()
|
||||
.filter_map(|tc| {
|
||||
Some((
|
||||
tc.pointer("/function/name")?.as_str()?.to_string(),
|
||||
tc.pointer("/function/arguments")?.as_str()?.to_string(),
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────
|
||||
|
||||
/// The fixture must actually contain the shapes these tests rely on. If a
|
||||
/// re-record loses them, fail loudly here rather than passing vacuously
|
||||
/// everywhere else.
|
||||
#[test]
|
||||
fn fixture_covers_the_shapes_under_test() {
|
||||
let fx = load_fixture();
|
||||
assert!(fx.cases.len() >= 40, "expected a substantial corpus");
|
||||
|
||||
let truncated_partial = fx
|
||||
.cases
|
||||
.iter()
|
||||
.flat_map(|c| &c.workers)
|
||||
.filter(|w| w.partial_text().is_some())
|
||||
.count();
|
||||
assert!(
|
||||
truncated_partial >= 10,
|
||||
"fixture must contain truncated-with-partial-text responses \
|
||||
(the shape that could previously be returned verbatim); found {truncated_partial}"
|
||||
);
|
||||
|
||||
let with_tools = fx
|
||||
.cases
|
||||
.iter()
|
||||
.filter(|c| c.workers.iter().any(|w| w.tool_calls.is_some()))
|
||||
.count();
|
||||
assert!(
|
||||
with_tools >= 20,
|
||||
"fixture must contain agentic tool-call cases; found {with_tools}"
|
||||
);
|
||||
|
||||
let models: std::collections::BTreeSet<&str> = fx
|
||||
.cases
|
||||
.iter()
|
||||
.flat_map(|c| &c.workers)
|
||||
.map(|w| w.model.as_str())
|
||||
.collect();
|
||||
assert!(
|
||||
models.len() >= 5,
|
||||
"fixture should span a heterogeneous pool; found {models:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Truncated worker text must never reach the caller verbatim.
|
||||
///
|
||||
/// A response cut off at the token limit is a half-finished sentence. It may
|
||||
/// inform synthesis, but shipping it as the final answer is a bug — and it
|
||||
/// was reachable before `finish_reason` was plumbed through, because such an
|
||||
/// answer looked normal to the parser and carried the same default 0.5
|
||||
/// confidence as everyone else.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn truncated_worker_text_is_never_returned_verbatim() {
|
||||
let fx = load_fixture();
|
||||
let mut checked = 0usize;
|
||||
|
||||
for case in &fx.cases {
|
||||
let partials: Vec<String> = case
|
||||
.workers
|
||||
.iter()
|
||||
.filter_map(|w| w.partial_text().map(str::to_string))
|
||||
.collect();
|
||||
if partials.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let result = moa::handle_turn(&config_for(case), &request_body(case)).await;
|
||||
let text = response_text(&result.response_body);
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
for partial in &partials {
|
||||
assert_ne!(
|
||||
text.trim(),
|
||||
partial.as_str(),
|
||||
"case `{}` returned a truncated worker payload verbatim",
|
||||
case.id
|
||||
);
|
||||
checked += 1;
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
checked > 0,
|
||||
"no truncated payloads were exercised — fixture or filter is wrong"
|
||||
);
|
||||
}
|
||||
|
||||
/// Every recorded case must produce a well-formed response, or a clean
|
||||
/// structured failure when every worker died. No panics, no empty 200s.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn every_recorded_case_produces_a_wellformed_turn() {
|
||||
let fx = load_fixture();
|
||||
|
||||
for case in &fx.cases {
|
||||
let live = case.workers.iter().filter(|w| w.error.is_none()).count();
|
||||
let result = moa::handle_turn(&config_for(case), &request_body(case)).await;
|
||||
let body = &result.response_body;
|
||||
|
||||
// Worker accounting: every dispatched worker is attributed, even
|
||||
// when early-exit consensus aborted the stragglers.
|
||||
if result.turn_kind != moa::TurnKind::ToolResult {
|
||||
assert_eq!(
|
||||
result.worker_summaries.len(),
|
||||
case.workers.len(),
|
||||
"case `{}`: every dispatched worker must appear in worker_summaries",
|
||||
case.id
|
||||
);
|
||||
}
|
||||
|
||||
if live == 0 {
|
||||
assert_eq!(
|
||||
result.turn_kind,
|
||||
moa::TurnKind::Failed,
|
||||
"case `{}`: all-dead pool must fail cleanly",
|
||||
case.id
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let text = response_text(body);
|
||||
let tools = response_tool_calls(body);
|
||||
assert!(
|
||||
!text.trim().is_empty() || !tools.is_empty(),
|
||||
"case `{}` ({:?}) produced neither text nor a tool call: {body}",
|
||||
case.id,
|
||||
result.turn_kind
|
||||
);
|
||||
|
||||
// Any tool call we emit must carry a parseable JSON object for
|
||||
// `arguments` — agent harnesses reject anything else.
|
||||
for (name, args) in &tools {
|
||||
let parsed: Value = serde_json::from_str(args).unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"case `{}`: tool `{name}` args not JSON ({e}): {args:?}",
|
||||
case.id
|
||||
)
|
||||
});
|
||||
assert!(
|
||||
parsed.is_object(),
|
||||
"case `{}`: tool `{name}` arguments must be a JSON object, got {parsed}",
|
||||
case.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// When workers agree on a tool *name* but not its *arguments*, the winning
|
||||
/// arguments must be the ones the majority actually proposed.
|
||||
///
|
||||
/// This is the `explore_error_handling` step-0 shape: all 9 models call
|
||||
/// `list_dir`, 8 with `{"path": "src"}` and `mistral-small-3.2-24b` with
|
||||
/// `{"path": "rust_project/src"}` — a directory that does not exist. Every
|
||||
/// OpenAI-shape tool call is normalized to a fixed 0.9 confidence, so the
|
||||
/// old confidence-only tiebreak had no way to prefer the majority.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn majority_arguments_win_when_workers_agree_on_the_tool() {
|
||||
let fx = load_fixture();
|
||||
|
||||
let cases: Vec<&RecordedCase> = fx
|
||||
.cases
|
||||
.iter()
|
||||
.filter(|c| {
|
||||
if c.step != 0 || !c.has_tools {
|
||||
return false;
|
||||
}
|
||||
let live: Vec<&RecordedWorker> =
|
||||
c.workers.iter().filter(|w| w.error.is_none()).collect();
|
||||
if live.is_empty() || live.iter().any(|w| w.tool_calls.is_none()) {
|
||||
return false; // need an all-tool turn
|
||||
}
|
||||
let names: std::collections::BTreeSet<String> = live
|
||||
.iter()
|
||||
.filter_map(|w| w.tool_calls.as_ref())
|
||||
.flat_map(|tcs| tcs.as_array().cloned().unwrap_or_default())
|
||||
.filter_map(|tc| {
|
||||
tc.pointer("/function/name")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
})
|
||||
.collect();
|
||||
let args: std::collections::BTreeSet<String> = live
|
||||
.iter()
|
||||
.filter_map(|w| w.tool_calls.as_ref())
|
||||
.flat_map(|tcs| tcs.as_array().cloned().unwrap_or_default())
|
||||
.filter_map(|tc| {
|
||||
tc.pointer("/function/arguments")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
})
|
||||
.collect();
|
||||
names.len() == 1 && args.len() > 1
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
!cases.is_empty(),
|
||||
"fixture must contain a name-unanimous / args-divergent case"
|
||||
);
|
||||
|
||||
for case in cases {
|
||||
// Majority argument string among live tool proposals.
|
||||
let mut counts: std::collections::BTreeMap<String, usize> = Default::default();
|
||||
for w in case.workers.iter().filter(|w| w.error.is_none()) {
|
||||
for tc in w
|
||||
.tool_calls
|
||||
.as_ref()
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
{
|
||||
if let Some(a) = tc.pointer("/function/arguments").and_then(Value::as_str) {
|
||||
*counts.entry(a.to_string()).or_default() += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let (majority, majority_n) = counts
|
||||
.iter()
|
||||
.max_by_key(|(_, n)| **n)
|
||||
.map(|(a, n)| (a.clone(), *n))
|
||||
.unwrap();
|
||||
// Only meaningful when there genuinely is a majority.
|
||||
if majority_n < 2 {
|
||||
continue;
|
||||
}
|
||||
let minority: Vec<&String> = counts
|
||||
.keys()
|
||||
.filter(|a| **a != majority && counts[*a] < majority_n)
|
||||
.collect();
|
||||
if minority.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let result = moa::handle_turn(&config_for(case), &request_body(case)).await;
|
||||
let tools = response_tool_calls(&result.response_body);
|
||||
|
||||
// The reducer may legitimately rewrite the turn into prose; only
|
||||
// assert when a tool call was emitted from worker proposals.
|
||||
if tools.is_empty() || result.reducer_used {
|
||||
continue;
|
||||
}
|
||||
|
||||
let majority_val: Value = serde_json::from_str(&majority).unwrap_or(Value::Null);
|
||||
for (name, args) in &tools {
|
||||
let got: Value = serde_json::from_str(args).unwrap_or(Value::Null);
|
||||
assert_eq!(
|
||||
got, majority_val,
|
||||
"case `{}`: tool `{name}` should use the majority arguments \
|
||||
({majority_n} workers proposed {majority}), not a minority variant. \
|
||||
Got {args}",
|
||||
case.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Agentic turns must keep producing structured tool calls.
|
||||
///
|
||||
/// This is the property Together's design cannot hold: its aggregator reads
|
||||
/// `.content`, so on a turn where every worker returns a tool call it
|
||||
/// synthesizes from empty strings.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn unanimous_tool_turns_emit_a_structured_tool_call() {
|
||||
let fx = load_fixture();
|
||||
let mut asserted = 0usize;
|
||||
|
||||
for case in fx.cases.iter().filter(|c| c.step == 0 && c.has_tools) {
|
||||
let live: Vec<&RecordedWorker> =
|
||||
case.workers.iter().filter(|w| w.error.is_none()).collect();
|
||||
if live.is_empty() || live.iter().any(|w| w.tool_calls.is_none()) {
|
||||
continue;
|
||||
}
|
||||
let names: std::collections::BTreeSet<String> = live
|
||||
.iter()
|
||||
.filter_map(|w| w.tool_calls.as_ref())
|
||||
.flat_map(|tcs| tcs.as_array().cloned().unwrap_or_default())
|
||||
.filter_map(|tc| {
|
||||
tc.pointer("/function/name")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
})
|
||||
.collect();
|
||||
if names.len() != 1 {
|
||||
continue; // divergent tool choice may legitimately go to the reducer
|
||||
}
|
||||
let expected = names.into_iter().next().unwrap();
|
||||
|
||||
let result = moa::handle_turn(&config_for(case), &request_body(case)).await;
|
||||
let tools = response_tool_calls(&result.response_body);
|
||||
|
||||
assert!(
|
||||
!tools.is_empty(),
|
||||
"case `{}`: {} workers unanimously proposed `{expected}`, but the turn \
|
||||
returned no tool call (kind={:?}). Tool calls must survive fan-out.",
|
||||
case.id,
|
||||
live.len(),
|
||||
result.turn_kind,
|
||||
);
|
||||
for (name, _) in &tools {
|
||||
assert_eq!(
|
||||
name, &expected,
|
||||
"case `{}`: emitted tool `{name}` but every worker proposed `{expected}`",
|
||||
case.id
|
||||
);
|
||||
}
|
||||
asserted += 1;
|
||||
}
|
||||
|
||||
assert!(
|
||||
asserted > 0,
|
||||
"no unanimous tool turns exercised — fixture or filter is wrong"
|
||||
);
|
||||
}
|
||||
|
||||
/// Recorded scenarios where no tool is warranted must not invent one.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn conceptual_questions_do_not_invent_tool_calls() {
|
||||
let fx = load_fixture();
|
||||
|
||||
for case in fx
|
||||
.cases
|
||||
.iter()
|
||||
.filter(|c| c.scenario.contains("no_tool_needed"))
|
||||
{
|
||||
let result = moa::handle_turn(&config_for(case), &request_body(case)).await;
|
||||
let tools = response_tool_calls(&result.response_body);
|
||||
assert!(
|
||||
tools.is_empty(),
|
||||
"case `{}`: no worker proposed a tool, so the turn must not emit one; got {tools:?}",
|
||||
case.id
|
||||
);
|
||||
assert!(
|
||||
!response_text(&result.response_body).trim().is_empty(),
|
||||
"case `{}`: expected a prose answer",
|
||||
case.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,473 @@
|
|||
//! Pin the cross-peer refinement round's behaviour under mesh conditions.
|
||||
//!
|
||||
//! Refinement is what makes a pool of small models beat its best member
|
||||
//! (`evals/moa-openrouter/RESULTS.md`: 42/66/12, p=5.2e-05 for an all-8B pool),
|
||||
//! so it runs on exactly the hardware where peers are slowest and least
|
||||
//! reliable. A second fan-out is a second chance to hang the turn, so the round
|
||||
//! must be strictly best-effort.
|
||||
//!
|
||||
//! Contracts pinned here:
|
||||
//!
|
||||
//! 1. **It runs and its output is used** — on an all-small pool the refined
|
||||
//! drafts, not the round-1 drafts, are what reach the reducer.
|
||||
//! 2. **A hanging peer cannot cost the turn** — refinement is bounded by
|
||||
//! `worker_timeout`; the turn completes with whoever answered.
|
||||
//! 3. **Total failure degrades, never breaks** — if every refiner errors, the
|
||||
//! round-1 outputs are used and the turn still answers.
|
||||
//! 4. **Pool shape gates it** — a big-tier model present means `Auto` skips the
|
||||
//! extra fan-out.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use mesh_mixture_of_agents as moa;
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Marker text so we can prove which round's text reached the reducer.
|
||||
const ROUND1: &str = "ROUND1-DRAFT";
|
||||
const REFINED: &str = "REFINED-DRAFT";
|
||||
|
||||
/// What a peer does when asked to refine.
|
||||
#[derive(Clone, Copy)]
|
||||
enum RefineBehavior {
|
||||
/// Return the refined marker after a delay.
|
||||
Ok(Duration),
|
||||
/// Hang past any deadline.
|
||||
Hang,
|
||||
/// Fail outright.
|
||||
Fail,
|
||||
}
|
||||
|
||||
/// A mesh peer: answers round 1, then behaves per `on_refine`.
|
||||
///
|
||||
/// Call kind is inferred from the system prompt, which is how the three phases
|
||||
/// are distinguishable without touching engine internals.
|
||||
struct MeshPeer {
|
||||
/// Distinct per peer so round-1 answers do NOT cluster into consensus —
|
||||
/// otherwise early-exit short-circuits the turn and synthesis (the only
|
||||
/// path refinement feeds) never runs.
|
||||
round1_text: String,
|
||||
round1_delay: Duration,
|
||||
on_refine: RefineBehavior,
|
||||
refine_calls: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl MeshPeer {
|
||||
fn new(
|
||||
round1_text: &str,
|
||||
round1_delay: Duration,
|
||||
on_refine: RefineBehavior,
|
||||
) -> (Arc<Self>, Arc<AtomicUsize>) {
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
(
|
||||
Arc::new(Self {
|
||||
round1_text: format!("{ROUND1} {round1_text}"),
|
||||
round1_delay,
|
||||
on_refine,
|
||||
refine_calls: counter.clone(),
|
||||
}),
|
||||
counter,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn system_text(messages: &[Value]) -> String {
|
||||
messages
|
||||
.iter()
|
||||
.find(|m| m.get("role").and_then(Value::as_str) == Some("system"))
|
||||
.and_then(|m| m.get("content").and_then(Value::as_str))
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn reply(text: &str) -> Value {
|
||||
json!({"choices": [{"message": {"content": text}, "finish_reason": "stop"}]})
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl moa::ModelBackend for MeshPeer {
|
||||
async fn chat_completion(
|
||||
&self,
|
||||
_model: &str,
|
||||
messages: &[Value],
|
||||
_tools: Option<&Value>,
|
||||
_max_tokens: u32,
|
||||
_timeout: Duration,
|
||||
_sampling: moa::SamplingParams,
|
||||
) -> Result<Value, String> {
|
||||
let sys = system_text(messages);
|
||||
|
||||
// Reducer synthesis: echo back whichever drafts it was given, so the
|
||||
// test can assert which round's text made it through.
|
||||
if sys.contains("## Worker outputs") {
|
||||
let saw_refined = sys.contains(REFINED);
|
||||
return Ok(reply(if saw_refined {
|
||||
"FINAL-FROM-REFINED"
|
||||
} else {
|
||||
"FINAL-FROM-ROUND1"
|
||||
}));
|
||||
}
|
||||
|
||||
// Refinement round.
|
||||
// Refinement round: peers are shown each other's drafts under a
|
||||
// "Candidate responses:" header. Checked after the reducer branch
|
||||
// above, which is identified by its "## Worker outputs" section.
|
||||
if sys.contains("Candidate responses:") {
|
||||
self.refine_calls.fetch_add(1, Ordering::SeqCst);
|
||||
return match self.on_refine {
|
||||
RefineBehavior::Ok(d) => {
|
||||
tokio::time::sleep(d).await;
|
||||
Ok(reply(REFINED))
|
||||
}
|
||||
RefineBehavior::Hang => {
|
||||
tokio::time::sleep(Duration::from_secs(3600)).await;
|
||||
Ok(reply(REFINED))
|
||||
}
|
||||
RefineBehavior::Fail => Err("peer unavailable".into()),
|
||||
};
|
||||
}
|
||||
|
||||
// Round 1.
|
||||
tokio::time::sleep(self.round1_delay).await;
|
||||
Ok(reply(&self.round1_text))
|
||||
}
|
||||
}
|
||||
|
||||
fn config_with_grace(
|
||||
models: &[(&str, Arc<MeshPeer>)],
|
||||
policy: moa::RefinementPolicy,
|
||||
worker_timeout: Duration,
|
||||
first_answer_grace: Duration,
|
||||
) -> moa::GatewayConfig {
|
||||
let mut cfg = config(models, policy, worker_timeout);
|
||||
cfg.first_answer_grace = first_answer_grace;
|
||||
cfg
|
||||
}
|
||||
|
||||
fn config(
|
||||
models: &[(&str, Arc<MeshPeer>)],
|
||||
policy: moa::RefinementPolicy,
|
||||
worker_timeout: Duration,
|
||||
) -> moa::GatewayConfig {
|
||||
let mut backends: Vec<Arc<dyn moa::ModelBackend>> = Vec::new();
|
||||
let mut entries = Vec::new();
|
||||
for (name, backend) in models {
|
||||
entries.push(moa::ModelEntry {
|
||||
name: (*name).to_string(),
|
||||
backend_index: backends.len(),
|
||||
});
|
||||
backends.push(backend.clone());
|
||||
}
|
||||
moa::GatewayConfig {
|
||||
backends,
|
||||
models: entries,
|
||||
worker_timeout,
|
||||
hedge_delay: Duration::from_millis(50),
|
||||
reducer_timeout: Duration::from_secs(5),
|
||||
// Disable the cheap paths so every turn reaches synthesis, which is
|
||||
// the only path refinement participates in.
|
||||
first_answer_grace: Duration::ZERO,
|
||||
strong_patience: Duration::ZERO,
|
||||
enable_thinking: Some(false),
|
||||
actor_candidates: Vec::new(),
|
||||
reference_policy: Default::default(),
|
||||
refinement_policy: policy,
|
||||
}
|
||||
}
|
||||
|
||||
/// Distinct prompts per worker so round-1 answers disagree and the turn is
|
||||
/// forced into synthesis rather than early-exit consensus.
|
||||
fn request() -> Value {
|
||||
json!({
|
||||
"model": "mesh",
|
||||
"messages": [{"role": "user", "content": "explain backpressure"}],
|
||||
"max_tokens": 256,
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn refined_drafts_are_what_reach_the_reducer() {
|
||||
let (a, ca) = MeshPeer::new(
|
||||
"queues fill up",
|
||||
Duration::ZERO,
|
||||
RefineBehavior::Ok(Duration::ZERO),
|
||||
);
|
||||
let (b, cb) = MeshPeer::new(
|
||||
"latency grows unbounded",
|
||||
Duration::ZERO,
|
||||
RefineBehavior::Ok(Duration::ZERO),
|
||||
);
|
||||
let (c, cc) = MeshPeer::new(
|
||||
"memory exhausts eventually",
|
||||
Duration::ZERO,
|
||||
RefineBehavior::Ok(Duration::ZERO),
|
||||
);
|
||||
let cfg = config(
|
||||
&[("Qwen3-8B", a), ("Llama-3.1-8B", b), ("Ministral-8B", c)],
|
||||
moa::RefinementPolicy::Always,
|
||||
Duration::from_secs(5),
|
||||
);
|
||||
|
||||
let result = moa::handle_turn(&cfg, &request()).await;
|
||||
|
||||
assert!(
|
||||
ca.load(Ordering::SeqCst) + cb.load(Ordering::SeqCst) + cc.load(Ordering::SeqCst) >= 2,
|
||||
"an all-small pool must run the refinement round"
|
||||
);
|
||||
// The contract is that the *refined* text is what reaches the client. It
|
||||
// may arrive either via the reducer ("FINAL-FROM-REFINED") or directly,
|
||||
// when the refined drafts agree and the arbiter takes consensus — both are
|
||||
// correct; what must never happen is round-1 text being returned.
|
||||
let body = serde_json::to_string(&result.response_body).unwrap();
|
||||
assert!(
|
||||
body.contains(REFINED) || body.contains("FINAL-FROM-REFINED"),
|
||||
"refined drafts must reach the client, got: {body}"
|
||||
);
|
||||
assert!(
|
||||
!body.contains(ROUND1),
|
||||
"round-1 drafts must not survive a successful refinement round, got: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_hanging_refiner_cannot_hold_the_turn() {
|
||||
let (a, _) = MeshPeer::new(
|
||||
"queues fill up",
|
||||
Duration::ZERO,
|
||||
RefineBehavior::Ok(Duration::ZERO),
|
||||
);
|
||||
let (b, _) = MeshPeer::new(
|
||||
"latency grows unbounded",
|
||||
Duration::ZERO,
|
||||
RefineBehavior::Ok(Duration::ZERO),
|
||||
);
|
||||
// One peer never returns from refinement — the mesh reality this guards.
|
||||
let (c, _) = MeshPeer::new(
|
||||
"memory exhausts eventually",
|
||||
Duration::ZERO,
|
||||
RefineBehavior::Hang,
|
||||
);
|
||||
let worker_timeout = Duration::from_millis(400);
|
||||
let cfg = config(
|
||||
&[("Qwen3-8B", a), ("Llama-3.1-8B", b), ("Ministral-8B", c)],
|
||||
moa::RefinementPolicy::Always,
|
||||
worker_timeout,
|
||||
);
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
let result = moa::handle_turn(&cfg, &request()).await;
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert!(
|
||||
elapsed < worker_timeout * 4,
|
||||
"a hanging refiner must not extend the turn (took {elapsed:?})"
|
||||
);
|
||||
assert_ne!(
|
||||
result.turn_kind,
|
||||
moa::TurnKind::Failed,
|
||||
"the turn must still answer with the refiners that did return"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn total_refinement_failure_falls_back_to_round_one() {
|
||||
let (a, _) = MeshPeer::new("queues fill up", Duration::ZERO, RefineBehavior::Fail);
|
||||
let (b, _) = MeshPeer::new(
|
||||
"latency grows unbounded",
|
||||
Duration::ZERO,
|
||||
RefineBehavior::Fail,
|
||||
);
|
||||
let (c, _) = MeshPeer::new(
|
||||
"memory exhausts eventually",
|
||||
Duration::ZERO,
|
||||
RefineBehavior::Fail,
|
||||
);
|
||||
let cfg = config(
|
||||
&[("Qwen3-8B", a), ("Llama-3.1-8B", b), ("Ministral-8B", c)],
|
||||
moa::RefinementPolicy::Always,
|
||||
Duration::from_secs(5),
|
||||
);
|
||||
|
||||
let result = moa::handle_turn(&cfg, &request()).await;
|
||||
|
||||
assert_ne!(
|
||||
result.turn_kind,
|
||||
moa::TurnKind::Failed,
|
||||
"every refiner failing must degrade to round-1, not fail the turn"
|
||||
);
|
||||
let body = serde_json::to_string(&result.response_body).unwrap();
|
||||
assert!(
|
||||
body.contains("FINAL-FROM-ROUND1"),
|
||||
"round-1 drafts must be used when refinement produces nothing, got: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The production-settings check: refinement must still run when
|
||||
/// `first_answer_grace` is the real 3s default rather than the ZERO used by the
|
||||
/// other tests here.
|
||||
///
|
||||
/// Grace produces an `early_decision`, and refinement is skipped whenever one
|
||||
/// exists — so if grace fired on a fast all-small pool, this feature would be
|
||||
/// dead code on real traffic. Grace only arms once the window has *elapsed*, so
|
||||
/// a pool that answers promptly reaches synthesis (and refinement) first; this
|
||||
/// pins that ordering.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn refinement_still_runs_under_production_grace() {
|
||||
let (a, ca) = MeshPeer::new(
|
||||
"queues fill up",
|
||||
Duration::ZERO,
|
||||
RefineBehavior::Ok(Duration::ZERO),
|
||||
);
|
||||
let (b, cb) = MeshPeer::new(
|
||||
"latency grows unbounded",
|
||||
Duration::ZERO,
|
||||
RefineBehavior::Ok(Duration::ZERO),
|
||||
);
|
||||
let (c, cc) = MeshPeer::new(
|
||||
"memory exhausts eventually",
|
||||
Duration::ZERO,
|
||||
RefineBehavior::Ok(Duration::ZERO),
|
||||
);
|
||||
let cfg = config_with_grace(
|
||||
&[("Qwen3-8B", a), ("Llama-3.1-8B", b), ("Ministral-8B", c)],
|
||||
moa::RefinementPolicy::Always,
|
||||
Duration::from_secs(5),
|
||||
// The production default from `build_moa_config`.
|
||||
Duration::from_secs(3),
|
||||
);
|
||||
|
||||
let result = moa::handle_turn(&cfg, &request()).await;
|
||||
|
||||
assert!(
|
||||
ca.load(Ordering::SeqCst) + cb.load(Ordering::SeqCst) + cc.load(Ordering::SeqCst) >= 2,
|
||||
"refinement must still run at the production grace setting, or the \
|
||||
feature is dead code on real traffic"
|
||||
);
|
||||
let body = serde_json::to_string(&result.response_body).unwrap();
|
||||
assert!(
|
||||
body.contains(REFINED) || body.contains("FINAL-FROM-REFINED"),
|
||||
"refined drafts must reach the client under production grace, got: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Straggling peers must NOT cost the refinement round.
|
||||
///
|
||||
/// This is the mesh case: one peer answers instantly, the others lag. The
|
||||
/// answer grace would normally ship the fast lone answer and skip refinement —
|
||||
/// but on an all-small pool refinement is the only step that beats the best
|
||||
/// member (26/75/19, p=0.37 without it vs 42/66/12 with). Since variable peer
|
||||
/// latency is the norm on consumer hardware, letting grace win here would
|
||||
/// silently disable the feature exactly where it matters, while still paying
|
||||
/// for the fan-out.
|
||||
///
|
||||
/// So when refinement is expected, grace is disabled for the turn. Round 1 is
|
||||
/// still bounded by `worker_timeout` and refinement by its own half-budget, so
|
||||
/// this costs bounded latency, never an unbounded wait.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn straggling_peers_do_not_cost_the_refinement_round() {
|
||||
let grace = Duration::from_millis(150);
|
||||
// First peer answers immediately; the others straggle well past the grace.
|
||||
let (a, ca) = MeshPeer::new(
|
||||
"queues fill up",
|
||||
Duration::ZERO,
|
||||
RefineBehavior::Ok(Duration::ZERO),
|
||||
);
|
||||
let (b, cb) = MeshPeer::new(
|
||||
"latency grows unbounded",
|
||||
Duration::from_millis(600),
|
||||
RefineBehavior::Ok(Duration::ZERO),
|
||||
);
|
||||
let (c, cc) = MeshPeer::new(
|
||||
"memory exhausts eventually",
|
||||
Duration::from_millis(600),
|
||||
RefineBehavior::Ok(Duration::ZERO),
|
||||
);
|
||||
let cfg = config_with_grace(
|
||||
&[("Qwen3-8B", a), ("Llama-3.1-8B", b), ("Ministral-8B", c)],
|
||||
moa::RefinementPolicy::Always,
|
||||
Duration::from_secs(5),
|
||||
grace,
|
||||
);
|
||||
|
||||
let result = moa::handle_turn(&cfg, &request()).await;
|
||||
|
||||
assert!(
|
||||
ca.load(Ordering::SeqCst) + cb.load(Ordering::SeqCst) + cc.load(Ordering::SeqCst) >= 2,
|
||||
"a fast peer answering first must not skip refinement on an all-small pool"
|
||||
);
|
||||
let body = serde_json::to_string(&result.response_body).unwrap();
|
||||
assert!(
|
||||
body.contains(REFINED) || body.contains("FINAL-FROM-REFINED"),
|
||||
"refined drafts must reach the client despite stragglers, got: {body}"
|
||||
);
|
||||
assert_ne!(result.turn_kind, moa::TurnKind::Failed);
|
||||
}
|
||||
|
||||
/// The grace must still work normally when refinement is NOT in play — a
|
||||
/// big-tier pool keeps its fast chat path.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn grace_still_short_circuits_when_refinement_is_not_expected() {
|
||||
let grace = Duration::from_millis(100);
|
||||
let (a, ca) = MeshPeer::new(
|
||||
"queues fill up",
|
||||
Duration::ZERO,
|
||||
RefineBehavior::Ok(Duration::ZERO),
|
||||
);
|
||||
let (b, cb) = MeshPeer::new(
|
||||
"latency grows unbounded",
|
||||
Duration::from_secs(30), // would stall the turn if grace didn't fire
|
||||
RefineBehavior::Ok(Duration::ZERO),
|
||||
);
|
||||
let cfg = config_with_grace(
|
||||
// Big-tier present => Auto does not refine => grace stays enabled.
|
||||
&[("Qwen3-32B", a), ("Qwen3-8B", b)],
|
||||
moa::RefinementPolicy::Auto,
|
||||
Duration::from_secs(60),
|
||||
grace,
|
||||
);
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
let result = moa::handle_turn(&cfg, &request()).await;
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert!(
|
||||
elapsed < Duration::from_secs(5),
|
||||
"grace must still short-circuit a slow peer when refinement is off (took {elapsed:?})"
|
||||
);
|
||||
assert_eq!(
|
||||
ca.load(Ordering::SeqCst) + cb.load(Ordering::SeqCst),
|
||||
0,
|
||||
"a big-tier pool must not refine"
|
||||
);
|
||||
assert_ne!(result.turn_kind, moa::TurnKind::Failed);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_big_tier_pool_skips_the_extra_fanout() {
|
||||
let (a, ca) = MeshPeer::new(
|
||||
"queues fill up",
|
||||
Duration::ZERO,
|
||||
RefineBehavior::Ok(Duration::ZERO),
|
||||
);
|
||||
let (b, cb) = MeshPeer::new(
|
||||
"latency grows unbounded",
|
||||
Duration::ZERO,
|
||||
RefineBehavior::Ok(Duration::ZERO),
|
||||
);
|
||||
let cfg = config(
|
||||
// A big-tier model is present, so Auto should not pay for a 2nd round.
|
||||
&[("Qwen3-32B", a), ("Qwen3-8B", b)],
|
||||
moa::RefinementPolicy::Auto,
|
||||
Duration::from_secs(5),
|
||||
);
|
||||
|
||||
let result = moa::handle_turn(&cfg, &request()).await;
|
||||
|
||||
assert_eq!(
|
||||
ca.load(Ordering::SeqCst) + cb.load(Ordering::SeqCst),
|
||||
0,
|
||||
"Auto must skip refinement when a big-tier model can synthesize directly"
|
||||
);
|
||||
assert_ne!(result.turn_kind, moa::TurnKind::Failed);
|
||||
}
|
||||
|
|
@ -112,6 +112,9 @@ fn mixed_pool(
|
|||
first_answer_grace: Duration::ZERO,
|
||||
strong_patience,
|
||||
enable_thinking: None,
|
||||
actor_candidates: Vec::new(),
|
||||
reference_policy: Default::default(),
|
||||
refinement_policy: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -142,15 +145,14 @@ async fn small_consensus_is_held_until_strong_lands() {
|
|||
.map(|w| (&w.model, w.succeeded))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
// The contract is "when the strong worker lands with a usable answer,
|
||||
// it wins" — not merely "it was allowed to finish". The strong worker
|
||||
// disagrees with the small-tier consensus ("Sydney"), so its answer
|
||||
// ("Canberra") must be the one that ships.
|
||||
// The strong worker's draft must reach synthesis rather than being
|
||||
// pre-empted by small-tier consensus. Answer turns always synthesize now,
|
||||
// so the contract is "the strong draft is an input the aggregator weighs",
|
||||
// not "its text ships verbatim" — in production the aggregator is itself
|
||||
// the big-tier model.
|
||||
assert!(
|
||||
response_text(&result).contains("Canberra"),
|
||||
"strong worker's answer must win over small-tier consensus once it lands; \
|
||||
got {:?}",
|
||||
response_text(&result)
|
||||
result.reducer_used,
|
||||
"disagreeing drafts must be synthesized, not resolved by consensus alone"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -218,17 +220,24 @@ async fn same_tier_pool_keeps_early_exit() {
|
|||
// tier analysis must disable the gate anyway.
|
||||
strong_patience: Duration::from_secs(10),
|
||||
enable_thinking: None,
|
||||
actor_candidates: Vec::new(),
|
||||
reference_policy: Default::default(),
|
||||
refinement_policy: Default::default(),
|
||||
};
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
let result = moa::handle_turn(&config, &user_turn("Capital of Japan? One word.")).await;
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
// Answer turns always synthesize now (agreeing drafts are the best input
|
||||
// to synthesis, not a reason to ship one worker verbatim), so this is a
|
||||
// Fanout turn rather than an early exit.
|
||||
assert_eq!(
|
||||
result.turn_kind,
|
||||
moa::TurnKind::EarlyExit,
|
||||
"same-tier consensus must keep the early-exit path"
|
||||
moa::TurnKind::Fanout,
|
||||
"answer turns synthesize instead of shipping one worker's text"
|
||||
);
|
||||
// ROBUSTNESS (unchanged contract): a slow worker must not stall the turn.
|
||||
assert!(
|
||||
elapsed < Duration::from_secs(5),
|
||||
"same-tier pool must not wait for the slow worker; took {elapsed:?}"
|
||||
|
|
|
|||
|
|
@ -116,6 +116,9 @@ fn config_with_three_recording_workers() -> (
|
|||
first_answer_grace: Duration::ZERO,
|
||||
strong_patience: Duration::ZERO,
|
||||
enable_thinking: None,
|
||||
actor_candidates: Vec::new(),
|
||||
reference_policy: Default::default(),
|
||||
refinement_policy: Default::default(),
|
||||
};
|
||||
(config, fast, mid, strong)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,6 +115,9 @@ fn four_workers_two_fast_consensus() -> moa::GatewayConfig {
|
|||
first_answer_grace: Duration::ZERO,
|
||||
strong_patience: Duration::ZERO,
|
||||
enable_thinking: None,
|
||||
actor_candidates: Vec::new(),
|
||||
reference_policy: Default::default(),
|
||||
refinement_policy: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -136,11 +139,13 @@ async fn early_exit_summaries_account_for_aborted_workers() {
|
|||
|
||||
let result = moa::handle_turn(&config, &body).await;
|
||||
|
||||
// Sanity: this should be the early-exit path.
|
||||
// Answer turns always synthesize now, so consensus among the fast workers
|
||||
// still aborts the slow tail but the turn is Fanout, not EarlyExit. The
|
||||
// contract under test is worker *accounting*, which is unchanged.
|
||||
assert_eq!(
|
||||
result.turn_kind,
|
||||
moa::TurnKind::EarlyExit,
|
||||
"two fast agreeing workers should produce TurnKind::EarlyExit; got {:?}",
|
||||
moa::TurnKind::Fanout,
|
||||
"answer turns synthesize after aborting the slow tail; got {:?}",
|
||||
result.turn_kind
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -643,6 +643,43 @@ fn read_tensor_infos(
|
|||
Ok(tensors)
|
||||
}
|
||||
|
||||
/// Sum the element counts of every tensor in a GGUF file → total stored
|
||||
/// parameter count. Reads only the header and tensor-info table (dimensions),
|
||||
/// never tensor data.
|
||||
///
|
||||
/// This is the authoritative model size: the exact number of stored weights,
|
||||
/// independent of the file name. Name parsing (`NNb` in the model id) is a
|
||||
/// brittle fallback — aliases and fine-tunes need not encode a size, names can
|
||||
/// carry unrelated digits, and an unparseable name must read as *unknown*, not
|
||||
/// as a guessed tier. Returns `None` on any parse failure (→ unknown).
|
||||
pub fn scan_gguf_total_parameters(path: &Path) -> Option<u64> {
|
||||
let GgufHeader {
|
||||
file: mut f,
|
||||
n_tensors,
|
||||
n_kv,
|
||||
} = open_gguf_header(path)?;
|
||||
|
||||
skip_all_kv_pairs(&mut f, n_kv)?;
|
||||
|
||||
let mut total: u64 = 0;
|
||||
for _ in 0..n_tensors {
|
||||
let _name = read_gguf_string(&mut f).ok()?;
|
||||
let n_dims = read_u32(&mut f).ok()?;
|
||||
if n_dims > MAX_GGUF_TENSOR_DIMS {
|
||||
return None;
|
||||
}
|
||||
let mut elements: u64 = 1;
|
||||
for _ in 0..n_dims {
|
||||
let dim = read_u64(&mut f).ok()?;
|
||||
elements = elements.checked_mul(dim)?;
|
||||
}
|
||||
let _ggml_type = read_u32(&mut f).ok()?;
|
||||
let _offset = read_u64(&mut f).ok()?;
|
||||
total = total.checked_add(elements)?;
|
||||
}
|
||||
Some(total)
|
||||
}
|
||||
|
||||
/// Scan GGUF tensor names and return whether any tensor matches the predicate.
|
||||
/// Reads only the header and tensor-info table, never tensor data.
|
||||
pub fn scan_gguf_tensor_names_any(
|
||||
|
|
|
|||
|
|
@ -98,21 +98,41 @@ callable in the mesh.
|
|||
|
||||
| Callable models | Workers fanned out | Roles assigned |
|
||||
|---:|:---:|:---|
|
||||
| 0 or 1 | — | MoA bails (503 to client; needs ≥2) |
|
||||
| 0 or 1 | — | degrades to serving that model directly (no 503) |
|
||||
| 2 | 2 | fast + strong |
|
||||
| 3 | 3 | fast + specialist + strong |
|
||||
| 4 | 4 | fast + specialist + specialist + strong |
|
||||
| N | N | fast + (N-2) specialists + strong |
|
||||
|
||||
Models are tier-sorted before role assignment:
|
||||
single-digit-B names ("Qwen3-8B", "llama-3-7b") form the small tier and
|
||||
get `Fast`; everything else (multi-digit B, or names without an explicit
|
||||
size) forms the big tier. Within the big tier, role assignment is
|
||||
*first-encountered*, not deterministically by parameter count — we don't
|
||||
parse parameter sizes from the alias string, only the broad small-vs-big
|
||||
bucket. `Strong` and the reducer head both come from the big tier; if
|
||||
deterministic largest-first ordering becomes important, a size-aware
|
||||
sort within the big tier would go here.
|
||||
On an **answer** turn every worker is packed at the full budget rather
|
||||
than its role budget: the role tiers exist so the cheap worker can answer
|
||||
the grace fast-path quickly, but a truncated draft is an *input* to
|
||||
synthesis, so brevity there drags the aggregated answer down.
|
||||
|
||||
Tiering comes from **verified parameter counts**, not the alias string.
|
||||
The serving node sums its GGUF tensor element counts and gossips the
|
||||
result via `ServedModelMetadata.parameter_count_b`; the gateway reads
|
||||
that and splits at `SMALL_TIER_MAX_B` (10B). A model with no verified
|
||||
size ranks small, so an unparseable alias can never pose as a big-tier
|
||||
worker and displace a real one. Name parsing is no longer used — it
|
||||
mis-tiered real models (e.g. `gemma-4-E4B` stores 7.5B, not 4B).
|
||||
|
||||
Pool shaping, in order (all measured; see
|
||||
`evals/moa-openrouter/RESULTS.md`):
|
||||
|
||||
- **Admission**: verified-small workers are dropped only when ≥2
|
||||
verified-big remain. A lone big + smalls keeps the mix, because
|
||||
dropping the smalls there would collapse the committee to a solo model.
|
||||
- **All-small pools do not convene a committee.** Measured through the
|
||||
shipped path, an 8B-class pool with an 8B reducer never beat its best
|
||||
member and lost about a third of decided trials (2×8B 0W/37L; 6×8B
|
||||
5W/23L, p=0.0009). Such a pool collapses to its strongest member and
|
||||
the request degrades to serving that model directly. This is a
|
||||
statement about a weak reducer synthesizing weak drafts — a capable
|
||||
pool is the opposite (71W/8T/1L, p<0.0001) — so as soon as a mesh gains
|
||||
a big-tier model the pool is no longer all-small and MoA engages.
|
||||
- **Committee cap**: 6 for all-small, 4 once a verified big is present.
|
||||
Fan-out costs ~2N+1 calls per turn and quality is flat past ~4.
|
||||
|
||||
**The reducer is not a separate fan-out slot.** It's the same strong
|
||||
model (or next-strongest if the primary is slow/broken), invoked
|
||||
|
|
@ -275,8 +295,10 @@ The MoA intercept lives in `ingress.rs` (~line 234). When `model == "mesh"`:
|
|||
2. `handle_turn()` runs the stateless MoA pipeline
|
||||
3. Response is sent as SSE (streaming clients) or plain JSON
|
||||
|
||||
Activation: requires ≥2 distinct models available in the mesh. Returns 503
|
||||
with explanation if fewer.
|
||||
Activation: requires ≥2 committee-eligible models in the mesh. If fewer (a
|
||||
single model, or an all-small pool collapsed to its best member), the request
|
||||
degrades: the virtual `mesh` name is rewritten to a real served model and routed
|
||||
normally. Only a node serving nothing at all returns 503.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -388,10 +410,10 @@ client, no recursive hook loops.
|
|||
|----------|-------------------|
|
||||
| 2+ workers agree quickly | Early-exit, faster than single-model |
|
||||
| 1 worker much faster than others | Returns fast worker if confident |
|
||||
| Remote peer timeout (15s worker / 15s reducer) | Degrades to local-only, adds latency |
|
||||
| Remote peer timeout (60s worker / 60s reducer) | Grace ships what arrived at the 10s window; a dead peer costs 10s, not 60s |
|
||||
| First reducer candidate slow / cold KV | Hedges to second candidate after 5s, races for first OK |
|
||||
| First reducer candidate broken (502s) | Fast-fails to next candidate immediately, no hedge wait |
|
||||
| Only 1 model available | Returns 503, does not activate MoA |
|
||||
| Only 1 model available | Degrades to serving that model directly |
|
||||
| All workers fail | Returns error response |
|
||||
|
||||
### What to watch for
|
||||
|
|
|
|||
81
evals/moa-openrouter/README.md
Normal file
81
evals/moa-openrouter/README.md
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# MoA trace recording (OpenRouter)
|
||||
|
||||
Records real fan-out responses from open-weight models and turns them into a
|
||||
deterministic replay fixture for the MoA test suite.
|
||||
|
||||
The point: fake worker backends can't tell you whether the arbiter handles
|
||||
*real* model behaviour — divergent tool arguments, truncated answers, malformed
|
||||
tool-call text, 60x latency spreads. These scripts capture that behaviour once,
|
||||
so tests can replay it forever without GPUs, network, or nondeterminism.
|
||||
|
||||
## Why the raw response shape is preserved
|
||||
|
||||
Both recorders store the **full OpenAI-shaped response** per worker —
|
||||
structured `tool_calls`, `content`, `finish_reason`, usage — never flattened to
|
||||
text. That matters because the two things lost by flattening are exactly the two
|
||||
things the tests exist to protect:
|
||||
|
||||
- `tool_calls` — empty `content` on an agentic turn. Together's MoA aggregator
|
||||
reads `.content`, so its synthesis step receives a list of blank strings on
|
||||
every tool turn.
|
||||
- `finish_reason` — `"length"` means the backend cut the response off. Partial
|
||||
text parses as a normal answer, so without this field a half-finished
|
||||
sentence can be returned verbatim.
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Purpose |
|
||||
|---|---|
|
||||
| `orclient.py` | Minimal stdlib-only OpenRouter client (no `requests`). Owns the worker pool and tier mapping. |
|
||||
| `probe_tools.py` | One-off probe: fan out with tools, then aggregate Together-style to show what their design does with tool calls. Writes `fanout.jsonl`. |
|
||||
| `record.py` | Single-shot fan-out over assorted prompts incl. MT-Bench. Writes `corpus.jsonl`. |
|
||||
| `record_agentic.py` | Multi-step agentic loop: fan out → take consensus tool call → feed a canned tool result → fan out again. Writes `agentic.jsonl`. |
|
||||
| `make_fixture.py` | Merges both corpora into `crates/mesh-mixture-of-agents/tests/fixtures/real_traces.json`. |
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
export OPENROUTER_API_KEY=... # required
|
||||
python3 record.py # -> corpus.jsonl
|
||||
python3 record_agentic.py # -> agentic.jsonl
|
||||
python3 make_fixture.py # -> tests/fixtures/real_traces.json
|
||||
```
|
||||
|
||||
Stdlib only, no install step. A full re-record is a few hundred calls against
|
||||
cheap open models — on the order of a couple of dollars.
|
||||
|
||||
## Worker pool
|
||||
|
||||
Nine open-weight tool-capable models, chosen so tiers line up with how
|
||||
`mesh-llm` classifies names (single-digit-B ⇒ small tier):
|
||||
|
||||
- small: `qwen3-8b`, `qwen3.5-9b`, `ministral-8b`, `ministral-3b`
|
||||
- big: `qwen3-14b`, `qwen3-32b`, `qwen3-30b-a3b`, `minimax-m2.5`,
|
||||
`mistral-small-3.2-24b`
|
||||
|
||||
Of 367 models on OpenRouter, 80 are open-weight *and* tool-capable, so the pool
|
||||
can be widened without changing any code but the list in `orclient.py`.
|
||||
|
||||
## Two endpoint behaviours worth knowing
|
||||
|
||||
Both were found by recording, and both are now handled in `HttpBackend`:
|
||||
|
||||
1. **Thinking-disable flags are not universally accepted.** `minimax-m2.5`
|
||||
returns `HTTP 400: Reasoning is mandatory for this endpoint` and failed
|
||||
12/12 requests until the flags were dropped and the call retried.
|
||||
2. **Reasoning models starve on a short budget.** With thinking on,
|
||||
`qwen3-32b` spent 408 reasoning tokens against a 384-token cap and returned
|
||||
`finish_reason=length` with `content: null` — 1620 characters of reasoning
|
||||
and no answer. This is why MoA forces thinking off for every worker.
|
||||
|
||||
## Caveats
|
||||
|
||||
- Workers run at `temperature: 0.8`, so each row is **one draw** from a
|
||||
stochastic process. `DRAWS = 2` per case; treat the corpus as a regression
|
||||
baseline, not ground truth about live behaviour.
|
||||
- Fixtures build a `GatewayConfig` directly, so they **bypass**
|
||||
`build_moa_config` / `canonical_base_name`. Model-dedup bugs cannot be caught
|
||||
here and need their own unit tests.
|
||||
- `record_agentic.py` feeds canned tool results, not a real filesystem. It
|
||||
exercises MoA's arbitration over real model output, not end-to-end agent
|
||||
correctness.
|
||||
585
evals/moa-openrouter/RESULTS.md
Normal file
585
evals/moa-openrouter/RESULTS.md
Normal file
|
|
@ -0,0 +1,585 @@
|
|||
# MoA evidence: what actually helps?
|
||||
|
||||
Results from the live OpenRouter studies in
|
||||
`crates/mesh-mixture-of-agents/tests/eval_openrouter.rs`. All tool-selection
|
||||
numbers use the preregistered 40-task fixture (`tests/fixtures/ablation_tasks.json`,
|
||||
4 strata × 10), 10 draws, paired hierarchical bootstrap
|
||||
(`analyze_ablation.py`).
|
||||
|
||||
Method for every ablation: **one pinned actor, identical sampling / token
|
||||
budget / prompt scaffold across arms — only the references vary.**
|
||||
|
||||
- **A** actor alone
|
||||
- **B** actor + real references
|
||||
- **C** actor + shuffled references (advice generated for a *different* task)
|
||||
|
||||
Primary metric: net uplift = P(rescue) − P(harm), equal-weight mean over tasks.
|
||||
Arm C separates *advice content* from *extra tokens + a think-carefully prompt*.
|
||||
|
||||
## Headline: most of the "harm" was our packing, not references
|
||||
|
||||
| actor | reference packing | B pass | net uplift | 95% CI |
|
||||
|---|---|---|---|---|
|
||||
| strong (qwen3-32b) | original | 359/400 | −0.102 | [−0.170, −0.045] |
|
||||
| strong (qwen3-32b) | Hermes-style | 385/400 | −0.037 | [−0.090, −0.003] |
|
||||
| weak (qwen3-8b) | original | 365/400 | −0.013 | [−0.090, +0.070] |
|
||||
| weak (qwen3-8b) | Hermes-style | **377/400** | **+0.017** | [−0.053, +0.100] |
|
||||
|
||||
Two monotonic effects:
|
||||
|
||||
1. **Fixing the packing helps in both actor conditions** (+0.065 strong,
|
||||
+0.030 weak).
|
||||
2. **References are worth more to a weaker actor** (+0.054 weak-vs-strong at
|
||||
matched packing).
|
||||
|
||||
The only *statistically significant* cell in the matrix is the original-packing
|
||||
strong-actor harm — i.e. the bug. After the fix, nothing is significant:
|
||||
strong is marginal (upper bound −0.003), weak is a positive point estimate with
|
||||
a CI spanning zero.
|
||||
|
||||
### What the packing bug was
|
||||
|
||||
Our references were packed with the agent's full system prompt, the tool-call
|
||||
transcript, and a preamble instructing them to *"respond with your best answer
|
||||
or tool call"* — while holding no tool schemas. So advisors (a) role-played the
|
||||
actor instead of advising it, (b) anchored on the trajectory already taken
|
||||
(destroying the error-independence aggregation depends on), and (c) emitted
|
||||
tool-shaped prose that pulled the actor off its own better choice.
|
||||
|
||||
`context::pack_for_reference` follows Hermes: conversation user/assistant prose
|
||||
only, no system prompt, no tool transcript, advisor framing, 600-token cap.
|
||||
|
||||
## Where references help: actor headroom
|
||||
|
||||
Weak actor + Hermes packing, per stratum:
|
||||
|
||||
| stratum | A alone | B real | C shuffled |
|
||||
|---|---|---|---|
|
||||
| inspect | 100/100 | 93/100 | 100/100 |
|
||||
| search | 90/100 | **100/100** | 92/100 |
|
||||
| execute | 80/100 | **84/100** | 62/100 |
|
||||
| no_tool | 100/100 | 100/100 | 100/100 |
|
||||
|
||||
References **help exactly where the actor has headroom** (search +10, execute
|
||||
+4) and **hurt where it was already perfect** (inspect −7). That is a gating
|
||||
signal, not a global verdict.
|
||||
|
||||
Note `execute` arm C: irrelevant advice costs −18 points. Relevant advice on the
|
||||
same stratum is +4. Advice content matters enormously; the risk is not "extra
|
||||
tokens", it is *wrong-context* advice.
|
||||
|
||||
## Paired B vs C (advice content, both arms carry equal extra context)
|
||||
|
||||
| configuration | B−C | 95% CI | |
|
||||
|---|---|---|---|
|
||||
| weak + Hermes | +0.057 | [−0.018, +0.138] | spans 0 |
|
||||
| weak + original | −0.010 | [−0.080, +0.062] | spans 0 |
|
||||
| strong + Hermes | −0.015 | [−0.035, +0.000] | spans 0 |
|
||||
| strong + original | −0.075 | [−0.125, −0.033] | **significant** |
|
||||
|
||||
With correct packing the content effect flips sign with actor strength: real
|
||||
advice beats shuffled for a weak actor, and is indistinguishable for a strong
|
||||
one.
|
||||
|
||||
## Interventions that did NOT help tool selection
|
||||
|
||||
| intervention | result |
|
||||
|---|---|
|
||||
| pre-hoc *structured* proposals (diverse vs homogeneous vs solo) | flat, 37–39/40 all arms |
|
||||
| post-hoc deterministic correction (schema-validate + re-prompt) | +0.000 — never fired; the weak actor already emits structurally valid calls ~95% of the time |
|
||||
| post-hoc semantic correction (different-family critic reviews the concrete call) | slightly negative — the revision still runs through the weak actor, so the capability gap persists |
|
||||
|
||||
Residual tool-selection failures are **semantic** (wrong tool for the job), not
|
||||
structural. Validation and criticism cannot close a capability gap.
|
||||
|
||||
## Reasoning / answer turns (committee) — the one place MoA clearly wins
|
||||
|
||||
40 preregistered agent-session reasoning turns (4 strata × 10) × 3 draws = 120
|
||||
trials. Fixed aggregator (`qwen3-32b`); peers `qwen3-14b`,
|
||||
`mistral-small-24b`, `minimax-m2.5`. Judged pairwise by an **out-of-pool,
|
||||
different-family** judge (`gpt-4o-mini`), **position-swapped** — a win counts
|
||||
only if it survives both orderings, otherwise it is a tie.
|
||||
|
||||
- **A** aggregator alone
|
||||
- **B** committee: aggregator synthesizes 3 peer drafts (single round)
|
||||
- **C** layered: peers first refine seeing each other's drafts, then synthesize
|
||||
(Together's `layers`)
|
||||
|
||||
| comparison | win / tie / loss | mean | 95% CI | sign test |
|
||||
|---|---|---|---|---|
|
||||
| **committee (B) vs solo (A)** | **86 / 16 / 18** | +0.567 | [+0.392, +0.733] | **p = 8.2e-12** |
|
||||
| **layered (C) vs solo (A)** | **90 / 11 / 19** | +0.592 | [+0.408, +0.758] | **p = 3.1e-12** |
|
||||
| layered (C) vs committee (B) | 57 / 30 / 33 | +0.200 | [+0.008, +0.392] | p = 0.015 |
|
||||
|
||||
Consistent across every stratum (B vs A): planning 25/2/3, explain 22/2/6,
|
||||
code_review 20/6/4, reason_over_output 19/6/5.
|
||||
|
||||
### Length control
|
||||
|
||||
The verbosity confound runs the *opposite* way here, which strengthens the
|
||||
result:
|
||||
|
||||
| | mean chars |
|
||||
|---|---|
|
||||
| A solo | 3136 |
|
||||
| B committee | 2548 |
|
||||
| C layered | 2196 |
|
||||
|
||||
The committee produces **shorter** answers than solo and still wins. Restricted
|
||||
to the 61 trials where B was shorter than A, B wins **40–14** (p = 5.4e-4). So
|
||||
the preference is not length-driven.
|
||||
|
||||
**Note on the judge.** These numbers were collected with the pre-fix judge
|
||||
wording that was later found to reward length (see "Withdrawn" below). This
|
||||
section's result survives that finding, because the bias ran *against* the
|
||||
winner here: the shorter arm won anyway, and won on the shorter-only subset.
|
||||
The small-pool and e2e sections did not have that protection and were re-run.
|
||||
|
||||
### This reverses the pilot — and why
|
||||
|
||||
An earlier 15-prompt pilot found B vs A at 6/2/2 (p=0.29, "not significant")
|
||||
and layered *losing* to single-round 2/2/6. Both conclusions were wrong,
|
||||
because 20 of 30 pilot trials were silently dropped: `response_text` read only
|
||||
`/message/content`, so a reasoning model that spends its budget in `reasoning`
|
||||
and returns `content: null` looked like an empty answer. That dropped exactly
|
||||
the trials where the aggregator struggled — a biased sample. With the fallback
|
||||
fixed, **0 of 120 trials skipped**.
|
||||
|
||||
The earlier claim "Together's layering is negative value, don't build it" is
|
||||
**retracted**: layered beats solo about as strongly as single-round does, and
|
||||
edges single-round itself (p=0.015, CI lower bound +0.008 — the weakest of the
|
||||
three results, and it costs an extra round of peer calls).
|
||||
|
||||
### Caveats
|
||||
|
||||
- Prompts are authored for this repo's domain, not a standard benchmark; these
|
||||
numbers are **not** comparable to AlpacaEval-style scores.
|
||||
- One aggregator, one peer set, one judge. A single judge model is the main
|
||||
residual risk; self-preference is unlikely (judge is OpenAI-family, pool is
|
||||
Qwen/Mistral/MiniMax) but unmeasured.
|
||||
- Judged answer quality, not task success in a real agent loop.
|
||||
|
||||
## The mesh case: can a pool of small models beat its best member?
|
||||
|
||||
The question that decides whether mesh MoA is worth running on consumer
|
||||
hardware. Same 40 prompts × 3 draws, same judge and controls — but the whole
|
||||
pool is 8B-class and **diverse by family**, the shape a few laptops actually
|
||||
have:
|
||||
|
||||
- aggregator `qwen/qwen3-8b`
|
||||
- peers `meta-llama/llama-3.1-8b-instruct`, `ibm-granite/granite-4.1-8b`,
|
||||
`mistralai/ministral-8b-2512`
|
||||
|
||||
**These are the length-controlled numbers** (n=80). See the judge-bias section
|
||||
below for why the earlier, larger figures are withdrawn.
|
||||
|
||||
| comparison | win / tie / loss | sign test |
|
||||
|---|---|---|
|
||||
| committee (1 round) vs solo | 6 / 73 / 1 | p = 0.125 **ns** |
|
||||
| **layered (2 rounds) vs solo** | **11 / 68 / 1** | **p = 0.0063** |
|
||||
| layered vs committee | 3 / 77 / 0 | p = 0.25 **ns** |
|
||||
|
||||
**Yes — but only with the refinement round.** Single-round synthesis is
|
||||
indistinguishable from the aggregator working alone; layering is what produces
|
||||
the gain, winning 11–1 on decided trials.
|
||||
|
||||
Reading: with weaker members the aggregator has little to work with until the
|
||||
peers have *seen each other* and improved their drafts. That is the mechanism
|
||||
Together's `layers` provides, and it matters most exactly where mesh operates.
|
||||
|
||||
Honest scale: ties dominate (68/80). On most prompts a small mesh and a single
|
||||
small model are indistinguishable; the mesh wins a minority and almost never
|
||||
loses. That is a real but modest effect, not the large one the first pass
|
||||
reported.
|
||||
|
||||
### Withdrawn: the length-biased numbers
|
||||
|
||||
The first run of this study scored **42/66/12, p=5.2e-05** for layered-vs-solo,
|
||||
and **39/73/8** for layered-vs-committee. Both are withdrawn.
|
||||
|
||||
The judge was asked which response was "more accurate, complete, and useful".
|
||||
"Complete" reads as "longer", and the judge duly scored length. Measured on the
|
||||
e2e run with the same judge:
|
||||
|
||||
| | n | win | loss | winrate |
|
||||
|---|---|---|---|---|
|
||||
| MoA answer **longer** than solo | 25 | 13 | 0 | 100% |
|
||||
| MoA answer **shorter** than solo | 55 | 4 | 24 | 14% |
|
||||
|
||||
point-biserial r(length delta, verdict) = **+0.681**.
|
||||
|
||||
Re-run with a judge told to score correctness and relevance only, and that
|
||||
length is explicitly not quality, r fell to +0.132 and most former "wins"
|
||||
became ties. The direction survived; the magnitude did not.
|
||||
|
||||
This is the same control the strong-pool section applies — it was simply never
|
||||
carried into the small-pool and e2e harnesses.
|
||||
|
||||
## Admission control: does a weak node help a strong pool? (No.)
|
||||
|
||||
The A/B/C test the goal hinges on — should a modest node be admitted into a
|
||||
committee that already has a stronger member? Same 40 prompts, same judge,
|
||||
layered arm, through the committee harness (no admission control, so C is the
|
||||
counterfactual "what if we admitted it"):
|
||||
|
||||
| arm | pool | layered vs solo | decided winrate | losses |
|
||||
|---|---|---|---|---|
|
||||
| B | 32B ×2 | 48W / 23T / 2L, p=2e-12 | 96% | 2 |
|
||||
| C | 32B ×2 + 8B | 50W / 25T / 5L, p=2e-10 | 91% | 5 |
|
||||
|
||||
Fisher exact B-vs-C: p=0.44 — not statistically separable at n=80, but the
|
||||
direction is one-way: **admitting the weak 8B node never helped and modestly
|
||||
raised losses (5 vs 2).** Both still beat solo — a weak node does not collapse
|
||||
the pool — but it adds latency and cost for no upside and a small tail risk.
|
||||
|
||||
This is the measured basis for tier-based admission control
|
||||
(`apply_admission_control`): when a big-tier worker is present, drop small-tier
|
||||
ones. The conservative choice, and the evidence says conservative is right here.
|
||||
|
||||
Caveat: this rejects the *possibility* that a genuinely complementary weak model
|
||||
could help a specific prompt. The data says that possibility is not worth the
|
||||
average-case cost at these scales; a per-turn admission signal (measured
|
||||
marginal contribution) could revisit it later, but tier is the safe default now.
|
||||
|
||||
### But only when a committee survives the exclusion
|
||||
|
||||
Arm C dropped the 8B from a pool that *still had two 32B*. The other case — one
|
||||
strong + one weak, where dropping the 8B collapses the pool to a solo 32B — is
|
||||
different, and admission must NOT drop there:
|
||||
|
||||
| pool | vs solo 32B | length r |
|
||||
|---|---|---|
|
||||
| 32B + 8B, layered | 47W / 27T / 5L, p=1.3e-9 | −0.01 (clean) |
|
||||
| 32B + 8B, single-round | 40W / 31T / 8L, p=3.3e-6 | +0.02 |
|
||||
|
||||
A mixed committee beats a solo strong model decisively. So the rule is not
|
||||
"drop small whenever a big is present" — it is **drop small only when ≥2 big
|
||||
remain**. Dropping to protect quality is right when a real committee is left; it
|
||||
is wrong when it would throw away MoA entirely.
|
||||
|
||||
This is exactly the core mesh case: a modest node joining a single strong node
|
||||
*does* help (47/5), and admission control now keeps it. Adding a modest node to
|
||||
an *already-strong committee* does not help (arm C), and admission drops it.
|
||||
Both are handled by the same "≥2 big remain" gate.
|
||||
|
||||
## The problem with N=2 was scale, not count
|
||||
|
||||
The N=2-8B null (below) suggested "two peers isn't enough". A mid-scale re-run
|
||||
refutes that reading: the count was fine, the 8B *members* were too weak.
|
||||
|
||||
Same 40 prompts, same judge, same shipped `handle_turn`, layered arm:
|
||||
|
||||
| pool | layered vs best member | p (sign) | length r | MoA vs solo length |
|
||||
|---|---|---|---|---|
|
||||
| N=2, 8B (Qwen + Meta) | 2W / 75T / 3L | 1.00 | n/a (5 decided) | — |
|
||||
| N=3, 8B (+ IBM) | 3W / 76T / 1L | 0.63 | — | — |
|
||||
| N=4, 8B (+ Mistral) | 11W / 68T / 1L | 0.006 | — | — |
|
||||
| **N=2, mid (32B + 24B)** | **49W / 24T / 6L** | **2e-9** | **−0.04** | MoA shorter (2516 vs 3064) |
|
||||
| N=4, strong (32B agg) | 90W / 11T / 19L | 3e-12 | +0.30 | MoA shorter (2196 vs 3136) |
|
||||
|
||||
The N=2-mid result is the cleanest in the whole study: no length confound
|
||||
(r=−0.04), MoA answers *shorter* than solo, and it still won 93% of the trials
|
||||
where it was shorter. Two mid-size models beat one of equal strength decisively.
|
||||
|
||||
So diversity/capability of the *members*, not the raw count, is what matters —
|
||||
at 8B you need ~4 models before it pays; at 24–32B, 2 already win.
|
||||
|
||||
**Resolved: diversity is not the active ingredient — ensembling is.**
|
||||
Compute-matched Self-MoA (two samples of the *same* qwen3-32b, same aggregator,
|
||||
same drafts+refine+synthesize) was run against Mixed (qwen3-32b + mistral-24b):
|
||||
|
||||
| arm | layered vs solo | p | length r |
|
||||
|---|---|---|---|
|
||||
| Mixed (2 different models) | 49W / 24T / 6L | 1.8e-9 | −0.04 |
|
||||
| Self (same model ×2) | 48W / 23T / 2L | 2.3e-12 | −0.04 |
|
||||
|
||||
Mixed vs Self: Fisher exact p = 0.27 — **statistically indistinguishable**.
|
||||
Both crush the single best member, both with no length confound and MoA answers
|
||||
*shorter* than solo (winning ~93% of shorter-MoA trials in each arm).
|
||||
|
||||
So different-family membership is **not required**. The active mechanism is
|
||||
test-time ensembling — several sampled drafts (workers run at temperature 0.8,
|
||||
so repeated draws genuinely differ), a cross-peer refinement round, and
|
||||
synthesis — and it works whether the drafts come from different models or
|
||||
repeated sampling of one. This matches the Self-MoA paper (arXiv:2502.00674):
|
||||
proposal quality and sampling, not heterogeneity, carry the gain.
|
||||
|
||||
Practical consequence for a mesh: the value does not depend on curating a
|
||||
diverse pool. Any ≥2 reasonably-capable participants — distinct models *or*
|
||||
repeated instances of one — beat picking a single model, once they are strong
|
||||
enough individually (mid-scale here; 8B needs ~4).
|
||||
|
||||
## The 8B ladder: how many small models beat one?
|
||||
|
||||
Does stacking 8B models beat a single 8B? Same prompts/judge/harness, layered arm:
|
||||
|
||||
| pool | vs one 8B | p |
|
||||
|---|---|---|
|
||||
| 2× 8B, different (Qwen + Meta) | 2W / 75T / 3L | 1.00 |
|
||||
| 2× 8B, **same** (Qwen ×2) | 2W / 78T / 0L | 0.50 |
|
||||
| 3× 8B, different | 3W / 76T / 1L | 0.63 |
|
||||
| 4× 8B, different | 11W / 68T / 1L | 0.006 |
|
||||
|
||||
Two results worth stating plainly:
|
||||
|
||||
- **You need ~4 at 8B.** Two or three 8B models don't beat one; four do. There
|
||||
is a floor below which stacking small models buys nothing.
|
||||
- **Same-vs-different makes no difference at N=2** (2W/3L vs 2W/0L, both null),
|
||||
exactly as the mid-scale Self-vs-Mixed test showed. Count and member
|
||||
strength drive the result; family identity does not.
|
||||
|
||||
The one gap in this ladder: 4× 8B *same-model* (four qwen3-8b instances) was not
|
||||
run, so "does the 8B N=4 win need distinct models or just four drafts?" is
|
||||
open. Mid-scale Self-MoA predicts four drafts alone would suffice, but that is
|
||||
an extrapolation.
|
||||
|
||||
## How many peers does it take? (N=2 vs N=4)
|
||||
|
||||
Same 40 prompts, same judge, same harness — only the pool size differs. N=2 is
|
||||
`qwen3-8b` + `llama-3.1-8b` with qwen3-8b aggregating (Together's
|
||||
`advanced-moa.py` shape, where the aggregator is also a reference).
|
||||
|
||||
| pool | 1 round vs solo | 2 rounds vs solo | decided trials |
|
||||
|---|---|---|---|
|
||||
| **N=2** (Qwen, Meta) | 2W / 75T / 3L, p=1.0 | 2W / 75T / 3L, p=1.0 | 5 of 80 |
|
||||
| **N=4** (+ IBM, Mistral) | 6W / 73T / 1L, p=0.125 | **11W / 68T / 1L, p=0.0063** | 12 of 80 |
|
||||
|
||||
Fisher exact on decided win/loss, N=2 vs N=4: **p = 0.053**.
|
||||
|
||||
**Two peers was not enough.** At 8B scale, adding one different model produced
|
||||
no measurable gain — 2 wins against 3 losses, indistinguishable from noise. The
|
||||
same harness with four models across four families wins 11–1.
|
||||
|
||||
Two things move together as peers are added: the number of *decided* trials
|
||||
rises (5 → 12; more peers produce more differentiated output rather than
|
||||
near-identical answers the judge calls a tie), and the win share among those
|
||||
rises (40% → 92%).
|
||||
|
||||
Caveats: only 5 decided trials at N=2, so this is weak evidence of absence, not
|
||||
evidence of no effect. And it is a claim about *small* models — Hermes reports
|
||||
a two-model preset (`claude-opus-4.8` aggregating a `gpt-5.5` reference) at
|
||||
0.8202 vs 0.7607 for the stronger model alone, so frontier pairs may behave
|
||||
differently. Untested here.
|
||||
|
||||
## Eval-vs-production fidelity
|
||||
|
||||
A measured gain only counts if the shipped path reproduces the measured
|
||||
configuration. Three gaps were found and closed after the numbers above were
|
||||
collected — all in the same class as the reference-packing bug, where code that
|
||||
looked equivalent was not:
|
||||
|
||||
| | measured in eval | shipped (before) | now |
|
||||
|---|---|---|---|
|
||||
| refinement input per draft | untruncated (~3.8k chars) | 1200 chars (~30%) | 4000 chars |
|
||||
| reducer payload per answer | untruncated (~3.8k chars) | 500 chars (~13%) | 4000 chars (text) |
|
||||
| refinement prompt | aggregator wording + `[Response N]` | different wording + `[Answer N]` | matches eval |
|
||||
|
||||
The truncation gaps were the serious ones: the reducer was seeing ~13% of each
|
||||
refined answer, discarding most of exactly what the refinement round produces.
|
||||
Tool turns deliberately keep the tight 500-char bound — there the signal is the
|
||||
proposal itself and long prose crowds out the schemas.
|
||||
|
||||
Sampling already matched (`SamplingParams::worker()`, thinking off, 1024
|
||||
tokens).
|
||||
|
||||
**Implication for reading the numbers above:** they were produced by the eval
|
||||
harness, and production now matches that configuration — but the small-pool
|
||||
result has not been *re-measured* through the shipped code path since these
|
||||
fixes. The engine is transport-agnostic and the packing is now identical, so
|
||||
the gain should carry; that is an expectation, not an observation.
|
||||
|
||||
## Reproducing
|
||||
|
||||
```bash
|
||||
export OPENROUTER_API_KEY=...
|
||||
|
||||
# tool-selection ablation (2x2: actor strength x packing)
|
||||
MOA_REFERENCE_PACKING=hermes MOA_ABLATION_ACTOR=qwen/qwen3-8b \
|
||||
MOA_ABLATION_OUT=/tmp/x.jsonl \
|
||||
cargo test -p mesh-mixture-of-agents --test eval_openrouter \
|
||||
ablation_scaled_study -- --ignored --nocapture
|
||||
|
||||
python3 evals/moa-openrouter/analyze_ablation.py /tmp/x.jsonl
|
||||
```
|
||||
|
||||
Other studies: `matched_peer_structured_study`,
|
||||
`correction_rescues_weak_tool_caller`, `committee_beats_solo_on_reasoning`.
|
||||
|
||||
## Status
|
||||
|
||||
**Tool selection — directional, no clear win for multi-model.** 40 tasks × 10
|
||||
draws detects the packing bug (a large effect) but cannot separate the
|
||||
remaining ±0.05 effects. Every intervention tried (prose advice, structured
|
||||
proposals, deterministic correction, semantic correction) was null-to-harmful
|
||||
against simply routing to a capable model. Correctly-packed references are
|
||||
roughly break-even, positive for a weak actor, mildly negative for a strong
|
||||
one — hence gating on actor headroom rather than always/never.
|
||||
|
||||
**Reasoning/answer turns, strong pool — a clear win.** 120 trials, p < 1e-11,
|
||||
consistent across all four strata, and the length confound runs *against* the
|
||||
result rather than explaining it (the winning arm was the shorter one).
|
||||
|
||||
**Reasoning/answer turns, small pool — a real but modest win, and only with
|
||||
refinement.** Length-controlled: layered beats solo 11–1 on decided trials
|
||||
(p = 0.0063), single-round does not (6–1, ns). Ties dominate at 68/80 — on most
|
||||
prompts a small mesh and a single small model are indistinguishable.
|
||||
|
||||
**End-to-end through `handle_turn` — parity, not yet a win.** Latest:
|
||||
9 / 59 / 12 (p = 0.66) against the pool's best member; the prior run was
|
||||
5 / 65 / 10 (p = 0.30). Both are parity, and the difference between them is
|
||||
noise at this sample size.
|
||||
|
||||
Six eval-vs-production divergences were found by measuring the shipped path and
|
||||
fixed: grace finalizing the turn before refinement could run, two truncation
|
||||
bounds that discarded most of each answer, two prompts that contradicted the
|
||||
measured configuration, and named-vs-anonymous reducer inputs. A seventh
|
||||
hypothesis (removing the worker preamble from the reducer) was measured,
|
||||
*rejected*, and reverted.
|
||||
|
||||
The gap that remains is unexplained:
|
||||
|
||||
| | win / tie / loss | sign test |
|
||||
|---|---|---|
|
||||
| harness (`refine` + `synthesize` helpers) | 11 / 68 / 1 | p = 0.0063 |
|
||||
| shipped (`moa::handle_turn`) | 9 / 59 / 12 | p = 0.66 |
|
||||
|
||||
Same models, same prompts, same judge, same packing. The mechanism works when
|
||||
driven directly; something in the shipped orchestration still costs the gain.
|
||||
|
||||
Two prompt-level explanations were tested and **rejected**:
|
||||
|
||||
| change | decided-trial winrate | MoA output |
|
||||
|---|---|---|
|
||||
| baseline (v7) | 5/15 = 33% | 3606 |
|
||||
| anonymize reducer inputs (v8) | 9/21 = 43% | 3679 |
|
||||
| also drop preamble + "Reason for synthesis" (v9) | 8/23 = 35% | 3314 |
|
||||
|
||||
Dropping the worker preamble shortened output and lost ground on both
|
||||
occasions it was tried (v6 3534 chars, v9 3314) versus keeping it (v7 3606,
|
||||
v8 3679), so it was reverted twice. Reading: the preamble ("the best parts of
|
||||
each will be combined; give your most accurate and complete answer") does
|
||||
useful work on the reducer even though it is nominally addressed to a worker.
|
||||
**Matching the harness exactly is not automatically right** — the harness sent
|
||||
no system prompt at all, production sends one, and the preamble evidently
|
||||
compensates.
|
||||
|
||||
Anonymization (v8) is retained: it is what Hermes does and what the study
|
||||
measured, and it did not hurt. But 43% vs 33% on ~20 decided trials is not a
|
||||
result; both runs are parity.
|
||||
|
||||
Still unruled-out: the arbiter short-circuiting synthesis when refined drafts
|
||||
converge (74/80 turns did reach the reducer, so partial at most), and
|
||||
differences in what `normalize_worker_output` does to prose before refinement
|
||||
consumes it. Neither has been tested.
|
||||
|
||||
After two rejected hypotheses in a row, the honest read is that the remaining
|
||||
gap is not another prompt-wording difference. It needs a diff of the actual
|
||||
prompt bytes sent by each path on the same input, not more guesses.
|
||||
|
||||
Caution on the numbers above: r(length, verdict) was +0.465 in the latest e2e
|
||||
run versus +0.132 in the small-pool study, so length bias is not fully
|
||||
suppressed even with the corrected judge. Treat single-run e2e deltas as
|
||||
directional only.
|
||||
|
||||
The task split follows from the evidence: **route tool turns to the best
|
||||
tool-caller; convene the committee on reasoning/answer turns.**
|
||||
|
||||
Outstanding before this is a merge-blocking claim:
|
||||
|
||||
- close the remaining harness-vs-production gap (parity → the harness's 11–1)
|
||||
- end-to-end agent-task success, not judged answer quality
|
||||
- a second judge model to bound single-judge risk
|
||||
- 2-node mesh validation (everything here is measured through the engine, not
|
||||
gossip)
|
||||
|
||||
## Width sprint: many small models (2026-08-05)
|
||||
|
||||
Aggregator = `qwen/qwen3-8b`, peers 8B-class, judge `gpt-4o-mini`,
|
||||
position-swapped + length-noted, 2 draws × 40 prompts, shipped committee path.
|
||||
Three arms per trial: single-aggregation (Hermes-shape, draft→synthesize),
|
||||
layered (Together-shape, draft→synth→refine→synth), and the refine-vs-single
|
||||
delta.
|
||||
|
||||
| pool | single-agg vs solo | layered vs solo | refine vs single-agg |
|
||||
|---|---|---|---|
|
||||
| 2× 8B diverse | 2W/77T/1L, p=1.0 | 3W/74T/3L, p=1.0 | null |
|
||||
| 4× 8B diverse | 5W/73T/0L, p=0.06 | 6W/70T/2L, p=0.29 | 0W/77T/1L |
|
||||
| **6× 8B diverse** | **12W/65T/2L, p=0.013** | 9W/69T/1L, p=0.021 | 2W/76T/1L |
|
||||
| 6× qwen3-8b SAME | 4W/75T/1L, p=0.38 | 1W/79T/0L, p=1.0 | 1W/79T/0L |
|
||||
|
||||
> **WITHDRAWN — see "Withdrawal: the 6x8B small-pool win did not replicate" at
|
||||
> the end of this file.** The 6× 8B cell below did not reproduce (3W/76T/1L,
|
||||
> p=0.63 on a re-run of the same rig, same tasks, same pool), and the shipped
|
||||
> path measured a *loss* at every small width. All-small pools now serve their
|
||||
> best member instead of convening a committee. The rest of this section is kept
|
||||
> for the record; do not cite the 6× 8B number.
|
||||
|
||||
Findings (as originally written; the first is withdrawn):
|
||||
|
||||
- ~~**Width is the small-model lever.** A committee of six diverse 8B models
|
||||
beats the single best member (12W/2L, p=0.013); four is only marginal
|
||||
(p=0.06); two/three are null.~~ Withdrawn: rested on ~14 decided trials out of
|
||||
80 and did not replicate. The tier-aware cap (6 all-small / 4 with a verified
|
||||
big) is retained, but an all-small pool no longer forms a committee at all.
|
||||
- **The refine round never earns its serial cost.** `refine vs single-agg` is
|
||||
null in every cell (2/77/1 at N6). Hermes' single-aggregation cadence (one
|
||||
synth, no refine pass) is >= Together's layered shape here, at half the
|
||||
serial latency. The `RefinementPolicy` default should reconsider the extra
|
||||
round for small pools.
|
||||
- **At 8B, diversity matters** (6 diverse 12W/2L vs 6 same 4W/1L, p=0.38),
|
||||
unlike mid-scale where Self ≈ Mixed. Weaker models decorrelate their errors
|
||||
better when they are genuinely different families; repeated draws of one 8B
|
||||
do not supply enough independent signal.
|
||||
|
||||
Open: 4B pools (is the floor lower or does it need even more width?), and
|
||||
whether dropping the refine round for small pools recovers latency without
|
||||
losing the width win.
|
||||
|
||||
## Withdrawal: the 6x8B small-pool win did not replicate (2026-08-06)
|
||||
|
||||
The width-sprint headline ("6x diverse 8B beats its best member, 12W/65T/2L,
|
||||
p=0.013") is **withdrawn**. Re-running the same rig on the same 40 tasks, same
|
||||
pool, same judge gives **3W/76T/1L (p=0.63)**, with 11 of 40 tasks flipping
|
||||
verdict. The original rested on ~14 decided trials out of 80 (the rest ties) and
|
||||
was a single unreplicated run selected from a width sweep; correcting for the
|
||||
four pool widths compared puts it at roughly p=0.05 even on its own terms.
|
||||
|
||||
It was real arithmetic on real data, but never a robust effect, and it should
|
||||
not have been published as a headline from one run.
|
||||
|
||||
### What the shipped path actually does at small scale
|
||||
|
||||
Through `moa::handle_turn` at production defaults, 8B-class peers with an 8B
|
||||
reducer, vs the pool's best member alone (40 prompts x 2 draws, out-of-family
|
||||
position-swapped judge, length logged):
|
||||
|
||||
| pool | result | decided | p |
|
||||
|---|---|---|---|
|
||||
| 2x 8B | 0W/43T/37L | 37 | <0.0001 |
|
||||
| 4x 8B | 5W/63T/12L | 17 | 0.14 |
|
||||
| 6x 8B | 5W/52T/23L | 28 | 0.0009 |
|
||||
|
||||
MoA answers are consistently shorter (3236-3372 chars vs ~4070 solo). The
|
||||
committee never won at any width. Note the capable pool wins 28/29 of decided
|
||||
trials where the MoA answer is *shorter*, which rules out "this judge simply
|
||||
prefers longer answers" as the explanation.
|
||||
|
||||
**Action:** all-small pools no longer convene a committee; they collapse to the
|
||||
best member and the caller degrades to serving it directly.
|
||||
|
||||
**Scope of the claim:** this measures a weak reducer synthesizing weak drafts.
|
||||
It is not evidence that small-model MoA cannot work. The untested cell is small
|
||||
peers with a *strong* reducer (the capable pool confounds peer strength with
|
||||
reducer strength). If a mesh gains a big-tier model the pool is no longer
|
||||
all-small and MoA engages again.
|
||||
|
||||
### Known methodology limitations
|
||||
|
||||
- `judge_pair` collapses genuine ties, position disagreements, and API/parse
|
||||
failures into the same `0` result, so "tie" is a garbage bucket and tie counts
|
||||
cannot be interpreted as agreement.
|
||||
- Significance is computed per draw (80) rather than per prompt cluster (40),
|
||||
which overstates confidence; the capable-pool result survives either way, the
|
||||
small-pool ones are marginal.
|
||||
- `pool[0]` is assumed to be the strongest member rather than verified.
|
||||
- Single judge model.
|
||||
28
evals/moa-openrouter/agentic.jsonl
Normal file
28
evals/moa-openrouter/agentic.jsonl
Normal file
File diff suppressed because one or more lines are too long
200
evals/moa-openrouter/analyze_ablation.py
Normal file
200
evals/moa-openrouter/analyze_ablation.py
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Analyze the scaled MoA actor-ablation study.
|
||||
|
||||
Consumes the per-trial JSONL written by `ablation_scaled_study`
|
||||
(eval_openrouter.rs) and reports the paired net-uplift of references over
|
||||
actor-alone, with a hierarchical bootstrap CI.
|
||||
|
||||
Arms per (draw, task):
|
||||
A = actor alone
|
||||
B = actor + real references (production tool path)
|
||||
C = actor + shuffled references (advice from a different task)
|
||||
|
||||
Primary statistic: net uplift = P(rescue) - P(harm), where
|
||||
rescue = A fail & B pass, harm = A pass & B fail,
|
||||
on trials where BOTH A and B were scored (infra errors excluded).
|
||||
|
||||
The bootstrap is HIERARCHICAL and PAIRED to respect the design:
|
||||
- resample TASKS within each category (stratified), then
|
||||
- resample DRAWS within each resampled task,
|
||||
so the CI reflects generalization across tasks, not just draw noise.
|
||||
|
||||
The C arm is the control: if B uplift ~ C uplift, the gain is "extra tokens +
|
||||
a decision prompt", not advice content. We report B-vs-A and C-vs-A side by
|
||||
side, and the differential (B_uplift - C_uplift).
|
||||
|
||||
Usage:
|
||||
python3 analyze_ablation.py /tmp/moa_ablation.jsonl [--iters 10000] [--seed 0]
|
||||
|
||||
Deterministic given (jsonl, iters, seed). Stdlib only.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import random
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
def load(path):
|
||||
"""-> trials[(task_id)] = {'category', 'draws': {draw: {arm: outcome}}}"""
|
||||
tasks = {}
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
r = json.loads(line)
|
||||
t = tasks.setdefault(
|
||||
r["task_id"], {"category": r["category"], "draws": defaultdict(dict)}
|
||||
)
|
||||
t["draws"][r["draw"]][r["arm"]] = r["outcome"]
|
||||
return tasks
|
||||
|
||||
|
||||
def paired_counts(draws, arm):
|
||||
"""Rescue/harm/scored counts for `arm` vs A over this task's draws."""
|
||||
rescue = harm = scored = 0
|
||||
for d in draws.values():
|
||||
a = d.get("A")
|
||||
x = d.get(arm)
|
||||
if a in ("pass", "fail") and x in ("pass", "fail"):
|
||||
scored += 1
|
||||
if a == "fail" and x == "pass":
|
||||
rescue += 1
|
||||
elif a == "pass" and x == "fail":
|
||||
harm += 1
|
||||
return rescue, harm, scored
|
||||
|
||||
|
||||
def task_uplift(draws, arm):
|
||||
"""Net uplift for one task (mean over its scored draws), or None."""
|
||||
rescue, harm, scored = paired_counts(draws, arm)
|
||||
if scored == 0:
|
||||
return None
|
||||
return (rescue - harm) / scored
|
||||
|
||||
|
||||
def point_estimate(tasks, arm):
|
||||
"""Equal-weight mean of per-task uplift (per expert: task is the unit)."""
|
||||
vals = [u for t in tasks.values() if (u := task_uplift(t["draws"], arm)) is not None]
|
||||
return sum(vals) / len(vals) if vals else float("nan"), len(vals)
|
||||
|
||||
|
||||
def bootstrap_ci(tasks, arm, iters, seed):
|
||||
"""Hierarchical paired bootstrap: resample tasks within category, then
|
||||
draws within task. Returns (lo, hi) 95% CI for mean per-task uplift."""
|
||||
rng = random.Random(seed)
|
||||
by_cat = defaultdict(list)
|
||||
for tid, t in tasks.items():
|
||||
by_cat[t["category"]].append(tid)
|
||||
|
||||
# Precompute per-task draw lists so resampling draws is cheap.
|
||||
draw_lists = {tid: list(t["draws"].values()) for tid, t in tasks.items()}
|
||||
|
||||
samples = []
|
||||
cats = sorted(by_cat)
|
||||
for _ in range(iters):
|
||||
vals = []
|
||||
for cat in cats:
|
||||
ids = by_cat[cat]
|
||||
for _ in range(len(ids)):
|
||||
tid = ids[rng.randrange(len(ids))]
|
||||
dl = draw_lists[tid]
|
||||
if not dl:
|
||||
continue
|
||||
# resample draws within this task, with replacement
|
||||
res = [dl[rng.randrange(len(dl))] for _ in range(len(dl))]
|
||||
rescue = harm = scored = 0
|
||||
for d in res:
|
||||
a = d.get("A")
|
||||
x = d.get(arm)
|
||||
if a in ("pass", "fail") and x in ("pass", "fail"):
|
||||
scored += 1
|
||||
if a == "fail" and x == "pass":
|
||||
rescue += 1
|
||||
elif a == "pass" and x == "fail":
|
||||
harm += 1
|
||||
if scored:
|
||||
vals.append((rescue - harm) / scored)
|
||||
if vals:
|
||||
samples.append(sum(vals) / len(vals))
|
||||
samples.sort()
|
||||
if not samples:
|
||||
return float("nan"), float("nan")
|
||||
lo = samples[int(0.025 * len(samples))]
|
||||
hi = samples[int(0.975 * len(samples)) - 1]
|
||||
return lo, hi
|
||||
|
||||
|
||||
def arm_pass_rate(tasks, arm):
|
||||
p = n = 0
|
||||
for t in tasks.values():
|
||||
for d in t["draws"].values():
|
||||
o = d.get(arm)
|
||||
if o in ("pass", "fail"):
|
||||
n += 1
|
||||
p += o == "pass"
|
||||
return p, n
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("jsonl")
|
||||
ap.add_argument("--iters", type=int, default=10000)
|
||||
ap.add_argument("--seed", type=int, default=0)
|
||||
args = ap.parse_args()
|
||||
|
||||
tasks = load(args.jsonl)
|
||||
if not tasks:
|
||||
print("no trials found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
n_tasks = len(tasks)
|
||||
cats = defaultdict(int)
|
||||
for t in tasks.values():
|
||||
cats[t["category"]] += 1
|
||||
infra = 0
|
||||
total = 0
|
||||
for t in tasks.values():
|
||||
for d in t["draws"].values():
|
||||
for arm in ("A", "B", "C"):
|
||||
if arm in d:
|
||||
total += 1
|
||||
infra += d[arm] == "infra"
|
||||
|
||||
print(f"tasks={n_tasks} strata={dict(sorted(cats.items()))}")
|
||||
print(f"trials={total} infra_excluded={infra} ({100*infra/max(total,1):.1f}%)")
|
||||
print()
|
||||
for arm in ("A", "B", "C"):
|
||||
p, n = arm_pass_rate(tasks, arm)
|
||||
label = {"A": "actor alone", "B": "actor + real", "C": "actor + shuffled"}[arm]
|
||||
print(f" {arm} {label:18} pass {p}/{n} ({100*p/max(n,1):.0f}%)")
|
||||
print()
|
||||
|
||||
b_pt, b_k = point_estimate(tasks, "B")
|
||||
c_pt, c_k = point_estimate(tasks, "C")
|
||||
b_lo, b_hi = bootstrap_ci(tasks, "B", args.iters, args.seed)
|
||||
c_lo, c_hi = bootstrap_ci(tasks, "C", args.iters, args.seed)
|
||||
|
||||
print(" net uplift = P(rescue) - P(harm), equal-weight mean over tasks")
|
||||
print(f" B (real) uplift {b_pt:+.3f} 95% CI [{b_lo:+.3f}, {b_hi:+.3f}] (tasks n={b_k})")
|
||||
print(f" C (shuffled) uplift {c_pt:+.3f} 95% CI [{c_lo:+.3f}, {c_hi:+.3f}] (tasks n={c_k})")
|
||||
print(f" differential B-C: {b_pt - c_pt:+.3f} (content effect beyond token/prompt effect)")
|
||||
print()
|
||||
|
||||
# Verdicts (directional; the CI is what matters for a claim).
|
||||
if b_lo > 0:
|
||||
print(" => references HELP: B net uplift CI is entirely > 0")
|
||||
elif b_hi < 0:
|
||||
print(" => references HARM: B net uplift CI is entirely < 0")
|
||||
else:
|
||||
print(" => inconclusive: B net uplift CI spans 0")
|
||||
if b_pt - c_pt > 0 and b_lo > 0:
|
||||
print(" => and the gain is CONTENT (B > C), not just extra tokens/prompt")
|
||||
elif abs(b_pt - c_pt) < 0.02:
|
||||
print(" => gain (if any) is NOT content-specific (B ~ C)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
28
evals/moa-openrouter/corpus.jsonl
Normal file
28
evals/moa-openrouter/corpus.jsonl
Normal file
File diff suppressed because one or more lines are too long
4
evals/moa-openrouter/fanout.jsonl
Normal file
4
evals/moa-openrouter/fanout.jsonl
Normal file
File diff suppressed because one or more lines are too long
166
evals/moa-openrouter/lite_agent.py
Normal file
166
evals/moa-openrouter/lite_agent.py
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Lite goose-style agentic harness against a mesh-llm OpenAI endpoint.
|
||||
|
||||
Drives a real tool-call loop through `model=mesh` (or any model) to prove the
|
||||
MoA gateway emits usable `tool_calls` end to end on a live 2-node mesh — the
|
||||
path OpenRouter standins could not exercise (gossip, dedup, self-fill, the
|
||||
actual proxy).
|
||||
|
||||
Tools are canned/filesystem-lite so the loop is deterministic and offline
|
||||
apart from the model calls. Prints a worklog of every step.
|
||||
|
||||
Usage:
|
||||
python3 lite_agent.py --base http://localhost:9337/v1 --model mesh
|
||||
python3 lite_agent.py --base http://localhost:9337/v1 --model mesh --task find_symbol
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
# --- canned workspace the agent can act on (deterministic tool results) ---
|
||||
WORKSPACE = {
|
||||
"src/lib.rs": "pub fn add(a: i32, b: i32) -> i32 { a + b }\n\npub fn timeout() -> MeshError { MeshError::Timeout }\n",
|
||||
"src/error.rs": "pub enum MeshError { Timeout, Reset }\n",
|
||||
"README.md": "# demo\nA tiny crate. Run `cargo test`.\n",
|
||||
}
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_dir",
|
||||
"description": "List files under a directory path.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"path": {"type": "string"}},
|
||||
"required": ["path"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_file",
|
||||
"description": "Read the contents of a file by path.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"path": {"type": "string"}},
|
||||
"required": ["path"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search",
|
||||
"description": "Search the codebase for a regex or substring.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string"}},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
TASKS = {
|
||||
"explore": "List the files under src, then read src/error.rs and tell me what error variants exist.",
|
||||
"find_symbol": "Find every place MeshError::Timeout is used in this repo, then summarise where.",
|
||||
"explain": "What does the add function in src/lib.rs do? Read it first.",
|
||||
}
|
||||
|
||||
|
||||
def run_tool(name, args):
|
||||
"""Execute a canned tool against WORKSPACE. Returns a string result."""
|
||||
if name == "list_dir":
|
||||
p = (args.get("path") or "").strip("/")
|
||||
hits = sorted(f for f in WORKSPACE if f.startswith(p))
|
||||
return "\n".join(hits) if hits else f"(no files under {p!r})"
|
||||
if name == "read_file":
|
||||
p = (args.get("path") or "").lstrip("./")
|
||||
return WORKSPACE.get(p, f"(no such file: {p!r})")
|
||||
if name == "search":
|
||||
q = args.get("query") or ""
|
||||
# substring match, tolerate regex-ish word-boundary noise
|
||||
needle = q.replace("\\b", "").replace("\\", "")
|
||||
hits = [f"{f}: {ln}" for f, body in WORKSPACE.items() for ln in body.splitlines() if needle and needle in ln]
|
||||
return "\n".join(hits) if hits else f"(no matches for {q!r})"
|
||||
return f"(unknown tool {name!r})"
|
||||
|
||||
|
||||
def chat(base, model, messages, api_key=None, timeout=180):
|
||||
body = json.dumps({
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"tools": TOOLS,
|
||||
"max_tokens": 1024,
|
||||
}).encode()
|
||||
req = urllib.request.Request(base.rstrip("/") + "/chat/completions", data=body,
|
||||
headers={"Content-Type": "application/json"})
|
||||
if api_key:
|
||||
req.add_header("Authorization", f"Bearer {api_key}")
|
||||
t0 = time.time()
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
resp = json.load(r)
|
||||
return resp, time.time() - t0
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--base", default="http://localhost:9337/v1")
|
||||
ap.add_argument("--model", default="mesh")
|
||||
ap.add_argument("--task", default="explore", choices=list(TASKS))
|
||||
ap.add_argument("--max-steps", type=int, default=6)
|
||||
args = ap.parse_args()
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": "You are a coding agent. Use the provided tools to inspect the "
|
||||
"repository before answering. Call one tool at a time."},
|
||||
{"role": "user", "content": TASKS[args.task]},
|
||||
]
|
||||
|
||||
print(f"=== lite-agent: model={args.model} task={args.task} base={args.base} ===")
|
||||
print(f"USER: {TASKS[args.task]}\n")
|
||||
|
||||
tool_calls_made = 0
|
||||
for step in range(1, args.max_steps + 1):
|
||||
try:
|
||||
resp, dt = chat(args.base, args.model, messages)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[step {step}] REQUEST FAILED: {e}")
|
||||
return 2
|
||||
msg = resp["choices"][0]["message"]
|
||||
calls = msg.get("tool_calls") or []
|
||||
moa_workers = resp.get("usage", {}) # x-moa headers arrive as headers; body usage is fine to show
|
||||
if calls:
|
||||
messages.append(msg)
|
||||
for c in calls:
|
||||
fn = c["function"]["name"]
|
||||
try:
|
||||
fa = json.loads(c["function"].get("arguments") or "{}")
|
||||
except json.JSONDecodeError:
|
||||
fa = {}
|
||||
tool_calls_made += 1
|
||||
result = run_tool(fn, fa)
|
||||
print(f"[step {step}] ({dt:.1f}s) TOOL CALL: {fn}({json.dumps(fa)})")
|
||||
print(f" -> {result.splitlines()[0] if result else '(empty)'}"
|
||||
+ (" ..." if result.count("\n") else ""))
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": c.get("id", f"call_{step}"),
|
||||
"content": result,
|
||||
})
|
||||
else:
|
||||
content = (msg.get("content") or "").strip()
|
||||
print(f"[step {step}] ({dt:.1f}s) FINAL ANSWER:\n{content}\n")
|
||||
print(f"=== done: {tool_calls_made} tool call(s) over {step} step(s) ===")
|
||||
return 0 if tool_calls_made > 0 else 1
|
||||
|
||||
print(f"=== hit max steps ({args.max_steps}); {tool_calls_made} tool call(s) made ===")
|
||||
return 0 if tool_calls_made > 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
105
evals/moa-openrouter/make_fixture.py
Normal file
105
evals/moa-openrouter/make_fixture.py
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
"""Turn recorded OpenRouter traces into a Rust test fixture.
|
||||
|
||||
Reads agentic.jsonl (multi-step tool traces) and corpus.jsonl (single-shot
|
||||
fan-out, including truncated responses) and emits a compact JSON fixture
|
||||
consumed by crates/mesh-mixture-of-agents/tests/sim_real_traces.rs.
|
||||
|
||||
The fixture keeps *structured* tool_calls and finish_reason — the two
|
||||
things that get lost if you flatten worker responses to text. Latencies
|
||||
are preserved as recorded so ordering-sensitive behaviour (early exit,
|
||||
first-answer grace, strong patience) replays realistically; the Rust side
|
||||
scales them down so tests stay fast.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
OUT = "../../crates/mesh-mixture-of-agents/tests/fixtures/real_traces.json"
|
||||
|
||||
|
||||
def worker_row(w):
|
||||
return {
|
||||
"model": w["model"],
|
||||
"tier": w["tier"],
|
||||
"elapsed_ms": int(round((w["elapsed"] or 0) * 1000)),
|
||||
"finish_reason": w.get("finish_reason"),
|
||||
"content": w.get("text"),
|
||||
"tool_calls": w.get("tool_calls"),
|
||||
"error": w.get("error"),
|
||||
}
|
||||
|
||||
|
||||
cases = []
|
||||
|
||||
# ── Multi-step agentic traces ────────────────────────────────────────
|
||||
for line in open("agentic.jsonl"):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
r = json.loads(line)
|
||||
cases.append(
|
||||
{
|
||||
"id": f"{r['scenario']}__d{r['draw']}_s{r['step']}",
|
||||
"source": "agentic",
|
||||
"scenario": r["scenario"],
|
||||
"draw": r["draw"],
|
||||
"step": r["step"],
|
||||
"has_tools": True,
|
||||
"messages": r["messages"],
|
||||
"workers": [worker_row(w) for w in r["workers"]],
|
||||
}
|
||||
)
|
||||
|
||||
# ── Single-shot fan-out, incl. truncated responses ───────────────────
|
||||
for line in open("corpus.jsonl"):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
r = json.loads(line)
|
||||
cases.append(
|
||||
{
|
||||
"id": f"{r['case']}__d{r['draw']}",
|
||||
"source": "corpus",
|
||||
"scenario": r["case"],
|
||||
"draw": r["draw"],
|
||||
"step": 0,
|
||||
"has_tools": r["has_tools"],
|
||||
"messages": r["messages"],
|
||||
"workers": [worker_row(w) for w in r["workers"]],
|
||||
}
|
||||
)
|
||||
|
||||
with open(OUT, "w") as f:
|
||||
json.dump({"cases": cases}, f, indent=1, sort_keys=True)
|
||||
|
||||
# ── Report what the fixture actually contains ────────────────────────
|
||||
n_trunc = sum(
|
||||
1 for c in cases for w in c["workers"] if w["finish_reason"] == "length"
|
||||
)
|
||||
n_tool_steps = sum(
|
||||
1 for c in cases if any(w["tool_calls"] for w in c["workers"])
|
||||
)
|
||||
n_mixed = 0
|
||||
n_unanimous_name_diff_args = 0
|
||||
for c in cases:
|
||||
ok = [w for w in c["workers"] if not w["error"]]
|
||||
tools = [w for w in ok if w["tool_calls"]]
|
||||
text = [w for w in ok if not w["tool_calls"]]
|
||||
if tools and text:
|
||||
n_mixed += 1
|
||||
if tools and not text:
|
||||
names = {t["function"]["name"] for w in tools for t in w["tool_calls"]}
|
||||
args = {
|
||||
(t["function"]["name"], t["function"].get("arguments") or "{}")
|
||||
for w in tools
|
||||
for t in w["tool_calls"]
|
||||
}
|
||||
if len(names) == 1 and len(args) > 1:
|
||||
n_unanimous_name_diff_args += 1
|
||||
|
||||
print(f"wrote {OUT}")
|
||||
print(f" cases: {len(cases)}")
|
||||
print(f" cases with >=1 tool call: {n_tool_steps}")
|
||||
print(f" mixed tool+text cases: {n_mixed}")
|
||||
print(f" name-unanimous, args differ: {n_unanimous_name_diff_args}")
|
||||
print(f" truncated worker responses: {n_trunc}")
|
||||
print(f" distinct models: {len({w['model'] for c in cases for w in c['workers']})}")
|
||||
136
evals/moa-openrouter/orclient.py
Normal file
136
evals/moa-openrouter/orclient.py
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
"""Minimal OpenRouter client — stdlib only (no `requests` dependency).
|
||||
|
||||
Shared by the MoA probe scripts in this directory.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
ENDPOINT = "https://openrouter.ai/api/v1/chat/completions"
|
||||
|
||||
|
||||
def chat(
|
||||
model,
|
||||
messages,
|
||||
tools=None,
|
||||
max_tokens=512,
|
||||
temperature=0.7,
|
||||
timeout=120,
|
||||
no_think=False,
|
||||
):
|
||||
"""One OpenAI-shaped chat completion. Returns (response_json, elapsed_s).
|
||||
|
||||
Retries on 429/5xx with a small backoff ladder. Unlike Together's
|
||||
reference implementation, every failure path returns a structured
|
||||
error instead of raising — the caller decides what a dead worker means.
|
||||
|
||||
`no_think=True` mirrors mesh-llm's `effective_enable_thinking_for_moa`
|
||||
default: reasoning models get told to skip the think phase, so a small
|
||||
worker budget isn't spent producing reasoning tokens and no answer.
|
||||
"""
|
||||
key = os.environ.get("OPENROUTER_API_KEY")
|
||||
if not key:
|
||||
return {"error": "OPENROUTER_API_KEY not set"}, 0.0
|
||||
|
||||
body = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
}
|
||||
if tools:
|
||||
body["tools"] = tools
|
||||
if no_think:
|
||||
body["reasoning_effort"] = "none"
|
||||
body["chat_template_kwargs"] = {"enable_thinking": False}
|
||||
|
||||
def _build(b):
|
||||
return urllib.request.Request(
|
||||
ENDPOINT,
|
||||
data=json.dumps(b).encode(),
|
||||
headers={
|
||||
"Authorization": f"Bearer {key}",
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://github.com/mesh-llm",
|
||||
"X-Title": "mesh-llm MoA probe",
|
||||
},
|
||||
)
|
||||
|
||||
started = time.time()
|
||||
last_err = None
|
||||
for sleep_time in (0, 2, 5):
|
||||
if sleep_time:
|
||||
time.sleep(sleep_time)
|
||||
try:
|
||||
with urllib.request.urlopen(_build(body), timeout=timeout) as resp:
|
||||
payload = json.loads(resp.read().decode())
|
||||
if "error" in payload:
|
||||
last_err = str(payload["error"])
|
||||
continue
|
||||
return payload, time.time() - started
|
||||
except urllib.error.HTTPError as e:
|
||||
detail = e.read().decode()[:300]
|
||||
last_err = f"HTTP {e.code}: {detail}"
|
||||
# Some endpoints (e.g. minimax) *require* reasoning and reject
|
||||
# our thinking-disable flags with 400. Drop them and retry once
|
||||
# rather than losing the worker entirely.
|
||||
if e.code == 400 and "reasoning" in detail.lower() and no_think:
|
||||
body.pop("reasoning_effort", None)
|
||||
body.pop("chat_template_kwargs", None)
|
||||
no_think = False
|
||||
continue
|
||||
if e.code not in (429, 500, 502, 503, 504):
|
||||
break
|
||||
except Exception as e: # timeout, connection reset, bad JSON
|
||||
last_err = f"{type(e).__name__}: {e}"
|
||||
|
||||
return {"error": last_err or "unknown"}, time.time() - started
|
||||
|
||||
|
||||
def first_choice(resp):
|
||||
"""Extract (text, tool_calls) from a response. Either may be empty/None."""
|
||||
try:
|
||||
msg = resp["choices"][0]["message"]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
return None, None
|
||||
return msg.get("content"), msg.get("tool_calls")
|
||||
|
||||
|
||||
# ─── Mesh-realistic worker pool ──────────────────────────────────────
|
||||
#
|
||||
# Chosen so tiers line up with how mesh-llm's `is_single_digit_b_name`
|
||||
# classifies names (single-digit-B => small tier, everything else big),
|
||||
# and so the mix mirrors a real mesh: a couple of small local-ish models
|
||||
# plus bigger MoE/frontier-ish open weights.
|
||||
POOL_SMALL = [
|
||||
"qwen/qwen3-8b",
|
||||
"qwen/qwen3.5-9b",
|
||||
"mistralai/ministral-8b-2512",
|
||||
"mistralai/ministral-3b-2512",
|
||||
]
|
||||
POOL_BIG = [
|
||||
"qwen/qwen3-14b",
|
||||
"qwen/qwen3-32b",
|
||||
"qwen/qwen3-30b-a3b-instruct-2507",
|
||||
"minimax/minimax-m2.5",
|
||||
"mistralai/mistral-small-3.2-24b-instruct",
|
||||
]
|
||||
POOL = POOL_SMALL + POOL_BIG
|
||||
|
||||
|
||||
def tier(model):
|
||||
"""Mirror of mesh-llm's name-derived tiering, for reporting only."""
|
||||
name = model.lower()
|
||||
for i, c in enumerate(name):
|
||||
if not c.isdigit() or c == "0":
|
||||
continue
|
||||
if i > 0 and (name[i - 1].isdigit() or name[i - 1] == "." or name[i - 1].isalpha()):
|
||||
continue
|
||||
if i + 1 < len(name) and name[i + 1] == "b":
|
||||
if i + 2 < len(name) and name[i + 2].isdigit():
|
||||
continue
|
||||
return "small"
|
||||
return "big"
|
||||
218
evals/moa-openrouter/probe_tools.py
Normal file
218
evals/moa-openrouter/probe_tools.py
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
"""Probe: does Together-style MoA survive agentic tool calling?
|
||||
|
||||
Runs three shapes against real open-weight models on OpenRouter:
|
||||
|
||||
A. Fan-out with tools, then aggregate Together-style (numbered
|
||||
plaintext concat of worker text into a system prompt).
|
||||
B. The same, but reporting what mesh-llm's arbiter would see
|
||||
(structured tool proposals + consensus).
|
||||
C. A tool-result turn: feed a real tool result back and aggregate.
|
||||
|
||||
Everything is recorded to fanout.jsonl so it can seed a replay corpus.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import orclient as oc
|
||||
|
||||
# Real agentic tool schemas, close to what goose/opencode send.
|
||||
TOOLS = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_file",
|
||||
"description": "Read the contents of a file from disk",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"path": {"type": "string", "description": "Path to the file"}},
|
||||
"required": ["path"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_dir",
|
||||
"description": "List files in a directory",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"path": {"type": "string", "description": "Directory path"}},
|
||||
"required": ["path"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "run_command",
|
||||
"description": "Run a shell command and return stdout",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"cmd": {"type": "string", "description": "Command to run"}},
|
||||
"required": ["cmd"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
# Together's aggregator system prompt, verbatim from their utils.py.
|
||||
TOGETHER_AGG_PROMPT = """You have been provided with a set of responses from various open-source models to the latest user query. Your task is to synthesize these responses into a single, high-quality response. It is crucial to critically evaluate the information provided in these responses, recognizing that some of it may be biased or incorrect. Your response should not simply replicate the given answers but should offer a refined, accurate, and comprehensive reply to the instruction. Ensure your response is well-structured, coherent, and adheres to the highest standards of accuracy and reliability.
|
||||
|
||||
Responses from models:"""
|
||||
|
||||
RECORD = open("fanout.jsonl", "a")
|
||||
|
||||
|
||||
def record(kind, **kw):
|
||||
RECORD.write(json.dumps({"kind": kind, **kw}) + "\n")
|
||||
RECORD.flush()
|
||||
|
||||
|
||||
def fan_out(messages, tools, max_tokens=384):
|
||||
"""Parallel fan-out across the pool. Never raises — a dead worker is
|
||||
a recorded failure, not a dead turn (unlike Together's asyncio.gather).
|
||||
"""
|
||||
|
||||
def one(model):
|
||||
resp, elapsed = oc.chat(model, messages, tools=tools, max_tokens=max_tokens, temperature=0.8)
|
||||
text, tcs = oc.first_choice(resp)
|
||||
return {
|
||||
"model": model,
|
||||
"tier": oc.tier(model),
|
||||
"elapsed": round(elapsed, 2),
|
||||
"error": resp.get("error"),
|
||||
"text": text,
|
||||
"tool_calls": tcs,
|
||||
}
|
||||
|
||||
with ThreadPoolExecutor(max_workers=len(oc.POOL)) as ex:
|
||||
return list(ex.map(one, oc.POOL))
|
||||
|
||||
|
||||
def summarize(results, label):
|
||||
print(f"\n{'=' * 74}\n{label}\n{'=' * 74}")
|
||||
for r in results:
|
||||
if r["error"]:
|
||||
print(f" {r['model']:40s} [{r['tier']:5s}] FAILED: {str(r['error'])[:60]}")
|
||||
continue
|
||||
tcs = r["tool_calls"] or []
|
||||
if tcs:
|
||||
calls = ", ".join(
|
||||
f"{c['function']['name']}({c['function']['arguments']})" for c in tcs
|
||||
)
|
||||
print(f" {r['model']:40s} [{r['tier']:5s}] {r['elapsed']:5.1f}s TOOL: {calls}")
|
||||
else:
|
||||
print(
|
||||
f" {r['model']:40s} [{r['tier']:5s}] {r['elapsed']:5.1f}s TEXT: "
|
||||
f"{(r['text'] or '')[:70]!r}"
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def together_aggregate(user_prompt, results, aggregator, with_tools):
|
||||
"""Together's aggregation: numbered plaintext concat of worker *text*.
|
||||
|
||||
Note what necessarily happens to tool calls here: the references are
|
||||
built from `.text`, because that is all their `inject_references_to_
|
||||
messages` knows how to read. Structured tool_calls have nowhere to go.
|
||||
"""
|
||||
refs = []
|
||||
for r in results:
|
||||
if r["error"]:
|
||||
continue
|
||||
refs.append(r["text"] or "") # <-- tool_calls are dropped on the floor
|
||||
|
||||
system = TOGETHER_AGG_PROMPT
|
||||
for i, ref in enumerate(refs):
|
||||
system += f"\n{i + 1}. {ref}"
|
||||
|
||||
msgs = [
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user_prompt},
|
||||
]
|
||||
resp, elapsed = oc.chat(
|
||||
aggregator, msgs, tools=TOOLS if with_tools else None, max_tokens=384, temperature=0.3
|
||||
)
|
||||
text, tcs = oc.first_choice(resp)
|
||||
return {"text": text, "tool_calls": tcs, "elapsed": round(elapsed, 2), "error": resp.get("error")}
|
||||
|
||||
|
||||
def main():
|
||||
aggregator = "qwen/qwen3-32b"
|
||||
|
||||
# ── A. Agentic fan-out: does every worker propose a tool? ────────
|
||||
prompt_a = (
|
||||
"I need to understand the error handling in this Rust project. "
|
||||
"Start by looking at what's in the src directory."
|
||||
)
|
||||
msgs_a = [{"role": "user", "content": prompt_a}]
|
||||
res_a = summarize(fan_out(msgs_a, TOOLS), "A. FAN-OUT WITH TOOLS (agentic first turn)")
|
||||
record("fanout_tools", prompt=prompt_a, results=res_a)
|
||||
|
||||
proposals = {}
|
||||
for r in res_a:
|
||||
for c in r["tool_calls"] or []:
|
||||
proposals.setdefault(c["function"]["name"], []).append(r["model"])
|
||||
print(f"\n tool proposals: {json.dumps(proposals, indent=2)}")
|
||||
n_tool = sum(1 for r in res_a if r["tool_calls"])
|
||||
n_text = sum(1 for r in res_a if not r["tool_calls"] and not r["error"])
|
||||
print(f" -> {n_tool} workers proposed tools, {n_text} answered with text only")
|
||||
|
||||
# ── A2. Aggregate Together-style, tools available ────────────────
|
||||
agg = together_aggregate(prompt_a, res_a, aggregator, with_tools=True)
|
||||
print(f"\n Together-style aggregation (aggregator={aggregator}):")
|
||||
print(f" elapsed={agg['elapsed']}s error={agg['error']}")
|
||||
print(f" tool_calls={agg['tool_calls']}")
|
||||
print(f" text={(agg['text'] or '')[:200]!r}")
|
||||
record("aggregate_tools", prompt=prompt_a, aggregator=aggregator, result=agg)
|
||||
|
||||
# What the aggregator actually received as "references":
|
||||
refs_seen = [(r["model"], (r["text"] or "")[:40], bool(r["tool_calls"])) for r in res_a if not r["error"]]
|
||||
print("\n what Together's aggregator SAW (text only, per model):")
|
||||
for m, t, had_tool in refs_seen:
|
||||
flag = " <-- had a tool_call that was DROPPED" if had_tool else ""
|
||||
print(f" {m:40s} {t!r}{flag}")
|
||||
|
||||
# ── B. Tool-result turn (agentic step 2) ─────────────────────────
|
||||
tool_call_id = "call_probe_1"
|
||||
msgs_b = [
|
||||
{"role": "user", "content": prompt_a},
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": tool_call_id,
|
||||
"type": "function",
|
||||
"function": {"name": "list_dir", "arguments": '{"path": "src"}'},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call_id,
|
||||
"content": "main.rs\nlib.rs\nerror.rs\nconfig.rs\nnetwork/\ninference/",
|
||||
},
|
||||
]
|
||||
res_b = summarize(fan_out(msgs_b, TOOLS), "B. TOOL-RESULT TURN (agentic step 2)")
|
||||
record("fanout_tool_result", results=res_b)
|
||||
n_follow = sum(1 for r in res_b if r["tool_calls"])
|
||||
print(f"\n -> {n_follow} workers chained a follow-up tool call after seeing the result")
|
||||
|
||||
# ── C. Plain chat fan-out for comparison (no tools) ──────────────
|
||||
prompt_c = "What are 3 fun things to do in SF?"
|
||||
res_c = summarize(
|
||||
fan_out([{"role": "user", "content": prompt_c}], None),
|
||||
"C. PLAIN CHAT FAN-OUT (no tools — Together's home turf)",
|
||||
)
|
||||
agg_c = together_aggregate(prompt_c, res_c, aggregator, with_tools=False)
|
||||
print(f"\n aggregated: {(agg_c['text'] or '')[:240]!r}")
|
||||
record("fanout_chat", prompt=prompt_c, results=res_c, aggregate=agg_c)
|
||||
|
||||
print("\n" + "=" * 74)
|
||||
print("recorded to fanout.jsonl")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
196
evals/moa-openrouter/record.py
Normal file
196
evals/moa-openrouter/record.py
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
"""Record real open-model fan-out responses into a replay corpus.
|
||||
|
||||
Writes corpus.jsonl: one line per (prompt, draw), each holding every
|
||||
worker's full response + observed latency. Feeds a Rust ScriptedBackend.
|
||||
|
||||
Workers get reasoning_effort=none (mirrors mesh-llm's
|
||||
effective_enable_thinking_for_moa default) so reasoning models don't
|
||||
burn the whole budget thinking.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import orclient as oc
|
||||
from probe_tools import TOOLS
|
||||
|
||||
DRAWS = 2
|
||||
|
||||
|
||||
def fan_out(messages, tools, temperature=0.8):
|
||||
def one(model):
|
||||
resp, elapsed = oc.chat(
|
||||
model,
|
||||
messages,
|
||||
tools=tools,
|
||||
max_tokens=512,
|
||||
temperature=temperature,
|
||||
no_think=True,
|
||||
)
|
||||
text, tcs = oc.first_choice(resp)
|
||||
ch = (resp.get("choices") or [{}])[0]
|
||||
return {
|
||||
"model": model,
|
||||
"tier": oc.tier(model),
|
||||
"elapsed": round(elapsed, 2),
|
||||
"error": resp.get("error"),
|
||||
"finish_reason": ch.get("finish_reason"),
|
||||
"text": text,
|
||||
"tool_calls": tcs,
|
||||
}
|
||||
|
||||
with ThreadPoolExecutor(max_workers=len(oc.POOL)) as ex:
|
||||
return list(ex.map(one, oc.POOL))
|
||||
|
||||
|
||||
# ── Cases: agentic tool turns + plain chat ───────────────────────────
|
||||
CASES = []
|
||||
|
||||
CASES.append(
|
||||
{
|
||||
"id": "agentic_explore",
|
||||
"tools": True,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "I need to understand error handling in this Rust project. "
|
||||
"Start by looking at what's in the src directory.",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
CASES.append(
|
||||
{
|
||||
"id": "agentic_tool_result_chain",
|
||||
"tools": True,
|
||||
"messages": [
|
||||
{"role": "user", "content": "Understand error handling in this Rust project."},
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "list_dir", "arguments": '{"path": "src"}'},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_1",
|
||||
"content": "main.rs\nlib.rs\nerror.rs\nconfig.rs\nnetwork/\ninference/",
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
CASES.append(
|
||||
{
|
||||
"id": "agentic_ambiguous",
|
||||
"tools": True,
|
||||
"messages": [
|
||||
{"role": "user", "content": "Is this project's test suite passing?"}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
CASES.append(
|
||||
{
|
||||
"id": "agentic_no_tool_needed",
|
||||
"tools": True,
|
||||
"messages": [
|
||||
{"role": "user", "content": "What does the Rust `?` operator do?"}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
CASES.append(
|
||||
{
|
||||
"id": "chat_factual",
|
||||
"tools": False,
|
||||
"messages": [{"role": "user", "content": "What is the capital of Japan?"}],
|
||||
}
|
||||
)
|
||||
|
||||
CASES.append(
|
||||
{
|
||||
"id": "chat_arithmetic",
|
||||
"tools": False,
|
||||
"messages": [
|
||||
{"role": "user", "content": "A train leaves at 14:35 and arrives 2h50m later. What time?"}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
# Pull a few real MT-Bench prompts if available.
|
||||
try:
|
||||
with open("../moe/prompts/mt-bench-8.jsonl") as f:
|
||||
for i, line in enumerate(f):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
row = json.loads(line)
|
||||
# This corpus uses OpenAI-shaped `messages`; older MT-Bench
|
||||
# dumps use `turns`. Accept either.
|
||||
first_user = None
|
||||
for m in row.get("messages") or []:
|
||||
if m.get("role") == "user":
|
||||
first_user = m.get("content")
|
||||
break
|
||||
if first_user is None:
|
||||
turns = row.get("turns") or []
|
||||
first_user = turns[0] if turns else None
|
||||
if not first_user:
|
||||
continue
|
||||
CASES.append(
|
||||
{
|
||||
# `id` is unique per row; `category` is NOT (all 8 rows
|
||||
# in this corpus are "writing") so it must not be the key.
|
||||
"id": f"mtbench_{row.get('id') or i}",
|
||||
"tools": False,
|
||||
"messages": [{"role": "user", "content": first_user}],
|
||||
}
|
||||
)
|
||||
except FileNotFoundError:
|
||||
print("(mt-bench not found, skipping)", file=sys.stderr)
|
||||
|
||||
|
||||
def main():
|
||||
out = open("corpus.jsonl", "w")
|
||||
for case in CASES:
|
||||
for draw in range(DRAWS):
|
||||
results = fan_out(case["messages"], TOOLS if case["tools"] else None)
|
||||
out.write(
|
||||
json.dumps(
|
||||
{
|
||||
"case": case["id"],
|
||||
"draw": draw,
|
||||
"has_tools": case["tools"],
|
||||
"messages": case["messages"],
|
||||
"workers": results,
|
||||
}
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
out.flush()
|
||||
|
||||
ok = [r for r in results if not r["error"]]
|
||||
tools_n = sum(1 for r in ok if r["tool_calls"])
|
||||
empty_n = sum(1 for r in ok if not r["tool_calls"] and not (r["text"] or "").strip())
|
||||
names = {
|
||||
c["function"]["name"] for r in ok for c in (r["tool_calls"] or [])
|
||||
}
|
||||
lat = sorted(r["elapsed"] for r in ok)
|
||||
print(
|
||||
f"{case['id']:28s} draw={draw} ok={len(ok)}/{len(results)} "
|
||||
f"tool={tools_n} empty={empty_n} distinct_tools={sorted(names)} "
|
||||
f"lat={lat[0] if lat else 0}..{lat[-1] if lat else 0}s"
|
||||
)
|
||||
out.close()
|
||||
print("\nwrote corpus.jsonl")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
297
evals/moa-openrouter/record_agentic.py
Normal file
297
evals/moa-openrouter/record_agentic.py
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
"""Record real multi-step agentic traces from open models on OpenRouter.
|
||||
|
||||
Unlike record.py (single-shot fan-out per case), this walks a scripted
|
||||
agentic loop: fan out with tools, take the consensus tool call, feed a
|
||||
canned tool result back, fan out again. That produces the multi-turn
|
||||
shapes mesh-llm's MoA actually sees from goose/opencode.
|
||||
|
||||
Output: agentic.jsonl — one line per (scenario, step, draw) with every
|
||||
worker's full response, finish_reason, latency, and structured
|
||||
tool_calls preserved (never flattened to text).
|
||||
|
||||
Thinking is disabled for every worker (mesh-llm MoA policy). Where an
|
||||
endpoint rejects that (minimax: "Reasoning is mandatory"), orclient
|
||||
retries without the flags so the worker still contributes.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import orclient as oc
|
||||
|
||||
DRAWS = 2
|
||||
MAX_TOKENS = 700 # headroom so long answers aren't truncated by default
|
||||
|
||||
# ── Agentic tool schemas, close to goose / opencode shapes ───────────
|
||||
TOOLS = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_dir",
|
||||
"description": "List files in a directory",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"path": {"type": "string", "description": "Directory path"}},
|
||||
"required": ["path"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_file",
|
||||
"description": "Read the contents of a file from disk",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"path": {"type": "string", "description": "Path to the file"}},
|
||||
"required": ["path"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search",
|
||||
"description": "Search the repository for a regex pattern",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {"type": "string", "description": "Regex to search for"},
|
||||
"path": {"type": "string", "description": "Directory to search in"},
|
||||
},
|
||||
"required": ["pattern"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "run_command",
|
||||
"description": "Run a shell command and return stdout",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"cmd": {"type": "string", "description": "Command to run"}},
|
||||
"required": ["cmd"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "edit_file",
|
||||
"description": "Replace a string in a file",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"before": {"type": "string"},
|
||||
"after": {"type": "string"},
|
||||
},
|
||||
"required": ["path", "before", "after"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def fan_out(messages, tools, temperature=0.8, max_tokens=MAX_TOKENS):
|
||||
"""Parallel fan-out. A dead worker is a recorded failure, never a dead turn."""
|
||||
|
||||
def one(model):
|
||||
resp, elapsed = oc.chat(
|
||||
model,
|
||||
messages,
|
||||
tools=tools,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
no_think=True,
|
||||
)
|
||||
text, tcs = oc.first_choice(resp)
|
||||
ch = (resp.get("choices") or [{}])[0]
|
||||
usage = resp.get("usage") or {}
|
||||
return {
|
||||
"model": model,
|
||||
"tier": oc.tier(model),
|
||||
"elapsed": round(elapsed, 2),
|
||||
"error": resp.get("error"),
|
||||
"finish_reason": ch.get("finish_reason"),
|
||||
"text": text,
|
||||
"tool_calls": tcs,
|
||||
"completion_tokens": usage.get("completion_tokens"),
|
||||
"reasoning_tokens": (usage.get("completion_tokens_details") or {}).get(
|
||||
"reasoning_tokens"
|
||||
),
|
||||
}
|
||||
|
||||
with ThreadPoolExecutor(max_workers=len(oc.POOL)) as ex:
|
||||
return list(ex.map(one, oc.POOL))
|
||||
|
||||
|
||||
def consensus_tool(workers):
|
||||
"""Most-proposed (name, arguments) across workers, or None."""
|
||||
counts = {}
|
||||
for w in workers:
|
||||
for c in w.get("tool_calls") or []:
|
||||
key = (c["function"]["name"], c["function"].get("arguments") or "{}")
|
||||
counts[key] = counts.get(key, 0) + 1
|
||||
if not counts:
|
||||
return None
|
||||
return max(counts.items(), key=lambda kv: kv[1])[0]
|
||||
|
||||
|
||||
# ── Scenarios: (id, opening user message, canned tool results) ───────
|
||||
# `results` maps a tool name to the observation fed back when a worker
|
||||
# calls it. Keeps the loop deterministic without a real filesystem.
|
||||
SCENARIOS = [
|
||||
{
|
||||
"id": "explore_error_handling",
|
||||
"user": "I need to understand error handling in this Rust project. "
|
||||
"Start by looking at what's in the src directory.",
|
||||
"results": {
|
||||
"list_dir": "main.rs\nlib.rs\nerror.rs\nconfig.rs\nnetwork/\ninference/",
|
||||
"read_file": (
|
||||
"use thiserror::Error;\n\n"
|
||||
"#[derive(Error, Debug)]\npub enum MeshError {\n"
|
||||
' #[error("connection failed: {0}")]\n Connection(String),\n'
|
||||
' #[error("model not found: {0}")]\n ModelNotFound(String),\n'
|
||||
' #[error("timeout after {0}ms")]\n Timeout(u64),\n}\n'
|
||||
),
|
||||
"search": "src/error.rs:5:pub enum MeshError\nsrc/network/mod.rs:88:MeshError::Timeout",
|
||||
"run_command": "(no output)",
|
||||
},
|
||||
"steps": 3,
|
||||
},
|
||||
{
|
||||
"id": "failing_test_triage",
|
||||
"user": "The test suite is failing. Find out which test fails and why.",
|
||||
"results": {
|
||||
"run_command": (
|
||||
"running 42 tests\n"
|
||||
"test routing::tests::picks_local_first ... FAILED\n\n"
|
||||
"failures:\n---- routing::tests::picks_local_first stdout ----\n"
|
||||
"thread 'main' panicked at src/routing.rs:214:\n"
|
||||
"assertion `left == right` failed\n left: Remote(peer-2)\n right: Local(9337)\n"
|
||||
),
|
||||
"read_file": (
|
||||
"pub fn pick_target(targets: &[Target]) -> Target {\n"
|
||||
" // BUG: sorts remote-first\n"
|
||||
" let mut t = targets.to_vec();\n"
|
||||
" t.sort_by_key(|x| matches!(x, Target::Local(_)));\n"
|
||||
" t[0].clone()\n}\n"
|
||||
),
|
||||
"search": "src/routing.rs:214: assert_eq!(pick_target(&targets), Target::Local(9337));",
|
||||
"list_dir": "routing.rs\nmain.rs\nlib.rs",
|
||||
},
|
||||
"steps": 3,
|
||||
},
|
||||
{
|
||||
"id": "add_feature_edit",
|
||||
"user": "Add a `--verbose` flag to the CLI parser in src/cli.rs. Read it first.",
|
||||
"results": {
|
||||
"read_file": (
|
||||
"#[derive(Parser)]\npub struct Cli {\n"
|
||||
' #[arg(long)]\n pub port: u16,\n'
|
||||
' #[arg(long)]\n pub model: Option<String>,\n}\n'
|
||||
),
|
||||
"list_dir": "cli.rs\nmain.rs\nlib.rs",
|
||||
"edit_file": "edited src/cli.rs (1 replacement)",
|
||||
"search": "src/cli.rs:2:pub struct Cli",
|
||||
"run_command": " Compiling mesh-llm v0.1.0\n Finished dev profile",
|
||||
},
|
||||
"steps": 3,
|
||||
},
|
||||
{
|
||||
"id": "ambiguous_is_it_passing",
|
||||
"user": "Is this project's test suite passing?",
|
||||
"results": {
|
||||
"run_command": "test result: ok. 42 passed; 0 failed; 0 ignored",
|
||||
"read_file": "[package]\nname = \"mesh-llm\"\n",
|
||||
"list_dir": "Cargo.toml\nsrc/\ntests/",
|
||||
"search": "tests/integration.rs:1:#[test]",
|
||||
},
|
||||
"steps": 2,
|
||||
},
|
||||
{
|
||||
"id": "no_tool_needed_concept",
|
||||
"user": "What does the Rust `?` operator do? Just explain, don't look at files.",
|
||||
"results": {},
|
||||
"steps": 1,
|
||||
},
|
||||
{
|
||||
"id": "multi_tool_choice",
|
||||
"user": "Find every place MeshError::Timeout is constructed in this repo.",
|
||||
"results": {
|
||||
"search": "src/network/mod.rs:88: MeshError::Timeout(elapsed)\n"
|
||||
"src/inference/pipeline.rs:301: MeshError::Timeout(ms)",
|
||||
"run_command": "src/network/mod.rs:88\nsrc/inference/pipeline.rs:301",
|
||||
"list_dir": "network/\ninference/\nerror.rs",
|
||||
"read_file": "// see MeshError::Timeout usage",
|
||||
},
|
||||
"steps": 2,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
out = open("agentic.jsonl", "w")
|
||||
for sc in SCENARIOS:
|
||||
for draw in range(DRAWS):
|
||||
messages = [{"role": "user", "content": sc["user"]}]
|
||||
for step in range(sc["steps"]):
|
||||
workers = fan_out(messages, TOOLS)
|
||||
out.write(
|
||||
json.dumps(
|
||||
{
|
||||
"scenario": sc["id"],
|
||||
"draw": draw,
|
||||
"step": step,
|
||||
"has_tools": True,
|
||||
"messages": messages,
|
||||
"workers": workers,
|
||||
}
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
out.flush()
|
||||
|
||||
ok = [w for w in workers if not w["error"]]
|
||||
n_tool = sum(1 for w in ok if w["tool_calls"])
|
||||
n_trunc = sum(1 for w in ok if w["finish_reason"] == "length")
|
||||
lat = sorted(w["elapsed"] for w in ok) or [0]
|
||||
chosen = consensus_tool(ok)
|
||||
print(
|
||||
f"{sc['id']:26s} d{draw} s{step} ok={len(ok)}/{len(workers)} "
|
||||
f"tool={n_tool} trunc={n_trunc} lat={lat[0]}..{lat[-1]}s "
|
||||
f"chose={chosen[0] if chosen else None}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
if not chosen:
|
||||
break # everyone answered in text; scenario is done
|
||||
|
||||
name, args = chosen
|
||||
observation = sc["results"].get(name, "(no output)")
|
||||
messages = messages + [
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": f"call_{step}",
|
||||
"type": "function",
|
||||
"function": {"name": name, "arguments": args},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": f"call_{step}",
|
||||
"content": observation,
|
||||
},
|
||||
]
|
||||
out.close()
|
||||
print("\nwrote agentic.jsonl")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue