noos 0.2.1

Reliability layer for Rust LLM agents: scope drift, cost circuit breaks, and procedural correction memory as event-driven Decisions.
Documentation
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
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
//! Adaptive Thresholds — universal precision gain control.
//!
//! Brain analog: Friston 2010 (precision modulates all processing),
//! Moran 2013 (gain control), Ernst & Banks 2002 (Bayesian optimal combination).
//!
//! Instead of hardcoded thresholds, each threshold adapts based on current
//! cognitive state (sensory PE, arousal, gate confidence, PE volatility).
//!
//! ## Gating (P10) — weights ARE the gating rules
//!
//! Each `ThresholdProfile` encodes priority/gating **implicitly** via its
//! weights. Signal dominance is defined by weight magnitude and sign:
//!
//! - `pe_weight > 0`: high PE raises the threshold (become conservative
//!   under surprise — match SC SIGNALS v2 "don't redirect when uncertain")
//! - `arousal_weight < 0`: high arousal LOWERS the threshold (amygdala
//!   low-road makes salient signals easier to ignite — LeDoux 1996)
//! - `confidence_weight > 0`: high confidence raises the threshold (trust
//!   the current precision, don't re-ignite spurious signals)
//! - `volatility_weight`: widens precision window when environment changes
//!   (Behrens 2007 — volatile world needs broader priors)
//!
//! This is the **single source of gating** in Noos: all other modules read
//! adaptive thresholds and inherit their priority structure. To add a new
//! gating rule, register a new threshold with the appropriate profile.
//!
//! Pure functions, <1ms, $0 LLM cost.

use crate::math::clamp;
use crate::types::belief::AffectValence;

// ── Shared Constants ───────────────────────────────────────────────────

/// Body budget level considered "healthy" — above this, no conservation pressure.
/// Below this, depletion drives both `threshold_body_budget_conservation` (when
/// to start conserving in delta modulation / sampling) AND threat-perception
/// modulation in `build_threshold_context` (deficit increases sensory PE bias).
///
/// **Calibrated 2026-04-14** (phase 6) — see
/// `threshold_body_budget_conservation` for the calibration analysis.
/// Two consumers MUST share this value to stay coherent: if conservation
/// triggers at budget < X, threat-perception bias should also key off X.
/// Previously the value was hardcoded inline at both sites (P3 violation
/// found in 2026-04-14 audit).
pub const BODY_BUDGET_HEALTHY_THRESHOLD: f64 = 0.70;

// ── Types ──────────────────────────────────────────────────────────────

/// Weights for how each state signal modulates a threshold.
#[derive(Debug, Clone)]
pub struct ThresholdProfile {
    /// Sensory PE weight (positive = conservative when surprised).
    pub pe_weight: f64,
    /// Arousal weight (negative = more sensitive under stress).
    pub arousal_weight: f64,
    /// Gate confidence weight (negative = lower threshold when confident).
    pub confidence_weight: f64,
    /// PE volatility weight (positive = widen threshold in volatile contexts).
    pub volatility_weight: f64,
}

/// Current cognitive state for threshold adaptation.
#[derive(Debug, Clone)]
pub struct ThresholdContext {
    /// 0-1, sensory prediction error.
    pub sensory_pe: f64,
    /// 0-1, emotional arousal.
    pub arousal: f64,
    /// 0-1, thalamic gate confidence.
    pub gate_confidence: f64,
    /// 0-1, PE volatility (Behrens 2007).
    pub pe_volatility: f64,
}

/// A registered threshold with base value and adaptation profile.
#[derive(Debug, Clone)]
pub struct ThresholdRegistration {
    pub id: String,
    pub base: f64,
    pub profile: ThresholdProfile,
    pub min: f64,
    pub max: f64,
}

/// Precision-weighted prediction error from a dimension.
#[derive(Debug, Clone)]
pub struct DimensionPE {
    /// 0-1, prediction error magnitude.
    pub pe: f64,
    /// Inverse variance — higher = more reliable.
    pub precision: f64,
}

// ── Pre-Registered Thresholds ──────────────────────────────────────────
//
// Each threshold is a ThresholdRegistration consumed via `get_adaptive_threshold(&t, &ctx)`.
// Removed orphans (phase 13, 2026-04-14):
//   - `threshold_ignition` — was wired by `integration.rs` Global Workspace ignition; module
//     removed, no remaining callers. Re-add with a live caller if Dehaene-style ignition gating
//     is re-introduced.
//   - `threshold_arousal_encoding` — flashbulb-memory encoding threshold, never wired to any
//     caller. Available in git history if memory-consolidation gating needs it.

// ── Intervention Thresholds (Tier 1) ──────────────────────────────────

