Skip to main content

fd_policy/airlock/
coherence.rs

1//! Coherence-divergence monitor — a trajectory-level Airlock RASP signal.
2//!
3//! Every layer in [`super::inspector`] inspects a *single* tool call in
4//! isolation. This monitor instead watches the agent run **trajectory** — the
5//! existing audit-trail event stream — for a sequential failure pattern that
6//! no per-call check can see: the agent **states a fact that should change its
7//! plan** ("tests still failing", "permission denied", "the file does not
8//! exist") and the very next *advancing* action proceeds **as if that fact
9//! were untrue** (marks the task done, commits, reports success).
10//!
11//! Anchored on Strained Coherence (arxiv:2606.07889): in that study, coding-
12//! agent trajectories exhibiting this divergence failed **94%** of the time
13//! versus **46%** for trajectories without it (Fisher's exact p = 0.003) — a
14//! pre-failure signal strong enough to be worth surfacing *before* the run
15//! finishes, not only in a post-hoc autopsy.
16//!
17//! ## Streaming, not post-hoc
18//!
19//! [`CoherenceMonitor::observe_event`] consumes one trajectory event at a
20//! time and returns `Some(CoherenceSpan)` the instant a divergence appears, so
21//! a streaming consumer (worker / gateway) can raise it mid-run. The stateless
22//! [`CoherenceMonitor::scan_trajectory`] replays a whole event slice for
23//! post-hoc analysis and tests; both share the exact same detection core.
24//!
25//! ## Diff-compatible with the audit schema
26//!
27//! The emitted [`CoherenceSpan`] maps cleanly onto the existing audit-event
28//! shape: [`CoherenceSpan::to_violation`] produces an [`AirlockViolation`]
29//! (`violation_type = coherence_divergence`, the span JSON in `details`) that
30//! flows through the identical `audit_events.details` path as every other
31//! Airlock layer — no parallel store, no parallel decision channel.
32
33use super::config::CoherenceConfig;
34use super::inspector::{AirlockViolation, RiskLevel, ViolationType};
35use crate::reversibility::ResponseLevel;
36use fd_core::RunId;
37use serde::{Deserialize, Serialize};
38use std::collections::{HashMap, VecDeque};
39use tokio::sync::RwLock;
40use tracing::warn;
41
42/// Stable anchor for the methodology, mirrored on every emitted span.
43pub const COHERENCE_ANCHOR: &str = "arxiv:2606.07889";
44
45/// Longest a stated-fact quote is retained verbatim in a span / violation,
46/// in characters. Keeps audit `details` bounded without losing the lede.
47const MAX_QUOTE_CHARS: usize = 280;
48
49/// Category of a blocking fact — a stated observation that should change the
50/// agent's plan. Drives both the resolution match and the span severity.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case")]
53pub enum BlockingCategory {
54    /// A test run reported failures.
55    TestFailure,
56    /// An action was denied / unauthorized / forbidden.
57    PermissionDenied,
58    /// A required file / path / resource was not found.
59    MissingResource,
60    /// A build / compile step failed.
61    BuildError,
62    /// A generic error / exception / panic with no more specific category.
63    GenericError,
64}
65
66impl BlockingCategory {
67    /// Stable snake_case label (matches the serde wire form), used in the
68    /// violation `trigger` string.
69    pub fn label(self) -> &'static str {
70        match self {
71            BlockingCategory::TestFailure => "test_failure",
72            BlockingCategory::PermissionDenied => "permission_denied",
73            BlockingCategory::MissingResource => "missing_resource",
74            BlockingCategory::BuildError => "build_error",
75            BlockingCategory::GenericError => "generic_error",
76        }
77    }
78}
79
80/// A single event in an agent run trajectory, projected from the audit-trail
81/// event stream. The monitor only needs to know whether the agent *stated*
82/// something or *did* something, plus the text.
83#[derive(Debug, Clone)]
84pub enum TrajectoryEvent {
85    /// Something the agent observed or asserted — a tool result, an assistant
86    /// message, a reasoning step. The candidate "stated fact".
87    Statement(String),
88    /// An advancing action: a status transition, a commit, a success report.
89    /// `name` is the action / tool identifier; `text` is any accompanying
90    /// detail (a commit message, a status note).
91    Action { name: String, text: String },
92}
93
94impl TrajectoryEvent {
95    /// Build a [`TrajectoryEvent::Statement`].
96    pub fn statement(text: impl Into<String>) -> Self {
97        TrajectoryEvent::Statement(text.into())
98    }
99
100    /// Build a [`TrajectoryEvent::Action`].
101    pub fn action(name: impl Into<String>, text: impl Into<String>) -> Self {
102        TrajectoryEvent::Action {
103            name: name.into(),
104            text: text.into(),
105        }
106    }
107}
108
109/// Structured span emitted when a coherence divergence is detected. This *is*
110/// the payload serialized into [`AirlockViolation::details`], so the audit
111/// trail carries the full evidence: the stated fact, the contradicting
112/// action, and a confidence / severity.
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct CoherenceSpan {
115    /// Run this divergence was observed in.
116    pub run_id: String,
117    /// Verbatim quote of the stated fact that should have changed the plan
118    /// (clipped to [`MAX_QUOTE_CHARS`]).
119    pub stated_fact: String,
120    /// Category of the blocking fact.
121    pub category: BlockingCategory,
122    /// The contradicting action that proceeded as if the fact were untrue.
123    pub contradicting_action: String,
124    /// Confidence in `[0, 1]` that this is a genuine divergence.
125    pub confidence: f64,
126    /// Risk score `0..=100` assigned to the divergence.
127    pub risk_score: u8,
128    /// Risk level derived from the score.
129    pub risk_level: RiskLevel,
130    /// Trajectory events between the stated fact and the contradicting action
131    /// (always `>= 1`).
132    pub gap: u64,
133    /// Stable methodology anchor ([`COHERENCE_ANCHOR`]).
134    pub anchor: String,
135}
136
137impl CoherenceSpan {
138    /// Project this span onto the shared [`AirlockViolation`] shape so it
139    /// flows through the identical audit path as every other Airlock layer.
140    pub fn to_violation(&self) -> AirlockViolation {
141        let details = serde_json::to_string(self).unwrap_or_else(|_| {
142            format!(
143                "coherence_divergence: {} -> {}",
144                self.stated_fact, self.contradicting_action
145            )
146        });
147        AirlockViolation {
148            violation_type: ViolationType::CoherenceDivergence,
149            risk_score: self.risk_score,
150            risk_level: self.risk_level,
151            details,
152            trigger: format!("coherence_divergence:{}", self.category.label()),
153        }
154    }
155
156    /// Map this divergence's severity onto the existing reversibility-ladder
157    /// [`ResponseLevel`] (the DeepMind R1–R3 rungs) — reusing the graduated
158    /// response type rather than inventing a new one. `Critical`/`High` → R3
159    /// `RequireApproval`, `Medium` → R2 `AllowUnderBudget`, `Low` → R1
160    /// `AllowAndLog`. The gateway consumer records this rung on every
161    /// divergence; in `enforce` mode an R3 rung gates the run for human review,
162    /// in `shadow` mode it is recorded only.
163    pub fn response_level(&self) -> ResponseLevel {
164        match self.risk_level {
165            RiskLevel::Critical | RiskLevel::High => ResponseLevel::RequireApproval,
166            RiskLevel::Medium => ResponseLevel::AllowUnderBudget,
167            RiskLevel::Low => ResponseLevel::AllowAndLog,
168        }
169    }
170}
171
172/// A blocking fact still "open" — stated, but not yet resolved — within the
173/// monitor's lookahead window.
174#[derive(Debug, Clone)]
175struct OpenFact {
176    quote: String,
177    category: BlockingCategory,
178    /// Sequence number of the statement that opened it.
179    seq: u64,
180}
181
182/// Per-run sequential state. Held under the monitor's `RwLock` map.
183#[derive(Debug, Default)]
184struct RunCoherenceState {
185    open_facts: VecDeque<OpenFact>,
186    seq: u64,
187}
188
189/// Tracks per-run trajectory state and reports coherence divergences.
190///
191/// Construct once at gateway / worker boot, wrap in `Arc`, and feed it the
192/// run's audit events as they stream in. Mirrors the sibling-monitor pattern
193/// used by [`super::behavioral_drift::BehavioralDriftMonitor`].
194#[derive(Debug, Default)]
195pub struct CoherenceMonitor {
196    runs: RwLock<HashMap<String, RunCoherenceState>>,
197}
198
199impl CoherenceMonitor {
200    /// Create an empty monitor.
201    pub fn new() -> Self {
202        Self {
203            runs: RwLock::new(HashMap::new()),
204        }
205    }
206
207    /// Whether this monitor holds any state for the given run — primarily for
208    /// tests.
209    pub async fn is_tracked(&self, run_id: &RunId) -> bool {
210        self.runs.read().await.contains_key(&run_id.to_string())
211    }
212
213    /// Drop all trajectory state for a completed run to free memory.
214    pub async fn clear_run(&self, run_id: &RunId) {
215        self.runs.write().await.remove(&run_id.to_string());
216    }
217
218    /// Ingest the next trajectory event for `run_id`. Returns `Some` the
219    /// instant this event completes a divergence (a closure action while a
220    /// blocking fact is still open), so the caller can surface it mid-run.
221    pub async fn observe_event(
222        &self,
223        run_id: &RunId,
224        event: &TrajectoryEvent,
225        config: &CoherenceConfig,
226    ) -> Option<CoherenceSpan> {
227        if !config.enabled {
228            return None;
229        }
230        let key = run_id.to_string();
231        let mut runs = self.runs.write().await;
232        let state = runs.entry(key.clone()).or_default();
233        let span = step(&key, state, event, config);
234        if let Some(ref s) = span {
235            warn!(
236                run_id = %run_id,
237                category = s.category.label(),
238                confidence = s.confidence,
239                gap = s.gap,
240                "coherence divergence detected"
241            );
242        }
243        span
244    }
245
246    /// Replay a whole trajectory statelessly and collect every divergence.
247    /// Convenience for post-hoc analysis and tests — shares the exact same
248    /// detection core as [`Self::observe_event`].
249    pub fn scan_trajectory(
250        run_id: &str,
251        events: &[TrajectoryEvent],
252        config: &CoherenceConfig,
253    ) -> Vec<CoherenceSpan> {
254        if !config.enabled {
255            return Vec::new();
256        }
257        let mut state = RunCoherenceState::default();
258        let mut spans = Vec::new();
259        for event in events {
260            if let Some(span) = step(run_id, &mut state, event, config) {
261                spans.push(span);
262            }
263        }
264        spans
265    }
266}
267
268// =============================================================================
269// Detection core (pure, synchronous — shared by both entry points)
270// =============================================================================
271
272/// Advance the per-run state by one event, returning a span iff this event is
273/// a closure action that contradicts a still-open blocking fact.
274fn step(
275    run_id: &str,
276    state: &mut RunCoherenceState,
277    event: &TrajectoryEvent,
278    config: &CoherenceConfig,
279) -> Option<CoherenceSpan> {
280    state.seq += 1;
281    let now = state.seq;
282
283    // Expire facts older than the lookahead window from the front of the
284    // deque (facts are pushed in sequence order, so the front is oldest).
285    let lookahead = config.lookahead.max(1) as u64;
286    while let Some(front) = state.open_facts.front() {
287        if now.saturating_sub(front.seq) > lookahead {
288            state.open_facts.pop_front();
289        } else {
290            break;
291        }
292    }
293
294    match event {
295        TrajectoryEvent::Statement(text) => {
296            let lower = text.to_lowercase();
297            // Resolution takes precedence over a blocking match in the same
298            // sentence ("tests were failing but now pass" is a resolution).
299            if is_generic_resolution(&lower) {
300                state.open_facts.clear();
301                return None;
302            }
303            if let Some(cat) = match_resolution(&lower) {
304                state.open_facts.retain(|f| f.category != cat);
305                return None;
306            }
307            if let Some(cat) = match_blocking(&lower) {
308                state.open_facts.push_back(OpenFact {
309                    quote: clip(text),
310                    category: cat,
311                    seq: now,
312                });
313            }
314            None
315        }
316        TrajectoryEvent::Action { name, text } => {
317            let combined = format!("{name} {text}").to_lowercase();
318            // An action that itself disclaims success — "cannot complete,
319            // tests still failing" — is coherent, not divergent. This is the
320            // primary false-positive guard, so it runs before the closure
321            // check.
322            if is_abort_or_disclaimer(&combined) {
323                return None;
324            }
325            if !is_closure_action(&combined) {
326                return None;
327            }
328            // Pair with the most recent still-open blocking fact.
329            let fact = state.open_facts.back().cloned()?;
330            let gap = now.saturating_sub(fact.seq).max(1);
331            let confidence = compute_confidence(fact.category, gap, config.lookahead);
332            if confidence < config.min_confidence {
333                return None;
334            }
335            // Consume the fact so the same statement can't fire twice.
336            state.open_facts.pop_back();
337
338            let risk_score = config.risk_score.min(100);
339            Some(CoherenceSpan {
340                run_id: run_id.to_string(),
341                stated_fact: fact.quote,
342                category: fact.category,
343                contradicting_action: render_action(name, text),
344                confidence,
345                risk_score,
346                risk_level: RiskLevel::from_score(risk_score),
347                gap,
348                anchor: COHERENCE_ANCHOR.to_string(),
349            })
350        }
351    }
352}
353
354/// Confidence that a closure-while-open pairing is a genuine divergence.
355/// Higher when the contradicting action follows the stated fact closely and
356/// when the fact has a specific (non-generic) category.
357fn compute_confidence(category: BlockingCategory, gap: u64, lookahead: usize) -> f64 {
358    let la = lookahead.max(1) as f64;
359    let base = 0.6;
360    // gap == 1 (adjacent) → full proximity; decays toward the window edge.
361    let proximity = ((la - (gap as f64 - 1.0)).max(0.0) / la) * 0.3;
362    let category_bonus = match category {
363        BlockingCategory::GenericError => 0.0,
364        _ => 0.1,
365    };
366    (base + proximity + category_bonus).clamp(0.0, 1.0)
367}
368
369/// Render an action event for the `contradicting_action` field.
370fn render_action(name: &str, text: &str) -> String {
371    let name = name.trim();
372    let text = text.trim();
373    let rendered = if text.is_empty() {
374        name.to_string()
375    } else if name.is_empty() {
376        text.to_string()
377    } else {
378        format!("{name}: {text}")
379    };
380    clip(&rendered)
381}
382
383/// Char-safe truncation to [`MAX_QUOTE_CHARS`], appending an ellipsis when cut.
384fn clip(text: &str) -> String {
385    let trimmed = text.trim();
386    if trimmed.chars().count() <= MAX_QUOTE_CHARS {
387        return trimmed.to_string();
388    }
389    let mut out: String = trimmed.chars().take(MAX_QUOTE_CHARS).collect();
390    out.push('…');
391    out
392}
393
394/// First matching blocking category for a lowercased statement. Specific
395/// categories are checked before [`BlockingCategory::GenericError`].
396fn match_blocking(lower: &str) -> Option<BlockingCategory> {
397    const TEST_FAILURE: &[&str] = &[
398        "test failed",
399        "tests failed",
400        "tests still failing",
401        "tests are failing",
402        "still failing",
403        "test failure",
404        "failing test",
405        "test suite failed",
406        "tests did not pass",
407        "tests didn't pass",
408        "assertion failed",
409    ];
410    const PERMISSION_DENIED: &[&str] = &[
411        "permission denied",
412        "access denied",
413        "not authorized",
414        "unauthorized",
415        "forbidden",
416        "403 forbidden",
417    ];
418    const MISSING_RESOURCE: &[&str] = &[
419        "no such file",
420        "does not exist",
421        "doesn't exist",
422        "not found",
423        "cannot find",
424        "could not find",
425        "missing file",
426        "404 not found",
427    ];
428    const BUILD_ERROR: &[&str] = &[
429        "build failed",
430        "compilation failed",
431        "compile error",
432        "failed to compile",
433        "does not compile",
434        "build error",
435    ];
436    const GENERIC_ERROR: &[&str] = &[
437        "error:",
438        "exception",
439        "panic",
440        "traceback",
441        "stack trace",
442        "returned non-zero",
443        "fatal:",
444    ];
445
446    if contains_any(lower, TEST_FAILURE) {
447        Some(BlockingCategory::TestFailure)
448    } else if contains_any(lower, PERMISSION_DENIED) {
449        Some(BlockingCategory::PermissionDenied)
450    } else if contains_any(lower, MISSING_RESOURCE) {
451        Some(BlockingCategory::MissingResource)
452    } else if contains_any(lower, BUILD_ERROR) {
453        Some(BlockingCategory::BuildError)
454    } else if contains_any(lower, GENERIC_ERROR) {
455        Some(BlockingCategory::GenericError)
456    } else {
457        None
458    }
459}
460
461/// A category-specific resolution: the agent stated the blocking fact is now
462/// cleared. Clears only open facts of that category.
463fn match_resolution(lower: &str) -> Option<BlockingCategory> {
464    const TEST_RESOLVED: &[&str] = &[
465        "tests now pass",
466        "tests pass",
467        "all tests pass",
468        "tests passing",
469        "tests are passing",
470        "test suite passes",
471        "tests green",
472        "tests succeed",
473    ];
474    const PERMISSION_RESOLVED: &[&str] = &[
475        "permission granted",
476        "access granted",
477        "now authorized",
478        "approval granted",
479        "approved access",
480    ];
481    const RESOURCE_RESOLVED: &[&str] = &[
482        "file created",
483        "created the file",
484        "now exists",
485        "file now exists",
486        "found the file",
487        "created file",
488    ];
489    const BUILD_RESOLVED: &[&str] = &[
490        "build succeeded",
491        "builds successfully",
492        "compiled successfully",
493        "now compiles",
494        "build passing",
495        "build is green",
496    ];
497
498    if contains_any(lower, TEST_RESOLVED) {
499        Some(BlockingCategory::TestFailure)
500    } else if contains_any(lower, PERMISSION_RESOLVED) {
501        Some(BlockingCategory::PermissionDenied)
502    } else if contains_any(lower, RESOURCE_RESOLVED) {
503        Some(BlockingCategory::MissingResource)
504    } else if contains_any(lower, BUILD_RESOLVED) {
505        Some(BlockingCategory::BuildError)
506    } else {
507        None
508    }
509}
510
511/// A generic statement of resolution that clears *all* open facts (the agent
512/// declared the blocking situation fixed without naming a category).
513fn is_generic_resolution(lower: &str) -> bool {
514    const GENERIC: &[&str] = &[
515        "now resolved",
516        "issue resolved",
517        "problem resolved",
518        "has been resolved",
519        "now works",
520        "working now",
521        "issue fixed",
522        "problem fixed",
523        "now succeeds",
524        "has been fixed",
525        "everything passes",
526    ];
527    contains_any(lower, GENERIC)
528}
529
530/// Whether a combined action string advances / closes the task (marks done,
531/// commits, reports success).
532fn is_closure_action(combined: &str) -> bool {
533    const CLOSURE: &[&str] = &[
534        "mark complete",
535        "mark done",
536        "mark as done",
537        "mark as complete",
538        "marked complete",
539        "marked done",
540        "task complete",
541        "task done",
542        "complete_task",
543        "completed the task",
544        "set status to done",
545        "status: done",
546        "status done",
547        "mark_done",
548        "finalize",
549        "finish task",
550        "commit",
551        "git commit",
552        "push to",
553        "merge",
554        "ship it",
555        "deploy",
556        "report success",
557        "reporting success",
558        "completed successfully",
559        "ready to merge",
560        "marking resolved",
561    ];
562    contains_any(combined, CLOSURE)
563}
564
565/// Whether a combined action string disclaims success / aborts — which makes
566/// the action coherent with an open blocking fact rather than divergent.
567fn is_abort_or_disclaimer(combined: &str) -> bool {
568    const ABORT: &[&str] = &[
569        "cannot",
570        "can't",
571        "could not",
572        "couldn't",
573        "unable to",
574        "blocked",
575        "abort",
576        "aborting",
577        "halt",
578        "halting",
579        "will not",
580        "won't",
581        "not complete",
582        "do not mark",
583        "don't mark",
584        "not marking",
585        "stopping",
586        "still failing",
587        "still broken",
588        "not safe",
589        "refuse",
590        "skipping commit",
591        "needs fixing",
592        "needs work",
593    ];
594    contains_any(combined, ABORT)
595}
596
597fn contains_any(haystack: &str, needles: &[&str]) -> bool {
598    needles.iter().any(|n| haystack.contains(n))
599}
600
601#[cfg(test)]
602mod tests {
603    use super::*;
604
605    fn config() -> CoherenceConfig {
606        CoherenceConfig::default()
607    }
608
609    fn run() -> RunId {
610        RunId::new()
611    }
612
613    // -- Divergence fires -----------------------------------------------------
614
615    #[test]
616    fn divergence_fires_tests_failing_then_mark_complete() {
617        let events = [
618            TrajectoryEvent::statement("the tests are still failing on CI"),
619            TrajectoryEvent::action("set_status", "mark task complete"),
620        ];
621        let spans = CoherenceMonitor::scan_trajectory("run_x", &events, &config());
622        assert_eq!(spans.len(), 1, "expected exactly one divergence: {spans:?}");
623        let s = &spans[0];
624        assert_eq!(s.category, BlockingCategory::TestFailure);
625        assert!(s.stated_fact.contains("still failing"));
626        assert!(s.contradicting_action.contains("mark task complete"));
627        assert_eq!(s.gap, 1);
628        assert!(
629            s.confidence >= 0.9,
630            "adjacent divergence should be high-confidence: {}",
631            s.confidence
632        );
633        assert_eq!(s.anchor, COHERENCE_ANCHOR);
634    }
635
636    #[test]
637    fn divergence_fires_permission_denied_then_commit() {
638        let events = [
639            TrajectoryEvent::statement("write failed: permission denied"),
640            TrajectoryEvent::action("git_commit", "commit the change"),
641        ];
642        let spans = CoherenceMonitor::scan_trajectory("run_x", &events, &config());
643        assert_eq!(spans.len(), 1);
644        assert_eq!(spans[0].category, BlockingCategory::PermissionDenied);
645    }
646
647    #[test]
648    fn divergence_fires_missing_file_then_report_success() {
649        let events = [
650            TrajectoryEvent::statement("config.yaml does not exist"),
651            TrajectoryEvent::statement("loading configuration"),
652            TrajectoryEvent::action("report", "completed successfully"),
653        ];
654        let spans = CoherenceMonitor::scan_trajectory("run_x", &events, &config());
655        assert_eq!(spans.len(), 1);
656        assert_eq!(spans[0].category, BlockingCategory::MissingResource);
657        assert_eq!(spans[0].gap, 2);
658    }
659
660    // -- Coherent runs do NOT fire (false-positive guard) ---------------------
661
662    #[test]
663    fn coherent_acknowledge_and_remediate_then_resolve_does_not_fire() {
664        // States the failure, remediates, states it's resolved, THEN commits.
665        let events = [
666            TrajectoryEvent::statement("tests still failing: 2 assertions"),
667            TrajectoryEvent::action("edit_file", "fix the off-by-one"),
668            TrajectoryEvent::statement("all tests pass now"),
669            TrajectoryEvent::action("git_commit", "commit the fix"),
670        ];
671        let spans = CoherenceMonitor::scan_trajectory("run_x", &events, &config());
672        assert!(spans.is_empty(), "resolved fact must not fire: {spans:?}");
673    }
674
675    #[test]
676    fn coherent_disclaiming_action_does_not_fire() {
677        // The action itself acknowledges the blocker — coherent.
678        let events = [
679            TrajectoryEvent::statement("permission denied on deploy"),
680            TrajectoryEvent::action(
681                "report",
682                "cannot mark complete: permission denied, escalating",
683            ),
684        ];
685        let spans = CoherenceMonitor::scan_trajectory("run_x", &events, &config());
686        assert!(
687            spans.is_empty(),
688            "disclaiming action must not fire: {spans:?}"
689        );
690    }
691
692    #[test]
693    fn clean_run_with_no_blocking_fact_does_not_fire() {
694        let events = [
695            TrajectoryEvent::statement("starting the task"),
696            TrajectoryEvent::action("git_commit", "commit the feature"),
697            TrajectoryEvent::action("report", "completed successfully"),
698        ];
699        let spans = CoherenceMonitor::scan_trajectory("run_x", &events, &config());
700        assert!(spans.is_empty(), "clean run must not fire: {spans:?}");
701    }
702
703    #[test]
704    fn remediation_action_alone_is_not_closure() {
705        // Re-running tests is remediation, not a closure action.
706        let events = [
707            TrajectoryEvent::statement("tests failed"),
708            TrajectoryEvent::action("run_tests", "re-running the suite"),
709        ];
710        let spans = CoherenceMonitor::scan_trajectory("run_x", &events, &config());
711        assert!(
712            spans.is_empty(),
713            "remediation is not a divergence: {spans:?}"
714        );
715    }
716
717    #[test]
718    fn generic_resolution_clears_all_open_facts() {
719        let events = [
720            TrajectoryEvent::statement("permission denied"),
721            TrajectoryEvent::statement("the issue has been resolved"),
722            TrajectoryEvent::action("git_commit", "commit"),
723        ];
724        let spans = CoherenceMonitor::scan_trajectory("run_x", &events, &config());
725        assert!(
726            spans.is_empty(),
727            "generic resolution must clear facts: {spans:?}"
728        );
729    }
730
731    // -- Window / threshold guards --------------------------------------------
732
733    #[test]
734    fn stale_fact_beyond_lookahead_does_not_fire() {
735        let mut cfg = config();
736        cfg.lookahead = 3;
737        let mut events = vec![TrajectoryEvent::statement("tests still failing")];
738        // Push the closure well past the lookahead window with neutral chatter.
739        for i in 0..6 {
740            events.push(TrajectoryEvent::statement(format!("status update {i}")));
741        }
742        events.push(TrajectoryEvent::action("set_status", "mark task complete"));
743        let spans = CoherenceMonitor::scan_trajectory("run_x", &events, &cfg);
744        assert!(spans.is_empty(), "stale fact must expire: {spans:?}");
745    }
746
747    #[test]
748    fn high_min_confidence_suppresses_low_confidence_divergence() {
749        let mut cfg = config();
750        cfg.min_confidence = 0.99; // unreachable for a generic, distant fact
751        let events = [
752            TrajectoryEvent::statement("error: something odd happened"),
753            TrajectoryEvent::statement("step a"),
754            TrajectoryEvent::statement("step b"),
755            TrajectoryEvent::statement("step c"),
756            TrajectoryEvent::action("report", "completed successfully"),
757        ];
758        let spans = CoherenceMonitor::scan_trajectory("run_x", &events, &cfg);
759        assert!(
760            spans.is_empty(),
761            "min_confidence gate must suppress: {spans:?}"
762        );
763    }
764
765    #[test]
766    fn disabled_config_never_fires() {
767        let mut cfg = config();
768        cfg.enabled = false;
769        let events = [
770            TrajectoryEvent::statement("tests still failing"),
771            TrajectoryEvent::action("set_status", "mark task complete"),
772        ];
773        let spans = CoherenceMonitor::scan_trajectory("run_x", &events, &cfg);
774        assert!(spans.is_empty());
775    }
776
777    // -- Streaming entry point ------------------------------------------------
778
779    #[tokio::test]
780    async fn observe_event_emits_exactly_at_the_contradicting_action() {
781        let monitor = CoherenceMonitor::new();
782        let run = run();
783        let cfg = config();
784
785        let first = monitor
786            .observe_event(
787                &run,
788                &TrajectoryEvent::statement("tests still failing"),
789                &cfg,
790            )
791            .await;
792        assert!(first.is_none(), "no span on the stated fact alone");
793
794        let second = monitor
795            .observe_event(
796                &run,
797                &TrajectoryEvent::action("set_status", "mark task complete"),
798                &cfg,
799            )
800            .await;
801        let span = second.expect("span must surface on the contradicting action");
802        assert_eq!(span.category, BlockingCategory::TestFailure);
803        assert_eq!(span.run_id, run.to_string());
804    }
805
806    #[tokio::test]
807    async fn clear_run_drops_state() {
808        let monitor = CoherenceMonitor::new();
809        let run = run();
810        let cfg = config();
811        monitor
812            .observe_event(
813                &run,
814                &TrajectoryEvent::statement("tests still failing"),
815                &cfg,
816            )
817            .await;
818        assert!(monitor.is_tracked(&run).await);
819        monitor.clear_run(&run).await;
820        assert!(!monitor.is_tracked(&run).await);
821        // After clear, the closure action has no open fact to contradict.
822        let span = monitor
823            .observe_event(
824                &run,
825                &TrajectoryEvent::action("set_status", "mark task complete"),
826                &cfg,
827            )
828            .await;
829        assert!(span.is_none());
830    }
831
832    // -- Diff-compatibility with the audit schema -----------------------------
833
834    #[test]
835    fn response_level_maps_severity_to_r_tier() {
836        let mut span = CoherenceMonitor::scan_trajectory(
837            "run_x",
838            &[
839                TrajectoryEvent::statement("tests still failing"),
840                TrajectoryEvent::action("set_status", "mark task complete"),
841            ],
842            &config(),
843        )
844        .remove(0);
845        // Default risk_score (70) → High → R3.
846        span.risk_score = 90;
847        span.risk_level = RiskLevel::from_score(90);
848        assert_eq!(span.response_level(), ResponseLevel::RequireApproval);
849        span.risk_level = RiskLevel::from_score(65);
850        assert_eq!(span.response_level(), ResponseLevel::RequireApproval);
851        span.risk_level = RiskLevel::from_score(45);
852        assert_eq!(span.response_level(), ResponseLevel::AllowUnderBudget);
853        span.risk_level = RiskLevel::from_score(10);
854        assert_eq!(span.response_level(), ResponseLevel::AllowAndLog);
855    }
856
857    #[test]
858    fn span_projects_onto_airlock_violation() {
859        let events = [
860            TrajectoryEvent::statement("tests still failing"),
861            TrajectoryEvent::action("set_status", "mark task complete"),
862        ];
863        let span = &CoherenceMonitor::scan_trajectory("run_x", &events, &config())[0];
864        let violation = span.to_violation();
865        assert_eq!(violation.violation_type, ViolationType::CoherenceDivergence);
866        assert_eq!(violation.trigger, "coherence_divergence:test_failure");
867        // details round-trips back to the span.
868        let recovered: CoherenceSpan = serde_json::from_str(&violation.details).unwrap();
869        assert_eq!(recovered.category, BlockingCategory::TestFailure);
870        assert_eq!(recovered.run_id, "run_x");
871    }
872
873    #[test]
874    fn long_quote_is_clipped() {
875        let long = "tests still failing ".repeat(100);
876        let events = [
877            TrajectoryEvent::statement(long),
878            TrajectoryEvent::action("set_status", "mark task complete"),
879        ];
880        let span = &CoherenceMonitor::scan_trajectory("run_x", &events, &config())[0];
881        assert!(span.stated_fact.chars().count() <= MAX_QUOTE_CHARS + 1);
882    }
883
884    #[test]
885    fn multiple_divergences_in_one_trajectory() {
886        let events = [
887            TrajectoryEvent::statement("tests still failing"),
888            TrajectoryEvent::action("set_status", "mark task complete"),
889            TrajectoryEvent::statement("permission denied"),
890            TrajectoryEvent::action("git_commit", "commit anyway"),
891        ];
892        let spans = CoherenceMonitor::scan_trajectory("run_x", &events, &config());
893        assert_eq!(spans.len(), 2, "both divergences should fire: {spans:?}");
894        assert_eq!(spans[0].category, BlockingCategory::TestFailure);
895        assert_eq!(spans[1].category, BlockingCategory::PermissionDenied);
896    }
897
898    // -- Cross-plane parity ---------------------------------------------------
899
900    /// The Python eval-plane mirror (`fd_evals.coherence`) asserts this exact
901    /// same golden fixture in `test_coherence.py`. Feeding each case through
902    /// the Rust detection core must yield the same ordered divergence
903    /// categories the Python plane expects — so the two cores can never
904    /// silently drift.
905    #[test]
906    fn golden_fixture_matches_python() {
907        let path = concat!(
908            env!("CARGO_MANIFEST_DIR"),
909            "/../../../python/packages/fd-evals/tests/fixtures/coherence_divergence.golden.json"
910        );
911        let raw = std::fs::read_to_string(path).expect("read coherence golden fixture");
912        let data: serde_json::Value = serde_json::from_str(&raw).expect("parse fixture");
913        assert_eq!(data["anchor"], COHERENCE_ANCHOR);
914
915        let cfg = config();
916        for case in data["cases"].as_array().expect("cases array") {
917            let name = case["name"].as_str().unwrap_or("");
918            let events: Vec<TrajectoryEvent> = case["events"]
919                .as_array()
920                .expect("events array")
921                .iter()
922                .map(|e| {
923                    let text = e["text"].as_str().unwrap_or("").to_string();
924                    match e["kind"].as_str() {
925                        Some("action") => {
926                            TrajectoryEvent::action(e["name"].as_str().unwrap_or(""), text)
927                        }
928                        _ => TrajectoryEvent::statement(text),
929                    }
930                })
931                .collect();
932            let got: Vec<&str> = CoherenceMonitor::scan_trajectory(name, &events, &cfg)
933                .iter()
934                .map(|s| s.category.label())
935                .collect();
936            let expected: Vec<&str> = case["expected_categories"]
937                .as_array()
938                .expect("expected array")
939                .iter()
940                .map(|c| c.as_str().unwrap_or(""))
941                .collect();
942            assert_eq!(got, expected, "case {name}");
943        }
944    }
945}