Skip to main content

clawft_kernel/
coherence.rs

1//! Conversation coherence as a **soft dampener**, not a hard gate (ADR-062 D6).
2//!
3//! The RSTE coherence read (`engines/04-RSTE.md §5.2`) scores how well the
4//! conversation graph hangs together. Below a threshold the Talk-Mode loop
5//! (Phase 2) does **not** block — it nudges, multiplying positive urgency
6//! scores by [`DAMPEN_FACTOR`] (×0.8) until coherence recovers. The dampener is
7//! hysteretic ("until recovery"): once tripped it stays engaged through the
8//! drift band and only clears when coherence climbs back to the recovery line.
9//!
10//! [`ImpulseType::CoherenceAlert`](crate::impulse::ImpulseType::CoherenceAlert)
11//! already exists; emitting it on a [`CoherenceBand::Warning`]/`Break` is Phase
12//! 2 work. This module is the read + the dampener only.
13
14// ── Coherence weights (ADR-062 D6 / RSTE §5.2 — sum to 1.0) ───────────────────
15
16/// Weight on relation coverage (fraction of expected relations present).
17pub const W_RELATION_COVERAGE: f32 = 0.30;
18/// Weight on mean relation confidence.
19pub const W_RELATION_CONFIDENCE: f32 = 0.25;
20/// Weight on structural connectivity of the graph.
21pub const W_STRUCTURAL_CONNECTIVITY: f32 = 0.20;
22/// Weight on QAP (question–answer pair) closure.
23pub const W_QAP_CLOSURE: f32 = 0.15;
24/// Weight on topic continuity across turns.
25pub const W_TOPIC_CONTINUITY: f32 = 0.10;
26
27/// The soft-dampening multiplier applied to positive scores while drifting.
28pub const DAMPEN_FACTOR: f32 = 0.8;
29
30/// Default coherence below which the dampener trips (the Strong/MildDrift
31/// boundary's lower neighbour — entering Warning territory).
32pub const DEFAULT_DAMPEN_BELOW: f32 = 0.65;
33/// Default coherence at/above which the dampener clears (back to Strong).
34pub const DEFAULT_RECOVER_AT: f32 = 0.85;
35
36// ── Coherence signals & read ─────────────────────────────────────────────────
37
38/// The five RSTE inputs feeding [`compute_coherence`]. Each `f32` is expected in
39/// `[0, 1]`; values are clamped.
40#[derive(Debug, Clone, Copy)]
41pub struct CoherenceSignals {
42    /// Fraction of expected relations actually present.
43    pub relation_coverage: f32,
44    /// Mean confidence of the present relations.
45    pub relation_confidence: f32,
46    /// Structural connectivity of the conversation graph.
47    pub structural_connectivity: f32,
48    /// Question–answer pair closure.
49    pub qap_closure: f32,
50    /// Topic continuity across turns.
51    pub topic_continuity: f32,
52}
53
54impl CoherenceSignals {
55    /// Coherence in `[0, 1]` (see [`compute_coherence`]).
56    pub fn score(&self) -> f32 {
57        compute_coherence(self)
58    }
59}
60
61/// Weighted RSTE coherence read in `[0, 1]` (ADR-062 D6).
62///
63/// ```text
64/// coherence = 0.30·coverage + 0.25·confidence + 0.20·connectivity
65///           + 0.15·qap_closure + 0.10·topic_continuity
66/// ```
67pub fn compute_coherence(s: &CoherenceSignals) -> f32 {
68    let c = |v: f32| v.clamp(0.0, 1.0);
69    W_RELATION_COVERAGE * c(s.relation_coverage)
70        + W_RELATION_CONFIDENCE * c(s.relation_confidence)
71        + W_STRUCTURAL_CONNECTIVITY * c(s.structural_connectivity)
72        + W_QAP_CLOSURE * c(s.qap_closure)
73        + W_TOPIC_CONTINUITY * c(s.topic_continuity)
74}
75
76/// Coherence band (ADR-062 D6 bands).
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum CoherenceBand {
79    /// ≥ 0.85 — strong, no action.
80    Strong,
81    /// 0.65–0.85 — mild drift; floor weights toward coherence-restorers.
82    MildDrift,
83    /// 0.40–0.65 — warning; refocus prompt (would emit `CoherenceAlert`).
84    Warning,
85    /// < 0.40 — break (would emit `CoherenceAlert`).
86    Break,
87}
88
89impl CoherenceBand {
90    /// Classify a coherence score into its band.
91    pub fn from_score(coherence: f32) -> Self {
92        if coherence >= 0.85 {
93            CoherenceBand::Strong
94        } else if coherence >= 0.65 {
95            CoherenceBand::MildDrift
96        } else if coherence >= 0.40 {
97            CoherenceBand::Warning
98        } else {
99            CoherenceBand::Break
100        }
101    }
102
103    /// Whether this band warrants a `CoherenceAlert` (Warning or Break).
104    pub fn is_alerting(self) -> bool {
105        matches!(self, CoherenceBand::Warning | CoherenceBand::Break)
106    }
107}
108
109// ── Soft dampener ────────────────────────────────────────────────────────────
110
111/// A hysteretic soft dampener (ADR-062 D6): below `dampen_below` it engages and
112/// multiplies positive scores by `factor` (×0.8); it only disengages once
113/// coherence reaches `recover_at`. Between the two it holds its prior state —
114/// this is "×0.8 until recovery", never a hard gate.
115#[derive(Debug, Clone)]
116pub struct CoherenceDampener {
117    dampen_below: f32,
118    recover_at: f32,
119    factor: f32,
120    dampened: bool,
121}
122
123impl Default for CoherenceDampener {
124    fn default() -> Self {
125        Self::new()
126    }
127}
128
129impl CoherenceDampener {
130    /// A dampener with the ADR-062 default thresholds (engage < 0.65, clear ≥
131    /// 0.85, factor 0.8).
132    pub fn new() -> Self {
133        Self {
134            dampen_below: DEFAULT_DAMPEN_BELOW,
135            recover_at: DEFAULT_RECOVER_AT,
136            factor: DAMPEN_FACTOR,
137            dampened: false,
138        }
139    }
140
141    /// A dampener with explicit thresholds. `recover_at` should be ≥
142    /// `dampen_below` for the hysteresis band to make sense.
143    pub fn with_thresholds(dampen_below: f32, recover_at: f32, factor: f32) -> Self {
144        Self {
145            dampen_below,
146            recover_at,
147            factor,
148            dampened: false,
149        }
150    }
151
152    /// Feed the latest coherence read; updates the engaged/cleared state with
153    /// hysteresis and returns whether the dampener is now engaged.
154    pub fn observe(&mut self, coherence: f32) -> bool {
155        if coherence < self.dampen_below {
156            self.dampened = true;
157        } else if coherence >= self.recover_at {
158            self.dampened = false;
159        }
160        // Otherwise hold prior state (hysteresis band).
161        self.dampened
162    }
163
164    /// Whether the dampener is currently engaged.
165    pub fn is_dampened(&self) -> bool {
166        self.dampened
167    }
168
169    /// Apply the dampener to a score: while engaged, **positive** scores are
170    /// scaled by `factor`; zero/negative scores pass through unchanged (a
171    /// dampener weakens pressure, it does not invent it).
172    pub fn apply(&self, score: f32) -> f32 {
173        if self.dampened && score > 0.0 {
174            score * self.factor
175        } else {
176            score
177        }
178    }
179
180    /// Force the dampener back to the cleared state (e.g. on calibration).
181    pub fn reset(&mut self) {
182        self.dampened = false;
183    }
184}
185
186// ── Tests ────────────────────────────────────────────────────────────────────
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    fn signals(cov: f32, conf: f32, conn: f32, qap: f32, topic: f32) -> CoherenceSignals {
193        CoherenceSignals {
194            relation_coverage: cov,
195            relation_confidence: conf,
196            structural_connectivity: conn,
197            qap_closure: qap,
198            topic_continuity: topic,
199        }
200    }
201
202    #[test]
203    fn coherence_matches_hand_computed() {
204        // 0.30*0.8 + 0.25*0.9 + 0.20*0.7 + 0.15*0.6 + 0.10*1.0
205        // = 0.24 + 0.225 + 0.14 + 0.09 + 0.10 = 0.795
206        let s = signals(0.8, 0.9, 0.7, 0.6, 1.0);
207        assert!((compute_coherence(&s) - 0.795).abs() < 1e-6);
208    }
209
210    #[test]
211    fn perfect_coherence_is_one() {
212        let s = signals(1.0, 1.0, 1.0, 1.0, 1.0);
213        assert!((compute_coherence(&s) - 1.0).abs() < 1e-6);
214    }
215
216    #[test]
217    fn bands_classify_correctly() {
218        assert_eq!(CoherenceBand::from_score(0.95), CoherenceBand::Strong);
219        assert_eq!(CoherenceBand::from_score(0.85), CoherenceBand::Strong);
220        assert_eq!(CoherenceBand::from_score(0.70), CoherenceBand::MildDrift);
221        assert_eq!(CoherenceBand::from_score(0.50), CoherenceBand::Warning);
222        assert_eq!(CoherenceBand::from_score(0.20), CoherenceBand::Break);
223        assert!(!CoherenceBand::Strong.is_alerting());
224        assert!(!CoherenceBand::MildDrift.is_alerting());
225        assert!(CoherenceBand::Warning.is_alerting());
226        assert!(CoherenceBand::Break.is_alerting());
227    }
228
229    /// The dampener applies below threshold and clears on recovery, with
230    /// hysteresis in the drift band (ADR-062 D6 Done-when).
231    #[test]
232    fn dampener_applies_below_threshold_and_clears_on_recovery() {
233        let mut d = CoherenceDampener::new();
234        // Starts cleared: scores pass through.
235        assert!(!d.is_dampened());
236        assert!((d.apply(1.0) - 1.0).abs() < 1e-6);
237
238        // Drop below 0.65 → engaged; positive scores ×0.8.
239        assert!(d.observe(0.50));
240        assert!(d.is_dampened());
241        assert!((d.apply(1.0) - 0.8).abs() < 1e-6);
242        assert!((d.apply(0.5) - 0.4).abs() < 1e-6);
243        // Zero/negative scores are untouched.
244        assert_eq!(d.apply(0.0), 0.0);
245        assert!((d.apply(-0.3) - (-0.3)).abs() < 1e-6);
246
247        // In the hysteresis band (0.65–0.85) the dampener holds.
248        assert!(d.observe(0.70));
249        assert!(d.is_dampened());
250        assert!((d.apply(1.0) - 0.8).abs() < 1e-6);
251
252        // Recover to ≥ 0.85 → cleared; full pressure restored.
253        assert!(!d.observe(0.90));
254        assert!(!d.is_dampened());
255        assert!((d.apply(1.0) - 1.0).abs() < 1e-6);
256    }
257
258    #[test]
259    fn dampener_reset_clears_state() {
260        let mut d = CoherenceDampener::new();
261        d.observe(0.1);
262        assert!(d.is_dampened());
263        d.reset();
264        assert!(!d.is_dampened());
265    }
266
267    #[test]
268    fn custom_thresholds_respected() {
269        let mut d = CoherenceDampener::with_thresholds(0.4, 0.6, 0.5);
270        assert!(d.observe(0.3));
271        assert!((d.apply(1.0) - 0.5).abs() < 1e-6);
272        // 0.5 is in the hysteresis band → still dampened.
273        assert!(d.observe(0.5));
274        // 0.6 clears.
275        assert!(!d.observe(0.6));
276    }
277}