/// Body budget conservation onset (Barrett 2017, Sterling 2012).
/// Below this threshold, system conserves (lower temp/top_p).
/// High PE and low confidence accelerate conservation.
///
/// **Calibrated 2026-04-14** (phase 6) to reach signal saturation (~0.5+ on
/// `signals.conservation`) under realistic sustained-stress + low-quality
/// work (simulating an agent struggling through hard content). Previous
/// base 0.30 kept `budget_factor` at 0 for typical session lengths —
/// conservation signal topped out ~0.30, never reaching the decision-grade
/// range implied by `docs/app-contract.md`. Raised base to 0.70 so
/// `budget_factor` starts contributing at 70% remaining budget — an "early
/// warning" zone rather than "near empty".
///
/// Relation to `COST_DEPLETION_RATE` (0.02 in `src/session.rs`): per-turn
/// cost depletion × avg cost ≈ 0.011/turn. With low reported quality
/// (negative RPE, no replenishment), body_budget drops below 0.70 after
/// ~30 turns of sustained high-cost reporting. At that point, sustained
/// (0.20) + resource_pressure (0.045) + budget_factor (rising) combine to
/// cross the 0.5 decision point documented in app-contract.md §1.1.
///
/// High-quality responses replenish body_budget via positive RPE in
/// consolidate — this is the correct allostatic behavior: successful work
/// doesn't trigger conservation even if it's costly. Conservation fires
/// when cost AND poor outcomes BOTH apply.
pub fn threshold_body_budget_conservation() -> ThresholdRegistration {
    ThresholdRegistration {
        id: "body-budget-conservation".into(),
        base: BODY_BUDGET_HEALTHY_THRESHOLD,
        profile: ThresholdProfile {
            pe_weight: 0.10,      // More surprise → conserve sooner
            arousal_weight: 0.05, // High arousal → conserve sooner
            confidence_weight: -0.10, // Low confidence → conserve sooner
            volatility_weight: 0.0,
        },
        min: 0.40,
        max: 0.85,
    }
}

/// Arousal intervention onset — frequency penalty under threat (LeDoux 1996).
/// High arousal + negative valence triggers tunnel vision prevention.
/// Base 0.40: calibrated to emotional.rs output range (stress ≈ 0.5-0.6).
/// Volatile environments lower the threshold (more sensitive to threat).
pub fn threshold_arousal_intervention() -> ThresholdRegistration {
    ThresholdRegistration {
        id: "arousal-intervention".into(),
        base: 0.40,
        profile: ThresholdProfile {
            pe_weight: -0.05,     // High PE → lower threshold (more vigilant)
            arousal_weight: 0.0,  // Arousal is the signal itself, no self-modulation
            confidence_weight: 0.10, // High confidence → higher threshold (less reactive)
            volatility_weight: -0.10, // Volatile env → lower threshold (more alert)
        },
        min: 0.25,
        max: 0.70,
    }
}

/// Resource pressure onset — presence penalty under allostatic load (Sterling 2012).
/// High pressure triggers concise output to conserve tokens.
/// High PE lowers the threshold (conserve under surprise).
pub fn threshold_resource_pressure() -> ThresholdRegistration {
    ThresholdRegistration {
        id: "resource-pressure".into(),
        base: 0.70,
        profile: ThresholdProfile {
            pe_weight: -0.05,     // High PE → conserve sooner
            arousal_weight: -0.05, // High arousal → conserve sooner
            confidence_weight: 0.10, // High confidence → tolerate more pressure
            volatility_weight: -0.05, // Volatile → conserve sooner
        },
        min: 0.50,
        max: 0.85,
    }
}

// ── Delta Modulation Thresholds (Tầng 2) ────────────────────────────

/// PE volatility threshold for delta exploration boost (Behrens 2007).
/// Above this, environment is unstable → increase delta → attend to new input.
/// High PE and volatile context lower the threshold (become exploration-sensitive sooner).
pub fn threshold_delta_volatility() -> ThresholdRegistration {
    ThresholdRegistration {
        id: "delta-volatility".into(),
        base: 0.60,
        profile: ThresholdProfile {
            pe_weight: -0.10,      // High PE → explore sooner (lower threshold)
            arousal_weight: -0.05, // Aroused → explore sooner
            confidence_weight: 0.10, // Confident → tolerate more volatility before exploring
            volatility_weight: 0.0,  // Volatility IS the signal, no self-modulation
        },
        min: 0.35,
        max: 0.80,
    }
}

