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