open-standards/eoc/eoc-rs/crates/eoc-route-learned/src/classifier.rs
Transaction Science 01d4ad9c7b Ingest batch 2: 9 new crates across openpay / smart-byte / eoc
openpay/ (+3 crates, ~8400 LOC, 162 tests)
  op-iso8583/         ISO 8583 messages for direct acquirer / card-network
                      connectivity. MTIs, primary+secondary bitmaps, full
                      DE catalogue, BCD/ASCII/EBCDIC/LLVAR/LLLVAR codecs,
                      five dialects (Visa Base I, Mastercard MDS, Amex GNS,
                      Discover, JCB), network-management messages
                      (sign-on/echo/cutover), DE 55 EMV TLV.
  op-mobile-wallets/  Apple Pay (ECDH-P256 + AES-256-GCM), Google Pay ECv2
                      (ECDH + HKDF + AES-256-CTR + HMAC), Samsung Pay. PAN
                      never leaves the decryption boundary as a String;
                      lands in VaultRef. No-PAN-leakage assertions per
                      wallet.
  op-routing/         Least-cost routing + MCC-aware routing + intelligent
                      retry / soft-decline recovery. 231-MCC catalogue.
                      Real ISO 8583 DE-39 soft/hard decline taxonomies.

smart-byte/smart-byte-rs/ (+3 crates, ~7100 LOC, 96 tests)
  smart-byte-vc/      W3C Verifiable Credentials 2.0: VCDM, presentations,
                      Data Integrity proofs (eddsa-jcs-2022), VC-JWT,
                      SD-JWT (selective disclosure), Bitstring Status
                      List 2021. Cargo-bridge maps VCs to Smart Byte
                      envelopes with SAID stability.
  smart-byte-did/     W3C DID resolution: did:key (Ed25519/P-256/secp256k1),
                      did:web (HTTPS), did:peer (numalgo 2+4), did:jwk,
                      did:ion (feature-gated stub). DID URL dereferencer.
  smart-byte-pq/      Post-quantum signatures: ML-DSA-44/65/87 (FIPS 204),
                      SLH-DSA across 12 parameter sets (FIPS 205), FN-DSA
                      (draft FIPS 206, feature-gated). Hybrid Ed25519 +
                      ML-DSA pattern for the transition. Secret-key
                      zeroize-on-drop.

eoc/eoc-rs/ (+3 crates, ~6450 LOC, 76 tests)
  eoc-vendor-api/     NeuralBackend implementations: Anthropic, OpenAI,
                      Google, Mistral, Cohere, Groq, Together, Fireworks.
                      SSE streaming. Prompt-caching where supported.
                      Joule estimates per published energy coefficient
                      (12-model curated table).
  eoc-embeddings/     21 embedding models behind one Embedder trait:
                      OpenAI v3 (3), Cohere v3 (3), Voyage (5), Mistral,
                      Jina, plus local ONNX (BGE x3, nomic, mxbai, GTE,
                      E5) behind 'local' feature. Matryoshka-style
                      dimension projection. Per-content-hash cache.
  eoc-route-learned/  RouteLLM-style matrix-factorisation router +
                      multinomial-logreg classifier + LinUCB / Thompson
                      bandits for online learning. LearnedCascade wraps
                      the rule-based cascade with a confidence-threshold
                      skip policy. Synthetic-eval accuracy: MF 0.935,
                      LogReg 0.980.

Quality:
- cargo check across all three workspaces: clean
- cargo clippy --all-targets -- -D warnings on every new crate: clean
- 334 new tests total; all passing
- #![forbid(unsafe_code)] across every new crate
- No unwrap/expect outside #[cfg(test)]

Rust 1.95.0 edition 2024 (inherited via root rust-toolchain.toml).
2026-05-23 18:12:24 -04:00

231 lines
6.9 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.

