open-standards/jouleclaw/jouleclaw-rs/crates/jouleclaw-routing/src/phasor.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

259 lines
8 KiB
Rust

//! Phasor-fingerprint router.
//!
//! A "phasor" here is a fixed-width bit fingerprint of a query. The
//! default [`HashPhasorEmbedder`] derives it deterministically from the
//! token set via FNV-1a so the crate needs no model. A consumer with
//! real phasor embeddings (e.g. from JouleDB) implements
//! [`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;
/// A 64-bit query fingerprint. Hamming distance approximates query
/// dissimilarity.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Phasor(pub u64);
impl Phasor {
/// Number of differing bits between two phasors (0..=64).
pub fn hamming(self, other: Phasor) -> u32 {
(self.0 ^ other.0).count_ones()
}
/// Similarity in `[0, 64]` — bits in common.
pub fn similarity(self, other: Phasor) -> u32 {
64 - self.hamming(other)
}
}
/// Maps a query to a [`Phasor`]. Implement this to plug in real
/// embeddings; the default is a deterministic hash.
pub trait PhasorEmbedder: Send + Sync {
fn embed(&self, q: &Query) -> Phasor;
}
/// Default deterministic embedder: FNV-1a over the sorted token set,
/// folded so each token sets a few bits. No model, no allocation beyond
/// the token split.
#[derive(Debug, Default, Clone, Copy)]
pub struct HashPhasorEmbedder;
impl PhasorEmbedder for HashPhasorEmbedder {
fn embed(&self, q: &Query) -> Phasor {
let mut tokens = crate::learned::tokenize(q);
tokens.sort();
let mut bits: u64 = 0;
for tok in &tokens {
// FNV-1a 64.
let mut h: u64 = 0xcbf29ce484222325;
for b in tok.bytes() {
h ^= b as u64;
h = h.wrapping_mul(0x100000001b3);
}
// Set the bit the hash points at, plus a fold for density.
bits |= 1u64 << (h % 64);
bits |= 1u64 << ((h >> 6) % 64);
}
Phasor(bits)
}
}
#[derive(Clone)]
struct PhasorEpisode {
phasor: Phasor,
winning_tier: TierId,
joules_spent: f64,
}
/// Router that orders tiers by the winning tier of phasor-nearest past
/// queries.
pub struct PhasorRouter<E: PhasorEmbedder = HashPhasorEmbedder> {
embedder: E,
episodes: Vec<PhasorEpisode>,
capacity: usize,
k: usize,
/// Minimum bits-in-common for an episode to count as a neighbour.
min_similarity: u32,
}
impl PhasorRouter<HashPhasorEmbedder> {
/// New router with the default hash embedder.
pub fn with_defaults() -> Self {
Self::with_embedder(HashPhasorEmbedder, 256, 8, 40)
}
}
impl<E: PhasorEmbedder> PhasorRouter<E> {
/// New router with a custom embedder, capacity, neighbourhood size,
/// and similarity floor (bits-in-common out of 64).
pub fn with_embedder(embedder: E, capacity: usize, k: usize, min_similarity: u32) -> Self {
Self {
embedder,
episodes: Vec::new(),
capacity: capacity.max(1),
k: k.max(1),
min_similarity: min_similarity.min(64),
}
}
/// Number of recorded episodes.
pub fn len(&self) -> usize {
self.episodes.len()
}
/// `true` iff no episodes have been recorded.
pub fn is_empty(&self) -> bool {
self.episodes.is_empty()
}
/// Record a dispatch outcome.
pub fn record_outcome(&mut self, q: &Query, winning_tier: TierId, joules_spent: f64) {
let phasor = self.embedder.embed(q);
if phasor.0 == 0 {
return; // empty / non-text query
}
if self.episodes.len() >= self.capacity {
self.episodes.remove(0);
}
self.episodes.push(PhasorEpisode {
phasor,
winning_tier,
joules_spent,
});
}
}
impl<E: PhasorEmbedder> Router for PhasorRouter<E> {
fn route(&self, q: &Query) -> RoutingPlan {
let phasor = self.embedder.embed(q);
if phasor.0 == 0 || self.episodes.is_empty() {
return RoutingPlan::fallback(
crate::ROUTING_JOULES,
"L5/phasor: cold start or non-text query",
);
}
let mut scored: Vec<(&PhasorEpisode, u32)> = self
.episodes
.iter()
.map(|e| (e, phasor.similarity(e.phasor)))
.filter(|(_, s)| *s >= self.min_similarity)
.collect();
scored.sort_by(|a, b| b.1.cmp(&a.1));
scored.truncate(self.k);
if scored.is_empty() {
return RoutingPlan::fallback(
crate::ROUTING_JOULES,
"L5/phasor: no episodes within similarity floor",
);
}
let mut wins: HashMap<TierId, (u64, f64, u32)> = HashMap::new();
for (ep, sim) in &scored {
let entry = wins.entry(ep.winning_tier).or_insert((0, 0.0, 0));
entry.0 += *sim as u64;
entry.1 += ep.joules_spent;
entry.2 += 1;
}
let mut ranked: Vec<(TierId, u64, f64)> = wins
.into_iter()
.map(|(t, (v, j, n))| (t, v, j / n as f64))
.collect();
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();
RoutingPlan {
tier_order,
router_joules: crate::ROUTING_JOULES,
router_energy_class: Provenance::Estimator,
reasoning: format!("L5/phasor: {} neighbours voted", scored.len()),
}
}
fn estimate_overhead(&self, _q: &Query) -> f64 {
crate::ROUTING_JOULES
}
}
#[cfg(test)]
mod tests {
use super::*;
use jouleclaw_cascade::types::{ContextRef, JouleBudget, QualityFloor, QueryInput};
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 embedder_deterministic() {
let e = HashPhasorEmbedder;
assert_eq!(e.embed(&q("hello world")), e.embed(&q("world hello")));
}
#[test]
fn embedder_empty_is_zero() {
let e = HashPhasorEmbedder;
assert_eq!(e.embed(&q(" ")).0, 0);
}
#[test]
fn hamming_and_similarity_complement() {
let a = Phasor(0b1010);
let b = Phasor(0b1001);
assert_eq!(a.hamming(b) + a.similarity(b), 64);
}
#[test]
fn cold_start_fallback() {
let r = PhasorRouter::with_defaults();
assert!(r.route(&q("anything")).is_fallback());
}
#[test]
fn learns_winner() {
let mut r = PhasorRouter::with_defaults();
for _ in 0..5 {
r.record_outcome(&q("capital of france is paris"), TierId::L0_1FactLut, 5e-6);
}
let plan = r.route(&q("capital of france is paris"));
assert!(!plan.is_fallback());
assert_eq!(plan.tier_order.first().copied(), Some(TierId::L0_1FactLut));
}
#[test]
fn non_text_not_recorded() {
let mut r = PhasorRouter::with_defaults();
let query = Query {
input: QueryInput::Binary(vec![9, 9]),
budget: JouleBudget::standard(),
quality: QualityFloor::any(),
context: ContextRef::fresh(),
deadline: None,
};
r.record_outcome(&query, TierId::L0, 1.0);
assert!(r.is_empty());
}
#[test]
fn capacity_bounds_memory() {
let mut r = PhasorRouter::with_embedder(HashPhasorEmbedder, 2, 8, 0);
r.record_outcome(&q("one"), TierId::L0, 1.0);
r.record_outcome(&q("two"), TierId::L0, 1.0);
r.record_outcome(&q("three"), TierId::L0, 1.0);
assert_eq!(r.len(), 2);
}
}