/// Arousal emergency threshold for delta override (LeDoux 1996 fast pathway).
/// Above this, threat detected → override to phasic delta (attend to threat).
/// Base 0.55: calibrated to emotional.rs arousal output range (stress ≈ 0.5-0.6).
/// Volatile environment lowers threshold (more alert).
pub fn threshold_delta_arousal_emergency() -> ThresholdRegistration {
    ThresholdRegistration {
        id: "delta-arousal-emergency".into(),
        base: 0.55,
        profile: ThresholdProfile {
            pe_weight: -0.05,      // High PE → lower threshold (more vigilant)
            arousal_weight: 0.0,   // Arousal IS the signal, no self-modulation
            confidence_weight: 0.05, // Confident → slightly higher threshold
            volatility_weight: -0.10, // Volatile env → lower threshold (more alert)
        },
        min: 0.40,
        max: 0.80,
    }
}

// ── Core Functions ─────────────────────────────────────────────────────

/// Compute adaptive threshold from base + cognitive state modulation.
///
/// Formula (centered around neutral = 0.5):
/// ```text
/// modulation = (PE - 0.5) × peWeight
///            + arousal × arousalWeight
///            + (confidence - 0.5) × confidenceWeight
///            + (volatility - 0.3) × volatilityWeight
/// effective = clamp(base × (1 + modulation), min, max)
/// ```
pub fn get_adaptive_threshold(threshold: &ThresholdRegistration, ctx: &ThresholdContext) -> f64 {
    let pe = safe_num(ctx.sensory_pe, 0.5);
    let arousal = safe_num(ctx.arousal, 0.0);
    let conf = safe_num(ctx.gate_confidence, 0.5);
    let vol = safe_num(ctx.pe_volatility, 0.3);

    let modulation = (pe - 0.5) * threshold.profile.pe_weight
        + arousal * threshold.profile.arousal_weight
        + (conf - 0.5) * threshold.profile.confidence_weight
        + (vol - 0.3) * threshold.profile.volatility_weight;

    clamp(
        threshold.base * (1.0 + modulation),
        threshold.min,
        threshold.max,
    )
}

/// Build threshold context with optional affect modulation (Barrett 2025, Seth 2024).
///
/// Negative valence → tunnel vision (arousal amplified).
/// Positive valence → broadened attention (arousal dampened).
/// Low body budget → increased threat perception (PE bias).
pub fn build_threshold_context(
    sensory_pe: f64,
    arousal: f64,
    gate_confidence: f64,
    pe_volatility: f64,
    valence: Option<AffectValence>,
    body_budget: Option<f64>,
) -> ThresholdContext {
    let mut adj_arousal = arousal;
    let mut adj_pe = sensory_pe;
    let mut adj_conf = gate_confidence;

    // Affect modulation (AD-180)
    if let Some(v) = valence {
        match v {
            AffectValence::Negative => adj_arousal *= 1.15, // Tunnel vision
            AffectValence::Positive => adj_arousal *= 0.9,  // Broadened attention
            AffectValence::Neutral => {}
        }
    }

    // Body budget modulation (allostatic). P3: same threshold as
    // `threshold_body_budget_conservation` so that "depleted enough to
    // shift threat perception" and "depleted enough to trigger conservation"
    // co-fire from the same trigger point. Don't drift these.
    if let Some(budget) = body_budget {
        if budget < BODY_BUDGET_HEALTHY_THRESHOLD {
            let deficit = BODY_BUDGET_HEALTHY_THRESHOLD - budget;
            adj_pe += deficit * 0.15;   // Perceive more threat
            adj_conf -= deficit * 0.1;  // Less model trust
        }
    }

    ThresholdContext {
        sensory_pe: clamp(adj_pe, 0.0, 1.0),
        arousal: clamp(adj_arousal, 0.0, 1.0),
        gate_confidence: clamp(adj_conf, 0.0, 1.0),
        pe_volatility: clamp(pe_volatility, 0.0, 1.0),
    }
}

/// Bayesian-optimal combination of multiple dimension PEs (Ernst & Banks 2002).
///
/// Unified PE = precision-weighted average: Σ(precision_i × PE_i) / Σ(precision_i).
/// Falls back to simple mean if total precision = 0.
pub fn compute_unified_pe(dimensions: &[DimensionPE]) -> f64 {
    if dimensions.is_empty() {
        return 0.5; // Neutral
    }

    let total_precision: f64 = dimensions.iter().map(|d| d.precision.max(0.0)).sum();

    if total_precision == 0.0 {
        // Fallback: simple mean
        return dimensions.iter().map(|d| d.pe).sum::<f64>() / dimensions.len() as f64;
    }

    let weighted: f64 = dimensions
        .iter()
        .map(|d| d.precision.max(0.0) * d.pe)
        .sum();

    weighted / total_precision
}

