use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum Attribution {
Agentic,
Process,
Human,
Unattributable,
}
impl Attribution {
pub fn code(&self) -> &'static str {
match self {
Attribution::Agentic => "AGENTIC",
Attribution::Process => "PROCESS",
Attribution::Human => "HUMAN",
Attribution::Unattributable => "UNATTRIBUTABLE",
}
}
pub fn is_promotable(&self) -> bool {
*self == Attribution::Agentic
}
pub fn all() -> &'static [Attribution] {
&[
Attribution::Agentic,
Attribution::Process,
Attribution::Human,
Attribution::Unattributable,
]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Class {
SpecAmbig,
SpecMissing,
LocWrong,
CtxStale,
CtxDrift,
EditCompile,
EditRuntime,
TestInvalid,
ScopeCreep,
ConvViolation,
}
impl Class {
pub fn code(&self) -> &'static str {
match self {
Class::SpecAmbig => "SPEC-AMBIG",
Class::SpecMissing => "SPEC-MISSING",
Class::LocWrong => "LOC-WRONG",
Class::CtxStale => "CTX-STALE",
Class::CtxDrift => "CTX-DRIFT",
Class::EditCompile => "EDIT-COMPILE",
Class::EditRuntime => "EDIT-RUNTIME",
Class::TestInvalid => "TEST-INVALID",
Class::ScopeCreep => "SCOPE-CREEP",
Class::ConvViolation => "CONV-VIOLATION",
}
}
pub fn parse(s: &str) -> Option<Class> {
Some(match s.trim().to_ascii_uppercase().as_str() {
"SPEC-AMBIG" => Class::SpecAmbig,
"SPEC-MISSING" => Class::SpecMissing,
"LOC-WRONG" => Class::LocWrong,
"CTX-STALE" => Class::CtxStale,
"CTX-DRIFT" => Class::CtxDrift,
"EDIT-COMPILE" => Class::EditCompile,
"EDIT-RUNTIME" => Class::EditRuntime,
"TEST-INVALID" => Class::TestInvalid,
"SCOPE-CREEP" => Class::ScopeCreep,
"CONV-VIOLATION" => Class::ConvViolation,
_ => return None,
})
}
pub fn locus(&self) -> &'static str {
match self {
Class::SpecAmbig | Class::SpecMissing => "spec",
Class::LocWrong | Class::CtxStale => "retrieval",
Class::CtxDrift => "context",
Class::EditCompile | Class::EditRuntime => "edit",
Class::TestInvalid => "verification",
Class::ScopeCreep => "plan",
Class::ConvViolation => "conventions",
}
}
pub fn is_harness_fixable(&self) -> bool {
matches!(
self,
Class::ScopeCreep
| Class::ConvViolation
| Class::SpecAmbig
| Class::SpecMissing
| Class::LocWrong
| Class::CtxStale
| Class::TestInvalid
)
}
pub fn all() -> &'static [Class] {
&[
Class::SpecAmbig, Class::SpecMissing, Class::LocWrong, Class::CtxStale,
Class::CtxDrift, Class::EditCompile, Class::EditRuntime, Class::TestInvalid,
Class::ScopeCreep, Class::ConvViolation,
]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_agentic_failures_are_promotable() {
assert!(Attribution::Agentic.is_promotable());
for a in [Attribution::Process, Attribution::Human, Attribution::Unattributable] {
assert!(!a.is_promotable(), "{} must never become a lesson", a.code());
}
}
#[test]
fn every_class_code_round_trips() {
for c in Class::all() {
assert_eq!(Class::parse(c.code()), Some(*c), "{} did not round trip", c.code());
}
assert_eq!(Class::parse("NOT-A-CLASS"), None);
assert_eq!(Class::parse("scope-creep"), Some(Class::ScopeCreep), "parsing is case-insensitive");
}
#[test]
fn codes_are_serialised_as_written_in_the_plan() {
assert_eq!(
serde_json::to_string(&Attribution::Unattributable).unwrap(),
"\"UNATTRIBUTABLE\""
);
assert_eq!(serde_json::to_string(&Class::ScopeCreep).unwrap(), "\"scope-creep\"");
}
#[test]
fn harness_fixable_classes_are_the_ones_gates_can_reach() {
assert!(Class::ScopeCreep.is_harness_fixable());
assert!(Class::ConvViolation.is_harness_fixable());
assert!(!Class::EditCompile.is_harness_fixable());
assert!(!Class::EditRuntime.is_harness_fixable());
}
#[test]
fn every_class_names_a_locus() {
for c in Class::all() {
assert!(!c.locus().is_empty(), "{} has no locus", c.code());
}
}
}