open-standards/jouleclaw/jouleclaw-rs/crates/jouleclaw-routing/src/learned.rs
Transaction Science b6ad1b0763
All checks were successful
jouleclaw fabrics / cuda (linux, RTX 4050 + Vulkan loader) (pull_request) Successful in 20s
jouleclaw fabrics / vulkan (linux, RTX 4050 + Vulkan loader) (pull_request) Successful in 20s
jouleclaw public surface must stay documented / Public-surface audit (jouleclaw-rs) (pull_request) Successful in 1m7s
joule-code conformance gate / Build + round-trip C1-C4 vectors (pull_request) Successful in 2m40s
repository checks / lockfiles, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Successful in 1m39s
standards workspaces build + test / which workspaces this change touches (pull_request) Successful in 7s
joule-code conformance gate / joule-moq-ingest, every target (pull_request) Successful in 2m12s
joule-code conformance gate / joule-moq-relay, every target (pull_request) Successful in 2m32s
standards workspaces build + test / jouleclaw-rs (-nodebug, jouleclaw/jouleclaw-rs, -p jouleclaw-energy --features rapl,nvml, jouleclaw-rs, linux-arm64, -C target-feature=+fp16) (pull_request) Successful in 3m27s
standards workspaces build + test / joule-code-rs (joule-code/joule-code-rs, joule-code-rs, linux-arm64, true) (pull_request) Successful in 4m9s
standards workspaces build + test / mesh-rs (mesh/mesh-rs, -p mesh-engine-gguf -p mesh-openai --features mesh-engine-gguf/gguf,mesh-openai/server, mesh-rs, linux-arm64) (pull_request) Successful in 1m4s
jouleclaw fabrics / metal (macOS, Apple GPU) (pull_request) Successful in 58s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 10m50s
standards workspaces build + test / jouledesk-rs (jouledesk/jouledesk-rs, jouledesk-rs, macos) (pull_request) Successful in 3m12s
jouleclaw: a routing plan carries the class of its energy figure
#390 said the enforceable fix for `router_joules` was to give `RoutingPlan` a
provenance, so a declared constant stops sharing a type with a measurement.
This is that change.

`RoutingPlan` gains `router_energy_class: Provenance`. Every router in this
workspace sets `Estimator`, because every one of them charges a constant or a
sum of constants — **nothing in routing is measured today**, and the type now
says so rather than leaving a reader to find out. It matters because the figure
is debited: `tier.rs` does `spent += plan.router_joules`, and a consumer that
cannot tell a measurement from a constant cannot tell whether the budget it is
enforcing means anything.

Three alternatives were rejected on the merits, not on taste:

- `JouleClass` is a tier taxonomy (Cache/Lawful/Embed/Model/Wire), not a grade.
- Moving `Provenance` into `jouleclaw-core` would be the tidier home, but it has
  **61 importers**; churning them to serve two crates is not proportionate.
- A local grade enum in cascade would make a third spelling of a vocabulary the
  family already has.

So cascade and routing depend on `jouleclaw-energy` with
`default-features = false`. Every hardware backend there is already opt-in, so
this adds no meter to the cascade's dependency graph — just the type.