/// Safe number with fallback for NaN/undefined.
fn safe_num(value: f64, fallback: f64) -> f64 {
    if value.is_nan() || value.is_infinite() {
        fallback
    } else {
        value
    }
}

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

    fn neutral_ctx() -> ThresholdContext {
        ThresholdContext {
            sensory_pe: 0.5,
            arousal: 0.0,
            gate_confidence: 0.5,
            pe_volatility: 0.3,
        }
    }

    // These 4 tests cover the core `get_adaptive_threshold` formula. Prior to phase 13 they
    // used `threshold_ignition` as a fixture; switched to `threshold_resource_pressure` (a live
    // threshold with negative `arousal_weight`, matching the original semantics) after
    // `threshold_ignition` was removed as orphan. Semantics preserved: arousal lowers, confidence
    // raises, neutral context returns base, output clamps to [min, max].

    #[test]
    fn neutral_context_returns_base() {
        let t = threshold_resource_pressure();
        let effective = get_adaptive_threshold(&t, &neutral_ctx());
        assert!((effective - t.base).abs() < 0.05);
    }

    #[test]
    fn high_arousal_lowers_threshold() {
        let t = threshold_resource_pressure();
        let base = get_adaptive_threshold(&t, &neutral_ctx());
        let aroused = get_adaptive_threshold(
            &t,
            &ThresholdContext {
                arousal: 0.9,
                ..neutral_ctx()
            },
        );
        assert!(aroused < base); // Arousal weight is negative → lowers threshold
    }

    #[test]
    fn high_confidence_raises_threshold() {
        let t = threshold_resource_pressure();
        let base = get_adaptive_threshold(&t, &neutral_ctx());
        let confident = get_adaptive_threshold(
            &t,
            &ThresholdContext {
                gate_confidence: 0.9,
                ..neutral_ctx()
            },
        );
        assert!(confident > base); // Confidence weight is positive → raises threshold
    }

    #[test]
    fn threshold_clamped_to_bounds() {
        let t = threshold_resource_pressure();
        let extreme = get_adaptive_threshold(
            &t,
            &ThresholdContext {
                sensory_pe: 1.0,
                arousal: 1.0,
                gate_confidence: 0.0,
                pe_volatility: 1.0,
            },
        );
        assert!(extreme >= t.min);
        assert!(extreme <= t.max);
    }

    #[test]
    fn unified_pe_precision_weighted() {
        let dims = vec![
            DimensionPE { pe: 0.8, precision: 0.9 }, // High PE, high precision
            DimensionPE { pe: 0.2, precision: 0.1 }, // Low PE, low precision
        ];
        let unified = compute_unified_pe(&dims);
        // Should be close to 0.8 (high-precision dimension dominates)
        assert!(unified > 0.6);
    }

    #[test]
    fn unified_pe_equal_precision() {
        let dims = vec![
            DimensionPE { pe: 0.8, precision: 1.0 },
            DimensionPE { pe: 0.2, precision: 1.0 },
        ];
        let unified = compute_unified_pe(&dims);
        // Equal precision → simple mean
        assert!((unified - 0.5).abs() < f64::EPSILON);
    }

    #[test]
    fn unified_pe_empty() {
        assert!((compute_unified_pe(&[]) - 0.5).abs() < f64::EPSILON);
    }

    #[test]
    fn unified_pe_zero_precision_fallback() {
        let dims = vec![
            DimensionPE { pe: 0.3, precision: 0.0 },
            DimensionPE { pe: 0.7, precision: 0.0 },
        ];
        let unified = compute_unified_pe(&dims);
        assert!((unified - 0.5).abs() < f64::EPSILON); // Fallback to mean
    }

    #[test]
    fn negative_valence_amplifies_arousal() {
        let ctx = build_threshold_context(0.5, 0.5, 0.5, 0.3, Some(AffectValence::Negative), None);
        assert!(ctx.arousal > 0.5); // Amplified
    }

    #[test]
    fn positive_valence_dampens_arousal() {
        let ctx = build_threshold_context(0.5, 0.5, 0.5, 0.3, Some(AffectValence::Positive), None);
        assert!(ctx.arousal < 0.5); // Dampened
    }

    #[test]
    fn low_body_budget_increases_pe() {
        let ctx = build_threshold_context(0.5, 0.3, 0.5, 0.3, None, Some(0.3));
        assert!(ctx.sensory_pe > 0.5); // More threat perception
        assert!(ctx.gate_confidence < 0.5); // Less model trust
    }

    #[test]
    fn safe_num_handles_nan() {
        assert_eq!(safe_num(f64::NAN, 0.5), 0.5);
        assert_eq!(safe_num(0.7, 0.5), 0.7);
    }
}