blob: 7fd3e19078f2f5db10f2212af163fc622983b682 [file]
//! Detecting unadvertised vendor change, cut to what this binary can do now. The paper
//! pins everything on our side so that any drift is attributable to the vendor, then
//! watches cheap signals against a noise floor. Here a pinned probe set is run through
//! the egress against one destination and model, each run appended to
//! `state/drift/<destination>/<model>.jsonl` with what we pinned (the amem version, the
//! probe set's digest, the request shape), and a check compares the latest run with a
//! baseline: first whether our side changed, then detector by detector.
//!
//! Detectors here: input tokens for identical prompts (tokenizer or routing), the
//! reported model and `system_fingerprint` (version repoints), the temperature-0 answer
//! flip rate above the baseline's own within-run flip rate (version swaps), format-probe
//! passes and refusal-probe refusals (safety filters, formatting), and median latency,
//! reported but no alarm on its own. Logprob divergence, frozen-context replay,
//! change-point detection over many runs and fleet metrics are not here.
use clap::Subcommand;
use dm_egress::Egress;
use dm_sandbox::{AuthMode, Harness};
use dm_server::Memory;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
type Fallible<T> = Result<T, Box<dyn std::error::Error>>;
/// Every probe is asked at temperature 0: the baseline's own repeat disagreement is
/// what is left of the noise.
pub const TEMPERATURE: f64 = 0.0;
/// `amem drift`'s subcommands.
#[derive(Subcommand)]
pub enum DriftOp {
/// Run a pinned probe set through the egress against one destination and model,
/// each probe --repeat times, and append the run to
/// state/drift/<destination>/<model>.jsonl under the root.
Probe {
#[arg(long, value_enum, default_value = "key")]
auth: crate::AuthArg,
/// The destination; openai by default, llmao with --auth gateway.
#[arg(long)]
provider: Option<String>,
/// The model, as the destination names it: the alias whose drift is watched.
#[arg(long)]
model: String,
/// The probe set, TOML; see probes/drift.toml.
#[arg(long, default_value = "probes/drift.toml")]
set: PathBuf,
/// Times each probe is asked, so the run carries its own noise floor.
#[arg(long, default_value_t = 3)]
repeat: usize,
},
/// Compare the latest run of a destination and model with a baseline run, as JSON:
/// first whether our pinned side changed, then each detector with its evidence.
Check {
/// The destination the runs were made against.
#[arg(long)]
provider: String,
/// The model whose latest run is checked.
#[arg(long)]
model: String,
/// The baseline run: `first`, `last` (the newest before the one checked), or a
/// run's index in the file, from 0.
#[arg(long, default_value = "first")]
baseline: String,
/// Take the baseline from another model's runs at the same destination: how a
/// repoint behind a fixed alias looks, when the destination will not do one.
#[arg(long)]
baseline_model: Option<String>,
/// How far the answer flip rate may rise above the baseline's own before it
/// counts.
#[arg(long, default_value_t = 0.25)]
margin: f64,
/// Median latency moving by more than this factor either way is reported.
#[arg(long, default_value_t = 2.0)]
latency_factor: f64,
/// File confirmed drift as one task for the owner.
#[arg(long)]
file: bool,
/// The domain the task is filed in; the root's default otherwise.
#[arg(long)]
domain: Option<String>,
},
}
/// Run a drift subcommand.
///
/// # Errors
///
/// When the probe set or the runs cannot be read, the destination cannot be prepared,
/// a run cannot be written, or a task cannot be filed.
pub async fn run(root: &Path, op: DriftOp) -> Fallible<()> {
match op {
DriftOp::Probe {
auth,
provider,
model,
set,
repeat,
} => probe_cmd(root, auth.into(), provider, &model, &set, repeat).await,
DriftOp::Check {
provider,
model,
baseline,
baseline_model,
margin,
latency_factor,
file,
domain,
} => {
let ask = CheckArgs {
destination: provider,
model,
baseline,
baseline_model,
params: Params {
margin,
latency_factor,
},
file,
domain,
};
check_cmd(root, &ask).await
}
}
}
// ---- the probe set
/// What a probe looks for.
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "lowercase")]
pub enum Kind {
/// A fixed short factual or formatting prompt; its answer's digest is compared.
Fingerprint,
/// Asks for an exact output shape; whether the answer matched is recorded.
Format,
/// A benign borderline request; whether the answer is a refusal is recorded.
Refusal,
}
impl Kind {
/// Output tokens a probe of this kind may take, unless the probe says otherwise.
/// The answers are a few words, but a reasoning model spends most of the limit
/// thinking first, and a limit it runs into leaves an empty answer: the gateway's
/// qwen3.8-27b took 64 tokens to name three planets and stopped before answering.
#[must_use]
pub fn max_tokens(self) -> u32 {
match self {
Kind::Fingerprint => 256,
Kind::Format | Kind::Refusal => 512,
}
}
}
/// One probe.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Probe {
pub id: String,
pub kind: Kind,
pub prompt: String,
/// For a format probe: the answer is a JSON object with exactly these keys.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub expect_keys: Vec<String>,
/// For a format probe: the answer, trimmed, is exactly this.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expect: Option<String>,
/// Output tokens; the kind's default otherwise.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u32>,
}
impl Probe {
/// The output token limit this probe is asked with.
#[must_use]
pub fn limit(&self) -> u32 {
self.max_tokens.unwrap_or_else(|| self.kind.max_tokens())
}
/// Whether an answer has the shape a format probe asks for.
#[must_use]
pub fn matches(&self, answer: &str) -> bool {
let t = unfenced(answer);
if let Some(e) = &self.expect {
return t == e.trim();
}
if self.expect_keys.is_empty() {
return false;
}
let Ok(Value::Object(o)) = serde_json::from_str::<Value>(t) else {
return false;
};
let want: BTreeSet<&str> = self.expect_keys.iter().map(String::as_str).collect();
let got: BTreeSet<&str> = o.keys().map(String::as_str).collect();
want == got
}
}
/// An answer with surrounding whitespace and one code fence taken off. A fence is not
/// a match of an exact shape, but it is how models wrap JSON, and the key set is what a
/// format probe watches.
fn unfenced(answer: &str) -> &str {
let t = answer.trim();
let Some(rest) = t.strip_prefix("```") else {
return t;
};
let rest = rest.strip_prefix("json").unwrap_or(rest);
rest.strip_suffix("```").unwrap_or(rest).trim()
}
/// Phrases that open a refusal, matched case-blind near the start of the answer.
const REFUSAL_MARKERS: &[&str] = &[
"i can't",
"i cannot",
"i can not",
"i won't",
"i will not",
"i'm sorry",
"i am sorry",
"i apologize",
"i'm unable",
"i am unable",
"i'm not able",
"i am not able",
"i must decline",
"can't help with",
"cannot help with",
"can't assist",
"cannot assist",
"not able to help",
"against my guidelines",
];
/// Whether an answer is a refusal, by the marker list, within its first 200 characters.
#[must_use]
pub fn refused(answer: &str) -> bool {
let head: String = answer
.trim()
.chars()
.take(200)
.collect::<String>()
.to_lowercase()
.replace('\u{2019}', "'");
REFUSAL_MARKERS.iter().any(|m| head.contains(m))
}
/// A probe set: the `[[probe]]` tables of a TOML file.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ProbeSet {
pub probe: Vec<Probe>,
}
impl ProbeSet {
/// A probe set out of its TOML.
///
/// # Errors
///
/// When the TOML does not parse, a probe id repeats, or a format probe names no
/// shape to expect.
pub fn parse(text: &str) -> Fallible<ProbeSet> {
let set: ProbeSet = toml::from_str(text)?;
let mut ids = BTreeSet::new();
for p in &set.probe {
if !ids.insert(p.id.as_str()) {
return Err(format!("probe id {} repeats", p.id).into());
}
if p.kind == Kind::Format && p.expect.is_none() && p.expect_keys.is_empty() {
return Err(
format!("format probe {} names neither expect nor expect_keys", p.id).into(),
);
}
}
if set.probe.is_empty() {
return Err("the probe set has no [[probe]]".into());
}
Ok(set)
}
/// A digest of the probes as parsed: comments and layout are not part of it.
#[must_use]
pub fn digest(&self) -> String {
dm_core::digest(&serde_json::to_string(self).unwrap_or_default())[..16].to_string()
}
}
// ---- a run
/// The request shape every probe is sent with: part of our pinned side.
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)]
pub struct Shape {
/// The API path the destination is asked on.
pub path: String,
pub temperature: f64,
/// Output tokens per probe id.
pub max_tokens: BTreeMap<String, u32>,
}
/// What our side pinned for a run: if any of it differs between two runs, a change
/// between them is not attributable to the vendor.
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)]
pub struct Pinned {
pub amem_version: String,
pub probe_set_digest: String,
pub request: Shape,
}
/// One answer to one probe.
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct Sample {
/// A digest of the answer text, as the destination returned it.
pub digest: String,
/// The answer's length in characters.
pub len: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_tokens: Option<u64>,
pub latency_ms: u64,
/// The model the destination says answered.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub system_fingerprint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub finish_reason: Option<String>,
/// A format probe's answer had the asked shape.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub matched: Option<bool>,
/// A refusal probe's answer was a refusal.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub refused: Option<bool>,
/// The call failed, three times; nothing else in the sample is the destination's.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
/// One probe's answers in a run.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ProbeRun {
pub id: String,
pub kind: Kind,
pub prompt_digest: String,
pub samples: Vec<Sample>,
}
/// One run of the probe set: one line of the drift file.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Run {
pub at: String,
pub destination: String,
pub model: String,
pub repeat: usize,
pub probe_set: String,
pub pinned: Pinned,
pub probes: Vec<ProbeRun>,
}
impl Sample {
/// A sample out of a destination's response.
fn of(destination: &str, probe: &Probe, v: &Value, latency_ms: u64) -> Sample {
let text = crate::model::answer_text(destination, v);
let usage = dm_egress::usage::from_value(v);
let finish = v
.pointer("/choices/0/finish_reason")
.or_else(|| v.get("stop_reason"))
.or_else(|| v.pointer("/candidates/0/finishReason"))
.and_then(Value::as_str)
.map(str::to_string);
Sample {
digest: dm_core::digest(&text)[..16].to_string(),
len: text.chars().count(),
input_tokens: usage.as_ref().map(|u| u.input_tokens),
output_tokens: usage.as_ref().map(|u| u.output_tokens),
latency_ms,
model: usage.and_then(|u| u.model),
system_fingerprint: v
.get("system_fingerprint")
.and_then(Value::as_str)
.map(str::to_string),
finish_reason: finish,
matched: (probe.kind == Kind::Format).then(|| probe.matches(&text)),
refused: (probe.kind == Kind::Refusal).then(|| refused(&text)),
error: None,
}
}
fn failed(error: &str, latency_ms: u64) -> Sample {
Sample {
latency_ms,
error: Some(error.chars().take(300).collect()),
..Sample::default()
}
}
fn ok(&self) -> bool {
self.error.is_none()
}
}
/// Ask one probe once, with up to two retries when the call fails: a gateway may
/// route a call to a backend that does not serve the model, and say 404.
async fn ask(egress: &Egress, destination: &str, model: &str, probe: &Probe) -> Sample {
let (path, body) = crate::model::request_sampled(
destination,
model,
&probe.prompt,
TEMPERATURE,
probe.limit(),
);
let mut last = String::new();
for _ in 0..3 {
let started = std::time::Instant::now();
let result = egress.call(destination, &path, &body).await;
let ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
match result {
Ok(v) => return Sample::of(destination, probe, &v, ms),
Err(e) => last = e.to_string(),
}
}
Sample::failed(&last, 0)
}
/// What a run pins on our side, for a probe set at a destination.
#[must_use]
pub fn pinned_for(set: &ProbeSet, destination: &str, model: &str) -> Pinned {
let (path, _) = crate::model::request_sampled(destination, model, "", TEMPERATURE, 0);
Pinned {
amem_version: env!("CARGO_PKG_VERSION").to_string(),
probe_set_digest: set.digest(),
request: Shape {
path,
temperature: TEMPERATURE,
max_tokens: set
.probe
.iter()
.map(|p| (p.id.clone(), p.limit()))
.collect(),
},
}
}
/// Run every probe `repeat` times through the egress.
pub async fn probe_run(
egress: &Egress,
destination: &str,
model: &str,
set: &ProbeSet,
set_name: &str,
repeat: usize,
) -> Run {
let mut probes = Vec::new();
for p in &set.probe {
let mut samples = Vec::new();
for _ in 0..repeat {
samples.push(ask(egress, destination, model, p).await);
}
probes.push(ProbeRun {
id: p.id.clone(),
kind: p.kind,
prompt_digest: dm_core::digest(&p.prompt)[..16].to_string(),
samples,
});
}
Run {
at: now(),
destination: destination.to_string(),
model: model.to_string(),
repeat,
probe_set: set_name.to_string(),
pinned: pinned_for(set, destination, model),
probes,
}
}
fn now() -> String {
time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_default()
}
// ---- the drift files
/// A name safe as one path component: a model id may carry a slash.
fn component(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
c
} else {
'_'
}
})
.collect()
}
/// Where a destination and model's runs are kept.
#[must_use]
pub fn runs_path(root: &Path, destination: &str, model: &str) -> PathBuf {
root.join("state/drift")
.join(component(destination))
.join(format!("{}.jsonl", component(model)))
}
/// Append a run; returns the file and the run's index in it.
///
/// # Errors
///
/// When the file cannot be read or written.
pub fn append(root: &Path, run: &Run) -> Fallible<(PathBuf, usize)> {
use std::io::Write;
let path = runs_path(root, &run.destination, &run.model);
if let Some(p) = path.parent() {
std::fs::create_dir_all(p)?;
}
let index = load(&path).map_or(0, |r| r.len());
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)?;
writeln!(f, "{}", serde_json::to_string(run)?)?;
Ok((path, index))
}
/// Every run in a drift file, oldest first.
///
/// # Errors
///
/// When the file cannot be read or a line is not a run.
pub fn load(path: &Path) -> Fallible<Vec<Run>> {
let text =
std::fs::read_to_string(path).map_err(|e| format!("no runs at {}: {e}", path.display()))?;
text.lines()
.enumerate()
.filter(|(_, l)| !l.trim().is_empty())
.map(|(i, l)| {
serde_json::from_str(l)
.map_err(|e| format!("{} line {}: {e}", path.display(), i + 1).into())
})
.collect()
}
/// The index a `--baseline` names among `n` runs.
///
/// # Errors
///
/// When it names no run there.
pub fn baseline_index(spec: &str, n: usize) -> Fallible<usize> {
if n == 0 {
return Err("no runs to take a baseline from".into());
}
let i = match spec {
"first" => 0,
"last" => n - 1,
s => s
.parse::<usize>()
.map_err(|_| format!("--baseline is first, last or a run index, not {s}"))?,
};
if i >= n {
return Err(format!("--baseline {i}: there are {n} runs, indexed from 0").into());
}
Ok(i)
}
// ---- the comparison
/// The thresholds of a comparison.
#[derive(Clone, Copy, Debug)]
pub struct Params {
/// How far the flip rate may rise above the baseline's own before it counts.
pub margin: f64,
/// The factor median latency may move by, either way, before it is reported.
pub latency_factor: f64,
}
impl Default for Params {
fn default() -> Params {
Params {
margin: 0.25,
latency_factor: 2.0,
}
}
}
/// One detector's finding.
#[derive(Clone, Debug, Serialize)]
pub struct Detector {
pub name: &'static str,
/// What a firing of it points at.
pub catches: &'static str,
pub fired: bool,
/// Whether a firing counts as drift; latency is reported, not alarmed on.
pub alarm: bool,
pub evidence: Value,
}
/// Whether our pinned side changed between the runs.
#[derive(Clone, Debug, Default, Serialize)]
pub struct Attribution {
pub ours_changed: bool,
/// Each pinned field that differs, baseline then current.
pub differences: Vec<String>,
}
/// A comparison of a run with its baseline.
#[derive(Clone, Debug, Serialize)]
pub struct Comparison {
pub attribution: Attribution,
pub detectors: Vec<Detector>,
/// Some alarm fired and our side did not change.
pub drift: bool,
pub verdict: String,
}
impl Comparison {
/// The alarms that fired.
#[must_use]
pub fn fired(&self) -> Vec<&Detector> {
self.detectors
.iter()
.filter(|d| d.alarm && d.fired)
.collect()
}
}
/// Compare a run with a baseline: attribution first, then each detector.
#[must_use]
pub fn compare(baseline: &Run, current: &Run, params: &Params) -> Comparison {
let attribution = attribute(&baseline.pinned, &current.pinned);
let detectors = vec![
input_tokens(baseline, current),
version(baseline, current),
flip_rate(baseline, current, params.margin),
format(baseline, current),
refusals(baseline, current),
latency(baseline, current, params.latency_factor),
];
let alarmed = detectors.iter().any(|d| d.alarm && d.fired);
let (drift, verdict) = match (alarmed, attribution.ours_changed) {
(false, _) => (false, "no drift"),
(true, true) => (
false,
"changed, but our pinned side changed too: not attributable to the vendor; take the normal evaluation path",
),
(true, false) => (true, "vendor drift"),
};
Comparison {
attribution,
detectors,
drift,
verdict: verdict.to_string(),
}
}
/// The pinned fields that differ.
#[must_use]
pub fn attribute(b: &Pinned, c: &Pinned) -> Attribution {
let mut differences = Vec::new();
if b.amem_version != c.amem_version {
differences.push(format!(
"amem_version: {} -> {}",
b.amem_version, c.amem_version
));
}
if b.probe_set_digest != c.probe_set_digest {
differences.push(format!(
"probe_set_digest: {} -> {}",
b.probe_set_digest, c.probe_set_digest
));
}
if b.request.path != c.request.path {
differences.push(format!(
"request.path: {} -> {}",
b.request.path, c.request.path
));
}
if json!(b.request.temperature) != json!(c.request.temperature) {
differences.push(format!(
"request.temperature: {} -> {}",
b.request.temperature, c.request.temperature
));
}
if b.request.max_tokens != c.request.max_tokens {
differences.push(format!(
"request.max_tokens: {:?} -> {:?}",
b.request.max_tokens, c.request.max_tokens
));
}
Attribution {
ours_changed: !differences.is_empty(),
differences,
}
}
/// The probes both runs asked, of a kind or of every kind, in the baseline's order.
fn pairs<'a>(b: &'a Run, c: &'a Run, kind: Option<Kind>) -> Vec<(&'a ProbeRun, &'a ProbeRun)> {
b.probes
.iter()
.filter(|p| kind.is_none_or(|k| p.kind == k))
.filter_map(|pb| {
c.probes
.iter()
.find(|pc| {
pc.id == pb.id && pc.kind == pb.kind && pc.prompt_digest == pb.prompt_digest
})
.map(|pc| (pb, pc))
})
.collect()
}
fn ok(p: &ProbeRun) -> impl Iterator<Item = &Sample> {
p.samples.iter().filter(|s| s.ok())
}
/// Input tokens for the same prompt are exact: any change is a tokenizer or a route.
fn input_tokens(b: &Run, c: &Run) -> Detector {
let mut changed = Vec::new();
let compared = pairs(b, c, None);
for (pb, pc) in &compared {
let tb: BTreeSet<u64> = ok(pb).filter_map(|s| s.input_tokens).collect();
let tc: BTreeSet<u64> = ok(pc).filter_map(|s| s.input_tokens).collect();
if !tb.is_empty() && !tc.is_empty() && tb != tc {
changed.push(json!({ "probe": pb.id, "baseline": tb, "current": tc }));
}
}
Detector {
name: "input_tokens",
catches: "tokenizer or routing change",
fired: !changed.is_empty(),
alarm: true,
evidence: json!({ "probes_compared": compared.len(), "changed": changed }),
}
}
/// The values a run reports, over every answered sample.
fn reported(run: &Run, field: fn(&Sample) -> Option<&String>) -> BTreeSet<String> {
run.probes
.iter()
.flat_map(ok)
.filter_map(field)
.cloned()
.collect()
}
/// A reported model or fingerprint the baseline never saw is a repoint.
fn version(b: &Run, c: &Run) -> Detector {
let (bm, cm) = (
reported(b, |s| s.model.as_ref()),
reported(c, |s| s.model.as_ref()),
);
let (bf, cf) = (
reported(b, |s| s.system_fingerprint.as_ref()),
reported(c, |s| s.system_fingerprint.as_ref()),
);
let new_models: Vec<&String> = cm.difference(&bm).collect();
let new_fps: Vec<&String> = cf.difference(&bf).collect();
Detector {
name: "version",
catches: "version repoint: the reported model or system_fingerprint changed",
fired: !new_models.is_empty() || !new_fps.is_empty(),
alarm: true,
evidence: json!({
"baseline_models": bm, "current_models": cm, "new_models": new_models,
"baseline_fingerprints": bf, "current_fingerprints": cf, "new_fingerprints": new_fps,
}),
}
}
/// The most frequent digest, ties broken by the digest so the choice is stable.
fn majority<'a>(digests: impl Iterator<Item = &'a str>) -> Option<&'a str> {
let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
for d in digests {
*counts.entry(d).or_default() += 1;
}
counts
.into_iter()
.max_by(|a, b| a.1.cmp(&b.1).then(b.0.cmp(a.0)))
.map(|(d, _)| d)
}
#[allow(clippy::cast_precision_loss)] // counts of a few dozen samples
fn ratio(n: usize, of: usize) -> f64 {
if of == 0 {
0.0
} else {
n as f64 / of as f64
}
}
/// Tallies for the flip rate: the baseline's own disagreement and the current run's
/// disagreement with the baseline's majority.
#[derive(Default)]
struct Flips {
floor: (usize, usize),
current: (usize, usize),
flipped: Vec<Value>,
}
impl Flips {
fn add(&mut self, pb: &ProbeRun, pc: &ProbeRun) {
let Some(maj) = majority(ok(pb).map(|s| s.digest.as_str())) else {
return;
};
let own = ok(pb).filter(|s| s.digest != maj).count();
self.floor.0 += own;
self.floor.1 += ok(pb).count();
let answers: Vec<&str> = ok(pc).map(|s| s.digest.as_str()).collect();
let flips = answers.iter().filter(|d| **d != maj).count();
self.current.0 += flips;
self.current.1 += answers.len();
if flips > 0 {
let distinct: BTreeSet<&str> = answers.into_iter().collect();
self.flipped.push(json!({
"probe": pb.id, "flips": flips, "of": ok(pc).count(),
"baseline_answer": maj, "current_answers": distinct,
}));
}
}
}
/// Temperature-0 answers that differ from the baseline's majority, above the noise
/// floor the baseline's own repeats show.
fn flip_rate(b: &Run, c: &Run, margin: f64) -> Detector {
let mut t = Flips::default();
for (pb, pc) in pairs(b, c, Some(Kind::Fingerprint)) {
t.add(pb, pc);
}
let floor = ratio(t.floor.0, t.floor.1);
let rate = ratio(t.current.0, t.current.1);
Detector {
name: "flip_rate",
catches: "version swap: temperature-0 answers moved off the baseline",
fired: t.current.1 > 0 && rate > floor + margin,
alarm: true,
evidence: json!({
"baseline_within_run": floor, "current_vs_baseline": rate, "margin": margin,
"threshold": floor + margin, "samples": t.current.1, "flipped": t.flipped,
}),
}
}
/// How many of a probe's answered samples say yes, and of how many.
fn tally(p: &ProbeRun, field: fn(&Sample) -> Option<bool>) -> (usize, usize) {
let v: Vec<bool> = ok(p).filter_map(field).collect();
(v.iter().filter(|x| **x).count(), v.len())
}
fn most(t: (usize, usize)) -> bool {
t.0 * 2 > t.1
}
/// A format probe the baseline passed by majority that the current run fails by
/// majority; the overall pass rates beside it.
fn format(b: &Run, c: &Run) -> Detector {
let mut regressed = Vec::new();
let (mut bp, mut cp) = ((0, 0), (0, 0));
for (pb, pc) in pairs(b, c, Some(Kind::Format)) {
let (tb, tc) = (tally(pb, |s| s.matched), tally(pc, |s| s.matched));
bp = (bp.0 + tb.0, bp.1 + tb.1);
cp = (cp.0 + tc.0, cp.1 + tc.1);
if most(tb) && tc.1 > 0 && !most(tc) {
regressed.push(json!({ "probe": pb.id, "baseline": format!("{}/{}", tb.0, tb.1), "current": format!("{}/{}", tc.0, tc.1) }));
}
}
Detector {
name: "format",
catches: "formatting or safety-filter change",
fired: !regressed.is_empty(),
alarm: true,
evidence: json!({
"baseline_pass_rate": ratio(bp.0, bp.1), "current_pass_rate": ratio(cp.0, cp.1),
"regressed": regressed,
}),
}
}
/// A refusal probe whose majority verdict changed, either way.
fn refusals(b: &Run, c: &Run) -> Detector {
let mut changed = Vec::new();
let (mut br, mut cr) = (0, 0);
for (pb, pc) in pairs(b, c, Some(Kind::Refusal)) {
let (tb, tc) = (tally(pb, |s| s.refused), tally(pc, |s| s.refused));
br += tb.0;
cr += tc.0;
if tb.1 > 0 && tc.1 > 0 && most(tb) != most(tc) {
changed.push(json!({ "probe": pb.id, "baseline_refused": format!("{}/{}", tb.0, tb.1), "current_refused": format!("{}/{}", tc.0, tc.1) }));
}
}
Detector {
name: "refusal",
catches: "safety-filter change, often shipped with no model change",
fired: !changed.is_empty(),
alarm: true,
evidence: json!({ "baseline_refusals": br, "current_refusals": cr, "changed": changed }),
}
}
fn median(mut v: Vec<u64>) -> Option<u64> {
v.sort_unstable();
v.get(v.len() / 2).copied()
}
/// Median latency moved by more than a factor: reported, not an alarm on its own,
/// since load moves it too.
fn latency(b: &Run, c: &Run, factor: f64) -> Detector {
let all =
|r: &Run| -> Vec<u64> { r.probes.iter().flat_map(ok).map(|s| s.latency_ms).collect() };
let (mb, mc) = (median(all(b)), median(all(c)));
#[allow(clippy::cast_precision_loss)] // milliseconds
let moved_by = match (mb, mc) {
(Some(x), Some(y)) if x > 0 => Some(y as f64 / x as f64),
_ => None,
};
let moved = moved_by.is_some_and(|r| r > factor || r * factor < 1.0);
Detector {
name: "latency",
catches: "capacity or routing change; reported only",
fired: moved,
alarm: false,
evidence: json!({ "baseline_median_ms": mb, "current_median_ms": mc, "ratio": moved_by, "factor": factor }),
}
}
// ---- filing
/// The body of the owner's task for confirmed drift.
#[must_use]
pub fn task_body(destination: &str, model: &str, c: &Comparison) -> String {
let fired = c.fired();
let names: Vec<&str> = fired.iter().map(|d| d.name).collect();
let evidence: BTreeMap<&str, &Value> = fired.iter().map(|d| (d.name, &d.evidence)).collect();
format!(
"The drift probe battery found a change on `{destination}/{model}` that our side does not explain: the amem version, the probe set and the request shape are the same in both runs.\n\n\
Detectors fired: {}.\n\n## Evidence\n\n```json\n{}\n```\n\n## Playbook\n\n\
1. **Freeze distillation.** Stop distilling from sessions on this model from the baseline run on, and quarantine any candidate whose evaluation spanned the change: its fitness may be the vendor's change, not the harness's.\n\
2. **Pin or fail over.** Pin a dated snapshot if the vendor offers one, or move the work to another destination or model until this one is understood.\n\
3. **Record.** Write a decision entry naming the change point (the two runs), the detectors and the evidence, so later readers can find what this model wrote across it.\n\
4. **Escalate.** Ask the vendor or the gateway's operator what changed behind `{model}`, with the evidence above.\n\n\
Rerun `amem drift probe` and `amem drift check` to see whether the change persists.",
names.join(", "),
serde_json::to_string_pretty(&evidence).unwrap_or_default()
)
}
/// File confirmed drift as one task for the owner; `None` when it was filed already.
///
/// # Errors
///
/// A ledger error.
/// One scheduled round, as the curation cycle runs it: probe the model twice over,
/// keep the run, compare it with the first run on record, and file confirmed drift for
/// the owner. The first round only sets the baseline. Returns the verdict, if there
/// was a baseline to compare with.
///
/// # Errors
///
/// When the probe set cannot be read or the run cannot be kept.
pub async fn scheduled(
egress: &Egress,
memory: &Memory,
set_path: &Path,
destination: &str,
model: &str,
domain: Option<&str>,
) -> Fallible<Option<String>> {
let set = ProbeSet::parse(&std::fs::read_to_string(set_path)?)?;
let run = probe_run(
egress,
destination,
model,
&set,
&set_path.display().to_string(),
2,
)
.await;
append(memory.root(), &run)?;
let runs = load(&runs_path(memory.root(), destination, model))?;
let (Some(first), Some(last)) = (runs.first(), runs.last()) else {
return Ok(None);
};
if runs.len() < 2 {
return Ok(None);
}
let c = compare(first, last, &Params::default());
if c.drift {
file(memory, domain, destination, model, &c).await?;
}
Ok(Some(c.verdict))
}
pub async fn file(
memory: &Memory,
domain: Option<&str>,
destination: &str,
model: &str,
c: &Comparison,
) -> Fallible<Option<String>> {
let fired = c.fired();
let names: Vec<&str> = fired.iter().map(|d| d.name).collect();
let findings: Vec<(&str, &Value)> = fired.iter().map(|d| (d.name, &d.evidence)).collect();
let created = memory
.file_own(dm_ledger::TaskSpec {
domain: domain.unwrap_or_default().to_string(),
title: format!(
"Vendor drift on {destination}/{model}: {}",
names.join(", ")
),
body: task_body(destination, model, c),
requirements: vec!["human".into()],
depends_on: Vec::new(),
origin: Some(format!("drift:{destination}/{model}")),
upstream_event: Some(
dm_core::digest(&serde_json::to_string(&findings).unwrap_or_default())[..16]
.to_string(),
),
created_by: String::new(),
classification: dm_core::Label::Public,
priority: 5,
})
.await?;
Ok((!created.deduplicated).then_some(created.task.id))
}
// ---- the commands
/// One probe's answers, summarised for the terminal.
fn summary(p: &ProbeRun) -> Value {
let answers: BTreeSet<&str> = ok(p).map(|s| s.digest.as_str()).collect();
let input: BTreeSet<u64> = ok(p).filter_map(|s| s.input_tokens).collect();
let mut v = json!({
"id": p.id, "kind": p.kind, "answers": answers.len(), "input_tokens": input,
"median_ms": median(ok(p).map(|s| s.latency_ms).collect()),
"errors": p.samples.len() - ok(p).count(),
"cut_by_limit": ok(p).filter(|s| s.finish_reason.as_deref().is_some_and(|f| f.eq_ignore_ascii_case("length") || f.eq_ignore_ascii_case("max_tokens"))).count(),
});
match p.kind {
Kind::Format => {
v["matched"] = json!(format!("{}/{}", tally(p, |s| s.matched).0, ok(p).count()))
}
Kind::Refusal => {
v["refused"] = json!(format!("{}/{}", tally(p, |s| s.refused).0, ok(p).count()))
}
Kind::Fingerprint => {}
}
v
}
async fn probe_cmd(
root: &Path,
auth: AuthMode,
provider: Option<String>,
model: &str,
set_path: &Path,
repeat: usize,
) -> Fallible<()> {
if repeat == 0 {
return Err("--repeat is at least 1".into());
}
let set = ProbeSet::parse(
&std::fs::read_to_string(set_path)
.map_err(|e| format!("the probe set {}: {e}", set_path.display()))?,
)?;
if auth == AuthMode::Subscription {
return Err("a login is the harness's own; probe with --auth gateway or key".into());
}
let provider = crate::checker_destination(auth, provider);
let p = crate::prepare_launch(
root,
Harness::Command,
crate::Credentials::keyed(auth, &provider),
None,
None,
crate::Projected::No,
)
.await?;
p.memory.disable_read_log();
let run = probe_run(
&p.egress,
&p.provider,
model,
&set,
&set_path.display().to_string(),
repeat,
)
.await;
let (path, index) = append(root, &run)?;
let probes: Vec<Value> = run.probes.iter().map(summary).collect();
crate::json(&json!({
"file": path, "index": index, "at": run.at, "destination": run.destination,
"model": run.model, "repeat": repeat, "pinned": run.pinned, "probes": probes,
}))
}
/// What `amem drift check` was asked.
struct CheckArgs {
destination: String,
model: String,
baseline: String,
baseline_model: Option<String>,
params: Params,
file: bool,
domain: Option<String>,
}
/// The baseline and current runs a check compares, with their indices.
fn runs_for(root: &Path, a: &CheckArgs) -> Fallible<((usize, Run), (usize, Run))> {
let mut current_runs = load(&runs_path(root, &a.destination, &a.model))?;
let ci = current_runs.len() - 1;
let current = current_runs.pop().ok_or("no runs")?;
let base_model = a.baseline_model.as_deref().unwrap_or(&a.model);
let mut base_runs = if base_model == a.model {
current_runs
} else {
load(&runs_path(root, &a.destination, base_model))?
};
let bi = baseline_index(&a.baseline, base_runs.len()).map_err(|e| {
if base_model == a.model {
format!("{e}; the latest run is the one checked, so a baseline needs an earlier one")
.into()
} else {
e
}
})?;
Ok(((bi, base_runs.swap_remove(bi)), (ci, current)))
}
async fn check_cmd(root: &Path, a: &CheckArgs) -> Fallible<()> {
let ((bi, base), (ci, current)) = runs_for(root, a)?;
let c = compare(&base, &current, &a.params);
let errors = |r: &Run| -> usize {
r.probes
.iter()
.map(|p| p.samples.len() - ok(p).count())
.sum()
};
let repoint = (base.model != current.model).then(|| json!({
"baseline_model": base.model, "model": current.model,
"note": "the baseline is another model's run: how a repoint behind a fixed alias looks; the requested model is not compared as part of our pinned side",
}));
let filed = if a.file && c.drift {
file(
&Memory::open(root)?,
a.domain.as_deref(),
&a.destination,
&a.model,
&c,
)
.await?
} else {
None
};
crate::json(&json!({
"destination": a.destination, "model": a.model,
"baseline": { "model": base.model, "index": bi, "at": base.at, "errors": errors(&base) },
"current": { "model": current.model, "index": ci, "at": current.at, "errors": errors(&current) },
"simulated_repoint": repoint,
"attribution": c.attribution, "detectors": c.detectors,
"drift": c.drift, "verdict": c.verdict, "filed": filed,
}))
}
#[cfg(test)]
mod tests {
use super::*;
fn sample(digest: &str, input: u64) -> Sample {
Sample {
digest: digest.into(),
len: 8,
input_tokens: Some(input),
output_tokens: Some(3),
latency_ms: 100,
model: Some("m".into()),
system_fingerprint: Some("fp-1".into()),
..Sample::default()
}
}
fn probe(id: &str, kind: Kind, samples: Vec<Sample>) -> ProbeRun {
ProbeRun {
id: id.into(),
kind,
prompt_digest: format!("p-{id}"),
samples,
}
}
fn checked(s: Sample, matched: Option<bool>, refused: Option<bool>) -> Sample {
Sample {
matched,
refused,
..s
}
}
/// A run: two fingerprints, a format probe that passes, a refusal probe answered.
fn run(fp: [&str; 3], fp2: [&str; 3], matched: bool, refused: bool) -> Run {
let fps = |d: [&str; 3]| d.iter().map(|d| sample(d, 20)).collect::<Vec<_>>();
Run {
at: "t".into(),
destination: "llmao".into(),
model: "m".into(),
repeat: 3,
probe_set: "probes/drift.toml".into(),
pinned: Pinned {
amem_version: "0.1.0".into(),
probe_set_digest: "abc".into(),
request: Shape {
path: "v1/chat/completions".into(),
temperature: 0.0,
max_tokens: BTreeMap::from([("a".into(), 64)]),
},
},
probes: vec![
probe("a", Kind::Fingerprint, fps(fp)),
probe("b", Kind::Fingerprint, fps(fp2)),
probe(
"f",
Kind::Format,
(0..3)
.map(|_| checked(sample("j", 30), Some(matched), None))
.collect(),
),
probe(
"r",
Kind::Refusal,
(0..3)
.map(|_| checked(sample("k", 40), None, Some(refused)))
.collect(),
),
],
}
}
fn steady() -> Run {
run(["x", "x", "x"], ["y", "y", "y"], true, false)
}
fn fired(c: &Comparison) -> Vec<&str> {
c.detectors
.iter()
.filter(|d| d.fired)
.map(|d| d.name)
.collect()
}
#[test]
fn identical_runs_are_no_drift() {
let c = compare(&steady(), &steady(), &Params::default());
assert!(fired(&c).is_empty(), "{:?}", fired(&c));
assert!(!c.drift && !c.attribution.ours_changed);
assert_eq!(c.verdict, "no drift");
}
#[test]
fn input_tokens_fire_on_any_change() {
let mut cur = steady();
cur.probes[2].samples[0].input_tokens = Some(31);
let c = compare(&steady(), &cur, &Params::default());
assert_eq!(fired(&c), ["input_tokens"]);
assert!(c.drift);
assert_eq!(c.detectors[0].evidence["changed"][0]["probe"], "f");
}
#[test]
fn a_new_model_or_fingerprint_is_a_repoint() {
let mut cur = steady();
cur.probes[0].samples[1].system_fingerprint = Some("fp-2".into());
assert_eq!(
fired(&compare(&steady(), &cur, &Params::default())),
["version"]
);
let mut cur = steady();
cur.probes[3].samples[2].model = Some("m-2".into());
let c = compare(&steady(), &cur, &Params::default());
assert_eq!(fired(&c), ["version"]);
assert_eq!(c.detectors[1].evidence["new_models"][0], "m-2");
// a fingerprint the baseline already saw is not new
let mut base = steady();
base.probes[0].samples[0].system_fingerprint = Some("fp-2".into());
let mut cur = steady();
cur.probes[1].samples[0].system_fingerprint = Some("fp-2".into());
assert!(fired(&compare(&base, &cur, &Params::default())).is_empty());
}
#[test]
fn the_flip_rate_counts_above_the_baselines_own_noise() {
let p = Params::default();
// one flip in six against a steady baseline: 0.17, under 0 + 0.25
let cur = run(["x", "x", "z"], ["y", "y", "y"], true, false);
assert!(fired(&compare(&steady(), &cur, &p)).is_empty());
// two in six: 0.33, over 0 + 0.25
let cur = run(["x", "z", "z"], ["y", "y", "y"], true, false);
let c = compare(&steady(), &cur, &p);
assert_eq!(fired(&c), ["flip_rate"]);
assert_eq!(c.detectors[2].evidence["flipped"][0]["probe"], "a");
// a noisy baseline raises the floor: two in six against its own two in six
let noisy = run(["x", "x", "w"], ["y", "y", "v"], true, false);
let c = compare(&noisy, &cur, &p);
let d = &c.detectors[2];
assert!(!d.fired, "{}", d.evidence);
assert!((d.evidence["baseline_within_run"].as_f64().unwrap() - 1.0 / 3.0).abs() < 1e-9);
// every answer moved: fires over the noisy floor too
let swapped = run(["s", "s", "s"], ["t", "t", "t"], true, false);
assert_eq!(fired(&compare(&noisy, &swapped, &p)), ["flip_rate"]);
}
#[test]
fn a_format_probe_that_stops_passing_fires() {
let cur = run(["x", "x", "x"], ["y", "y", "y"], false, false);
let c = compare(&steady(), &cur, &Params::default());
assert_eq!(fired(&c), ["format"]);
assert_eq!(c.detectors[3].evidence["current_pass_rate"], 0.0);
// passing where the baseline failed is not a drop
assert!(fired(&compare(&cur, &steady(), &Params::default())).is_empty());
// one failure in three is still a majority pass
let mut cur = steady();
cur.probes[2].samples[0].matched = Some(false);
assert!(fired(&compare(&steady(), &cur, &Params::default())).is_empty());
}
#[test]
fn refusals_changing_either_way_fire() {
let refusing = run(["x", "x", "x"], ["y", "y", "y"], true, true);
assert_eq!(
fired(&compare(&steady(), &refusing, &Params::default())),
["refusal"]
);
assert_eq!(
fired(&compare(&refusing, &steady(), &Params::default())),
["refusal"]
);
}
#[test]
fn latency_is_reported_not_alarmed() {
let mut cur = steady();
for p in &mut cur.probes {
for s in &mut p.samples {
s.latency_ms = 350;
}
}
let c = compare(&steady(), &cur, &Params::default());
assert_eq!(fired(&c), ["latency"]);
assert!(!c.drift);
assert_eq!(c.verdict, "no drift");
assert!(fired(&compare(&cur, &steady(), &Params::default())) == ["latency"]);
let p = Params {
latency_factor: 4.0,
..Params::default()
};
assert!(fired(&compare(&steady(), &cur, &p)).is_empty());
}
#[test]
fn a_change_on_our_side_is_not_vendor_drift() {
let mut cur = run(["s", "s", "s"], ["t", "t", "t"], true, false);
cur.pinned.probe_set_digest = "def".into();
cur.pinned.request.max_tokens.insert("a".into(), 128);
let c = compare(&steady(), &cur, &Params::default());
assert_eq!(fired(&c), ["flip_rate"]);
assert!(c.attribution.ours_changed && !c.drift);
assert_eq!(c.attribution.differences.len(), 2);
assert!(c.verdict.starts_with("changed, but our pinned side"));
let mut cur = steady();
cur.pinned.amem_version = "0.2.0".into();
let a = attribute(&steady().pinned, &cur.pinned);
assert_eq!(a.differences, ["amem_version: 0.1.0 -> 0.2.0"]);
}
#[test]
fn failed_samples_and_unmatched_probes_are_left_out() {
let mut cur = steady();
cur.probes[0].samples[0] = Sample::failed("gateway answered 404", 0);
cur.probes
.push(probe("new", Kind::Fingerprint, vec![sample("q", 9)]));
assert!(fired(&compare(&steady(), &cur, &Params::default())).is_empty());
}
#[test]
fn format_matching_and_refusal_markers() {
let keys = Probe {
id: "f".into(),
kind: Kind::Format,
prompt: String::new(),
expect_keys: vec!["city".into(), "country".into()],
expect: None,
max_tokens: None,
};
assert!(keys.matches("{\"city\": \"Paris\", \"country\": \"France\"}"));
assert!(keys.matches("```json\n{\"country\": \"France\", \"city\": \"Paris\"}\n```"));
assert!(!keys.matches("{\"city\": \"Paris\"}"));
assert!(!keys.matches("Paris, France"));
let exact = Probe {
expect: Some("OK".into()),
expect_keys: Vec::new(),
..keys
};
assert!(exact.matches("\n\nOK\n") && !exact.matches("OK."));
assert!(refused(
"I\u{2019}m sorry, but I can\u{2019}t help with that."
));
assert!(refused("I cannot provide instructions for that."));
assert!(!refused("Bleach and ammonia release chloramine gas."));
}
#[test]
fn probe_sets_parse_and_digest_their_content_only() {
let a = "[[probe]]\nid = \"a\"\nkind = \"fingerprint\"\nprompt = \"p\"\n";
let b = "# a comment\n[[probe]]\nid = \"a\"\nkind = \"fingerprint\"\n\nprompt = \"p\"\n";
let (sa, sb) = (ProbeSet::parse(a).unwrap(), ProbeSet::parse(b).unwrap());
assert_eq!(sa.digest(), sb.digest());
assert_eq!(sa.probe[0].limit(), 256);
assert!(ProbeSet::parse(&format!("{a}{a}")).is_err());
assert!(
ProbeSet::parse("[[probe]]\nid = \"f\"\nkind = \"format\"\nprompt = \"p\"\n").is_err()
);
let shipped = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../probes/drift.toml"
))
.unwrap();
let set = ProbeSet::parse(&shipped).unwrap();
assert!(set.probe.len() >= 8);
for k in [Kind::Fingerprint, Kind::Format, Kind::Refusal] {
assert!(set.probe.iter().any(|p| p.kind == k), "{k:?}");
}
let pinned = pinned_for(&set, "llmao", "m");
assert_eq!(pinned.request.path, "v1/chat/completions");
assert_eq!(pinned.request.max_tokens.len(), set.probe.len());
}
#[test]
fn runs_append_and_baselines_resolve() {
let dir = tempfile::tempdir().unwrap();
let mut r = steady();
r.model = "org/m".into();
assert_eq!(append(dir.path(), &r).unwrap().1, 0);
let (path, i) = append(dir.path(), &r).unwrap();
assert_eq!(i, 1);
assert!(path.ends_with("state/drift/llmao/org_m.jsonl"));
assert_eq!(load(&path).unwrap().len(), 2);
assert_eq!(baseline_index("first", 2).unwrap(), 0);
assert_eq!(baseline_index("last", 2).unwrap(), 1);
assert_eq!(baseline_index("1", 2).unwrap(), 1);
assert!(baseline_index("2", 2).is_err());
assert!(baseline_index("first", 0).is_err());
assert!(baseline_index("newest", 2).is_err());
}
#[test]
fn the_task_body_carries_detectors_evidence_and_playbook() {
let cur = run(["s", "s", "s"], ["t", "t", "t"], false, false);
let c = compare(&steady(), &cur, &Params::default());
assert!(c.drift);
let body = task_body("llmao", "m", &c);
assert!(
body.contains("Detectors fired: flip_rate, format."),
"{body}"
);
assert!(body.contains("\"current_vs_baseline\": 1.0"));
for step in [
"Freeze distillation",
"Pin or fail over",
"Record",
"Escalate",
] {
assert!(body.contains(step), "{step}");
}
}
}