//! Multinomial logistic-regression router.
//!
//! Linear-softmax classifier over query embeddings:
//!
//! ```text
//! logits = W · embedding + b
//! probs = softmax(logits)
//! ```
//!
//! Trained by stochastic gradient descent on cross-entropy loss with L2
//! regularisation. The output is a softmax over the four cascade stages
//! (`Cache`, `Kv`, `Graph`, `Neural` — in that order).
use std::collections::BTreeMap;
use async_trait::async_trait;
use eoc_core::{Query, Stage};
use nalgebra::{DMatrix, DVector};
use rand::SeedableRng;
use rand_distr::{Distribution, Normal};
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
use crate::router::{ALL_STAGES, LearnedRouter, RouterState, StagePrediction};
const NUM_STAGES: usize = 4;
fn stage_index(s: Stage) -> usize {
match s {
Stage::Cache => 0,
Stage::Kv => 1,
Stage::Graph => 2,
Stage::Neural => 3,
}
}
fn stage_from_index(i: usize) -> Stage {
ALL_STAGES[i.min(3)]
}
/// L2-regularised multinomial logistic regression.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LogRegRouter {
/// Embedding dimension `d`.
embedding_dim: usize,
/// Weights `W ∈ R^{4 × d}`.
pub weights: DMatrix<f32>,
/// Per-stage bias `b ∈ R^4`.
pub bias: DVector<f32>,
/// L2 regularisation strength `λ`.
pub regularization: f32,
}
impl LogRegRouter {
/// Construct an untrained router with random init.
pub fn new(embedding_dim: usize, regularization: f32, seed: u64) -> Self {
let mut rng = rand::rngs::StdRng::seed_from_u64(seed);
let init = Normal::new(0.0_f32, 0.05).expect("valid normal");
let weights =
DMatrix::<f32>::from_fn(NUM_STAGES, embedding_dim, |_, _| init.sample(&mut rng));
let bias = DVector::<f32>::zeros(NUM_STAGES);
Self {
embedding_dim,
weights,
bias,
regularization,
}
}
/// Embedding dimension `d`.
pub fn embedding_dim(&self) -> usize {
self.embedding_dim
}
/// Forward pass: softmax probabilities over stages.
pub fn predict_probs(&self, embedding: &[f32]) -> Result<[f32; 4]> {
if embedding.len() != self.embedding_dim {
return Err(Error::DimensionMismatch {
expected: self.embedding_dim,
got: embedding.len(),
});
}
let emb = DVector::<f32>::from_row_slice(embedding);
let logits_vec: DVector<f32> = &self.weights * emb + &self.bias;
let mut logits = [0.0_f32; 4];
for (i, l) in logits.iter_mut().enumerate() {
*l = logits_vec[i];
}
Ok(softmax4(&logits))
}
/// Argmax over softmax probabilities.
pub fn predict(&self, embedding: &[f32]) -> Result<(Stage, f32, [f32; 4])> {
let probs = self.predict_probs(embedding)?;
let (idx, p) = argmax(&probs);
Ok((stage_from_index(idx), p, probs))
}
/// One SGD step (cross-entropy + L2). Returns the loss.
pub fn sgd_step(&mut self, embedding: &[f32], stage: Stage, lr: f32) -> Result<f32> {
if embedding.len() != self.embedding_dim {
return Err(Error::DimensionMismatch {
expected: self.embedding_dim,
got: embedding.len(),
});
}
let emb = DVector::<f32>::from_row_slice(embedding);
let probs = self.predict_probs(embedding)?;
let target = stage_index(stage);
// ∂L/∂logits_i = probs_i - 1{i == target}
let mut grad_logits = [0.0_f32; 4];
for (i, g) in grad_logits.iter_mut().enumerate() {
*g = probs[i] - if i == target { 1.0 } else { 0.0 };
}
// ∂L/∂W[i, :] = grad_logits[i] · emb + λ · W[i, :]
// ∂L/∂b[i] = grad_logits[i]
let lambda = self.regularization;
for (i, &g) in grad_logits.iter().enumerate() {
for j in 0..self.embedding_dim {
let w = self.weights[(i, j)];
self.weights[(i, j)] = w - lr * (g * emb[j] + lambda * w);
}
self.bias[i] -= lr * g;
}
let eps = 1e-7_f32;
let loss = -(probs[target] + eps).ln();
Ok(loss)
}
}
fn softmax4(logits: &[f32; 4]) -> [f32; 4] {
let max = logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
let mut exps = [0.0_f32; 4];
let mut sum = 0.0_f32;
for (i, e) in exps.iter_mut().enumerate() {
*e = (logits[i] - max).exp();
sum += *e;
}
if sum <= 0.0 {
return [0.25; 4];
}
for e in exps.iter_mut() {
*e /= sum;
}
exps
}
fn argmax(xs: &[f32; 4]) -> (usize, f32) {
let mut best = 0;
let mut bv = xs[0];
for (i, v) in xs.iter().enumerate().skip(1) {
if *v > bv {
best = i;
bv = *v;
}
}
(best, bv)
}
#[async_trait]
impl LearnedRouter for LogRegRouter {
async fn route(&self, query: &Query) -> StagePrediction {
let Some(emb) = query.embedding.as_ref() else {
return StagePrediction::new(Stage::Cache, 0.0);
};
match self.predict(emb) {
Ok((stage, conf, _probs)) => StagePrediction::new(stage, conf),
Err(_) => StagePrediction::new(Stage::Cache, 0.0),
}
}
async fn observe(
&mut self,
query: &Query,
chosen_stage: Stage,
was_correct: bool,
_joule_cost: u64,
) {
// Only train on successful resolutions — we want P(stage = correct | x).
if was_correct && let Some(emb) = query.embedding.as_ref() {
let _ = self.sgd_step(emb, chosen_stage, 0.01);
}
}
fn export_state(&self) -> RouterState {
let blob = serde_json::to_vec(self).unwrap_or_default();
let mut meta = BTreeMap::new();
meta.insert("embedding_dim".to_string(), self.embedding_dim.to_string());
meta.insert(
"regularization".to_string(),
self.regularization.to_string(),
);
RouterState {
algorithm: "logreg".to_string(),
weights_blob: blob,
metadata: meta,
}
}
fn import_state(state: RouterState) -> Result<Self> {
if state.algorithm != "logreg" {
return Err(Error::StateImportFailed(format!(
"expected algorithm=logreg, got {}",
state.algorithm
)));
}
serde_json::from_slice(&state.weights_blob)
.map_err(|e| Error::StateImportFailed(e.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn softmax_sums_to_one() {
let p = softmax4(&[1.0, 2.0, 3.0, 4.0]);
let s: f32 = p.iter().sum();
assert!((s - 1.0).abs() < 1e-5);
}
#[test]
fn predict_probs_dim_check() {
let r = LogRegRouter::new(8, 1e-3, 1);
assert!(r.predict_probs(&[0.0; 7]).is_err());
}
}