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
#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]>
311 lines
10 KiB
Rust
311 lines
10 KiB
Rust
//! 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);
|
||
}
|
||
}
|