noos 0.2.1

Reliability layer for Rust LLM agents: scope drift, cost circuit breaks, and procedural correction memory as event-driven Decisions.
Documentation
//! Scope tracker — detects when an LLM response drifts beyond the
//! keywords of the user's task.
//!
//! **Scope note (P1 / P9b)**: like the other `regulator` sub-modules,
//! this is an I/O adapter, not a cognitive module. Keyword extraction
//! reuses [`cognition::detector::extract_topics`](crate::cognition::detector::extract_topics)
//! per P3 — that utility strips stop-words and short tokens and returns
//! a small sorted set of "meaningful words". Here those sets are
//! treated as opaque keyword bags, not cognitive topic models. The only
//! computation performed is a set-difference ratio — no sentiment
//! lexicon, no topic reasoning. P1 applies to the wrapped
//! [`CognitiveSession`](crate::session::CognitiveSession); P9b is
//! satisfied by construction.
//!
//! ## Drift metric (MVP)
//!
//! ```text
//! drift_score = |response_keywords \ task_keywords| / |response_keywords|
//! ```
//!
//! - `0.0` = every meaningful word in the response also appears in the
//!   task (minimal expansion).
//! - `1.0` = the response's keyword bag is disjoint from the task's
//!   (the Session 18 plan example: task "refactor function" vs
//!   response "add logging + error handling").
//! - Returns `None` when either keyword bag is empty (no baseline).
//!
//! The metric is response-centric: it asks *"how much of the response
//! has no anchor in the task"*. It will flag verbose or pedagogical
//! responses even when they're on-topic (discussing background
//! concepts), which is the expected false-positive regime the plan's
//! Session 18 decision checkpoint measures. Embedding-based detection
//! is deferred until the 10-case false-positive audit runs.
//!
//! ## Reuse-vs-roll decision for `detector` (Session 18)
//!
//! Reused, three functions:
//!
//! - [`extract_topics`](crate::cognition::detector::extract_topics) —
//!   keyword extraction (stop-word filter, min-length 3, top-10
//!   alphabetical). Already used by `memory/retrieval.rs`.
//! - [`to_topic_set`](crate::cognition::detector::to_topic_set) —
//!   lowercased `HashSet<String>` builder for O(1) membership lookup.
//! - [`count_topic_overlap`](crate::cognition::detector::count_topic_overlap) —
//!   intersection size between a token list and a topic set.
//!
//! Rolling any of these inline would duplicate the same lowercasing +
//! stop-word logic the crate already ships in one place (P3 violation
//! precedent: the Session 18 first-pass did this and was corrected
//! before Session 19). The only scope-specific logic below is the
//! set-difference *subtraction* (`|response| − overlap`) and the
//! `drift_tokens` filter — neither has a pre-existing utility.
//!
//! If the Session 18 decision checkpoint surfaces a specific regime
//! where `extract_topics` is wrong for scope-drift (e.g. alphabetical
//! sort clipping important long-tail keywords after position 10),
//! revisit then — not preemptively.

use serde::{Deserialize, Serialize};

use crate::cognition::detector;

// ── Constants ──────────────────────────────────────────────────────────

/// Drift score at or above which [`Regulator::decide`](super::Regulator::decide)
/// emits [`Decision::ScopeDriftWarn`](super::Decision::ScopeDriftWarn).
///
/// Set at 0.5 — the plan test target ("refactor function" vs "add
/// logging + error handling") produces `drift_score = 1.0` (disjoint
/// keyword bags), so this threshold fires unambiguously on the target
/// case while leaving headroom for on-topic-but-verbose responses in
/// the [0.3, 0.5) band. The Session 18 decision checkpoint measures
/// false-positive rate on 10 hand-crafted cases; if FPR > 20%, this
/// constant (or the metric itself) is the first knob to revisit.
pub const DRIFT_WARN_THRESHOLD: f64 = 0.5;

// ── ScopeTracker ───────────────────────────────────────────────────────

/// Per-turn scope state: task keywords (from the user's message) and
/// response keywords (from the LLM's output).
///
/// Lifecycle is per-turn: [`set_task`](Self::set_task) clears any
/// previous response before loading the new task keywords, and
/// [`set_response`](Self::set_response) is called once the LLM output
/// settles. [`drift_score`](Self::drift_score) returns `None` while
/// either side is empty, so an in-progress turn never triggers a false
/// warning.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ScopeTracker {
    /// Keywords extracted from the user's task message (opaque, in the
    /// `detector::extract_topics` sense: lowercased, stop-words
    /// filtered, min-length 3, top-10 alphabetical).
    task_keywords: Vec<String>,
    /// Keywords extracted from the LLM's response.
    response_keywords: Vec<String>,
}

impl ScopeTracker {
    /// Construct an empty tracker. Equivalent to `Self::default()`.
    pub fn new() -> Self {
        Self::default()
    }

