Skip to main content

edda_postmortem/
signals.rs

1//! Decision signals: timestamped supersede / binding-conflict markers recorded
2//! at `edda decide` time, counted back by session window at postmortem time.
3//!
4//! Why: the two natural High-severity postmortem paths (decision reversal,
5//! multi-agent conflict) starved because their inputs were hardcoded to
6//! `0`/`false` at the SessionEnd wiring (flywheel drill 1 finding). The CLI
7//! already *detects* both cases when a decide lands — this module just gives
8//! those detections a durable, windowable trace.
9//!
10//! Storage: `<project state>/decision_signals.jsonl`, append-only, one JSON
11//! object per line: `{"ts": "<rfc3339>", "kind": "superseded"|"binding_conflict",
12//! "key": "<decision key>"}`. Recording is best-effort — a failed append must
13//! never block a decide.
14
15use serde::{Deserialize, Serialize};
16use std::fs;
17use std::io::{self, Write};
18use std::path::{Path, PathBuf};
19use time::format_description::well_known::Rfc3339;
20use time::OffsetDateTime;
21
22/// What the decide path observed.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum SignalKind {
25    /// A prior decision for the same key (different value) was superseded.
26    Superseded,
27    /// A peer holds a conflicting binding for the same key.
28    BindingConflict,
29}
30
31impl SignalKind {
32    fn as_str(self) -> &'static str {
33        match self {
34            SignalKind::Superseded => "superseded",
35            SignalKind::BindingConflict => "binding_conflict",
36        }
37    }
38}
39
40#[derive(Debug, Serialize, Deserialize)]
41struct SignalLine {
42    ts: String,
43    kind: String,
44    key: String,
45}
46
47/// Windowed counts consumed by the postmortem wiring.
48#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
49pub struct SignalCounts {
50    pub superseded: u64,
51    pub conflicts: u64,
52}
53
54/// Signals file for a project (lives next to lessons/rules state).
55pub fn signals_path(project_id: &str) -> PathBuf {
56    edda_store::project_dir(project_id)
57        .join("state")
58        .join("decision_signals.jsonl")
59}
60
61/// Append one signal for `project_id` (creates parent dirs on first use).
62pub fn record_decision_signal(project_id: &str, kind: SignalKind, key: &str) -> io::Result<()> {
63    record_signal_at(&signals_path(project_id), kind, key)
64}
65
66/// SELECTOR3 病一:conflict 訊號的自/他區分。
67/// 同 actor(session 進版自己的 binding)=不記 conflict;異 actor=真跨 agent 衝突,記。
68/// `superseded` 訊號**不**經此路——那是判斷含量的正貨,自/他都要記。
69pub fn should_record_conflict(existing_actor: &str, current_actor: &str) -> bool {
70    existing_actor != current_actor
71}
72
73/// Record a `binding_conflict` signal only when the two actors differ.
74/// Wraps `record_signal_at` for the tested path (病一 治法).
75pub fn record_conflict_if_cross_actor(
76    path: &Path,
77    key: &str,
78    existing_actor: &str,
79    current_actor: &str,
80) -> io::Result<()> {
81    if !should_record_conflict(existing_actor, current_actor) {
82        return Ok(());
83    }
84    record_signal_at(path, SignalKind::BindingConflict, key)
85}
86
87/// Same as `record_conflict_if_cross_actor` but resolves the project's signals file.
88pub fn record_conflict_signal_if_cross_actor(
89    project_id: &str,
90    key: &str,
91    existing_actor: &str,
92    current_actor: &str,
93) -> io::Result<()> {
94    record_conflict_if_cross_actor(
95        &signals_path(project_id),
96        key,
97        existing_actor,
98        current_actor,
99    )
100}
101
102/// Append one signal to an explicit file path (testable core).
103pub fn record_signal_at(path: &Path, kind: SignalKind, key: &str) -> io::Result<()> {
104    if let Some(parent) = path.parent() {
105        fs::create_dir_all(parent)?;
106    }
107    let line = SignalLine {
108        ts: OffsetDateTime::now_utc()
109            .format(&Rfc3339)
110            .unwrap_or_default(),
111        kind: kind.as_str().to_string(),
112        key: key.to_string(),
113    };
114    let mut f = fs::OpenOptions::new()
115        .create(true)
116        .append(true)
117        .open(path)?;
118    writeln!(f, "{}", serde_json::to_string(&line)?)?;
119    Ok(())
120}
121
122/// Count signals for `project_id` whose ts falls inside `[first_ts, last_ts]`.
123/// `None` bounds are open-ended. Missing file ⇒ zeros; malformed lines skipped.
124pub fn count_signals_between(
125    project_id: &str,
126    first_ts: Option<&str>,
127    last_ts: Option<&str>,
128) -> SignalCounts {
129    count_signals_at(&signals_path(project_id), first_ts, last_ts)
130}
131
132/// Windowed count against an explicit file path (testable core).
133pub fn count_signals_at(
134    path: &Path,
135    first_ts: Option<&str>,
136    last_ts: Option<&str>,
137) -> SignalCounts {
138    let mut counts = SignalCounts::default();
139    let Ok(content) = fs::read_to_string(path) else {
140        return counts;
141    };
142    let lo = first_ts.and_then(parse_ts);
143    let hi = last_ts.and_then(parse_ts);
144
145    for line in content.lines() {
146        if line.trim().is_empty() {
147            continue;
148        }
149        let Ok(sig) = serde_json::from_str::<SignalLine>(line) else {
150            continue;
151        };
152        let Some(ts) = parse_ts(&sig.ts) else {
153            continue;
154        };
155        if lo.map(|l| ts < l).unwrap_or(false) || hi.map(|h| ts > h).unwrap_or(false) {
156            continue;
157        }
158        match sig.kind.as_str() {
159            "superseded" => counts.superseded += 1,
160            "binding_conflict" => counts.conflicts += 1,
161            _ => {}
162        }
163    }
164    counts
165}
166
167fn parse_ts(s: &str) -> Option<OffsetDateTime> {
168    OffsetDateTime::parse(s, &Rfc3339).ok()
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    fn write_lines(path: &Path, lines: &[&str]) {
176        fs::create_dir_all(path.parent().unwrap()).unwrap();
177        fs::write(path, lines.join("\n")).unwrap();
178    }
179
180    // SELECTOR3 病一:solo/peer 區分——同 by_label=進版不記 conflict,異 label=真衝突才記
181    #[test]
182    fn conflict_signal_dropped_when_same_actor_as_existing_binding() {
183        let dir = tempfile::tempdir().unwrap();
184        let path = dir.path().join("sig.jsonl");
185        // 同 actor 進版:should_record_conflict=false
186        assert!(
187            !should_record_conflict("cli", "cli"),
188            "same actor progressing own binding is not a conflict (病一)"
189        );
190        // 呼叫 record_conflict_if_cross_actor 應該不寫檔
191        record_conflict_if_cross_actor(&path, "db.engine", "cli", "cli").unwrap();
192        assert_eq!(count_signals_at(&path, None, None).conflicts, 0);
193    }
194
195    #[test]
196    fn conflict_signal_recorded_when_different_actor() {
197        let dir = tempfile::tempdir().unwrap();
198        let path = dir.path().join("sig.jsonl");
199        assert!(
200            should_record_conflict("cli", "product"),
201            "different actor = real cross-agent conflict (照記)"
202        );
203        record_conflict_if_cross_actor(&path, "db.engine", "product", "cli").unwrap();
204        assert_eq!(count_signals_at(&path, None, None).conflicts, 1);
205    }
206
207    #[test]
208    fn superseded_signal_still_recorded_regardless_of_actor() {
209        let dir = tempfile::tempdir().unwrap();
210        let path = dir.path().join("sig.jsonl");
211        // Supersede 訊號:自己改自己=正常進版訊號,自/他都要記(analyzer 判斷含量真源)
212        record_signal_at(&path, SignalKind::Superseded, "db.engine").unwrap();
213        assert_eq!(count_signals_at(&path, None, None).superseded, 1);
214    }
215
216    #[test]
217    fn record_then_count_roundtrip() {
218        let dir = tempfile::tempdir().unwrap();
219        let path = dir.path().join("state").join("decision_signals.jsonl");
220        record_signal_at(&path, SignalKind::Superseded, "db.engine").unwrap();
221        record_signal_at(&path, SignalKind::BindingConflict, "db.engine").unwrap();
222        record_signal_at(&path, SignalKind::Superseded, "auth.method").unwrap();
223
224        let counts = count_signals_at(&path, None, None);
225        assert_eq!(counts.superseded, 2);
226        assert_eq!(counts.conflicts, 1);
227    }
228
229    #[test]
230    fn missing_file_counts_zero() {
231        let dir = tempfile::tempdir().unwrap();
232        let path = dir.path().join("nope.jsonl");
233        assert_eq!(count_signals_at(&path, None, None), SignalCounts::default());
234    }
235
236    #[test]
237    fn window_filters_outside_signals() {
238        let dir = tempfile::tempdir().unwrap();
239        let path = dir.path().join("sig.jsonl");
240        write_lines(
241            &path,
242            &[
243                r#"{"ts":"2026-07-08T01:00:00Z","kind":"superseded","key":"a"}"#,
244                r#"{"ts":"2026-07-08T02:30:00Z","kind":"superseded","key":"b"}"#,
245                r#"{"ts":"2026-07-08T02:45:00.5Z","kind":"binding_conflict","key":"c"}"#,
246                r#"{"ts":"2026-07-08T04:00:00Z","kind":"superseded","key":"d"}"#,
247            ],
248        );
249        let counts = count_signals_at(
250            &path,
251            Some("2026-07-08T02:00:00Z"),
252            Some("2026-07-08T03:00:00Z"),
253        );
254        assert_eq!(counts.superseded, 1, "only the in-window supersede");
255        assert_eq!(counts.conflicts, 1, "fractional-second ts inside window");
256
257        let open_start = count_signals_at(&path, None, Some("2026-07-08T03:00:00Z"));
258        assert_eq!(open_start.superseded, 2);
259    }
260
261    #[test]
262    fn malformed_and_unknown_lines_skipped() {
263        let dir = tempfile::tempdir().unwrap();
264        let path = dir.path().join("sig.jsonl");
265        write_lines(
266            &path,
267            &[
268                "not json",
269                r#"{"ts":"garbage","kind":"superseded","key":"a"}"#,
270                r#"{"ts":"2026-07-08T01:00:00Z","kind":"weird","key":"a"}"#,
271                r#"{"ts":"2026-07-08T01:00:00Z","kind":"superseded","key":"a"}"#,
272                "",
273            ],
274        );
275        let counts = count_signals_at(&path, None, None);
276        assert_eq!(counts.superseded, 1);
277        assert_eq!(counts.conflicts, 0);
278    }
279}