**The magnitude is unchanged, deliberately.** `ROUTING_JOULES = 8e-9` J is
0.008 µJ: below the integer-microjoule unit the protocol uses (`EnergyReading`
is `u64` µJ, and "floating-point is forbidden in the protocol for
determinism"), so it rounds to zero there, and six orders of magnitude below
the ~1 mJ floor `jouleclaw-energy` itself calls realistic for an `Estimator`
source. The significand is unjustified. Correcting it needs a measurement;
substituting a different invented number would repeat the error this change
exists to label.

Falsification found two real gaps in my own tests before this was right:

- Mutating `LearnedRouter`'s and `PhasorRouter`'s own `RoutingPlan` literals
  changed **nothing**, because the test exercised `RoutingPlan::fallback` for
  both — their constructions were never reached. Fixed by driving each past
  cold start with `record_outcome` and asserting `!plan.is_fallback()` *first*,
  so the test cannot silently slide back onto the path that pins nothing.
- Mutating `PrivacyRouter`'s composition to take the inner plan's class was
  **invisible**, because with every shipped router on `Estimator` the two
  expressions agree. Separating them needed a stub inner router claiming
  `HwShunt`, which pins the actual rule: a sum containing an unmeasured
  constant is an estimate, however good the measured term was.

All four mutations now fail exactly one named test each.

Lockfiles: both workspaces that name these crates are refreshed
(`jouleclaw/jouleclaw-rs`, `joule-code/examples/l3-jouleclaw`); 47 lockfiles
current.

Verified: workspace check 0 errors, 486 test binaries green, 0 failures;
`l3-jouleclaw` check 0 errors. The 10 clippy findings in these crates are
pre-existing, unchanged in count, and all in lines this change did not author
(`coord_router_impl.rs:241` shifted to `:242` behind an inserted line); neither
crate is in CI's clippy list.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-21 19:44:14 -04:00

311 lines
10 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Token-Jaccard nearest-episode router.
use jouleclaw_energy::Provenance;
use jouleclaw_cascade::router::{Router, RoutingPlan};
use jouleclaw_cascade::types::{Query, QueryInput, TierId};
use std::collections::HashMap;
/// One recorded outcome: a query (reduced to its token set) and the
/// tier that resolved it, plus the joules that tier spent.
#[derive(Debug, Clone)]
pub struct Episode {
/// Lowercased, deduplicated token set of the query text.
pub tokens: Vec<String>,
/// The tier that produced the accepted answer.
pub winning_tier: TierId,
/// Joules that tier spent. Lower is better when two tiers tie on
/// win count.
pub joules_spent: f64,
}
/// Learned router. Keeps a bounded ring of [`Episode`]s and, on each
/// query, orders tiers by how often they won for the `k` most similar
/// past queries (ties broken by lower mean joules).
pub struct LearnedRouter {
episodes: Vec<Episode>,
capacity: usize,
k: usize,
}
impl LearnedRouter {
/// New router with the given episode-memory capacity and
/// neighbourhood size `k`. A `capacity` of 0 is treated as 1.
pub fn new(capacity: usize, k: usize) -> Self {
Self {
episodes: Vec::new(),
capacity: capacity.max(1),
k: k.max(1),
}
}
/// A router with sensible defaults (256 episodes, k=8).
pub fn with_defaults() -> Self {
Self::new(256, 8)
}
/// Number of episodes currently remembered.
pub fn len(&self) -> usize {
self.episodes.len()
}
/// `true` iff no episodes have been recorded yet.
pub fn is_empty(&self) -> bool {
self.episodes.is_empty()
}
/// Record the outcome of a dispatch so future similar queries
/// prefer the tier that worked. Call this after the cascade accepts
/// an answer.
pub fn record_outcome(&mut self, q: &Query, winning_tier: TierId, joules_spent: f64) {
let tokens = tokenize(q);
if tokens.is_empty() {
return;
}
if self.episodes.len() >= self.capacity {
// Evict the oldest. Ring semantics, FIFO.
self.episodes.remove(0);
}
self.episodes.push(Episode {
tokens,
winning_tier,
joules_spent,
});
}
/// The `k` nearest episodes to `tokens` by token-set Jaccard, paired
/// with their similarity (q16.16-ish integer score to keep ordering
/// deterministic: `intersection * 10_000 / union`).
fn nearest(&self, tokens: &[String]) -> Vec<(&Episode, u32)> {
let mut scored: Vec<(&Episode, u32)> = self
.episodes
.iter()
.map(|e| (e, jaccard_milli(tokens, &e.tokens)))
.filter(|(_, s)| *s > 0)
.collect();
// Highest similarity first; stable so insertion order breaks ties.
scored.sort_by(|a, b| b.1.cmp(&a.1));
scored.truncate(self.k);
scored
}
}
impl Router for LearnedRouter {
fn route(&self, q: &Query) -> RoutingPlan {
let tokens = tokenize(q);
if tokens.is_empty() || self.episodes.is_empty() {
return RoutingPlan::fallback(
crate::ROUTING_JOULES,
"L5: cold start — no episode memory, walking registration order",
);
}
let neighbours = self.nearest(&tokens);
if neighbours.is_empty() {
return RoutingPlan::fallback(
crate::ROUTING_JOULES,
"L5: no similar episodes — walking registration order",
);
}
// Tally wins per tier, weighted by similarity, and accumulate
// joules so ties prefer the cheaper tier.
let mut wins: HashMap<TierId, (u64, f64, u32)> = HashMap::new();
for (ep, sim) in &neighbours {
let entry = wins.entry(ep.winning_tier).or_insert((0, 0.0, 0));
entry.0 += *sim as u64; // similarity-weighted vote
entry.1 += ep.joules_spent;
entry.2 += 1;
}
let mut ranked: Vec<(TierId, u64, f64)> = wins
.into_iter()
.map(|(tier, (vote, joules, n))| (tier, vote, joules / n as f64))
.collect();
// Highest vote first; ties → lower mean joules first; final tie
// → stable by wire tag for determinism.
ranked.sort_by(|a, b| {
b.1.cmp(&a.1)
.then(a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Equal))
.then(a.0.wire_tag().cmp(b.0.wire_tag()))
});
let tier_order: Vec<TierId> = ranked.iter().map(|(t, _, _)| *t).collect();
let top = tier_order
.first()
.map(|t| t.wire_tag())
.unwrap_or("none");
RoutingPlan {
tier_order,
router_joules: crate::ROUTING_JOULES,
router_energy_class: Provenance::Estimator,
reasoning: format!(
"L5: {} similar episodes voted; leading tier {}",
neighbours.len(),
top
),
}
}
fn estimate_overhead(&self, _q: &Query) -> f64 {
crate::ROUTING_JOULES
}
}
/// Lowercased, deduplicated token set of the query text. Non-text
/// queries reduce to the empty set (the router has no opinion).
pub(crate) fn tokenize(q: &Query) -> Vec<String> {
let text = match &q.input {
QueryInput::Text(t) => t.as_str(),
QueryInput::Multimodal { text, .. } => text.as_str(),
_ => return Vec::new(),
};
let mut seen = Vec::new();
for raw in text.split(|c: char| !c.is_alphanumeric()) {
if raw.is_empty() {
continue;
}
let tok = raw.to_lowercase();
if !seen.contains(&tok) {
seen.push(tok);
}
}
seen
}
/// Jaccard similarity of two token sets, scaled to `[0, 10_000]`
/// (per-mille × 10). Integer-valued so ordering is deterministic.
pub(crate) fn jaccard_milli(a: &[String], b: &[String]) -> u32 {
if a.is_empty() || b.is_empty() {
return 0;
}
let inter = a.iter().filter(|t| b.contains(t)).count();
if inter == 0 {
return 0;
}
// |A ∪ B| = |A| + |B| - |A ∩ B|
let union = a.len() + b.len() - inter;
((inter as u64 * 10_000) / union as u64) as u32
}
#[cfg(test)]
mod tests {
use super::*;
use jouleclaw_cascade::types::{
ContextRef, JouleBudget, L3ModelId, QualityFloor, TierId,
};
fn q(text: &str) -> Query {
Query {
input: QueryInput::Text(text.to_string()),
budget: JouleBudget::standard(),
quality: QualityFloor::any(),
context: ContextRef::fresh(),
deadline: None,
}
}
#[test]
fn cold_start_is_fallback() {
let r = LearnedRouter::with_defaults();
let plan = r.route(&q("what is the capital of france"));
assert!(plan.is_fallback());
assert_eq!(plan.router_joules, crate::ROUTING_JOULES);
}
#[test]
fn non_text_is_fallback() {
let r = LearnedRouter::with_defaults();
let query = Query {
input: QueryInput::Binary(vec![1, 2, 3]),
budget: JouleBudget::standard(),
quality: QualityFloor::any(),
context: ContextRef::fresh(),
deadline: None,
};
assert!(r.route(&query).is_fallback());
}
#[test]
fn learns_winning_tier() {
let mut r = LearnedRouter::new(64, 8);
for _ in 0..5 {
r.record_outcome(&q("capital of france paris"), TierId::L0_1FactLut, 5e-6);
}
let plan = r.route(&q("capital of france"));
assert!(!plan.is_fallback());
assert_eq!(plan.tier_order.first().copied(), Some(TierId::L0_1FactLut));
}
#[test]
fn prefers_more_frequent_winner() {
let mut r = LearnedRouter::new(64, 16);
for _ in 0..6 {
r.record_outcome(&q("translate hello to french"), TierId::L3(L3ModelId(0)), 2.0);
}
for _ in 0..2 {
r.record_outcome(&q("translate hello to french"), TierId::L0_1FactLut, 5e-6);
}
let plan = r.route(&q("translate hello to french"));
assert_eq!(plan.tier_order.first().copied(), Some(TierId::L3(L3ModelId(0))));
}
#[test]
fn dissimilar_query_gets_no_neighbours() {
let mut r = LearnedRouter::new(64, 8);
r.record_outcome(&q("capital of france"), TierId::L0_1FactLut, 5e-6);
let plan = r.route(&q("integrate x squared dx"));
assert!(plan.is_fallback());
}
#[test]
fn capacity_evicts_oldest() {
let mut r = LearnedRouter::new(3, 8);
r.record_outcome(&q("alpha one"), TierId::L0, 1.0);
r.record_outcome(&q("beta two"), TierId::L0, 1.0);
r.record_outcome(&q("gamma three"), TierId::L0, 1.0);
r.record_outcome(&q("delta four"), TierId::L0, 1.0);
assert_eq!(r.len(), 3);
}
#[test]
fn empty_query_not_recorded() {
let mut r = LearnedRouter::new(8, 8);
r.record_outcome(&q(" "), TierId::L0, 1.0);
assert!(r.is_empty());
}
#[test]
fn jaccard_basics() {
let a = vec!["x".to_string(), "y".to_string()];
let b = vec!["x".to_string(), "y".to_string()];
assert_eq!(jaccard_milli(&a, &b), 10_000);
let c = vec!["z".to_string()];
assert_eq!(jaccard_milli(&a, &c), 0);
}
#[test]
fn tie_break_prefers_cheaper_tier() {
let mut r = LearnedRouter::new(64, 16);
// Equal vote weight (same query, same count), different joules.
for _ in 0..3 {
r.record_outcome(&q("foo bar baz"), TierId::L4_5Proof, 60e-6);
}
for _ in 0..3 {
r.record_outcome(&q("foo bar baz"), TierId::L3(L3ModelId(0)), 2.0);
}
let plan = r.route(&q("foo bar baz"));
// Both have equal similarity-weighted votes; cheaper (Proof) wins.
assert_eq!(plan.tier_order.first().copied(), Some(TierId::L4_5Proof));
}
#[test]
fn determinism_same_state_same_plan() {
let mut r = LearnedRouter::new(64, 8);
for _ in 0..4 {
r.record_outcome(&q("sort these numbers"), TierId::L0_5ToolCompute, 15e-6);
}
let p1 = r.route(&q("sort these numbers please"));
let p2 = r.route(&q("sort these numbers please"));
assert_eq!(p1.tier_order, p2.tier_order);
}
}