    /// Mutable: set the task keywords from the user's message and clear
    /// any stale response keywords from a previous turn. Requires
    /// mutation because scope state is per-turn — a fresh task must
    /// reset the baseline.
    pub fn set_task(&mut self, user_message: &str) {
        self.task_keywords = detector::extract_topics(user_message);
        self.response_keywords.clear();
    }

    /// Mutable: set the response keywords. Requires mutation because
    /// the tracker accumulates per-turn evidence before drift can be
    /// computed.
    pub fn set_response(&mut self, full_response: &str) {
        self.response_keywords = detector::extract_topics(full_response);
    }

    /// Keywords extracted from the most recent `set_task` call. Empty
    /// before any task is set.
    pub fn task_tokens(&self) -> &[String] {
        &self.task_keywords
    }

    /// Keywords extracted from the most recent `set_response` call.
    /// Empty before any response is set or after `set_task` resets the
    /// turn.
    pub fn response_tokens(&self) -> &[String] {
        &self.response_keywords
    }

    /// Drift score in `[0, 1]` — fraction of response keywords that do
    /// not appear in the task. Returns `None` when either bag is empty
    /// (no baseline for comparison).
    ///
    /// See module docs for the metric definition and its known
    /// false-positive regime.
    pub fn drift_score(&self) -> Option<f64> {
        if self.task_keywords.is_empty() || self.response_keywords.is_empty() {
            return None;
        }
        let task_set = detector::to_topic_set(&self.task_keywords);
        let overlap = detector::count_topic_overlap(&self.response_keywords, &task_set);
        let non_task = self.response_keywords.len() - overlap;
        Some(non_task as f64 / self.response_keywords.len() as f64)
    }

