router: weight 'auto' selection by locally observed tok/s

Before, 'auto' picked uniformly at random within the multi-digit-B
tier. On the public mesh this meant a fast MiniMax on a 4090 and a
slow 35B-A3B on an M2 Air were equally likely to be chosen, even
though we'd already measured the throughput gap in routing_metrics
and were just not reading it.

Now: each big-tier candidate is weighted by its locally observed
avg_tokens_per_second (clamped to [5, 100] tok/s so nothing fully
starves and no outlier monopolizes). Models without enough samples
(< 3) get a neutral weight so they compete fairly until data
accumulates. A 15% exploration probability ignores weights and
picks uniformly, which keeps the system from locking onto stale
rankings and guarantees cold peers see traffic.

Plumbing:
- RoutingMetrics::tps_for_model(name) -> Option<(f64, u64)>: cheap
  per-model lookup that locks only the relevant shard, avoiding the
  per-call HashMap allocation model_snapshots() does in the hot path.
- Node::routing_metrics() public accessor (Arc-backed, cheap).
- RoutingCandidate { name, caps, tps_hint, throughput_samples }
  replaces the anonymous (&str, f64, ModelCapabilities) tuple whose
  middle slot was literally always 0.0 at every populated call site.
  The struct makes the tps hint a real, typed concept rather than a
  dangling hook.

