diff --git a/joule-code/examples/l3-jouleclaw/Cargo.lock b/joule-code/examples/l3-jouleclaw/Cargo.lock index 36d0ba076..3cf0fadf5 100644 --- a/joule-code/examples/l3-jouleclaw/Cargo.lock +++ b/joule-code/examples/l3-jouleclaw/Cargo.lock @@ -417,6 +417,7 @@ name = "jouleclaw-cascade" version = "0.1.0" dependencies = [ "jouleclaw-core", + "jouleclaw-energy", ] [[package]] diff --git a/jouleclaw/jouleclaw-rs/Cargo.lock b/jouleclaw/jouleclaw-rs/Cargo.lock index 7be2e91bb..f6290f1fc 100644 --- a/jouleclaw/jouleclaw-rs/Cargo.lock +++ b/jouleclaw/jouleclaw-rs/Cargo.lock @@ -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", diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-cascade/Cargo.toml b/jouleclaw/jouleclaw-rs/crates/jouleclaw-cascade/Cargo.toml index ce1fbf7a4..d8e13caaa 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-cascade/Cargo.toml +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-cascade/Cargo.toml @@ -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 diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-cascade/src/coord_router_impl.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-cascade/src/coord_router_impl.rs index 5db8fed8f..81660947c 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-cascade/src/coord_router_impl.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-cascade/src/coord_router_impl.rs @@ -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()), } diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-cascade/src/router.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-cascade/src/router.rs index cf24ce879..085005d9c 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-cascade/src/router.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-cascade/src/router.rs @@ -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) -> Self { Self { tier_order: Vec::new(), router_joules, + router_energy_class: Provenance::Estimator, reasoning: reasoning.into(), } } diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-routing/Cargo.toml b/jouleclaw/jouleclaw-rs/crates/jouleclaw-routing/Cargo.toml index 911fc4ce4..2069cb2c3 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-routing/Cargo.toml +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-routing/Cargo.toml @@ -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 } diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-routing/src/learned.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-routing/src/learned.rs index 4634203f6..8e027377b 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-routing/src/learned.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-routing/src/learned.rs @@ -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(), diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-routing/src/lib.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-routing/src/lib.rs index 8f213ade9..755f771f6 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-routing/src/lib.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-routing/src/lib.rs @@ -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); + } +} diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-routing/src/phasor.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-routing/src/phasor.rs index 1c63b708b..6c0e5cab6 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-routing/src/phasor.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-routing/src/phasor.rs @@ -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 Router for PhasorRouter { RoutingPlan { tier_order, router_joules: crate::ROUTING_JOULES, + router_energy_class: Provenance::Estimator, reasoning: format!("L5/phasor: {} neighbours voted", scored.len()), } } diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-routing/src/privacy.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-routing/src/privacy.rs index 8bc71d080..867c7029f 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-routing/src/privacy.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-routing/src/privacy.rs @@ -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); 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 {