feat(decide): cm-decide — typed calibrated decisions; Jev + local NLI backends; skill triage in shadow
deploy / test (push) Failing after 1m54s
deploy / build (push) Skipped

A third kind of decision-maker between deterministic code and a full LLM
call: Choice / Score / Noul questions answered as probability
distributions with a confidence, behind one Decider trait, with the
composition patterns (confidence gating, composite scoring, rerank) as
code. Two backends: TypeSafe's Jev over HTTP, and a DeBERTa-v3 MNLI
cross-encoder run in-process with candle (feature nli; metal/cuda).

decide-eval measures a backend on labelled cases the way judge-eval
measures the judge. eval/skill-triage.json: 20 mission tasks × 53 skills,
75 positives, hand-labelled. Measured 2026-09-21:

  lexical overlap        AUROC 0.851  [email protected] 0.47  top-k 48/75  ECE 0.095
  jev (named wording)    AUROC 0.989  [email protected] 0.84  top-k 63/75  ECE 0.064  213 ms
  jev (plain wording)    AUROC 0.970  [email protected] 0.66  top-k 52/75
  nli mnli-base          AUROC 0.790  [email protected] 0.28  top-k 38/75  ECE 0.263  1.5 s
  nli zeroshot-v2        AUROC 0.782  [email protected] 0.43  top-k 39/75  ECE 0.054  1.2 s

The vendor's calibration claim survives our data; the local cross-encoder
ranks below keyword overlap on either checkpoint or wording and is kept
as the measured negative, not shipped. A local backend would need the
logit-readout route over the fleet's 9B model — a separate spike.

