jouleclaw: a routing plan carries the class of its energy figure #418

Merged
dcharlot merged 1 commit from fix/routing-plan-carries-its-class into main 2026-10-22 06:49:04 -04:00
10 changed files with 161 additions and 2 deletions

View file

@ -417,6 +417,7 @@ name = "jouleclaw-cascade"
version = "0.1.0"
dependencies = [
"jouleclaw-core",
"jouleclaw-energy",
]
[[package]]

View file

@ -3181,6 +3181,7 @@ name = "jouleclaw-cascade"
version = "0.1.0"
dependencies = [
"jouleclaw-core",
"jouleclaw-energy",
]
[[package]]
@ -4687,6 +4688,7 @@ name = "jouleclaw-routing"
version = "0.1.0"
dependencies = [
"jouleclaw-cascade",
"jouleclaw-energy",
"serde",
"serde_json",
"thiserror 2.0.20",

View file

@ -10,6 +10,10 @@ description = "JouleClaw cascade dispatcher. L0 (cache) → L1 (lawful) → L2 (
[dependencies]
jouleclaw-core = { path = "../jouleclaw-core" }
# For `Provenance` only — the family's grade vocabulary. `default-features =
# false` keeps serde out; every hardware backend is already opt-in, so this
# adds no meter to the cascade's graph.
jouleclaw-energy = { path = "../jouleclaw-energy", default-features = false }
# Note: pattern-lang/joule-cascade has a dev-dep on joule-l1 for integration
# tests that exercise the lawful-primitive tier. Those tests don't port

View file

@ -5,6 +5,7 @@
//! this router doesn't know which concrete tiers exist — it asks the
//! cascade and matches by coordinate.
use jouleclaw_energy::Provenance;
use crate::coord::Coord;
use crate::coord_route::{CoordPredicate, SortStrategy};
use crate::router::{Router, RoutingPlan};
@ -260,6 +261,8 @@ impl Router for CoordRouter {
RoutingPlan {
tier_order,
router_joules: self.overhead_joules,
// A configured constant, not a reading.
router_energy_class: Provenance::Estimator,
reasoning: format!("coord_router({}): {} tier(s) match",
label, matching.len()),
}

View file

@ -16,6 +16,7 @@
//! sub-components (R6's classifier), but those must themselves be
//! deterministic given fixed weights.
use jouleclaw_energy::Provenance;
use crate::types::*;
pub trait Router: Send {
@ -63,10 +64,24 @@ pub struct RoutingPlan {
/// A router whose routing cost scales with its own state, or that runs a
/// model to choose a route, must report what it spent rather than a
/// constant; see `jouleclaw_routing::ROUTING_JOULES` for why the flat
/// constant is a floor. Carrying a provenance beside this field is what
/// would let a verifier tell the two apart.
/// constant is a floor.
pub router_joules: f64,
/// The class of [`router_joules`](Self::router_joules) — what kind of
/// evidence that number is.
///
/// Every router shipped 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 this field is what makes that legible
/// rather than something a reader has to go and discover.
///
/// It exists because the figure is **debited** (`spent += router_joules`
/// in the tier walk), so a consumer that cannot tell a measurement from a
/// declared constant cannot tell whether the budget it is enforcing means
/// anything. A router that measures its own cost sets `HwShunt` or
/// `ModelBased` here and the distinction becomes checkable.
pub router_energy_class: Provenance,
/// Human-readable explanation of WHY this ordering. Surfaces in
/// the answer's trace.
pub reasoning: String,
@ -75,10 +90,14 @@ pub struct RoutingPlan {
impl RoutingPlan {
/// A fallback plan that says "walk everything in registration
/// order." Used when the router has no opinion about the query.
/// The figure is graded [`Provenance::Estimator`]: every caller passes a
/// constant. A router that measured its cost builds the struct directly
/// and says so.
pub fn fallback(router_joules: f64, reasoning: impl Into<String>) -> Self {
Self {
tier_order: Vec::new(),
router_joules,
router_energy_class: Provenance::Estimator,
reasoning: reasoning.into(),
}
}

View file

@ -9,6 +9,8 @@ authors.workspace = true
description = "JouleClaw L5 learned-routing tier — episode-memory + similarity dispatch ordering. Meta-cognitive control plane, pure Rust."
[dependencies]
# For `Provenance` only — the grade every routing figure now declares.
jouleclaw-energy = { path = "../jouleclaw-energy", default-features = false }
jouleclaw-cascade = { path = "../jouleclaw-cascade" }
serde = { workspace = true }
serde_json = { workspace = true }

View file

@ -1,5 +1,6 @@
//! 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;
@ -136,6 +137,7 @@ impl Router for LearnedRouter {
RoutingPlan {
tier_order,
router_joules: crate::ROUTING_JOULES,
router_energy_class: Provenance::Estimator,
reasoning: format!(
"L5: {} similar episodes voted; leading tier {}",
neighbours.len(),

View file

@ -71,3 +71,73 @@ pub use privacy::{
/// [`RoutingPlan`]: jouleclaw_cascade::router::RoutingPlan
/// [`RoutingPlan::router_joules`]: jouleclaw_cascade::router::RoutingPlan::router_joules
pub const ROUTING_JOULES: f64 = 8e-9;
#[cfg(test)]
mod class_tests {
use super::*;
use jouleclaw_cascade::router::{Router, RoutingPlan};
use jouleclaw_cascade::types::{
ContextRef, JouleBudget, QualityFloor, Query, QueryInput, TierId,
};
use jouleclaw_energy::Provenance;
fn q(text: &str) -> Query {
Query {
input: QueryInput::Text(text.to_string()),
budget: JouleBudget::standard(),
quality: QualityFloor::any(),
context: ContextRef::fresh(),
deadline: None,
}
}
/// **Every router shipped here charges a constant, so every plan must
/// declare `Estimator`** — on the path that builds a real plan, not only
/// on the fallback.
///
/// The figure is debited (`spent += plan.router_joules` in the tier walk),
/// so a plan claiming `HwShunt` or `ModelBased` without measuring anything
/// would tell a budget it was enforcing a measured cost.
///
/// Each router is driven past its cold start with `record_outcome` and the
/// plan is asserted **not** to be a fallback first. Without that assertion
/// this test silently exercised `RoutingPlan::fallback` for both routers
/// and pinned neither of their own constructions — falsification caught
/// exactly that: mutating either literal changed nothing.
#[test]
fn every_shipped_router_declares_an_estimator_class() {
let learn = q("capital of france paris");
let ask = q("capital of france");
let mut learned = LearnedRouter::new(64, 8);
let mut phasor = PhasorRouter::with_defaults();
for _ in 0..5 {
learned.record_outcome(&learn, TierId::L0_1FactLut, 5e-6);
phasor.record_outcome(&learn, TierId::L0_1FactLut, 5e-6);
}
for (name, plan) in [
("LearnedRouter", learned.route(&ask)),
("PhasorRouter", phasor.route(&ask)),
] {
assert!(
!plan.is_fallback(),
"{name} fell back, so its own RoutingPlan construction was never reached \
and this test would pin nothing"
);
assert_eq!(
plan.router_energy_class,
Provenance::Estimator,
"{name} charged {} J without declaring Estimator; nothing in routing is measured",
plan.router_joules
);
}
}
/// A fallback plan is a constant too, and says so.
#[test]
fn a_fallback_plan_is_estimator_class() {
let plan = RoutingPlan::fallback(ROUTING_JOULES, "no opinion");
assert_eq!(plan.router_energy_class, Provenance::Estimator);
}
}

View file

@ -7,6 +7,7 @@
//! [`PhasorEmbedder`] and plugs it in; routing then votes by Hamming
//! proximity over learned embeddings instead of token overlap.
use jouleclaw_energy::Provenance;
use jouleclaw_cascade::router::{Router, RoutingPlan};
use jouleclaw_cascade::types::{Query, TierId};
use std::collections::HashMap;
@ -172,6 +173,7 @@ impl<E: PhasorEmbedder> Router for PhasorRouter<E> {
RoutingPlan {
tier_order,
router_joules: crate::ROUTING_JOULES,
router_energy_class: Provenance::Estimator,
reasoning: format!("L5/phasor: {} neighbours voted", scored.len()),
}
}

View file

@ -36,6 +36,7 @@
//! "walk everything" fallback, [`PrivacyRouter`] still produces a
//! filtered ordered plan from `tier_coords`.
use jouleclaw_energy::Provenance;
use std::collections::HashSet;
use jouleclaw_cascade::coord::Coord;
@ -502,6 +503,9 @@ fn filter_plan(
RoutingPlan {
tier_order: filtered,
router_joules: inner.router_joules + PRIVACY_FILTER_JOULES,
// The sum inherits the weaker class, and the added term is a
// constant, so it is `Estimator` whatever the inner plan claimed.
router_energy_class: Provenance::Estimator,
reasoning,
}
}
@ -640,6 +644,55 @@ mod tests {
// ── PrivacyRouter ──────────────────────────────────────────────
/// An inner router that claims a **measured** figure, so the composition
/// rule can be tested rather than merely assumed.
struct MeasuredRouter;
impl Router for MeasuredRouter {
fn route(&self, _q: &Query) -> RoutingPlan {
RoutingPlan {
tier_order: vec![TierId::L0],
router_joules: 1.0,
reasoning: "measured".to_string(),
router_energy_class: Provenance::HwShunt,
}
}
fn estimate_overhead(&self, _q: &Query) -> f64 {
0.0
}
}
/// **Adding a constant to a measured figure yields an estimate.**
///
/// `PrivacyRouter` adds `PRIVACY_FILTER_JOULES` — a constant nobody
/// measured — to whatever the inner router charged. The sum therefore
/// carries the weaker class, and since the added term is always an
/// estimate the sum is always `Estimator`, however good the inner figure
/// was.
///
/// With every shipped router on `Estimator`, taking the inner class
/// instead would read identically today — which is why falsification
/// could not tell the two apart until this test existed. An inner router
/// claiming `HwShunt` separates them.
#[test]
fn a_constant_added_to_a_measured_figure_degrades_the_class() {
let r = PrivacyRouter::new(
HeuristicPrivacyClassifier::new(),
TierSensitivityPolicy::default(),
MeasuredRouter,
);
let plan = r.route(&q("hello"));
assert!(
plan.router_joules > 1.0,
"the filter's constant must be added to the inner figure, got {}",
plan.router_joules
);
assert_eq!(
plan.router_energy_class,
Provenance::Estimator,
"a sum containing an unmeasured constant claimed the inner router's measured class"
);
}
/// Simple inner router that always names a fixed tier order.
struct StaticRouter(Vec<TierId>);
impl Router for StaticRouter {
@ -648,6 +701,7 @@ mod tests {
tier_order: self.0.clone(),
router_joules: 0.0,
reasoning: "static".to_string(),
router_energy_class: Provenance::Estimator,
}
}
fn estimate_overhead(&self, _q: &Query) -> f64 {