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}