Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
77 commits
Select commit Hold shift + click to select a range
7dbd564
feat(moa): give MoA workers tools, synthesize disagreement, survive f…
michaelneale Jul 30, 2026
fd48692
feat(moa): asymmetric tool turns — references advise, best tool-calle…
michaelneale Jul 31, 2026
4485a64
refactor(moa): remove dead majority tool-vote from the arbiter
michaelneale Jul 31, 2026
4a97047
test(moa): controlled actor-ablation eval — do references actually help?
michaelneale Jul 31, 2026
d096bdc
test(moa): preregistered scaled actor-ablation study + bootstrap anal…
michaelneale Aug 1, 2026
fa89c94
docs(moa): trim over-long rationale comments in the asymmetric tool path
michaelneale Aug 1, 2026
4e380e8
test(moa): pre-hoc structured-proposal and post-hoc correction studies
michaelneale Aug 1, 2026
aed2313
test(moa): committee study on realistic reasoning/answer turns
michaelneale Aug 1, 2026
84c48e7
feat(moa): Hermes-style reference packing — strip system prompt + too…
michaelneale Aug 2, 2026
91d2767
docs(moa): record the MoA evidence — packing bug, actor headroom, wha…
michaelneale Aug 2, 2026
dd03279
fix(moa): use the validated reference packing in production
michaelneale Aug 2, 2026
f3748e0
feat(moa): gate advisory references on actor headroom
michaelneale Aug 2, 2026
1ba3273
test(moa): scale the committee study — 40 preregistered prompts, conc…
michaelneale Aug 2, 2026
b044618
docs(moa): committee wins decisively on reasoning turns (120 trials, …
michaelneale Aug 2, 2026
c2e9d56
feat(moa): cross-peer refinement round — a small-model mesh beats its…
michaelneale Aug 2, 2026
2b54167
test(moa): pin refinement behaviour under mesh conditions; record sma…
michaelneale Aug 2, 2026
f2b3e08
fix(moa): bound the refinement round to half the worker budget
michaelneale Aug 2, 2026
049820c
feat(moa): shorter patience on public meshes
michaelneale Aug 2, 2026
0863b47
test(moa): prove refinement runs under the production grace setting
michaelneale Aug 2, 2026
47259fe
fix(moa): straggling peers must not cost the refinement round
michaelneale Aug 2, 2026
4417195
fix(moa): grace must bound the wait, not forfeit the refinement round
michaelneale Aug 3, 2026
2680d6d
fix(moa): let the reducer and refiners actually see the answers
michaelneale Aug 3, 2026
342fa78
fix(moa): ship the refinement prompt that was actually measured
michaelneale Aug 3, 2026
9d9e2b1
docs(moa): record the eval-vs-production fidelity gaps and their fixes
michaelneale Aug 3, 2026
f85f31a
fix(guardrails): panic slicing tool-call text with a multi-byte prefix
michaelneale Aug 3, 2026
68aa3a4
fix(moa): stop telling the text reducer to "Be concise"
michaelneale Aug 3, 2026
52b690c
fix(moa): give every peer the full budget when refinement is expected
michaelneale Aug 3, 2026
8a4cf6d
fix(moa): text workers get a text preamble, not the tool-turn one
michaelneale Aug 3, 2026
a8ed26c
fix(moa): don't give the reducer the worker preamble
michaelneale Aug 3, 2026
c9992e3
Revert "fix(moa): don't give the reducer the worker preamble"
michaelneale Aug 3, 2026
6341fde
fix(eval): the e2e judge was scoring length, not quality
michaelneale Aug 3, 2026
57d3878
docs(moa): withdraw the length-biased numbers, record the corrected ones
michaelneale Aug 3, 2026
5bf1489
fix(moa): commit the grace_finalizes field definition
michaelneale Aug 3, 2026
9fe3acf
fix(moa): anonymize reducer inputs on text turns
michaelneale Aug 3, 2026
4497d81
docs(moa): record that anonymization did not close the harness gap
michaelneale Aug 3, 2026
3d3e826
fix(moa): text reducer gets only the synthesis framing
michaelneale Aug 3, 2026
69f15a0
Revert "fix(moa): text reducer gets only the synthesis framing"
michaelneale Aug 3, 2026
a149cb3
docs(moa): record the two rejected hypotheses for the harness gap
michaelneale Aug 3, 2026
60073aa
test(moa): diagnostic that dumps every prompt handle_turn actually sends
michaelneale Aug 3, 2026
0faca56
fix(moa): refine over all drafts, not the bare minimum
michaelneale Aug 3, 2026
ad41ed9
fix(moa): always synthesize after a refinement round
michaelneale Aug 3, 2026
9e5b447
docs(moa): N=2 is not enough — pool-size comparison
michaelneale Aug 3, 2026
97cce58
docs(moa): N=2 null was scale, not count — mid-scale N=2 wins cleanly
michaelneale Aug 3, 2026
2280c2f
docs(moa): Self-MoA settles it — ensembling, not diversity, is the me…
michaelneale Aug 3, 2026
2679c87
docs(moa): the 8B ladder — need ~4, and same-vs-different doesn't matter
michaelneale Aug 3, 2026
66c0464
fix(moa): refine on correlated drafts (homogeneous pools), not just s…
michaelneale Aug 3, 2026
0adbaca
feat(moa): admission control — weak workers don't join a strong pool
michaelneale Aug 3, 2026
ff3b4c4
feat(moa): same-model mesh forms a pool (self-fill from extra instances)
michaelneale Aug 3, 2026
1fcf1e3
chore(moa): drop diag_prompt_diff scratch test
michaelneale Aug 4, 2026
daf8f61
docs(moa): admission control validated — weak node in strong pool is …
michaelneale Aug 4, 2026
76ac5e8
fix(moa): admission control must not collapse a lone-strong pool to solo
michaelneale Aug 4, 2026
56f2b8f
feat(moa): model=mesh gracefully degrades to single-model instead of 503
michaelneale Aug 4, 2026
993a4ed
feat(moa): cap the committee at 4 workers on large meshes
michaelneale Aug 4, 2026
f4ebc5e
test(moa): pin partial-worker-survival robustness
michaelneale Aug 4, 2026
4723334
refactor(moa): split pool assembly out of workers.rs into pool.rs
michaelneale Aug 4, 2026
2309bca
test(moa): loosen partial-survival assertion that raced under early-exit
michaelneale Aug 4, 2026
d642df5
Merge remote-tracking branch 'origin/main' into feat/moa-synthesis
michaelneale Aug 4, 2026
991c3ea
fix(moa): model=mesh degrades to single-model serving, verified live
michaelneale Aug 5, 2026
32b84f1
Merge remote-tracking branch 'origin/main' into feat/moa-synthesis
michaelneale Aug 5, 2026
3b94cf0
fix(moa): tier from verified GGUF/gossiped size, not name guessing
michaelneale Aug 5, 2026
14f5e8f
Merge remote-tracking branch 'origin/main' into feat/moa-synthesis
michaelneale Aug 5, 2026
968636c
Merge remote-tracking branch 'origin/main' into feat/moa-synthesis
michaelneale Aug 5, 2026
ef24fab
Merge branch 'main' into feat/moa-synthesis
michaelneale Aug 5, 2026
ed90d75
fix(moa): drop name-based size fallback — no GGUF size ⇒ weakest
michaelneale Aug 5, 2026
9e21d45
fix(moa): iron law — single physical endpoint never fakes a committee
michaelneale Aug 5, 2026
365d1c0
fix(moa): reject missing/malformed messages with 400 (homelab API-1)
michaelneale Aug 5, 2026
e7454d9
feat(moa): tier-aware committee width — small pools fan out to 6
michaelneale Aug 5, 2026
09edeec
fix(moa): don't refine small pools — width + single-agg wins, refine …
michaelneale Aug 5, 2026
ad6059e
Merge branch 'main' into feat/moa-synthesis
michaelneale Aug 5, 2026
66a4041
Merge branch 'main' into feat/moa-synthesis
michaelneale Aug 6, 2026
28dd89f
fix(moa): make the shipped path deliver the measured win
michaelneale Aug 6, 2026
bf36984
Merge remote-tracking branch 'origin/feat/moa-synthesis' into feat/mo…
michaelneale Aug 6, 2026
4a7ffe2
fix(moa): all-small pools serve the best member instead of a committee
michaelneale Aug 6, 2026
08a9c5d
docs(moa): withdraw the unreplicated 6x8B win, record all-small shipp…
michaelneale Aug 6, 2026
c033e97
fix(moa): min_grace_answers back to 1 — N-1 stalled turns to worker_t…
michaelneale Aug 6, 2026
b7b1454
docs(moa): correct the gateway doc and flag the withdrawn small-pool …
michaelneale Aug 6, 2026
5c93bc7
test(moa): pin the tool-turn contract against a live multi-model pool
michaelneale Aug 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion crates/mesh-llm-host-runtime/src/models/gguf.rs
Original file line number Diff line number Diff line change
@@ -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,
};
70 changes: 15 additions & 55 deletions crates/mesh-llm-host-runtime/src/models/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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()
},
Expand Down Expand Up @@ -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() {
Expand All @@ -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));
}
}
55 changes: 42 additions & 13 deletions crates/mesh-llm-host-runtime/src/network/openai/ingress.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -756,24 +764,34 @@ 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,
decision.effective_model.as_deref(),
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(
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
Expand Down
73 changes: 71 additions & 2 deletions crates/mesh-llm-host-runtime/src/network/openai/moa_gateway/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -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;

Expand All @@ -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;
Expand Down
Loading
Loading