Behaviour preserved:
- Single-digit-B partition (smalls stay last-resort) unchanged.
- All-cold candidate pool falls back to ~uniform pick (regression
  test confirms no model is starved when there's no data yet).
- Capability filtering for tools / reasoning / vision unchanged.

Plumbing per call site:
- ingress.rs + transport.rs: live routing path, look up tps_hint
  from the local RoutingMetrics handle for each candidate.
- discovery.rs + integrations.rs: pre-startup paths with no live
  metrics; build candidates with RoutingCandidate::unscored() so
  they get the cold-neutral weight.

Tests:
- weighted_pick_all_cold_is_roughly_uniform — regression safety.
- weighted_pick_fast_wins_majority_but_slow_still_gets_some —
  fast wins by >=1.5x but slow still gets >30/600 picks.
- weighted_pick_cold_model_competes_with_hot_fast — newcomer gets
  >100/600 picks against an established fast peer (so it can
  actually accumulate samples and earn its score).
- weighted_pick_low_sample_count_treated_as_cold — 1-sample
  measurements don't dominate routing.
- candidate_weight_clamps_extremes — weight stays in [5, 100],
  cold = 25.

Removed:
- shuffle_in_place (replaced by SplitMix64 + pick_weighted).
- The dishonest 0.0 f64 slot in the candidate tuple, everywhere.

Validation:
  cargo fmt --all -- --check                  # clean
  cargo check  -p mesh-llm-host-runtime       # clean
  cargo clippy -p mesh-llm-host-runtime --lib # clean
  cargo clippy -p mesh-mixture-of-agents --all-targets -- -D warnings # clean
  cargo test   -p mesh-llm-host-runtime --lib # 1398 passed (17 in router)
  cargo test   -p mesh-mixture-of-agents --lib # 59 passed (no regression)
This commit is contained in:
Michael Neale 2026-05-18 17:10:31 +10:00
parent ebb167dc4c
commit 2524840991
7 changed files with 448 additions and 92 deletions

View file

@ -255,11 +255,13 @@ async fn fetch_mesh_models(
}
model.clone()
} else {
let available: Vec<(&str, f64, crate::models::ModelCapabilities)> = models
// Pre-startup path: no live routing metrics yet, so candidates
// are scored as cold (uniform weight).
let available: Vec<crate::network::router::RoutingCandidate<'_>> = models
.iter()
.map(|name| {
let caps = crate::models::installed_model_capabilities(name);
(name.as_str(), 0.0, caps)
crate::network::router::RoutingCandidate::unscored(name.as_str(), caps)
})
.collect();
let agentic = crate::network::router::Classification {

View file

@ -1851,6 +1851,12 @@ impl Node {
.load(std::sync::atomic::Ordering::Relaxed) as u64
}
/// Locally observed routing metrics, used by the auto-router to score
/// models by their measured throughput from this node's perspective.
pub fn routing_metrics(&self) -> &crate::network::metrics::RoutingMetrics {
&self.routing_metrics
}
pub fn inflight_change_rx(&self) -> watch::Receiver<u64> {
self.inflight_change_tx.subscribe()
}

View file

@ -422,6 +422,26 @@ impl RoutingMetrics {
}
}
/// Cheap per-model throughput lookup for routing decisions.
///
/// Returns `(avg_tokens_per_second, throughput_samples)` if the model has
/// observed throughput, `None` if the model is unknown or has never
/// recorded a token-bearing attempt. Avoids the per-call HashMap
/// allocation that [`model_snapshots`](Self::model_snapshots) does —
/// callers in the routing hot path can poll this once per candidate
/// without rebuilding every model's full snapshot.
pub fn tps_for_model(&self, model: &str) -> Option<(f64, u64)> {
let shard_index = self.shard_index(model);
let shard = self.shards[shard_index].lock().unwrap();
let metrics = shard.models.get(model)?;
let samples = metrics.throughput_samples;
if samples == 0 {
return None;
}
let tps = average_milli(metrics.throughput_tps_milli_sum, samples)?;
Some((tps, samples))
}
fn shard_index(&self, model: &str) -> usize {
let mut hasher = DefaultHasher::new();
model.hash(&mut hasher);

View file

@ -183,15 +183,23 @@ pub(crate) async fn api_proxy(
}
}
}
let available: Vec<(&str, f64, crate::models::ModelCapabilities)> =
available_models
.iter()
.map(|name| {
let caps =
proxy::capabilities_for_model(name, &descriptors);
(name.as_str(), 0.0, caps)
})
.collect();
let routing_metrics = node.routing_metrics();
let available: Vec<router::RoutingCandidate<'_>> = available_models
.iter()
.map(|name| {
let caps = proxy::capabilities_for_model(name, &descriptors);
let (tps_hint, throughput_samples) = routing_metrics
.tps_for_model(name)
.map(|(tps, samples)| (Some(tps), samples))
.unwrap_or((None, 0));
router::RoutingCandidate {
name: name.as_str(),
caps,
tps_hint,
throughput_samples,
}
})
.collect();
let Some(available) =
router::filter_media_compatible_candidates(&available, &media)
else {

View file

@ -2386,11 +2386,21 @@ pub async fn handle_mesh_request(
Some(name)
} else {
let cl = router::classify(body_json);
let with_caps: Vec<(&str, f64, crate::models::ModelCapabilities)> = served
let routing_metrics = node.routing_metrics();
let with_caps: Vec<router::RoutingCandidate<'_>> = served
.iter()
.map(|name| {
let caps = capabilities_for_model(name, &descriptors);
(name.as_str(), 0.0, caps)
let (tps_hint, throughput_samples) = routing_metrics
.tps_for_model(name)
.map(|(tps, samples)| (Some(tps), samples))
.unwrap_or((None, 0));
router::RoutingCandidate {
name: name.as_str(),
caps,
tps_hint,
throughput_samples,
}
})
.collect();
let Some(available) =

View file

@ -381,12 +381,12 @@ pub(crate) fn model_satisfies_media_requirements(
}
pub(crate) fn filter_media_compatible_candidates<'a>(
candidates: &[(&'a str, f64, crate::models::ModelCapabilities)],
candidates: &[RoutingCandidate<'a>],
media: &MediaRequirements,
) -> Option<Vec<(&'a str, f64, crate::models::ModelCapabilities)>> {
) -> Option<Vec<RoutingCandidate<'a>>> {
let media_available: Vec<_> = candidates
.iter()
.filter(|(_, _, caps)| model_satisfies_media_requirements(caps, media))
.filter(|c| model_satisfies_media_requirements(&c.caps, media))
.cloned()
.collect();
if media_available.is_empty() && media.requires_runtime_modality() {
@ -449,8 +449,64 @@ fn message_text(msg: &Value) -> String {
// ── Model selection ─────────────────────────────────────────────────
/// Pick the best model using full classification (category + complexity + tools).
/// Pick the best model for a classified request using gossiped capabilities.
/// A candidate model in the auto-routing pool.
///
/// `tps_hint` and `throughput_samples` come from the node's locally
/// observed `RoutingMetrics`. They are `None` / `0` when we've never
/// successfully completed a token-bearing request against this model
/// (cold start, brand-new peer) — such candidates get a neutral weight
/// so they still participate in routing while accumulating data.
#[derive(Clone, Debug)]
pub struct RoutingCandidate<'a> {
pub name: &'a str,
pub caps: crate::models::ModelCapabilities,
/// Locally observed throughput in tokens/sec. `None` if no
/// throughput-bearing attempts have completed for this model yet.
pub tps_hint: Option<f64>,
/// How many throughput samples back `tps_hint`. Used to decide
/// whether we trust the hint or treat the model as "cold".
pub throughput_samples: u64,
}
impl<'a> RoutingCandidate<'a> {
/// Build a candidate without any throughput hint. Useful for
/// pre-startup paths or test fixtures.
pub fn unscored(name: &'a str, caps: crate::models::ModelCapabilities) -> Self {
Self {
name,
caps,
tps_hint: None,
throughput_samples: 0,
}
}
}
/// Minimum number of throughput samples before `tps_hint` is allowed
/// to influence weighting. Below this, the candidate is treated as
/// cold (neutral weight).
const TPS_MIN_SAMPLES: u64 = 3;
/// Lower clamp on tps used as a weight. Prevents catastrophic peers
/// (~1 tok/s) from being completely starved — they still get the
/// occasional request so they can re-prove themselves.
const TPS_WEIGHT_MIN: f64 = 5.0;
/// Upper clamp on tps used as a weight. Prevents a single very fast
/// outlier from monopolizing routing.
const TPS_WEIGHT_MAX: f64 = 100.0;
/// Neutral weight assigned to cold / unscored candidates so they get
/// a fair shot at picking up data. Set to the midpoint of the clamp
/// range so a cold model is treated as "average" against scored peers.
const TPS_NEUTRAL_WEIGHT: f64 = 25.0;
/// Probability of ignoring weights and picking uniformly. Keeps the
/// system from locking onto stale rankings and gives cold models a
/// guaranteed share of traffic so they accumulate data.
const EXPLORATION_PROBABILITY: f64 = 0.15;
/// Pick the best model for a classified request using gossiped capabilities
/// and locally observed throughput.
///
/// Filtering:
/// - `needs_tools` → prefer models with `tool_use != None`
@ -458,11 +514,14 @@ fn message_text(msg: &Value) -> String {
/// - `Image` → prefer models with `vision != None`
/// - anything else → no capability filter
///
/// Falls back to all models if the filter matches nothing.
/// Among candidates, pick randomly to spread load.
/// Falls back to all models if the filter matches nothing. Then biases
/// toward larger models by partitioning single-digit-B names to the
/// bottom tier. Within the chosen tier, picks weighted by observed
/// tok/s (with cold models treated as average), with a configurable
/// exploration probability that ignores weights and picks uniformly.
pub fn pick_model_classified<'a>(
classification: &Classification,
available_models: &[(&'a str, f64, crate::models::ModelCapabilities)],
available_models: &[RoutingCandidate<'a>],
) -> Option<&'a str> {
use crate::models::CapabilityLevel;
@ -470,29 +529,28 @@ pub fn pick_model_classified<'a>(
return None;
}
if available_models.len() == 1 {
return Some(available_models[0].0);
return Some(available_models[0].name);
}
// Capability filter based on what the request needs
let filtered: Vec<&(&str, f64, crate::models::ModelCapabilities)> =
match classification.category {
_ if classification.needs_tools => available_models
.iter()
.filter(|(_, _, caps)| caps.tool_use != CapabilityLevel::None)
.collect(),
Category::Reasoning => available_models
.iter()
.filter(|(_, _, caps)| caps.reasoning != CapabilityLevel::None)
.collect(),
Category::Image => available_models
.iter()
.filter(|(_, _, caps)| caps.vision != CapabilityLevel::None)
.collect(),
_ => Vec::new(),
};
// Capability filter based on what the request needs.
let filtered: Vec<&RoutingCandidate<'a>> = match classification.category {
_ if classification.needs_tools => available_models
.iter()
.filter(|c| c.caps.tool_use != CapabilityLevel::None)
.collect(),
Category::Reasoning => available_models
.iter()
.filter(|c| c.caps.reasoning != CapabilityLevel::None)
.collect(),
Category::Image => available_models
.iter()
.filter(|c| c.caps.vision != CapabilityLevel::None)
.collect(),
_ => Vec::new(),
};
// Fall back to all models if filter matched nothing
let candidates: Vec<&(&str, f64, crate::models::ModelCapabilities)> = if filtered.is_empty() {
// Fall back to all models if the filter matched nothing.
let candidates: Vec<&RoutingCandidate<'a>> = if filtered.is_empty() {
available_models.iter().collect()
} else {
filtered
@ -502,21 +560,103 @@ pub fn pick_model_classified<'a>(
// parameter count (e.g. "2B", "9B") go to the bottom. Everything
// else — multi-digit billions (31B, 70B) or names that don't encode
// a size at all (MiniMax, Coder-Next, fine-tune tags) — stays on
// top. Each tier is shuffled independently so sessions organically
// spread across the strong-tier models over time while smalls still
// act as a fallback when nothing stronger is around.
let (mut big, mut small): (Vec<_>, Vec<_>) = candidates
// top. The big tier is sampled by tok/s-weighted draw; the small
// tier acts only as a fallback when the big tier is empty.
let (big, small): (Vec<_>, Vec<_>) = candidates
.into_iter()
.partition(|(name, _, _)| !is_single_digit_b_name(name));
.partition(|c| !is_single_digit_b_name(c.name));
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.subsec_nanos() as u64;
shuffle_in_place(&mut big, nanos);
shuffle_in_place(&mut small, nanos.wrapping_add(0x9E37_79B9_7F4A_7C15));
big.into_iter().chain(small).next().map(|&(n, _, _)| n)
if !big.is_empty() {
return Some(pick_weighted(&big, nanos));
}
if !small.is_empty() {
return Some(pick_weighted(
&small,
nanos.wrapping_add(0x9E37_79B9_7F4A_7C15),
));
}
None
}
/// Compute the routing weight for a single candidate. See the module-level
/// `TPS_*` constants for the rationale on each clamp.
fn candidate_weight(candidate: &RoutingCandidate<'_>) -> f64 {
if candidate.throughput_samples >= TPS_MIN_SAMPLES {
candidate
.tps_hint
.unwrap_or(TPS_NEUTRAL_WEIGHT)
.clamp(TPS_WEIGHT_MIN, TPS_WEIGHT_MAX)
} else {
TPS_NEUTRAL_WEIGHT
}
}
/// Pick one candidate from a non-empty slice using tok/s-weighted draw,
/// with `EXPLORATION_PROBABILITY` chance of a uniform pick.
fn pick_weighted<'a>(candidates: &[&RoutingCandidate<'a>], seed: u64) -> &'a str {
debug_assert!(!candidates.is_empty(), "pick_weighted requires non-empty");
let mut rng = SplitMix64::new(seed);
// Exploration branch: ignore weights, pick uniformly. Keeps the
// system from locking onto stale rankings.
if rng.next_f64() < EXPLORATION_PROBABILITY {
let idx = (rng.next_u64() as usize) % candidates.len();
return candidates[idx].name;
}
let total_weight: f64 = candidates.iter().map(|c| candidate_weight(c)).sum();
// Defensive: if all weights are somehow zero (shouldn't happen given
// TPS_WEIGHT_MIN > 0), fall back to a uniform pick.
if total_weight <= 0.0 {
let idx = (rng.next_u64() as usize) % candidates.len();
return candidates[idx].name;
}
let pick = rng.next_f64() * total_weight;
let mut acc = 0.0;
for c in candidates {
acc += candidate_weight(c);
if pick < acc {
return c.name;
}
}
// Numerical tail: pick the last candidate.
candidates[candidates.len() - 1].name
}
/// Small deterministic PRNG so a single seed drives both the
/// exploration coin flip and the weighted draw. Avoids pulling in a
/// rand dependency just for routing.
struct SplitMix64 {
state: u64,
}
impl SplitMix64 {
fn new(seed: u64) -> Self {
// Avoid the zero state which gives a degenerate sequence.
Self {
state: seed.wrapping_add(0x9E37_79B9_7F4A_7C15),
}
}
fn next_u64(&mut self) -> u64 {
self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
fn next_f64(&mut self) -> f64 {
// Use the top 53 bits for a uniform float in [0, 1).
(self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
}
}
/// Return true if `name` advertises a single-digit billion-parameter
@ -570,21 +710,6 @@ fn is_single_digit_b_name(name: &str) -> bool {
false
}
/// In-place Fisher-Yates shuffle seeded from `seed`.
fn shuffle_in_place<T>(items: &mut [T], seed: u64) {
if items.len() < 2 {
return;
}
let mut state = seed.wrapping_mul(0x2545_F491_4F6C_DD1D).wrapping_add(1);
for i in (1..items.len()).rev() {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
let j = (state as usize) % (i + 1);
items.swap(i, j);
}
}
// ── Tests ───────────────────────────────────────────────────────────
#[cfg(test)]
@ -873,18 +998,23 @@ mod tests {
};
let text_only = MediaRequirements::default();
let text_candidates = vec![("text", 0.0, text_caps)];
let text_candidates = vec![RoutingCandidate::unscored("text", text_caps)];
assert!(filter_media_compatible_candidates(&text_candidates, &image).is_none());
let mixed_candidates = vec![("text", 0.0, text_caps), ("vision", 0.0, vision_caps)];
let mixed_candidates = vec![
RoutingCandidate::unscored("text", text_caps),
RoutingCandidate::unscored("vision", vision_caps),
];
let filtered = filter_media_compatible_candidates(&mixed_candidates, &image)
.expect("vision candidate should satisfy image media request");
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].0, "vision");
assert_eq!(filtered[0].name, "vision");
let unfiltered = filter_media_compatible_candidates(&text_candidates, &text_only)
.expect("text-only requests should keep normal router fallback behavior");
assert_eq!(unfiltered, text_candidates);
// Text-only requests with text-only candidates pass through unfiltered.
assert_eq!(unfiltered.len(), text_candidates.len());
assert_eq!(unfiltered[0].name, text_candidates[0].name);
}
#[test]
@ -898,8 +1028,8 @@ mod tests {
let no_caps = ModelCapabilities::default();
let available = vec![
("reasoning-model", 10.0, no_caps),
("tool-model", 10.0, tool_caps),
RoutingCandidate::unscored("reasoning-model", no_caps),
RoutingCandidate::unscored("tool-model", tool_caps),
];
let cl = Classification {
category: Category::Code,
@ -922,8 +1052,8 @@ mod tests {
let no_caps = ModelCapabilities::default();
let available = vec![
("chat-model", 10.0, no_caps),
("reasoning-model", 10.0, reasoning_caps),
RoutingCandidate::unscored("chat-model", no_caps),
RoutingCandidate::unscored("reasoning-model", reasoning_caps),
];
let cl = Classification {
category: Category::Reasoning,
@ -946,8 +1076,8 @@ mod tests {
let no_caps = ModelCapabilities::default();
let available = vec![
("text-model", 10.0, no_caps),
("vision-model", 10.0, vision_caps),
RoutingCandidate::unscored("text-model", no_caps),
RoutingCandidate::unscored("vision-model", vision_caps),
];
let cl = Classification {
category: Category::Image,
@ -964,7 +1094,10 @@ mod tests {
use crate::models::ModelCapabilities;
let no_caps = ModelCapabilities::default();
let available = vec![("model-a", 10.0, no_caps), ("model-b", 10.0, no_caps)];
let available = vec![
RoutingCandidate::unscored("model-a", no_caps),
RoutingCandidate::unscored("model-b", no_caps),
];
let cl = Classification {
category: Category::Code,
complexity: Complexity::Moderate,
@ -978,7 +1111,7 @@ mod tests {
#[test]
fn test_pick_empty_returns_none() {
let available: Vec<(&str, f64, crate::models::ModelCapabilities)> = vec![];
let available: Vec<RoutingCandidate<'_>> = vec![];
let cl = Classification {
category: Category::Chat,
complexity: Complexity::Moderate,
@ -992,7 +1125,10 @@ mod tests {
fn test_pick_single_model() {
use crate::models::ModelCapabilities;
let available = vec![("only-model", 10.0, ModelCapabilities::default())];
let available = vec![RoutingCandidate::unscored(
"only-model",
ModelCapabilities::default(),
)];
let cl = Classification {
category: Category::Chat,
complexity: Complexity::Moderate,
@ -1007,7 +1143,10 @@ mod tests {
use crate::models::ModelCapabilities;
let no_caps = ModelCapabilities::default();
let available = vec![("model-a", 10.0, no_caps), ("model-b", 10.0, no_caps)];
let available = vec![
RoutingCandidate::unscored("model-a", no_caps),
RoutingCandidate::unscored("model-b", no_caps),
];
let cl = Classification {
category: Category::Chat,
complexity: Complexity::Moderate,
@ -1074,12 +1213,12 @@ mod tests {
let no_caps = ModelCapabilities::default();
let available = vec![
("Qwen3.5-2B-Q4_K_M", 0.0, no_caps),
("Qwen3.5-9B-Q4_K_M", 0.0, no_caps),
("gemma-4-31B-it-Q8_0", 0.0, no_caps),
("Qwen3.6-35B-A3B-BF16", 0.0, no_caps),
("MiniMax-M2.5-Q4_K_M", 0.0, no_caps),
("Qwen3-Coder-Next-Q4_K_M", 0.0, no_caps),
RoutingCandidate::unscored("Qwen3.5-2B-Q4_K_M", no_caps),
RoutingCandidate::unscored("Qwen3.5-9B-Q4_K_M", no_caps),
RoutingCandidate::unscored("gemma-4-31B-it-Q8_0", no_caps),
RoutingCandidate::unscored("Qwen3.6-35B-A3B-BF16", no_caps),
RoutingCandidate::unscored("MiniMax-M2.5-Q4_K_M", no_caps),
RoutingCandidate::unscored("Qwen3-Coder-Next-Q4_K_M", no_caps),
];
let cl = Classification {
category: Category::Chat,
@ -1105,8 +1244,8 @@ mod tests {
let no_caps = ModelCapabilities::default();
let available = vec![
("Qwen3.5-2B-Q4_K_M", 0.0, no_caps),
("Qwen3.5-9B-Q4_K_M", 0.0, no_caps),
RoutingCandidate::unscored("Qwen3.5-2B-Q4_K_M", no_caps),
RoutingCandidate::unscored("Qwen3.5-9B-Q4_K_M", no_caps),
];
let cl = Classification {
category: Category::Chat,
@ -1126,10 +1265,10 @@ mod tests {
let no_caps = ModelCapabilities::default();
let available = vec![
("gemma-4-31B-it-Q8_0", 0.0, no_caps),
("Qwen3.6-35B-A3B-BF16", 0.0, no_caps),
("MiniMax-M2.5-Q4_K_M", 0.0, no_caps),
("Qwen3-Coder-Next-Q4_K_M", 0.0, no_caps),
RoutingCandidate::unscored("gemma-4-31B-it-Q8_0", no_caps),
RoutingCandidate::unscored("Qwen3.6-35B-A3B-BF16", no_caps),
RoutingCandidate::unscored("MiniMax-M2.5-Q4_K_M", no_caps),
RoutingCandidate::unscored("Qwen3-Coder-Next-Q4_K_M", no_caps),
];
let cl = Classification {
category: Category::Chat,
@ -1154,4 +1293,173 @@ mod tests {
"expected spread across big-tier models, only saw {seen:?}"
);
}
// ── tok/s-aware weighting ────────────────────────────────────────
/// Helper: build a scored candidate.
fn scored<'a>(
name: &'a str,
caps: crate::models::ModelCapabilities,
tps: f64,
samples: u64,
) -> RoutingCandidate<'a> {
RoutingCandidate {
name,
caps,
tps_hint: Some(tps),
throughput_samples: samples,
}
}
fn count_picks(available: &[RoutingCandidate<'_>], iterations: usize) -> HashMapCounts {
use std::collections::HashMap;
let cl = Classification {
category: Category::Chat,
complexity: Complexity::Moderate,
needs_tools: false,
has_media_inputs: false,
};
let mut counts: HashMap<String, usize> = HashMap::new();
for _ in 0..iterations {
if let Some(name) = pick_model_classified(&cl, available) {
*counts.entry(name.to_string()).or_insert(0) += 1;
}
// Bump the nanosecond seed between iterations.
std::thread::sleep(std::time::Duration::from_nanos(1));
}
HashMapCounts(counts)
}
struct HashMapCounts(std::collections::HashMap<String, usize>);
impl HashMapCounts {
fn get(&self, name: &str) -> usize {
self.0.get(name).copied().unwrap_or(0)
}
fn total(&self) -> usize {
self.0.values().sum()
}
}
#[test]
fn weighted_pick_all_cold_is_roughly_uniform() {
// Backwards-compat: when nothing has tps data, picks should be
// roughly uniform — same effective shape as the old random shuffle.
use crate::models::ModelCapabilities;
let no_caps = ModelCapabilities::default();
let available = vec![
RoutingCandidate::unscored("alpha-31B", no_caps),
RoutingCandidate::unscored("beta-31B", no_caps),
RoutingCandidate::unscored("gamma-31B", no_caps),
];
let counts = count_picks(&available, 600);
let expected = counts.total() / 3;
// Allow ±50% (300 picks across 3 models is loose statistical ground,
// but we just need to see no model is starved).
for name in ["alpha-31B", "beta-31B", "gamma-31B"] {
let got = counts.get(name);
assert!(
got > expected / 2,
"cold model {name} was starved: {got}/{expected} expected"
);
}
}
#[test]
fn weighted_pick_fast_wins_majority_but_slow_still_gets_some() {
// Core design claim: fast tok/s tilts routing without starving the slow.
use crate::models::ModelCapabilities;
let no_caps = ModelCapabilities::default();
let available = vec![
scored("fast-31B", no_caps, 80.0, 50),
scored("slow-31B", no_caps, 6.0, 50),
];
let counts = count_picks(&available, 600);
let fast = counts.get("fast-31B");
let slow = counts.get("slow-31B");
// Fast should win clearly more often.
assert!(
fast > slow,
"fast tok/s model should win majority: fast={fast} slow={slow}"
);
// Fast wins by a meaningful margin (≥ 1.5x).
assert!(
fast as f64 > 1.5 * slow as f64,
"fast model should win by at least 1.5x: fast={fast} slow={slow}",
);
// Slow model still gets meaningful traffic (exploration + clamp keep it alive).
assert!(
slow > 30,
"slow model must not be starved (exploration keeps it alive): got {slow}",
);
}
#[test]
fn weighted_pick_cold_model_competes_with_hot_fast() {
// A brand-new peer (no samples) must still get meaningful traffic
// against an established fast peer — otherwise it can never
// accumulate the data it needs to be scored.
use crate::models::ModelCapabilities;
let no_caps = ModelCapabilities::default();
let available = vec![
scored("hot-fast-31B", no_caps, 80.0, 50),
RoutingCandidate::unscored("cold-newcomer-31B", no_caps),
];
let counts = count_picks(&available, 600);
let cold = counts.get("cold-newcomer-31B");
// Cold gets NEUTRAL_WEIGHT (25) vs hot's clamped 80 — so cold
// should still see at least a healthy minority of traffic.
assert!(
cold > 100,
"cold newcomer must get fair traffic to accumulate samples: got {cold}/600"
);
}
#[test]
fn weighted_pick_low_sample_count_treated_as_cold() {
// A model with only 1-2 samples shouldn't have those samples
// dominate routing — we want a few real measurements before tps
// participates.
use crate::models::ModelCapabilities;
let no_caps = ModelCapabilities::default();
let available = vec![
// Both are 31B "big-tier" names — single-digit-B partition
// doesn't separate them.
scored("alpha-31B", no_caps, 100.0, 1), // 1 sample of "fast" — should be ignored
scored("beta-31B", no_caps, 100.0, 1),
scored("gamma-31B", no_caps, 100.0, 1),
];
let counts = count_picks(&available, 600);
// All three should land near uniform since none has enough samples.
let expected = counts.total() / 3;
for name in ["alpha-31B", "beta-31B", "gamma-31B"] {
let got = counts.get(name);
assert!(
got > expected / 2,
"low-sample model {name} was treated as scored instead of cold: {got}"
);
}
}
#[test]
fn candidate_weight_clamps_extremes() {
// Sanity: weight stays bounded so no peer can fully starve or monopolize.
use crate::models::ModelCapabilities;
let no_caps = ModelCapabilities::default();
let glacial = scored("glacial", no_caps, 0.5, 100);
let blazing = scored("blazing", no_caps, 500.0, 100);
let cold = RoutingCandidate::unscored("cold", no_caps);
let wg = candidate_weight(&glacial);
let wb = candidate_weight(&blazing);
let wc = candidate_weight(&cold);
assert!(wg >= TPS_WEIGHT_MIN, "glacial weight floored: {wg}");
assert!(wb <= TPS_WEIGHT_MAX, "blazing weight capped: {wb}");
assert!(
(wc - TPS_NEUTRAL_WEIGHT).abs() < f64::EPSILON,
"cold weight should be neutral: {wc}",
);
}
}

View file

@ -272,11 +272,13 @@ pub(crate) async fn check_mesh(
}
m.clone()
} else {
let available: Vec<(&str, f64, crate::models::ModelCapabilities)> = models
// Pre-startup path: no live routing metrics yet, so candidates
// are scored as cold (uniform weight).
let available: Vec<router::RoutingCandidate<'_>> = models
.iter()
.map(|n| {
let caps = crate::models::installed_model_capabilities(n);
(n.as_str(), 0.0, caps)
router::RoutingCandidate::unscored(n.as_str(), caps)
})
.collect();
let agentic = router::Classification {