    /// Response keywords that do not appear in the task — the concrete
    /// "drift" set surfaced in [`Decision::ScopeDriftWarn`](super::Decision::ScopeDriftWarn).
    ///
    /// Empty when no response is set or when every response keyword is
    /// anchored in the task. Ordering mirrors
    /// [`response_tokens`](Self::response_tokens) (alphabetical).
    pub fn drift_tokens(&self) -> Vec<String> {
        let task_set = detector::to_topic_set(&self.task_keywords);
        self.response_keywords
            .iter()
            .filter(|k| !task_set.contains(&k.to_lowercase()))
            .cloned()
            .collect()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn empty_tracker_has_no_drift_score() {
        let tracker = ScopeTracker::new();
        assert!(tracker.drift_score().is_none());
        assert!(tracker.task_tokens().is_empty());
        assert!(tracker.response_tokens().is_empty());
        assert!(tracker.drift_tokens().is_empty());
    }

    #[test]
    fn task_only_has_no_drift_score() {
        // Without a response, there's nothing to compare against —
        // the tracker returns None rather than claiming zero drift.
        let mut tracker = ScopeTracker::new();
        tracker.set_task("refactor this function to be async");
        assert!(!tracker.task_tokens().is_empty());
        assert!(tracker.drift_score().is_none());
    }

    #[test]
    fn response_only_has_no_drift_score() {
        // Without a task, there's no baseline — same None contract.
        let mut tracker = ScopeTracker::new();
        tracker.set_response("Here is the refactored async function.");
        assert!(!tracker.response_tokens().is_empty());
        assert!(tracker.drift_score().is_none());
    }

    #[test]
    fn plan_example_flags_high_drift() {
        // Session 18 test target from the architecture plan:
        // task "refactor function" vs response "add logging + error
        // handling" → drift_score > 0.3. With disjoint keyword bags we
        // expect the maximum score 1.0.
        let mut tracker = ScopeTracker::new();
        tracker.set_task("refactor this function to be async");
        tracker.set_response("add logging and error handling");
        let drift = tracker.drift_score().expect("both sides populated");
        assert!(
            drift > 0.3,
            "plan test target must produce drift > 0.3 (got {drift})"
        );
        // Additionally: at or above the warning threshold — the
        // Regulator-level test confirms this triggers ScopeDriftWarn.
        assert!(drift >= DRIFT_WARN_THRESHOLD);
    }

    #[test]
    fn on_task_response_keeps_drift_low() {
        // Response that echoes and confirms the task keywords should
        // sit well below the warning threshold. We intentionally
        // constrain the response vocabulary so the metric behaves
        // predictably; verbose on-task responses drift higher and are
        // the known false-positive regime measured by the decision
        // checkpoint.
        let mut tracker = ScopeTracker::new();
        tracker.set_task("refactor the async function");
        tracker.set_response("refactor async function");
        let drift = tracker.drift_score().expect("both sides populated");
        assert!(
            drift < DRIFT_WARN_THRESHOLD,
            "minimal on-task response should stay under warning threshold (got {drift})"
        );
    }

    #[test]
    fn drift_tokens_only_contains_non_task_keywords() {
        let mut tracker = ScopeTracker::new();
        tracker.set_task("refactor the async function");
        tracker.set_response("add logging telemetry for the async function");
        let drift_tokens = tracker.drift_tokens();
        // Task keywords: {refactor, async, function} (stop-words "the"
        // filtered, min-len 3). Drift keywords should NOT include any
        // task keyword.
        for token in tracker.task_tokens() {
            assert!(
                !drift_tokens.contains(token),
                "drift_tokens must not contain task keyword {token:?}"
            );
        }
        // Drift set should be non-empty on this input (added logging /
        // telemetry / add / for).
        assert!(!drift_tokens.is_empty());
    }

    #[test]
    fn set_task_resets_previous_response() {
        // A new task invalidates the previous turn's response — the
        // tracker must clear it so drift isn't computed against stale
        // data.
        let mut tracker = ScopeTracker::new();
        tracker.set_task("refactor the async function");
        tracker.set_response("add logging and error handling");
        assert!(tracker.drift_score().is_some());

        // New turn starts — response cleared, only task present.
        tracker.set_task("explain tokio runtime");
        assert!(tracker.response_tokens().is_empty());
        assert!(
            tracker.drift_score().is_none(),
            "new task must clear stale response"
        );
    }

    #[test]
    fn drift_score_bounded_zero_to_one() {
        // Defensive: whatever the inputs, the metric must stay in
        // [0, 1]. Otherwise downstream threshold logic breaks.
        let mut tracker = ScopeTracker::new();
        tracker.set_task("task keyword");
        tracker.set_response("completely different response content");
        let drift = tracker.drift_score().expect("both sides populated");
        assert!((0.0..=1.0).contains(&drift));
    }

    /// Design checkpoint: false-positive-rate audit on 10 hand-crafted
    /// cases.
    ///
    /// Does keyword-overlap produce believable drift scores on real text?
    /// If total error rate (FPR + FNR) > 20% on 10 hand-crafted test cases,
    /// reconsider embedding-based detection.
    ///
    /// This test encodes the audit. Each case has a manually-assigned
    /// ground-truth label (`should_flag`) and the test asserts that
    /// the keyword-overlap metric mis-classifies no more than 2 of 10
    /// (≤ 20% total error rate, which bounds FPR + FNR above the
    /// specific 20% FPR figure).
    #[test]
    fn decision_checkpoint_fpr_on_hand_crafted_cases() {
        // Each case: (task, response, expected-to-flag).
        // Drift-expected (should flag):
        //   D1 — plan target: refactor vs logging/errors
        //   D2 — unrelated recipe
        //   D3 — JS answer to SQL question
        //   D4 — architecture musings in a bug-fix request
        //   D5 — cake recipe when asked about docker
        // Non-drift (should NOT flag):
        //   N1 — minimal on-task echo
        //   N2 — on-task with light restatement
        //   N3 — explain-tokio → tokio-focused answer
        //   N4 — fix-error → fix-error answer
        //   N5 — explain-jwt → jwt-focused answer
        let cases: &[(&str, &str, bool)] = &[
            (
                "refactor this function to be async",
                "add logging and error handling",
                true, // D1: plan target
            ),
            (
                "explain tokio runtime",
                "here is a recipe for chocolate cake with frosting",
                true, // D2
            ),
            (
                "help me with SQL queries",
                "JavaScript frameworks overview: React, Vue, Angular",
                true, // D3
            ),
            (
                "fix the authentication bug",
                "my thoughts on microservice architecture patterns",
                true, // D4
            ),
            (
                "explain docker containers",
                "chocolate cake baking instructions with butter",
                true, // D5
            ),
            (
                "refactor async function",
                "refactor async function",
                false, // N1: identical → 0 drift
            ),
            (
                "refactor the async function",
                "refactored async function returned",
                false, // N2: stemming would help but on-task overall
            ),
            (
                "tokio async runtime rust",
                "tokio async runtime rust futures scheduling",
                false, // N3: response extends task keywords
            ),
            (
                "fix error authentication rust",
                "fix error authentication rust verify",
                false, // N4: response stays within task vocabulary
            ),
            (
                "jwt token format explain",
                "jwt token format explain signature",
                false, // N5: minimal extension
            ),
        ];

        let mut mis_classifications = 0usize;
        let mut report = String::new();
        for (task, response, should_flag) in cases {
            let mut tracker = ScopeTracker::new();
            tracker.set_task(task);
            tracker.set_response(response);
            let drift = tracker.drift_score().unwrap_or(0.0);
            let flagged = drift >= DRIFT_WARN_THRESHOLD;
            let correct = flagged == *should_flag;
            if !correct {
                mis_classifications += 1;
            }
            report.push_str(&format!(
                "  [{}] task={:?} resp={:?} drift={:.2} flag={} expected={} {}\n",
                if correct { "" } else { "" },
                task,
                response,
                drift,
                flagged,
                should_flag,
                if correct { "" } else { "← MIS" },
            ));
        }

        let total = cases.len();
        let error_rate = mis_classifications as f64 / total as f64;
        assert!(
            error_rate <= 0.2,
            "decision checkpoint failed: {mis_classifications}/{total} \
             mis-classified ({:.0}% error rate, bar ≤ 20%). Report:\n{report}",
            error_rate * 100.0
        );
    }
}