use crate::verifier::VerifierVerdict;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FailureClass {
Precondition,
Policy,
ToolError,
Timeout,
Budget,
Verification,
GoalUnmet,
Unknown,
}
impl FailureClass {
pub const fn as_str(&self) -> &'static str {
match self {
FailureClass::Precondition => "precondition",
FailureClass::Policy => "policy",
FailureClass::ToolError => "tool_error",
FailureClass::Timeout => "timeout",
FailureClass::Budget => "budget",
FailureClass::Verification => "verification",
FailureClass::GoalUnmet => "goal_unmet",
FailureClass::Unknown => "unknown",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "outcome", rename_all = "snake_case")]
pub enum AttemptOutcome {
Succeeded,
Failed {
class: FailureClass,
#[serde(default)]
detail: String,
},
}
impl AttemptOutcome {
pub fn failed(class: FailureClass, detail: impl Into<String>) -> Self {
AttemptOutcome::Failed {
class,
detail: detail.into(),
}
}
pub const fn is_failure(&self) -> bool {
matches!(self, AttemptOutcome::Failed { .. })
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Attempt {
pub id: String,
pub approach: String,
#[serde(default)]
pub assumptions: Vec<String>,
pub outcome: AttemptOutcome,
#[serde(default)]
pub evidence: Vec<String>,
#[serde(default)]
pub verdicts: Vec<VerifierVerdict>,
#[serde(default)]
pub retry_when: Vec<String>,
}
impl Attempt {
pub fn failure(
id: impl Into<String>,
approach: impl Into<String>,
assumptions: impl IntoIterator<Item = String>,
class: FailureClass,
detail: impl Into<String>,
) -> Self {
Self {
id: id.into(),
approach: approach.into(),
assumptions: assumptions.into_iter().collect(),
outcome: AttemptOutcome::failed(class, detail),
evidence: Vec::new(),
verdicts: Vec::new(),
retry_when: Vec::new(),
}
}
pub fn success(
id: impl Into<String>,
approach: impl Into<String>,
assumptions: impl IntoIterator<Item = String>,
) -> Self {
Self {
id: id.into(),
approach: approach.into(),
assumptions: assumptions.into_iter().collect(),
outcome: AttemptOutcome::Succeeded,
evidence: Vec::new(),
verdicts: Vec::new(),
retry_when: Vec::new(),
}
}
pub fn retry_when(mut self, keys: impl IntoIterator<Item = String>) -> Self {
self.retry_when = keys.into_iter().collect();
self
}
pub fn with_evidence(mut self, refs: impl IntoIterator<Item = String>) -> Self {
self.evidence = refs.into_iter().collect();
self
}
pub fn with_verdicts(mut self, verdicts: impl IntoIterator<Item = VerifierVerdict>) -> Self {
self.verdicts = verdicts.into_iter().collect();
self
}
pub fn is_verified_exclusion(&self) -> bool {
self.outcome.is_failure()
&& self.verdicts.iter().any(|v| {
v.outcome == crate::VerifierOutcome::Fail && v.verifier.authority.can_satisfy()
})
}
fn assumption_set(&self) -> BTreeSet<String> {
self.assumptions.iter().map(|a| normalize(a)).collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Exclusion<'a> {
pub attempt: &'a Attempt,
pub class: FailureClass,
pub deciding: Vec<&'a VerifierVerdict>,
}
fn normalize(s: &str) -> String {
s.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_lowercase()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "advice", rename_all = "snake_case")]
pub enum AttemptAdvice {
Untried,
KnownSuccess {
attempt_id: String,
},
KnownFailure {
attempt_id: String,
class: FailureClass,
detail: String,
verified: bool,
occurrences: usize,
},
RetryUnblocked {
attempt_id: String,
changed: Vec<String>,
},
SimilarFailure {
attempt_id: String,
class: FailureClass,
differing: Vec<String>,
},
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AttemptLedger {
attempts: Vec<Attempt>,
}
impl AttemptLedger {
pub fn new() -> Self {
Self::default()
}
pub fn from_attempts(attempts: impl IntoIterator<Item = Attempt>) -> Self {
Self {
attempts: attempts.into_iter().collect(),
}
}
pub fn record(&mut self, attempt: Attempt) {
self.attempts.push(attempt);
}
pub fn attempts(&self) -> &[Attempt] {
&self.attempts
}
pub fn len(&self) -> usize {
self.attempts.len()
}
pub fn is_empty(&self) -> bool {
self.attempts.is_empty()
}
pub fn verified_exclusions(&self) -> Vec<Exclusion<'_>> {
self.attempts
.iter()
.filter(|a| a.is_verified_exclusion())
.map(|a| Exclusion {
attempt: a,
class: match &a.outcome {
AttemptOutcome::Failed { class, .. } => *class,
AttemptOutcome::Succeeded => FailureClass::Unknown,
},
deciding: a
.verdicts
.iter()
.filter(|v| {
v.outcome == crate::VerifierOutcome::Fail
&& v.verifier.authority.can_satisfy()
})
.collect(),
})
.collect()
}
pub fn consult<'a>(
&self,
approach: &str,
assumptions: impl IntoIterator<Item = &'a str>,
) -> AttemptAdvice {
let approach_key = normalize(approach);
let now: BTreeSet<String> = assumptions.into_iter().map(normalize).collect();
let same_approach: Vec<&Attempt> = self
.attempts
.iter()
.filter(|a| normalize(&a.approach) == approach_key)
.collect();
if same_approach.is_empty() {
return AttemptAdvice::Untried;
}
let exact: Vec<&&Attempt> = same_approach
.iter()
.filter(|a| a.assumption_set() == now)
.collect();
if let Some(latest) = exact.last() {
return match &latest.outcome {
AttemptOutcome::Succeeded => AttemptAdvice::KnownSuccess {
attempt_id: latest.id.clone(),
},
AttemptOutcome::Failed { class, detail } => AttemptAdvice::KnownFailure {
attempt_id: latest.id.clone(),
class: *class,
detail: detail.clone(),
verified: latest.is_verified_exclusion(),
occurrences: exact.iter().filter(|a| a.outcome.is_failure()).count(),
},
};
}
let failures: Vec<&&Attempt> = same_approach
.iter()
.filter(|a| a.outcome.is_failure())
.collect();
for prior in failures.iter().rev() {
let then = prior.assumption_set();
let changed: Vec<String> = prior
.retry_when
.iter()
.map(|k| normalize(k))
.filter(|k| then.contains(k) != now.contains(k))
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
if !changed.is_empty() {
return AttemptAdvice::RetryUnblocked {
attempt_id: prior.id.clone(),
changed,
};
}
}
if let Some(prior) = failures.last() {
let then = prior.assumption_set();
let differing: Vec<String> = then.symmetric_difference(&now).cloned().collect();
let class = match &prior.outcome {
AttemptOutcome::Failed { class, .. } => *class,
AttemptOutcome::Succeeded => FailureClass::Unknown,
};
return AttemptAdvice::SimilarFailure {
attempt_id: prior.id.clone(),
class,
differing,
};
}
AttemptAdvice::Untried
}
}
#[cfg(test)]
mod tests {
use super::*;
fn assumptions(items: &[&str]) -> Vec<String> {
items.iter().map(|s| s.to_string()).collect()
}
fn ledger_with_one_failure() -> AttemptLedger {
AttemptLedger::from_attempts([Attempt::failure(
"a1",
"retry upload with backoff",
assumptions(&["endpoint is v1", "token is valid"]),
FailureClass::ToolError,
"503 from the upload endpoint",
)])
}
#[test]
fn empty_ledger_reports_untried() {
let l = AttemptLedger::new();
assert!(l.is_empty());
assert_eq!(l.consult("anything", ["x"]), AttemptAdvice::Untried);
}
#[test]
fn exact_match_reports_known_failure_with_its_class_and_detail() {
let l = ledger_with_one_failure();
let advice = l.consult(
"retry upload with backoff",
["endpoint is v1", "token is valid"],
);
match advice {
AttemptAdvice::KnownFailure {
attempt_id,
class,
detail,
verified,
occurrences,
} => {
assert_eq!(attempt_id, "a1");
assert_eq!(class, FailureClass::ToolError);
assert_eq!(detail, "503 from the upload endpoint");
assert!(!verified, "no verdicts attached, so not citable");
assert_eq!(occurrences, 1);
}
other => panic!("expected KnownFailure, got {other:?}"),
}
}
#[test]
fn assumption_order_and_duplicates_do_not_matter() {
let l = ledger_with_one_failure();
let advice = l.consult(
"retry upload with backoff",
["token is valid", "endpoint is v1", "token is valid"],
);
assert!(matches!(advice, AttemptAdvice::KnownFailure { .. }));
}
#[test]
fn approach_matching_ignores_case_and_incidental_whitespace() {
let l = ledger_with_one_failure();
let advice = l.consult(
" Retry Upload With Backoff ",
["endpoint is v1", "token is valid"],
);
assert!(matches!(advice, AttemptAdvice::KnownFailure { .. }));
}
#[test]
fn a_different_approach_is_untried() {
let l = ledger_with_one_failure();
assert_eq!(
l.consult("upload via the batch api", ["endpoint is v1"]),
AttemptAdvice::Untried
);
}
#[test]
fn repeated_identical_failures_raise_the_occurrence_count() {
let mut l = ledger_with_one_failure();
l.record(Attempt::failure(
"a2",
"retry upload with backoff",
assumptions(&["endpoint is v1", "token is valid"]),
FailureClass::ToolError,
"503 again",
));
match l.consult(
"retry upload with backoff",
["endpoint is v1", "token is valid"],
) {
AttemptAdvice::KnownFailure {
attempt_id,
occurrences,
detail,
..
} => {
assert_eq!(occurrences, 2);
assert_eq!(attempt_id, "a2");
assert_eq!(detail, "503 again");
}
other => panic!("expected KnownFailure, got {other:?}"),
}
}
#[test]
fn a_later_success_supersedes_earlier_failures() {
let mut l = ledger_with_one_failure();
l.record(Attempt::success(
"a2",
"retry upload with backoff",
assumptions(&["endpoint is v1", "token is valid"]),
));
assert_eq!(
l.consult(
"retry upload with backoff",
["endpoint is v1", "token is valid"]
),
AttemptAdvice::KnownSuccess {
attempt_id: "a2".into()
}
);
}
#[test]
fn declared_retry_condition_unblocks_when_it_changes() {
let l = AttemptLedger::from_attempts([Attempt::failure(
"a1",
"retry upload with backoff",
assumptions(&["endpoint is v1", "token is valid"]),
FailureClass::ToolError,
"503",
)
.retry_when(assumptions(&["endpoint is v1"]))]);
match l.consult(
"retry upload with backoff",
["endpoint is v2", "token is valid"],
) {
AttemptAdvice::RetryUnblocked {
attempt_id,
changed,
} => {
assert_eq!(attempt_id, "a1");
assert_eq!(changed, vec!["endpoint is v1".to_string()]);
}
other => panic!("expected RetryUnblocked, got {other:?}"),
}
}
#[test]
fn a_declared_condition_that_did_not_change_does_not_unblock() {
let l = AttemptLedger::from_attempts([Attempt::failure(
"a1",
"retry upload with backoff",
assumptions(&["endpoint is v1", "token is valid"]),
FailureClass::Policy,
"denied",
)
.retry_when(assumptions(&["token is valid"]))]);
match l.consult(
"retry upload with backoff",
["endpoint is v1", "token is valid", "region is eu"],
) {
AttemptAdvice::SimilarFailure {
attempt_id,
class,
differing,
} => {
assert_eq!(attempt_id, "a1");
assert_eq!(class, FailureClass::Policy);
assert_eq!(differing, vec!["region is eu".to_string()]);
}
other => panic!("expected SimilarFailure, got {other:?}"),
}
}
#[test]
fn exact_match_outranks_a_retry_condition() {
let l = AttemptLedger::from_attempts([
Attempt::failure(
"a1",
"flush the cache",
assumptions(&["lock held"]),
FailureClass::Timeout,
"timed out",
)
.retry_when(assumptions(&["lock held"])),
Attempt::failure(
"a2",
"flush the cache",
assumptions(&["lock free"]),
FailureClass::Timeout,
"timed out again",
),
]);
match l.consult("flush the cache", ["lock free"]) {
AttemptAdvice::KnownFailure { attempt_id, .. } => assert_eq!(attempt_id, "a2"),
other => panic!("expected KnownFailure, got {other:?}"),
}
}
#[test]
fn no_declared_condition_yields_similar_failure_not_a_hard_block() {
let l = ledger_with_one_failure();
match l.consult(
"retry upload with backoff",
["endpoint is v2", "token is valid"],
) {
AttemptAdvice::SimilarFailure { differing, .. } => {
assert_eq!(
differing,
vec!["endpoint is v1".to_string(), "endpoint is v2".to_string()]
);
}
other => panic!("expected SimilarFailure, got {other:?}"),
}
}
#[test]
fn a_success_under_different_assumptions_is_not_evidence_for_these() {
let l = AttemptLedger::from_attempts([Attempt::success(
"a1",
"flush the cache",
assumptions(&["lock free"]),
)]);
assert_eq!(
l.consult("flush the cache", ["lock held"]),
AttemptAdvice::Untried
);
}
#[test]
fn empty_assumption_sets_match_each_other() {
let l = AttemptLedger::from_attempts([Attempt::failure(
"a1",
"just try it",
[],
FailureClass::GoalUnmet,
"no",
)]);
let empty: [&str; 0] = [];
assert!(matches!(
l.consult("just try it", empty),
AttemptAdvice::KnownFailure { .. }
));
}
#[test]
fn consult_is_deterministic() {
let l = ledger_with_one_failure();
let first = l.consult(
"retry upload with backoff",
["token is valid", "endpoint is v1"],
);
let second = l.consult(
"retry upload with backoff",
["endpoint is v1", "token is valid"],
);
assert_eq!(first, second);
}
#[test]
fn evidence_references_round_trip() {
let a = Attempt::failure("a1", "x", [], FailureClass::Unknown, "y")
.with_evidence(assumptions(&["trajectory:abc", "log:/tmp/run.jsonl"]));
let json = serde_json::to_string(&a).unwrap();
let back: Attempt = serde_json::from_str(&json).unwrap();
assert_eq!(back, a);
assert_eq!(back.evidence.len(), 2);
}
#[test]
fn ledger_round_trips_through_serde() {
let mut l = ledger_with_one_failure();
l.record(Attempt::success("a2", "other", assumptions(&["k"])));
let json = serde_json::to_string(&l).unwrap();
let back: AttemptLedger = serde_json::from_str(&json).unwrap();
assert_eq!(back.len(), 2);
assert_eq!(back.attempts()[1].id, "a2");
}
fn binding_fail() -> VerifierVerdict {
VerifierVerdict::fail(
crate::VerifierDescriptor::binding("counterexample_check", "proof"),
"24-vertex witness disproves the reduction",
)
}
fn advisory_fail() -> VerifierVerdict {
let mut d = crate::VerifierDescriptor::binding("model_judge", "proof");
d.authority = crate::VerifierAuthority::Advisory;
d.tier = crate::EvidenceTier::Heuristic;
VerifierVerdict::fail(d, "seems wrong")
}
#[test]
fn a_failure_with_a_binding_failing_verdict_is_a_verified_exclusion() {
let a = Attempt::failure("a1", "reduce via P13", [], FailureClass::Verification, "no")
.with_verdicts([binding_fail()]);
assert!(a.is_verified_exclusion());
}
#[test]
fn a_bare_failure_is_not_a_verified_exclusion() {
let a = Attempt::failure("a1", "reduce via P13", [], FailureClass::Verification, "no");
assert!(!a.is_verified_exclusion());
}
#[test]
fn an_advisory_failure_does_not_foreclose_a_route() {
let a = Attempt::failure("a1", "reduce via P13", [], FailureClass::Verification, "no")
.with_verdicts([advisory_fail()]);
assert!(!a.is_verified_exclusion());
}
#[test]
fn a_success_is_never_an_exclusion_even_carrying_a_failed_verdict() {
let a = Attempt::success("a1", "reduce via P13", []).with_verdicts([binding_fail()]);
assert!(!a.is_verified_exclusion());
}
#[test]
fn verified_exclusions_lists_only_citable_failures() {
let l = AttemptLedger::from_attempts([
Attempt::failure("a1", "route one", [], FailureClass::Verification, "no")
.with_verdicts([binding_fail()]),
Attempt::failure("a2", "route two", [], FailureClass::ToolError, "503"),
Attempt::failure("a3", "route three", [], FailureClass::Verification, "no")
.with_verdicts([advisory_fail()]),
Attempt::success("a4", "route four", []),
]);
let ex = l.verified_exclusions();
assert_eq!(ex.len(), 1);
assert_eq!(ex[0].attempt.id, "a1");
assert_eq!(ex[0].class, FailureClass::Verification);
assert_eq!(ex[0].deciding.len(), 1);
assert_eq!(ex[0].deciding[0].verifier.id, "counterexample_check");
}
#[test]
fn deciding_omits_advisory_verdicts_on_the_same_attempt() {
let l = AttemptLedger::from_attempts([Attempt::failure(
"a1",
"route one",
[],
FailureClass::Verification,
"no",
)
.with_verdicts([advisory_fail(), binding_fail()])]);
let ex = l.verified_exclusions();
assert_eq!(
ex[0].deciding.len(),
1,
"advisory verdict leaked into deciding"
);
assert_eq!(ex[0].attempt.verdicts.len(), 2, "both stay on the attempt");
}
#[test]
fn consult_reports_whether_a_known_failure_is_verified() {
let mut l = AttemptLedger::from_attempts([Attempt::failure(
"a1",
"route one",
[],
FailureClass::Verification,
"no",
)]);
let empty: [&str; 0] = [];
match l.consult("route one", empty) {
AttemptAdvice::KnownFailure { verified, .. } => assert!(!verified),
other => panic!("expected KnownFailure, got {other:?}"),
}
l.record(
Attempt::failure("a2", "route one", [], FailureClass::Verification, "no")
.with_verdicts([binding_fail()]),
);
match l.consult("route one", empty) {
AttemptAdvice::KnownFailure {
verified,
attempt_id,
occurrences,
..
} => {
assert!(verified);
assert_eq!(attempt_id, "a2");
assert_eq!(occurrences, 2);
}
other => panic!("expected KnownFailure, got {other:?}"),
}
}
#[test]
fn verdicts_on_an_attempt_fold_into_check_records() {
let a = Attempt::failure("a1", "route one", [], FailureClass::Verification, "no")
.with_verdicts([binding_fail()]);
let records: Vec<_> = a.verdicts.iter().map(|v| v.to_check_record()).collect();
assert_eq!(records.len(), 1);
assert_eq!(records[0].name, "counterexample_check");
assert_eq!(records[0].findings, 1);
assert!(records[0].ran);
}
#[test]
fn verdicts_round_trip_through_serde() {
let a = Attempt::failure("a1", "route one", [], FailureClass::Verification, "no")
.with_verdicts([binding_fail()]);
let back: Attempt = serde_json::from_str(&serde_json::to_string(&a).unwrap()).unwrap();
assert_eq!(back, a);
assert!(back.is_verified_exclusion());
}
#[test]
fn an_attempt_without_verdicts_still_deserializes_from_older_records() {
let json = r#"{"id":"a1","approach":"x","assumptions":[],
"outcome":{"outcome":"failed","class":"tool_error","detail":"503"}}"#;
let a: Attempt = serde_json::from_str(json).unwrap();
assert!(a.verdicts.is_empty());
assert!(!a.is_verified_exclusion());
}
#[test]
fn failure_class_labels_match_serde_representation() {
for class in [
FailureClass::Precondition,
FailureClass::Policy,
FailureClass::ToolError,
FailureClass::Timeout,
FailureClass::Budget,
FailureClass::Verification,
FailureClass::GoalUnmet,
FailureClass::Unknown,
] {
assert_eq!(
serde_json::to_value(class).unwrap(),
serde_json::json!(class.as_str())
);
}
}
}