Shadow: one Jev call per phase launch (spawned, 10 s cap, silent without
TYPESAFE_API_KEY) records a skill.triage event; the Skill-Use report
carries triage_p beside each skill's Trigger verdict. It selects nothing.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
This commit is contained in:
Omar Sobh
2026-09-21 10:08:31 -05:00
co-authored by Claude Opus 5
parent 650a556029
commit 0a2bd6f868
17 changed files with 3203 additions and 87 deletions
+348
View File
@@ -0,0 +1,348 @@
//! Measure a decision backend against labelled cases.
//!
//! The judge got an eval before it got trusted (`scripts/judge-eval.sh`);
//! a triage model gets the same. Every backend answers the SAME Noul per
//! (task, skill) pair, and is scored on ranking (AUROC), on the operating
//! point (best-F1 threshold and F1 at 0.5), and on calibration (Brier, ECE)
//! — because a calibrated middle band is the whole reason to have this tier,
//! and a backend that ranks well but says 0.9 to everything has none.
//!
//! decide-eval [--backend jev|nli|lexical|all] [--wording named|plain]
//! [--set path] [--skills dir] [--dump dir]
//!
//! `--dump` writes every pair's probability so a temperature can be fitted
//! offline without re-running the model.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::Instant;
use cm_decide::{Answer, DecideError, Decider, Decision, Question};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct EvalSet {
cases: Vec<Case>,
}
#[derive(Deserialize, Clone)]
struct Case {
id: String,
task: String,
positives: Vec<String>,
}
#[derive(Clone)]
struct Skill {
name: String,
when_to_use: String,
}
/// The question a backend gets. `--wording` picks the template; a backend
/// is reported under the wording it was run with, and each backend ships
/// with the wording that measured best FOR IT — a prompt is part of the
/// backend, and an NLI cross-encoder and a decision model do not want the
/// same sentence. Both are run on both so the table says so.
fn question_for(skill: &Skill, wording: &str) -> Question {
let text = match wording {
// The shipped question, shared with the server's shadow path.
"named" => return cm_decide::triage::question(&skill.name, &skill.when_to_use),
// The when_to_use line alone, second person rewritten to the agent,
// as a plain declarative the NLI head was trained on.
"plain" => third_person(&skill.when_to_use),
other => panic!("unknown wording {other}"),
};
Question::noul(text)
}
/// "You're the tester on a mobile team" → "The agent is the tester on a
/// mobile team". Crude on purpose: it is a rewrite of a dozen fixed
/// openings, not a grammar, and it exists so the NLI hypothesis reads like
/// an MNLI hypothesis.
fn third_person(when: &str) -> String {
let w = when.trim();
let rules: &[(&str, &str)] = &[
("You're the ", "The agent is the "),
("You are the ", "The agent is the "),
("You're a ", "The agent is a "),
("You are a ", "The agent is a "),
("You're on a ", "The agent is on a "),
("You are on a ", "The agent is on a "),
("You're ", "The agent is "),
("You are ", "The agent is "),
("You need ", "The agent needs "),
("You have ", "The agent has "),
];
for (from, to) in rules {
if let Some(rest) = w.strip_prefix(from) {
return format!("{to}{rest}");
}
}
format!("In this task, {}", w.trim_end_matches('.').to_string() + ".")
}
fn load_skills(dir: &Path) -> Vec<Skill> {
let mut out = Vec::new();
fn walk(dir: &Path, out: &mut Vec<Skill>) {
let Ok(rd) = std::fs::read_dir(dir) else { return };
let mut entries: Vec<_> = rd.flatten().collect();
entries.sort_by_key(|e| e.path());
for e in entries {
let p = e.path();
if p.is_dir() {
walk(&p, out);
} else if p.extension().is_some_and(|x| x == "md") {
let Ok(text) = std::fs::read_to_string(&p) else { continue };
let field = |k: &str| {
text.lines()
.find_map(|l| l.strip_prefix(k).map(|v| v.trim().trim_matches('"').to_string()))
};
if let (Some(name), Some(when)) = (field("name:"), field("when_to_use:")) {
out.push(Skill { name, when_to_use: when });
}
}
}
}
walk(dir, &mut out);
out
}
/// Keyword overlap between the task and the skill's name + when_to_use:
/// the bar a model has to clear to be worth a network call.
struct Lexical;
fn tokens(s: &str) -> std::collections::BTreeSet<String> {
s.to_ascii_lowercase()
.split(|c: char| !c.is_alphanumeric())
.filter(|w| w.len() > 3)
.map(str::to_string)
.collect()
}
#[async_trait::async_trait]
impl Decider for Lexical {
fn name(&self) -> &str {
"lexical"
}
async fn decide(
&self,
state: &str,
questions: &BTreeMap<String, Question>,
) -> Result<Decision, DecideError> {
let started = Instant::now();
let st = tokens(state);
let answers = questions
.iter()
.map(|(id, q)| {
let Question::Noul { instructions, .. } = q else { unreachable!() };
let ht = tokens(instructions);
let inter = st.intersection(&ht).count() as f64;
let p = if ht.is_empty() { 0.0 } else { (inter / ht.len() as f64 * 4.0).min(1.0) };
(id.clone(), Answer::Noul { noul: p })
})
.collect();
Ok(Decision { model: "lexical".into(), answers, usage: None, latency: started.elapsed() })
}
}
#[derive(Serialize)]
struct Pair {
case: String,
skill: String,
label: bool,
p: f64,
}
#[derive(Default)]
struct Report {
pairs: Vec<Pair>,
latency_ms: Vec<u128>,
input_tokens: u64,
per_case_topk_hits: Vec<(usize, usize)>,
errors: usize,
}
fn auroc(pairs: &[Pair]) -> f64 {
// MannWhitney: fraction of (positive, negative) pairs ranked correctly.
let pos: Vec<f64> = pairs.iter().filter(|p| p.label).map(|p| p.p).collect();
let neg: Vec<f64> = pairs.iter().filter(|p| !p.label).map(|p| p.p).collect();
if pos.is_empty() || neg.is_empty() {
return f64::NAN;
}
let mut s = 0.0;
for a in &pos {
for b in &neg {
s += if a > b { 1.0 } else if a == b { 0.5 } else { 0.0 };
}
}
s / (pos.len() * neg.len()) as f64
}
fn f1_at(pairs: &[Pair], t: f64) -> (f64, f64, f64) {
let (mut tp, mut fp, mut fn_) = (0.0, 0.0, 0.0);
for p in pairs {
match (p.p >= t, p.label) {
(true, true) => tp += 1.0,
(true, false) => fp += 1.0,
(false, true) => fn_ += 1.0,
_ => {}
}
}
let prec = if tp + fp > 0.0 { tp / (tp + fp) } else { 0.0 };
let rec = if tp + fn_ > 0.0 { tp / (tp + fn_) } else { 0.0 };
let f1 = if prec + rec > 0.0 { 2.0 * prec * rec / (prec + rec) } else { 0.0 };
(f1, prec, rec)
}
fn brier(pairs: &[Pair]) -> f64 {
pairs.iter().map(|p| (p.p - if p.label { 1.0 } else { 0.0 }).powi(2)).sum::<f64>() / pairs.len() as f64
}
/// Expected calibration error, ten equal-width bins.
fn ece(pairs: &[Pair]) -> f64 {
let mut bins = vec![(0usize, 0.0f64, 0.0f64); 10];
for p in pairs {
let b = ((p.p * 10.0).floor() as usize).min(9);
bins[b].0 += 1;
bins[b].1 += p.p;
bins[b].2 += if p.label { 1.0 } else { 0.0 };
}
let n = pairs.len() as f64;
bins.iter()
.filter(|(c, _, _)| *c > 0)
.map(|(c, sp, sy)| (*c as f64 / n) * ((sp / *c as f64) - (sy / *c as f64)).abs())
.sum()
}
async fn run(backend: &dyn Decider, cases: &[Case], skills: &[Skill], wording: &str) -> Report {
let mut r = Report::default();
let questions: BTreeMap<String, Question> =
skills.iter().map(|s| (s.name.clone(), question_for(s, wording))).collect();
for case in cases {
let d = match backend.decide(&case.task, &questions).await {
Ok(d) => d,
Err(e) => {
eprintln!(" {}: {} failed: {e}", backend.name(), case.id);
r.errors += 1;
continue;
}
};
r.latency_ms.push(d.latency.as_millis());
if let Some(u) = d.usage {
r.input_tokens += u.input_tokens;
}
let mut ranked: Vec<(String, f64)> = Vec::new();
for s in skills {
let p = match d.answers.get(&s.name) {
Some(Answer::Noul { noul }) => *noul,
_ => 0.0,
};
let label = case.positives.iter().any(|x| x == &s.name);
r.pairs.push(Pair { case: case.id.clone(), skill: s.name.clone(), label, p });
ranked.push((s.name.clone(), p));
}
ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
let k = case.positives.len();
let hits = ranked.iter().take(k).filter(|(n, _)| case.positives.contains(n)).count();
r.per_case_topk_hits.push((hits, k));
}
r
}
fn print_report(name: &str, r: &Report) {
if r.pairs.is_empty() {
println!("{name:<24} no results ({} errors)", r.errors);
return;
}
let best = (5..=95)
.map(|i| i as f64 / 100.0)
.map(|t| (t, f1_at(&r.pairs, t)))
.max_by(|a, b| a.1 .0.partial_cmp(&b.1 .0).unwrap())
.unwrap();
let (f1_half, p_half, r_half) = f1_at(&r.pairs, 0.5);
let topk: (usize, usize) = r.per_case_topk_hits.iter().fold((0, 0), |a, b| (a.0 + b.0, a.1 + b.1));
let lat = if r.latency_ms.is_empty() { 0 } else { r.latency_ms.iter().sum::<u128>() / r.latency_ms.len() as u128 };
println!(
"{name:<24} AUROC {:.3} [email protected] {:.2} (P {:.2} R {:.2}) bestF1 {:.2}@{:.2} top-k {}/{} Brier {:.3} ECE {:.3} {} ms/call {} tok errors {}",
auroc(&r.pairs), f1_half, p_half, r_half, best.1 .0, best.0, topk.0, topk.1,
brier(&r.pairs), ece(&r.pairs), lat, r.input_tokens, r.errors
);
}
#[tokio::main]
async fn main() {
let mut args = std::env::args().skip(1);
let (mut backend, mut set, mut skills_dir, mut dump, mut wording) = (
"all".to_string(),
PathBuf::from("crates/cm-decide/eval/skill-triage.json"),
PathBuf::from("skills"),
None::<PathBuf>,
"named".to_string(),
);
while let Some(a) = args.next() {
match a.as_str() {
"--backend" => backend = args.next().unwrap_or_default(),
"--wording" => wording = args.next().unwrap_or_default(),
"--set" => set = args.next().map(PathBuf::from).unwrap_or(set),
"--skills" => skills_dir = args.next().map(PathBuf::from).unwrap_or(skills_dir),
"--dump" => dump = args.next().map(PathBuf::from),
other => {
eprintln!("unknown arg {other}");
std::process::exit(2);
}
}
}
let text = std::fs::read_to_string(&set).expect("read eval set");
let set: EvalSet = serde_json::from_str(&text).expect("parse eval set");
let skills = load_skills(&skills_dir);
// Every positive must name a real skill, or the label is a typo scored
// as a miss against every backend.
for c in &set.cases {
for p in &c.positives {
assert!(skills.iter().any(|s| &s.name == p), "case {}: unknown skill {p:?}", c.id);
}
}
println!(
"{} cases × {} skills = {} pairs, {} positive\n",
set.cases.len(),
skills.len(),
set.cases.len() * skills.len(),
set.cases.iter().map(|c| c.positives.len()).sum::<usize>()
);
let mut backends: Vec<Box<dyn Decider>> = Vec::new();
if backend == "all" || backend == "lexical" {
backends.push(Box::new(Lexical));
}
#[cfg(feature = "jev")]
if backend == "all" || backend == "jev" {
match cm_decide::jev::Jev::from_env() {
Some(j) => backends.push(Box::new(j)),
None => eprintln!("jev: TYPESAFE_API_KEY unset — skipped"),
}
}
#[cfg(feature = "nli")]
if backend == "all" || backend == "nli" {
let t0 = Instant::now();
match cm_decide::nli::Nli::load(cm_decide::nli::NliConfig::from_env()) {
Ok(n) => {
eprintln!("nli: loaded {} in {:?}", n.name(), t0.elapsed());
backends.push(Box::new(n));
}
Err(e) => eprintln!("nli: could not load — {e}"),
}
}
if backends.is_empty() {
eprintln!("no backend to run");
std::process::exit(2);
}
for b in &backends {
let r = run(b.as_ref(), &set.cases, &skills, &wording).await;
print_report(&format!("{} [{wording}]", b.name()), &r);
if let Some(dir) = &dump {
std::fs::create_dir_all(dir).ok();
let path = dir.join(format!("{}-{wording}.json", b.name().replace([':', '/'], "-")));
std::fs::write(&path, serde_json::to_string_pretty(&r.pairs).unwrap()).ok();
}
}
}
+206
View File
@@ -0,0 +1,206 @@
//! TypeSafe's Jev over `POST /v1/systemone`.
//!
//! Text-only, 64 K context (32 K for the state), no tools, no reasoning.
//! Priced on input tokens only. Not trained on customer requests. The key is
//! `TYPESAFE_API_KEY` and lives in the server's environment — it is never
//! handed to a mission container, and this client is only ever called from
//! the server.
use std::collections::BTreeMap;
use std::time::{Duration, Instant};
use serde::Deserialize;
use crate::{Answer, DecideError, Decider, Decision, Question, Usage};
pub const ENDPOINT: &str = "https://api.typesafe.ai/v1/systemone";
pub const DEFAULT_MODEL: &str = "jev-latest";
pub struct Jev {
client: reqwest::Client,
key: String,
model: String,
}
impl Jev {
/// From `TYPESAFE_API_KEY` (and `TYPESAFE_MODEL`, default `jev-latest`).
/// `None` when the key is unset: the caller decides whether that means
/// "skip the decision" or "use another backend" — it never means guess.
pub fn from_env() -> Option<Self> {
let key = std::env::var("TYPESAFE_API_KEY").ok()?;
let key = key.trim().to_string();
if key.is_empty() {
return None;
}
let model = std::env::var("TYPESAFE_MODEL")
.ok()
.filter(|m| !m.trim().is_empty())
.unwrap_or_else(|| DEFAULT_MODEL.to_string());
Some(Self::new(key, model))
}
pub fn new(key: String, model: String) -> Self {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.expect("reqwest client");
Self { client, key, model }
}
}
#[derive(Deserialize)]
struct Reply {
model: String,
answers: BTreeMap<String, RawAnswer>,
#[serde(default)]
usage: Option<RawUsage>,
}
#[derive(Deserialize)]
struct RawUsage {
#[serde(default)]
input_tokens: u64,
#[serde(default)]
output_tokens: u64,
}
/// Their answer shapes. Score keys `probabilities` by level number as a
/// STRING ("0", "1", …); a `legend` mirrors the criteria and is dropped.
#[derive(Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
enum RawAnswer {
Choice {
choice: String,
probabilities: BTreeMap<String, f64>,
confidence: f64,
},
Score {
score: f64,
probabilities: BTreeMap<String, f64>,
confidence: f64,
},
Noul {
noul: f64,
},
}
impl RawAnswer {
fn into_answer(self) -> Result<Answer, DecideError> {
Ok(match self {
RawAnswer::Choice { choice, probabilities, confidence } => Answer::Choice {
choice,
probabilities,
confidence,
},
RawAnswer::Score { score, probabilities, confidence } => {
let mut levels: Vec<(usize, f64)> = probabilities
.into_iter()
.map(|(k, v)| {
k.parse::<usize>()
.map(|i| (i, v))
.map_err(|_| DecideError::Shape(format!("score level key {k:?}")))
})
.collect::<Result<_, _>>()?;
levels.sort_by_key(|(i, _)| *i);
Answer::Score {
score,
probabilities: levels.into_iter().map(|(_, p)| p).collect(),
confidence,
}
}
RawAnswer::Noul { noul } => Answer::Noul { noul },
})
}
}
#[async_trait::async_trait]
impl Decider for Jev {
fn name(&self) -> &str {
"jev"
}
async fn decide(
&self,
state: &str,
questions: &BTreeMap<String, Question>,
) -> Result<Decision, DecideError> {
let body = serde_json::json!({
"state": state,
"model": self.model,
"questions": questions,
});
let started = Instant::now();
let resp = self
.client
.post(ENDPOINT)
.bearer_auth(&self.key)
.json(&body)
.send()
.await
.map_err(|e| DecideError::Transport(e.to_string()))?;
let status = resp.status();
let text = resp
.text()
.await
.map_err(|e| DecideError::Transport(e.to_string()))?;
if !status.is_success() {
return Err(DecideError::Model(format!(
"HTTP {status}: {}",
text.chars().take(300).collect::<String>()
)));
}
let reply: Reply =
serde_json::from_str(&text).map_err(|e| DecideError::Shape(e.to_string()))?;
let mut answers = BTreeMap::new();
for (id, raw) in reply.answers {
answers.insert(id, raw.into_answer()?);
}
Ok(Decision {
model: reply.model,
answers,
usage: reply.usage.map(|u| Usage {
input_tokens: u.input_tokens,
output_tokens: u.output_tokens,
}),
latency: started.elapsed(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The documented response, read back into our shapes — including the
/// string-keyed Score levels, which arrive unordered in a JSON object.
#[test]
fn the_documented_reply_parses() {
let text = r#"{"model":"jev-1.13.0","answers":{
"department":{"type":"choice","choice":"technical","confidence":0.78,
"probabilities":{"technical":0.85,"sales":0.0,"billing":0.15}},
"frustration":{"type":"score","score":1.0,"confidence":1.0,
"legend":{"0":"calm","1":"frustrated","2":"angry"},
"probabilities":{"2":0.0,"0":0.0,"1":1.0}},
"is_urgent":{"type":"noul","noul":1.0}},
"usage":{"input_tokens":392,"output_tokens":65}}"#;
let reply: Reply = serde_json::from_str(text).unwrap();
let a = reply.answers.get("frustration").unwrap();
let RawAnswer::Score { .. } = a else { panic!() };
let converted: BTreeMap<String, Answer> = reply
.answers
.into_iter()
.map(|(k, v)| (k, v.into_answer().unwrap()))
.collect();
match &converted["frustration"] {
Answer::Score { probabilities, score, .. } => {
assert_eq!(probabilities, &vec![0.0, 1.0, 0.0]);
assert_eq!(*score, 1.0);
}
other => panic!("{other:?}"),
}
match &converted["department"] {
Answer::Choice { choice, .. } => assert_eq!(choice, "technical"),
other => panic!("{other:?}"),
}
}
}
+270
View File
@@ -0,0 +1,270 @@
//! Typed gut-check decisions for the platform's code to branch on.
//!
//! ClawMates has had exactly two kinds of decision-maker: deterministic code,
//! and a full LLM chat call parsed back into a boolean. There was no cheap,
//! calibrated classifier in between — every model decision was forced binary,
//! took seconds, and drew on a provider quota that has emptied twice. This
//! crate is that middle tier, in the shape TypeSafe's Jev popularised
//! (`docs.typesafe.ai`): a [`Question`] is a **Choice** over labelled options,
//! a **Score** over ordered levels, or a **Noul** (a yes/no); an [`Answer`] is
//! a probability distribution plus a confidence, never text.
//!
//! Two backends implement [`Decider`]: [`jev::Jev`] (hosted) and
//! [`nli::Nli`] (a DeBERTa-v3 MNLI cross-encoder run in-process). Neither
//! reasons or runs tools, and that is the point — the judge and the tool
//! gate stay where they are. What goes here is triage: which skills apply to
//! a task, whether an outbound action looks like exfiltration, which past
//! verdict is relevant. Every use is measured on labelled cases first
//! (`decide-eval`) and shipped in shadow mode before it changes behaviour.
//!
//! The `patterns` module carries the composition rules as code, because the
//! vendor's advice is right and worth keeping regardless of vendor: ask every
//! question in one call, gate on confidence, combine dimensions with weights
//! your code owns.
use std::collections::BTreeMap;
use std::time::Duration;
use serde::{Deserialize, Serialize};
#[cfg(feature = "jev")]
pub mod jev;
#[cfg(feature = "nli")]
pub mod nli;
pub mod patterns;
pub mod triage;
/// One question, evaluated on its own against the state.
///
/// Each variant answers a different kind of question, and the shape of the
/// answer follows: an option, a position, or a probability.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum Question {
/// One option from a fixed, unordered set. `criteria` maps an option name
/// to its description (`None` when the name is clear on its own).
Choice {
instructions: String,
criteria: BTreeMap<String, Option<String>>,
},
/// A position on a spectrum described in steps, low to high. At least two
/// levels; each is a situation, not a degree ("workaround exists", not
/// "moderately severe").
Score {
instructions: String,
criteria: Vec<String>,
},
/// Yes or no. `criteria` optionally says what a yes and a no look like.
Noul {
instructions: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
criteria: Option<NoulCriteria>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct NoulCriteria {
#[serde(rename = "true")]
pub yes: String,
#[serde(rename = "false")]
pub no: String,
}
impl Question {
pub fn noul(instructions: impl Into<String>) -> Self {
Question::Noul {
instructions: instructions.into(),
criteria: None,
}
}
pub fn choice<K: Into<String>, V: Into<String>>(
instructions: impl Into<String>,
options: impl IntoIterator<Item = (K, Option<V>)>,
) -> Self {
Question::Choice {
instructions: instructions.into(),
criteria: options
.into_iter()
.map(|(k, v)| (k.into(), v.map(Into::into)))
.collect(),
}
}
pub fn score<L: Into<String>>(
instructions: impl Into<String>,
levels: impl IntoIterator<Item = L>,
) -> Self {
Question::Score {
instructions: instructions.into(),
criteria: levels.into_iter().map(Into::into).collect(),
}
}
}
/// The typed answer to one question.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum Answer {
Choice {
/// The option with the highest probability.
choice: String,
/// Every option; sums to 1.
probabilities: BTreeMap<String, f64>,
/// How peaked the distribution is: 1.0 on a single option, toward 0
/// as it flattens. See [`confidence_of`].
confidence: f64,
},
Score {
/// Probability-weighted position on the level line, 0 to `levels-1`.
score: f64,
/// Per level, in order; sums to 1.
probabilities: Vec<f64>,
confidence: f64,
},
Noul {
/// Probability that the answer is yes.
noul: f64,
},
}
impl Answer {
/// Confidence for the shapes that have one; a Noul's value already
/// describes its whole two-outcome distribution, so it reports how far
/// the value sits from 0.5.
pub fn confidence(&self) -> f64 {
match self {
Answer::Choice { confidence, .. } | Answer::Score { confidence, .. } => *confidence,
Answer::Noul { noul } => (noul - 0.5).abs() * 2.0,
}
}
}
/// What a backend returned for one call: every answer, who answered, what
/// it cost. `usage` is `None` for a local backend (nothing is billed) and
/// `latency` is wall-clock as this process saw it.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Decision {
pub model: String,
pub answers: BTreeMap<String, Answer>,
#[serde(default)]
pub usage: Option<Usage>,
#[serde(with = "duration_ms")]
pub latency: Duration,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct Usage {
pub input_tokens: u64,
pub output_tokens: u64,
}
mod duration_ms {
use serde::{Deserialize, Deserializer, Serializer};
use std::time::Duration;
pub fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
s.serialize_u64(d.as_millis() as u64)
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
Ok(Duration::from_millis(u64::deserialize(d)?))
}
}
#[derive(Debug, thiserror::Error)]
pub enum DecideError {
#[error("{0}")]
Config(String),
#[error("transport: {0}")]
Transport(String),
#[error("the backend answered in a shape this crate does not read: {0}")]
Shape(String),
#[error("model: {0}")]
Model(String),
}
/// A source of typed answers. Every question in `questions` is answered
/// against the same `state` in one call; backends are expected to evaluate
/// them independently, so an answer never depends on which other questions
/// were asked.
#[async_trait::async_trait]
pub trait Decider: Send + Sync {
/// Stable name for logs and eval tables (`jev`, `nli`).
fn name(&self) -> &str;
async fn decide(
&self,
state: &str,
questions: &BTreeMap<String, Question>,
) -> Result<Decision, DecideError>;
}
/// Confidence from a distribution: `1 H(p) / ln(n)`, so a single peak is
/// 1.0 and a flat spread is 0.0. The vendor documents only that confidence
/// is "computed from how the probabilities are spread"; this is our
/// definition and it is applied to the local backend's answers. Jev's own
/// figure is passed through untouched, so the two are comparable in shape
/// but not guaranteed identical in value.
pub fn confidence_of(probabilities: &[f64]) -> f64 {
let n = probabilities.len();
if n < 2 {
return 1.0;
}
let h: f64 = probabilities
.iter()
.filter(|p| **p > 0.0)
.map(|p| -p * p.ln())
.sum();
(1.0 - h / (n as f64).ln()).clamp(0.0, 1.0)
}
/// Softmax over raw scores, numerically stable.
pub fn softmax(logits: &[f64]) -> Vec<f64> {
let max = logits.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let exps: Vec<f64> = logits.iter().map(|l| (l - max).exp()).collect();
let sum: f64 = exps.iter().sum();
exps.iter().map(|e| e / sum).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn confidence_is_one_on_a_peak_and_zero_when_flat() {
assert!((confidence_of(&[1.0, 0.0, 0.0]) - 1.0).abs() < 1e-9);
assert!(confidence_of(&[1.0 / 3.0; 3]).abs() < 1e-9);
let mid = confidence_of(&[0.6, 0.4]);
assert!(mid > 0.0 && mid < 1.0);
// A two-outcome distribution and a Noul report the same certainty
// ordering: further from even is more confident.
assert!(
Answer::Noul { noul: 0.9 }.confidence() > Answer::Noul { noul: 0.6 }.confidence()
);
}
#[test]
fn softmax_sums_to_one_and_keeps_order() {
let p = softmax(&[2.0, 1.0, 0.1]);
assert!((p.iter().sum::<f64>() - 1.0).abs() < 1e-9);
assert!(p[0] > p[1] && p[1] > p[2]);
}
/// The wire shape matches the vendor's, so a question written for one
/// backend is the same question for the other.
#[test]
fn questions_serialise_in_the_vendor_shape() {
let q = Question::choice(
"Which team?",
[("billing", Some("charges")), ("returns", None)],
);
let v = serde_json::to_value(&q).unwrap();
assert_eq!(v["type"], "choice");
assert_eq!(v["criteria"]["billing"], "charges");
assert!(v["criteria"]["returns"].is_null());
let n = Question::Noul {
instructions: "Is it urgent?".into(),
criteria: Some(NoulCriteria { yes: "y".into(), no: "n".into() }),
};
let v = serde_json::to_value(&n).unwrap();
assert_eq!(v["criteria"]["true"], "y");
}
}
+349
View File
@@ -0,0 +1,349 @@
//! A local decision model: a DeBERTa-v3 MNLI cross-encoder, in-process.
//!
//! A Noul IS an entailment probability — P(the hypothesis follows from the
//! state) — and natural-language inference is the oldest working form of
//! zero-shot classification. A Choice is one hypothesis per option with a
//! softmax over their entailment logits; a Score is the same over ordered
//! levels, then the probability-weighted position. Nothing here reasons or
//! generates: one forward pass per (state, hypothesis) pair, the answer read
//! from the three-way head.
//!
//! What this does NOT have is the vendor's calibration training. Its
//! probabilities are as calibrated as MNLI made them, which on our domain is
//! an open question until `decide-eval` answers it; temperature scaling on
//! our own labelled cases is the standard fix and the `temperature` knob is
//! where it goes.
//!
//! Model files come from the Hugging Face hub on first use
//! (`CLAWMATES_NLI_MODEL`, default `cross-encoder/nli-deberta-v3-base`;
//! `-xsmall` is 4× faster and worse) or from `CLAWMATES_NLI_MODEL_DIR` for a
//! host with no egress. CPU by default; `metal`/`cuda` features select a GPU.
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::Instant;
use candle_core::{DType, Device, Tensor};
use candle_nn::VarBuilder;
use candle_transformers::models::debertav2::{Config, DebertaV2SeqClassificationModel, Id2Label};
use tokenizers::{EncodeInput, InputSequence, PaddingParams, Tokenizer, TruncationParams};
use crate::{confidence_of, softmax, Answer, DecideError, Decider, Decision, Question};
pub const DEFAULT_MODEL: &str = "cross-encoder/nli-deberta-v3-base";
/// Token budget per pair. DeBERTa-v3 was trained at 512; the STATE is what
/// gets cut when a pair is too long (`OnlyFirst`), so the hypothesis — the
/// question — always survives whole.
const MAX_TOKENS: usize = 512;
pub struct Nli {
model: Mutex<DebertaV2SeqClassificationModel>,
tokenizer: Tokenizer,
device: Device,
/// Index of each MNLI label in the head, read from `config.json`.
entail: usize,
contra: usize,
/// Divides the logits before the softmax. 1.0 = the model's own
/// calibration; fitted on labelled cases by the eval when it disagrees.
temperature: f64,
name: String,
}
pub struct NliConfig {
pub model: String,
pub dir: Option<PathBuf>,
pub temperature: f64,
}
impl NliConfig {
pub fn from_env() -> Self {
Self {
model: std::env::var("CLAWMATES_NLI_MODEL")
.ok()
.filter(|m| !m.trim().is_empty())
.unwrap_or_else(|| DEFAULT_MODEL.to_string()),
dir: std::env::var("CLAWMATES_NLI_MODEL_DIR").ok().map(PathBuf::from),
temperature: std::env::var("CLAWMATES_NLI_TEMPERATURE")
.ok()
.and_then(|t| t.parse().ok())
.unwrap_or(1.0),
}
}
}
impl Nli {
pub fn load(cfg: NliConfig) -> Result<Self, DecideError> {
let (config_path, tokenizer_path, weights_path) = match &cfg.dir {
Some(dir) => (
dir.join("config.json"),
dir.join("tokenizer.json"),
dir.join("model.safetensors"),
),
None => {
let api = hf_hub::api::sync::Api::new()
.map_err(|e| DecideError::Config(format!("hf hub: {e}")))?;
let repo = api.model(cfg.model.clone());
let get = |f: &str| {
repo.get(f)
.map_err(|e| DecideError::Config(format!("fetch {f} for {}: {e}", cfg.model)))
};
(get("config.json")?, get("tokenizer.json")?, get("model.safetensors")?)
}
};
let config_text = std::fs::read_to_string(&config_path)
.map_err(|e| DecideError::Config(format!("{}: {e}", config_path.display())))?;
let config: Config = serde_json::from_str(&config_text)
.map_err(|e| DecideError::Config(format!("config.json: {e}")))?;
// The head's label order is the model's, not ours: read it.
let id2label: Id2Label = serde_json::from_str::<serde_json::Value>(&config_text)
.ok()
.and_then(|v| v.get("id2label").cloned())
.and_then(|v| {
v.as_object().map(|m| {
m.iter()
.filter_map(|(k, v)| {
Some((k.parse::<u32>().ok()?, v.as_str()?.to_lowercase()))
})
.collect()
})
})
.ok_or_else(|| DecideError::Config("config.json has no id2label".into()))?;
let find = |name: &str| {
id2label
.iter()
.find(|(_, v)| v.as_str() == name)
.map(|(k, _)| *k as usize)
.ok_or_else(|| DecideError::Config(format!("head has no {name:?} label")))
};
let entail = find("entailment")?;
// MNLI heads have three labels; the zero-shot-tuned checkpoints
// collapse to entailment / not_entailment. Either "no" label works.
let contra = find("contradiction").or_else(|_| find("not_entailment"))?;
let device = pick_device();
// Buffered, not mmap'd: the workspace denies `unsafe`, and the weights
// (740 MB for -base) are read once into memory and kept for the life
// of the process anyway.
let bytes = std::fs::read(&weights_path)
.map_err(|e| DecideError::Config(format!("{}: {e}", weights_path.display())))?;
let vb = VarBuilder::from_buffered_safetensors(bytes, DType::F32, &device)
.map_err(|e| DecideError::Config(format!("weights: {e}")))?;
// The backbone's tensors are `deberta.*`; the pooler and classifier
// read from the root. candle's own example does exactly this.
let vb = vb.set_prefix("deberta");
let model = DebertaV2SeqClassificationModel::load(vb, &config, Some(id2label))
.map_err(|e| DecideError::Config(format!("model: {e}")))?;
let mut tokenizer = Tokenizer::from_file(&tokenizer_path)
.map_err(|e| DecideError::Config(format!("tokenizer: {e}")))?;
tokenizer.with_padding(Some(PaddingParams::default()));
tokenizer
.with_truncation(Some(TruncationParams {
max_length: MAX_TOKENS,
strategy: tokenizers::TruncationStrategy::OnlyFirst,
..Default::default()
}))
.map_err(|e| DecideError::Config(format!("truncation: {e}")))?;
Ok(Self {
model: Mutex::new(model),
tokenizer,
device,
entail,
contra,
temperature: cfg.temperature.max(1e-3),
name: format!("nli:{}", cfg.model.rsplit('/').next().unwrap_or(&cfg.model)),
})
}
/// Entailment and contradiction logits for every (state, hypothesis)
/// pair, one batch, one forward pass.
fn logits(&self, state: &str, hypotheses: &[String]) -> Result<Vec<(f64, f64)>, DecideError> {
if hypotheses.is_empty() {
return Ok(Vec::new());
}
let inputs: Vec<EncodeInput> = hypotheses
.iter()
.map(|h| {
EncodeInput::Dual(
InputSequence::Raw(state.into()),
InputSequence::Raw(h.as_str().into()),
)
})
.collect();
let encodings = self
.tokenizer
.encode_batch(inputs, true)
.map_err(|e| DecideError::Model(format!("tokenize: {e}")))?;
let to_tensor = |rows: Vec<&[u32]>| -> Result<Tensor, DecideError> {
let stacked: Vec<Tensor> = rows
.iter()
.map(|r| Tensor::new(*r, &self.device))
.collect::<Result<_, _>>()
.map_err(|e| DecideError::Model(e.to_string()))?;
Tensor::stack(&stacked, 0).map_err(|e| DecideError::Model(e.to_string()))
};
let ids = to_tensor(encodings.iter().map(|e| e.get_ids()).collect())?;
let mask = to_tensor(encodings.iter().map(|e| e.get_attention_mask()).collect())?;
let types = to_tensor(encodings.iter().map(|e| e.get_type_ids()).collect())?;
let out = {
let model = self.model.lock().map_err(|_| DecideError::Model("model lock".into()))?;
model
.forward(&ids, Some(types), Some(mask))
.map_err(|e| DecideError::Model(format!("forward: {e}")))?
};
let rows: Vec<Vec<f32>> = out
.to_dtype(DType::F32)
.and_then(|t| t.to_vec2())
.map_err(|e| DecideError::Model(e.to_string()))?;
Ok(rows
.iter()
.map(|r| {
(
r[self.entail] as f64 / self.temperature,
r[self.contra] as f64 / self.temperature,
)
})
.collect())
}
/// P(yes) for one hypothesis: entailment against contradiction, the
/// neutral class left out — the standard zero-shot NLI reading.
fn p_yes(entail: f64, contra: f64) -> f64 {
softmax(&[entail, contra])[0]
}
}
fn pick_device() -> Device {
#[cfg(feature = "cuda")]
if let Ok(d) = Device::new_cuda(0) {
return d;
}
#[cfg(feature = "metal")]
if let Ok(d) = Device::new_metal(0) {
return d;
}
Device::Cpu
}
/// The hypothesis a question becomes. Kept in one place because the wording
/// is the whole "prompt" this backend has, and the eval is what tunes it.
pub fn hypotheses(question: &Question) -> Vec<String> {
match question {
Question::Noul { instructions, criteria } => match criteria {
Some(c) => vec![
format!("{instructions} {}", c.yes),
format!("{instructions} {}", c.no),
],
None => vec![instructions.clone()],
},
Question::Choice { instructions, criteria } => criteria
.iter()
.map(|(name, desc)| match desc {
Some(d) => format!("{instructions} {name}: {d}"),
None => format!("{instructions} {name}"),
})
.collect(),
Question::Score { instructions, criteria } => criteria
.iter()
.map(|level| format!("{instructions} {level}"))
.collect(),
}
}
#[async_trait::async_trait]
impl Decider for Nli {
fn name(&self) -> &str {
&self.name
}
async fn decide(
&self,
state: &str,
questions: &BTreeMap<String, Question>,
) -> Result<Decision, DecideError> {
let started = Instant::now();
// Every hypothesis of every question in one batch; then read each
// question's slice back out.
let mut all: Vec<String> = Vec::new();
let mut spans: Vec<(String, usize, usize)> = Vec::new();
for (id, q) in questions {
let hs = hypotheses(q);
let start = all.len();
all.extend(hs);
spans.push((id.clone(), start, all.len()));
}
let logits = self.logits(state, &all)?;
let mut answers = BTreeMap::new();
for (id, start, end) in spans {
let slice = &logits[start..end];
let q = &questions[&id];
let answer = match q {
Question::Noul { criteria, .. } => {
let p = if criteria.is_some() {
// yes-description vs no-description: which does the
// state entail more?
softmax(&[slice[0].0, slice[1].0])[0]
} else {
Self::p_yes(slice[0].0, slice[0].1)
};
Answer::Noul { noul: p }
}
Question::Choice { criteria, .. } => {
let probs = softmax(&slice.iter().map(|(e, _)| *e).collect::<Vec<_>>());
let names: Vec<&String> = criteria.keys().collect();
let (best, _) = probs
.iter()
.enumerate()
.fold((0, f64::NEG_INFINITY), |acc, (i, p)| if *p > acc.1 { (i, *p) } else { acc });
Answer::Choice {
choice: names[best].clone(),
confidence: confidence_of(&probs),
probabilities: names.iter().map(|n| (*n).clone()).zip(probs).collect(),
}
}
Question::Score { .. } => {
let probs = softmax(&slice.iter().map(|(e, _)| *e).collect::<Vec<_>>());
let score = probs.iter().enumerate().map(|(i, p)| i as f64 * p).sum();
Answer::Score {
score,
confidence: confidence_of(&probs),
probabilities: probs,
}
}
};
answers.insert(id, answer);
}
Ok(Decision {
model: self.name.clone(),
answers,
usage: None,
latency: started.elapsed(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_question_becomes_the_expected_hypotheses() {
let n = Question::noul("The task needs web search.");
assert_eq!(hypotheses(&n), vec!["The task needs web search."]);
let c = Question::choice("This work is", [("research", Some("reading")), ("coding", None)]);
let h = hypotheses(&c);
assert_eq!(h, vec!["This work is coding", "This work is research: reading"]);
let s = Question::score("Severity:", ["cosmetic", "blocking"]);
assert_eq!(hypotheses(&s).len(), 2);
}
#[test]
fn p_yes_is_entailment_against_contradiction() {
assert!(Nli::p_yes(3.0, -3.0) > 0.99);
assert!(Nli::p_yes(-3.0, 3.0) < 0.01);
assert!((Nli::p_yes(0.0, 0.0) - 0.5).abs() < 1e-9);
}
}
+88
View File
@@ -0,0 +1,88 @@
//! The composition rules, as code. Each is one of the vendor's documented
//! patterns, kept here because they are the right way to use ANY calibrated
//! classifier and should not live in one call site's `if` chain.
use crate::Answer;
/// Confidence-gated routing: three outcomes, not two. The answer says what;
/// the confidence (or a Noul's distance from even) says whether to act.
///
/// `act_above` is the probability/confidence at which code may act on its
/// own; `dismiss_below` the one under which the answer is a clear no. In
/// between is the band a person, or a slower model, gets to decide.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Gate {
Act,
Review,
Dismiss,
}
pub fn gate_noul(p: f64, dismiss_below: f64, act_above: f64) -> Gate {
debug_assert!(dismiss_below <= act_above);
if p >= act_above {
Gate::Act
} else if p < dismiss_below {
Gate::Dismiss
} else {
Gate::Review
}
}
/// Gate a Choice or Score on its confidence alone.
pub fn gate_confidence(answer: &Answer, review_below: f64) -> Gate {
if answer.confidence() >= review_below {
Gate::Act
} else {
Gate::Review
}
}
/// Composite scoring: normalise each Score to 0..1 by its top level and
/// combine with weights the caller owns. `parts` is `(score, levels, weight)`.
/// Weights need not sum to one; the result is divided by their sum.
pub fn composite(parts: &[(f64, usize, f64)]) -> f64 {
let total: f64 = parts.iter().map(|(_, _, w)| w).sum();
if total <= 0.0 {
return 0.0;
}
parts
.iter()
.map(|(score, levels, w)| {
let top = (*levels as f64 - 1.0).max(1.0);
(score / top).clamp(0.0, 1.0) * w
})
.sum::<f64>()
/ total
}
/// Rerank: order candidates by a per-candidate Noul, highest first.
pub fn rerank<T>(mut items: Vec<(T, f64)>) -> Vec<(T, f64)> {
items.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
items
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_noul_gates_three_ways() {
assert_eq!(gate_noul(0.95, 0.2, 0.8), Gate::Act);
assert_eq!(gate_noul(0.05, 0.2, 0.8), Gate::Dismiss);
assert_eq!(gate_noul(0.5, 0.2, 0.8), Gate::Review);
}
#[test]
fn composite_normalises_by_top_level_and_weights() {
// severity 2 of 0..2 at weight 0.6, frustration 1 of 0..2 at 0.3,
// report quality 3 of 0..3 at 0.1 → 0.6*1 + 0.3*0.5 + 0.1*1 = 0.85
let c = composite(&[(2.0, 3, 0.6), (1.0, 3, 0.3), (3.0, 4, 0.1)]);
assert!((c - 0.85).abs() < 1e-9);
}
#[test]
fn rerank_is_descending() {
let r = rerank(vec![("a", 0.2), ("b", 0.9), ("c", 0.5)]);
assert_eq!(r.iter().map(|x| x.0).collect::<Vec<_>>(), ["b", "c", "a"]);
}
}
+22
View File
@@ -0,0 +1,22 @@
//! The skill-triage question, in one place.
//!
//! Measured 2026-09-21 on `eval/skill-triage.json` (20 tasks × 53 skills):
//! this wording — the skill's NAME plus its `when_to_use` line — scored
//! AUROC 0.989 / F1 0.84 on Jev against 0.970 / 0.66 for the when_to_use
//! line alone. The name carries signal. The server's shadow path and the
//! eval both call this, so what is measured is what runs.
use crate::Question;
pub const WORDING: &str = "named";
pub fn question(name: &str, when_to_use: &str) -> Question {
Question::noul(format!(
"This task calls for the skill \"{name}\", which applies when: {when_to_use}"
))
}
/// The probability at which a skill counts as "applies" when the triage is
/// read back. 0.5 is where Jev's best F1 sat (0.84 at 0.51); it is not a
/// gate on anything yet — shadow mode records, it does not select.
pub const APPLIES_AT: f64 = 0.5;