Skip to main content

aristo_cli/nudge/
state.rs

1//! Persistent, git-untracked engine state: `.aristo/nudge-state.toml`
2//! (Phase 18 #9, S0c). Holds the runtime facts the index can't carry:
3//!
4//! - the **reviewed map** (`annotation id → {text_hash, body_hash, reviewed}`)
5//!   — the reviewed/unreviewed axis #7 drives; a hash drift re-flips an entry
6//!   to unreviewed (D6/Q3);
7//! - the **proof-reviewed map** (`proof id → reviewed`);
8//! - the **edit-window baseline** (score + tier captured at SessionStart) for
9//!   the congrats / slump signals;
10//! - the per-window **edit counter** for the authoring-debt signal;
11//! - per-signal **throttle** records (last fired + last surfaced metric).
12//!
13//! Writes are atomic (tempfile + rename), tolerant on read (a missing or
14//! corrupt file degrades to default state — the engine never fails a hook on
15//! bad state). The file is git-untracked: it is per-user runtime, like
16//! `.aristo/sessions/`.
17
18use std::collections::{BTreeMap, BTreeSet};
19use std::path::Path;
20
21use serde::{Deserialize, Serialize};
22
23/// Basename under `.aristo/`.
24pub const STATE_FILENAME: &str = "nudge-state.toml";
25
26/// One annotation's review record. `reviewed` is re-flipped to the
27/// unreviewed view whenever the live text/body hashes drift from these.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct ReviewRecord {
30    pub text_hash: String,
31    pub body_hash: String,
32    pub reviewed: bool,
33}
34
35/// The score/tier snapshot captured at the start of an edit window, against
36/// which congrats (gain) and slump (drop) are measured.
37#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
38pub struct Baseline {
39    pub score: f64,
40    /// The tier label at baseline (compared by string; the engine only needs
41    /// "did it change", not ordering).
42    pub tier: String,
43}
44
45/// Per-signal throttle bookkeeping.
46#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
47pub struct ThrottleRecord {
48    /// Unix epoch seconds the signal last surfaced (0 if never).
49    pub last_fired_epoch: u64,
50    /// The metric value at the last surfacing (for material-change re-arm).
51    pub last_surfaced_metric: f64,
52}
53
54/// The whole engine state file. Every field defaults, so older / partial
55/// files deserialize cleanly.
56#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
57#[serde(default)]
58pub struct NudgeState {
59    /// Source edits since the last annotation was added (authoring-debt).
60    pub edits_since_annotation: usize,
61    /// The edit-window baseline, if captured.
62    pub baseline: Option<Baseline>,
63    /// The set of authored-intent ids present when the current edit window
64    /// began (captured at SessionStart). Powers #7's new-vs-backlog split: an
65    /// unreviewed intent is "new this session" when its id is absent from this
66    /// set, else "backlog". `None` means no window was captured (no hooks /
67    /// fresh checkout) — the split is then suppressed rather than guessed.
68    pub window_intent_ids: Option<BTreeSet<String>>,
69    /// annotation id → review record.
70    pub reviewed: BTreeMap<String, ReviewRecord>,
71    /// proof id → reviewed.
72    pub proof_reviewed: BTreeMap<String, bool>,
73    /// signal id → throttle record.
74    pub throttle: BTreeMap<String, ThrottleRecord>,
75}
76
77impl NudgeState {
78    /// Read the state file, tolerating absence and corruption (either yields
79    /// default state). A nudge hook must never fail on bad state.
80    pub fn load(path: &Path) -> Self {
81        match std::fs::read_to_string(path) {
82            Ok(text) => toml::from_str(&text).unwrap_or_default(),
83            Err(_) => Self::default(),
84        }
85    }
86
87    /// Atomically write the state file (tempfile in the same dir + rename).
88    pub fn save(&self, path: &Path) -> std::io::Result<()> {
89        if let Some(parent) = path.parent() {
90            std::fs::create_dir_all(parent)?;
91        }
92        let text = toml::to_string_pretty(self)
93            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
94        let tmp = path.with_extension("toml.tmp");
95        std::fs::write(&tmp, text)?;
96        std::fs::rename(&tmp, path)?;
97        Ok(())
98    }
99
100    /// Count intents the user has not (currently) reviewed. An intent is
101    /// unreviewed when it has no record, its record is `reviewed = false`, or
102    /// its live hashes have drifted from the reviewed snapshot (a post-review
103    /// edit re-opens it). `current` is `(id, text_hash, body_hash)` for every
104    /// authored intent in the index.
105    pub fn unreviewed_count<'a>(
106        &self,
107        current: impl IntoIterator<Item = (&'a str, &'a str, &'a str)>,
108    ) -> usize {
109        current
110            .into_iter()
111            .filter(|(id, text_hash, body_hash)| !self.is_reviewed(id, text_hash, body_hash))
112            .count()
113    }
114
115    #[aristo::intent(
116        "A review is current only while the annotation's text AND body hashes \
117         still match the snapshot taken when it was reviewed. Editing the \
118         claim or the covered code after review re-opens it (reads as \
119         unreviewed) — a reviewer approved a specific version, not the id \
120         forever. Without the hash check, a post-review edit would keep a \
121         stale 'reviewed' badge and the review backlog would under-count work \
122         that genuinely needs another look.",
123        verify = "test",
124        id = "nudge_review_is_current_only_until_hash_drift"
125    )]
126    /// True iff this annotation is currently reviewed (record present,
127    /// `reviewed = true`, and both hashes still match — no drift).
128    pub fn is_reviewed(&self, id: &str, text_hash: &str, body_hash: &str) -> bool {
129        match self.reviewed.get(id) {
130            Some(r) => r.reviewed && r.text_hash == text_hash && r.body_hash == body_hash,
131            None => false,
132        }
133    }
134
135    /// Mark an annotation reviewed against its current hashes (clears any
136    /// prior drift).
137    pub fn mark_reviewed(&mut self, id: &str, text_hash: &str, body_hash: &str) {
138        self.reviewed.insert(
139            id.to_string(),
140            ReviewRecord {
141                text_hash: text_hash.to_string(),
142                body_hash: body_hash.to_string(),
143                reviewed: true,
144            },
145        );
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    fn rec(text: &str, body: &str, reviewed: bool) -> ReviewRecord {
154        ReviewRecord {
155            text_hash: text.into(),
156            body_hash: body.into(),
157            reviewed,
158        }
159    }
160
161    #[test]
162    fn missing_file_loads_default_state() {
163        let dir = tempfile::tempdir().unwrap();
164        let s = NudgeState::load(&dir.path().join("nope.toml"));
165        assert_eq!(s, NudgeState::default());
166    }
167
168    #[test]
169    fn corrupt_file_loads_default_state() {
170        let dir = tempfile::tempdir().unwrap();
171        let p = dir.path().join(STATE_FILENAME);
172        std::fs::write(&p, "this is { not valid toml ][").unwrap();
173        // Must not panic / error — a hook can't fail on bad state.
174        assert_eq!(NudgeState::load(&p), NudgeState::default());
175    }
176
177    #[test]
178    fn save_then_load_round_trips() {
179        let dir = tempfile::tempdir().unwrap();
180        let p = dir.path().join(STATE_FILENAME);
181        let mut s = NudgeState {
182            edits_since_annotation: 4,
183            baseline: Some(Baseline {
184                score: 0.42,
185                tier: "Adept".into(),
186            }),
187            window_intent_ids: Some(BTreeSet::from(["aret_a".to_string(), "aret_b".to_string()])),
188            ..Default::default()
189        };
190        s.mark_reviewed("aret_abc12345", "sha256:t", "sha256:b");
191        s.proof_reviewed.insert("aret_abc12345".into(), true);
192        s.throttle.insert(
193            "review_backlog".into(),
194            ThrottleRecord {
195                last_fired_epoch: 1_700_000_000,
196                last_surfaced_metric: 3.0,
197            },
198        );
199        s.save(&p).unwrap();
200        assert_eq!(NudgeState::load(&p), s);
201    }
202
203    #[test]
204    fn unreviewed_counts_absent_unmarked_and_drifted() {
205        let mut s = NudgeState::default();
206        s.reviewed.insert("reviewed".into(), rec("t1", "b1", true));
207        s.reviewed.insert("unmarked".into(), rec("t2", "b2", false));
208        s.reviewed.insert("drifted".into(), rec("t3", "b3", true));
209
210        let current = vec![
211            ("reviewed", "t1", "b1"),  // reviewed, hashes match → reviewed
212            ("unmarked", "t2", "b2"),  // record says reviewed=false → unreviewed
213            ("drifted", "tX", "b3"),   // text drifted → unreviewed
214            ("brand_new", "t4", "b4"), // no record → unreviewed
215        ];
216        assert_eq!(s.unreviewed_count(current), 3);
217    }
218
219    #[test]
220    fn body_drift_after_review_reopens_the_intent() {
221        let mut s = NudgeState::default();
222        s.mark_reviewed("x", "sha256:t", "sha256:b");
223        assert!(
224            s.is_reviewed("x", "sha256:t", "sha256:b"),
225            "fresh review holds"
226        );
227        assert!(
228            !s.is_reviewed("x", "sha256:t", "sha256:b2"),
229            "a body edit after review re-opens it"
230        );
231    }
232
233    #[test]
234    fn empty_reviewed_map_means_everything_unreviewed() {
235        let s = NudgeState::default();
236        let current = vec![("a", "t", "b"), ("c", "t", "b")];
237        assert_eq!(s.unreviewed_count(current), 2);
238    }
239}