use serde::{Deserialize, Serialize};
use crate::learn::affinity::Affinity;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Trend {
Improving,
Declining,
Stable,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Actor {
Human(String),
Agent(String),
System,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum LifeEvent {
Born,
Matured,
Scored(Affinity),
Narrated {
claimed: Trend,
note: String,
},
Fired,
Drifted,
Ratified {
who: Actor,
why: String,
},
Retired,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LifeRecord {
class: String,
events: Vec<LifeEvent>,
}
impl LifeRecord {
#[must_use]
pub fn new(class: &str) -> Self {
Self {
class: class.to_owned(),
events: Vec::new(),
}
}
#[must_use]
pub fn class(&self) -> &str {
&self.class
}
pub fn append(&mut self, event: LifeEvent) {
self.events.push(event);
}
#[must_use]
pub fn events(&self) -> &[LifeEvent] {
&self.events
}
#[must_use]
pub fn is_retired(&self) -> bool {
self.events.iter().any(|e| matches!(e, LifeEvent::Retired))
}
#[must_use]
pub fn render(&self) -> String {
let mut out = format!("Life of `{}`:\n", self.class);
for event in &self.events {
let line = match event {
LifeEvent::Born => " • born — proposed from a defect cluster".to_owned(),
LifeEvent::Matured => " • matured — affinity tightened".to_owned(),
LifeEvent::Scored(affinity) => format!(
" • scored — affinity recall={:.2} precision={:.2}",
affinity.recall, affinity.precision
),
LifeEvent::Narrated { claimed, note } => {
let dir = match claimed {
Trend::Improving => "improving",
Trend::Declining => "declining",
Trend::Stable => "stable",
};
format!(" • narrated [{dir}] — {note}")
},
LifeEvent::Fired => " • fired — flagged a real defect site".to_owned(),
LifeEvent::Drifted => " • drifted — its rate-stream shifted".to_owned(),
LifeEvent::Ratified { who, why } => {
let by = match who {
Actor::Human(name) => format!("human {name}"),
Actor::Agent(name) => format!("agent {name}"),
Actor::System => "the system".to_owned(),
};
format!(" • ratified by {by} — {why}")
},
LifeEvent::Retired => {
" • retired — tombstone; this dead end was walked".to_owned()
},
};
out.push_str(&line);
out.push('\n');
}
out
}
#[must_use]
pub fn score_trajectory(&self) -> Vec<Affinity> {
self.events
.iter()
.filter_map(|e| match e {
LifeEvent::Scored(affinity) => Some(*affinity),
_ => None,
})
.collect()
}
#[must_use]
pub fn trajectory_direction(&self) -> Option<Trend> {
let traj = self.score_trajectory();
if traj.len() < 2 {
return None;
}
let first = traj.first()?;
let last = traj.last()?;
let last_dominates = last.recall >= first.recall
&& last.precision >= first.precision
&& (last.recall > first.recall || last.precision > first.precision);
let first_dominates = first.recall >= last.recall
&& first.precision >= last.precision
&& (first.recall > last.recall || first.precision > last.precision);
Some(if last_dominates {
Trend::Improving
} else if first_dominates {
Trend::Declining
} else {
Trend::Stable
})
}
#[must_use]
pub fn check_story_coherence(&self, claimed: Trend) -> Option<StoryDivergence> {
let actual = self.trajectory_direction()?;
let opposed = matches!(
(claimed, actual),
(Trend::Improving | Trend::Stable, Trend::Declining)
| (Trend::Declining, Trend::Improving)
);
opposed.then_some(StoryDivergence { claimed, actual })
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StoryDivergence {
pub claimed: Trend,
pub actual: Trend,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn trajectory_direction_reads_rising_falling_and_none() {
let mut rec = LifeRecord::new("c");
assert_eq!(rec.trajectory_direction(), None);
rec.append(LifeEvent::Scored(Affinity::new(0.5, 0.5)));
assert_eq!(
rec.trajectory_direction(),
None,
"one point has no direction"
);
let mut up = LifeRecord::new("c");
up.append(LifeEvent::Scored(Affinity::new(0.4, 0.4)));
up.append(LifeEvent::Scored(Affinity::new(0.9, 0.8)));
assert_eq!(up.trajectory_direction(), Some(Trend::Improving));
let mut down = LifeRecord::new("c");
down.append(LifeEvent::Scored(Affinity::new(0.9, 0.8)));
down.append(LifeEvent::Scored(Affinity::new(0.5, 0.4)));
assert_eq!(down.trajectory_direction(), Some(Trend::Declining));
let mut mixed = LifeRecord::new("c");
mixed.append(LifeEvent::Scored(Affinity::new(0.5, 0.9)));
mixed.append(LifeEvent::Scored(Affinity::new(0.9, 0.5)));
assert_eq!(
mixed.trajectory_direction(),
Some(Trend::Stable),
"a mixed trade-off neither dominates — honestly not a clean improvement"
);
}
#[test]
fn coherence_check_flags_only_opposed_claims() {
let mut down = LifeRecord::new("c");
down.append(LifeEvent::Scored(Affinity::new(0.9, 0.8)));
down.append(LifeEvent::Scored(Affinity::new(0.5, 0.4)));
let d = down.check_story_coherence(Trend::Improving);
assert_eq!(
d,
Some(StoryDivergence {
claimed: Trend::Improving,
actual: Trend::Declining,
})
);
assert_eq!(down.check_story_coherence(Trend::Declining), None);
let empty = LifeRecord::new("c");
assert_eq!(empty.check_story_coherence(Trend::Improving), None);
}
#[test]
fn stable_claim_while_declining_is_flagged() {
let mut down = LifeRecord::new("c");
down.append(LifeEvent::Scored(Affinity::new(0.9, 0.8)));
down.append(LifeEvent::Scored(Affinity::new(0.3, 0.2)));
assert_eq!(
down.check_story_coherence(Trend::Stable),
Some(StoryDivergence {
claimed: Trend::Stable,
actual: Trend::Declining,
}),
"a Narrated claim of Stable ('holding steady / fine') over a real \
DECLINING trajectory is a downside-hiding lie — the exact story-vs-struct \
drift REQ-4 exists to catch. It must be flagged."
);
}
#[test]
fn stable_claim_while_improving_is_not_flagged() {
let mut up = LifeRecord::new("c");
up.append(LifeEvent::Scored(Affinity::new(0.3, 0.2)));
up.append(LifeEvent::Scored(Affinity::new(0.9, 0.8)));
assert_eq!(
up.check_story_coherence(Trend::Stable),
None,
"Stable over Improving is a benign under-claim — it hides no drop, so it \
is not a REQ-4 divergence (the asymmetry is deliberate)."
);
}
#[test]
fn score_trajectory_reads_scored_events_in_order() {
let mut rec = LifeRecord::new("demo-class");
rec.append(LifeEvent::Born);
rec.append(LifeEvent::Scored(Affinity::new(0.9, 0.8)));
rec.append(LifeEvent::Matured);
rec.append(LifeEvent::Scored(Affinity::new(0.5, 0.4)));
let traj = rec.score_trajectory();
assert_eq!(
traj.len(),
2,
"only the two Scored events are in the trajectory"
);
assert_eq!(traj[0], Affinity::new(0.9, 0.8));
assert_eq!(traj[1], Affinity::new(0.5, 0.4));
}
#[test]
fn append_only_history_only_grows() {
let mut rec = LifeRecord::new("demo-class");
rec.append(LifeEvent::Born);
assert_eq!(rec.events().len(), 1);
rec.append(LifeEvent::Matured);
assert_eq!(rec.events().len(), 2);
assert!(matches!(rec.events()[0], LifeEvent::Born));
}
#[test]
fn current_state_derives_from_events() {
let mut rec = LifeRecord::new("demo-class");
rec.append(LifeEvent::Born);
assert!(!rec.is_retired());
rec.append(LifeEvent::Retired);
assert!(rec.is_retired());
}
#[test]
fn render_is_a_pure_projection() {
let mut a = LifeRecord::new("demo-class");
a.append(LifeEvent::Born);
a.append(LifeEvent::Matured);
let mut b = LifeRecord::new("demo-class");
b.append(LifeEvent::Born);
b.append(LifeEvent::Matured);
assert_eq!(a.render(), b.render());
}
#[test]
fn ratified_who_is_read_structurally() {
let mut rec = LifeRecord::new("demo-class");
rec.append(LifeEvent::Ratified {
who: Actor::Human("alice".into()),
why: "bit us twice — worth a standing defense".into(),
});
let ratifier = rec.events().iter().find_map(|e| match e {
LifeEvent::Ratified { who, .. } => Some(who.clone()),
_ => None,
});
assert_eq!(ratifier, Some(Actor::Human("alice".into())));
}
}