Skip to main content

zero_operator_state/
friction.rs

1//! Friction ladder — **Addendum A §3 and §6.3.**
2//!
3//! # The invariant
4//!
5//! Every command carries a [`RiskDirection`]. Only `Increases` is
6//! gated. `Reduces` is **always instant**. `Neutral` is passthrough.
7//! Applying a friction gate to a `Reduces` command is a compile
8//! error (see [`FrictionGate::apply`]). See ADR-014.
9//!
10//! This is the single most important invariant in the crate graph.
11//! Violating it means a tired operator at 2 AM can't kill their
12//! positions. That outcome is not allowed to be reachable from any
13//! code path in this repository.
14
15use std::time::Duration;
16
17use serde::{Deserialize, Serialize};
18
19use crate::label::Label;
20
21/// Classification of a command's effect on operator risk.
22///
23/// See **Addendum A §6.3**. The enum is the declaration; the
24/// invariant is enforced by [`FrictionGate::apply`] being generic
25/// over this type.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum RiskDirection {
29    /// The command opens, enlarges, or unsafes a position. Subject
30    /// to friction when operator state is not STEADY/FRESH/RECOVERY.
31    Increases,
32    /// The command closes, shrinks, or risk-offs. **Never gated.**
33    /// `/kill`, `/flatten-all`, `/close`, `/pause-entries`, `/break`.
34    Reduces,
35    /// The command is informational or cosmetic. Passthrough.
36    Neutral,
37}
38
39/// The friction escalator's current level.
40///
41/// Level definitions come straight from Addendum A §3.1. Every
42/// variant is reachable: L0–L2 from [`FrictionLevel::from_label`]
43/// alone, L3/L4 from [`FrictionLevel::from_label_and_risk`] when
44/// the engine reports guardrail proximity or a halt flag alongside
45/// a TILT label. See M2_PLAN §3.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum FrictionLevel {
49    /// No friction — FRESH / STEADY / RECOVERY.
50    L0,
51    /// 3 s visible countdown before execute — ELEVATED.
52    L1,
53    /// 10 s pause + typed `execute` — TILT.
54    L2,
55    /// 30 s pause + typed re-read of the guardrail-proximity
56    /// disclosure — TILT **and** engine-reported drawdown within
57    /// [`RiskContext::PROXIMITY_PCT`] of the last alert threshold.
58    /// The command still runs if the operator completes the
59    /// re-read; this is a deliberate friction bump, not a refusal.
60    L3,
61    /// Hard refusal — TILT **and** the engine reports any halt
62    /// flag (`risk.halted`, `global_halt`, `stop_failure_halt`).
63    /// Risk-increasing commands are dropped; only `Reduces`
64    /// (`/kill`, `/flatten-all`, `/close`, `/break`, …) continue
65    /// to pass through the un-gated path. This is the dead-man
66    /// switch — a tired operator at 2 AM must not be able to
67    /// reach for a risk-increasing command while the engine is
68    /// already halted.
69    L4,
70}
71
72/// Engine-reported risk context the classifier/friction layer
73/// consults to escalate TILT → L3 or L4.
74///
75/// Defaults describe "engine is healthy, no proximity alert" so a
76/// caller without engine access (test harnesses, headless replay)
77/// gets pre-M2 behaviour: L2 cap, no escalation. Every field is
78/// optional / boolean; the escalation is strictly a one-way bump
79/// (never down-graded below `from_label`).
80#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
81#[serde(default)]
82pub struct RiskContext {
83    /// Distance in percentage points between the engine's current
84    /// `drawdown_pct` and its `last_drawdown_alert_pct` threshold.
85    /// `None` when either field is missing from the engine mirror
86    /// (no proximity signal → no L3 escalation).
87    pub guardrail_proximity_pct: Option<f64>,
88    /// Any of the halt booleans on `Risk` is set — see
89    /// `Risk::is_halted`. Setting this alongside `Label::Tilt`
90    /// escalates to L4.
91    pub halted: bool,
92}
93
94impl RiskContext {
95    /// Threshold (in percentage points) within which TILT escalates
96    /// to L3. Taken from M2_PLAN §3 ("guardrail-proximity within 1
97    /// percent of a hard limit"). The overlay-side threshold in §4
98    /// is separately tuned (0.5 pp) — do not unify without updating
99    /// both plan rows.
100    pub const PROXIMITY_PCT: f64 = 1.0;
101
102    /// Construct a context from the engine mirror's
103    /// `drawdown_pct` + `last_drawdown_alert_pct` pair, plus a halt
104    /// boolean. Returns the default (no-escalation) shape when
105    /// either percentage field is missing.
106    #[must_use]
107    pub fn from_engine(
108        drawdown_pct: Option<f64>,
109        last_drawdown_alert_pct: Option<f64>,
110        halted: bool,
111    ) -> Self {
112        let proximity = match (drawdown_pct, last_drawdown_alert_pct) {
113            (Some(dd), Some(alert)) => Some((alert - dd).abs()),
114            _ => None,
115        };
116        Self {
117            guardrail_proximity_pct: proximity,
118            halted,
119        }
120    }
121
122    /// True when drawdown is within
123    /// [`Self::PROXIMITY_PCT`] of the last alert threshold. False
124    /// when the proximity reading is missing — the honest behaviour
125    /// is "no proximity signal → no escalation", not "missing ⇒
126    /// conservatively escalate", because the latter would penalise
127    /// an engine restart that hasn't populated
128    /// `last_drawdown_alert_pct` yet.
129    #[must_use]
130    pub fn near_guardrail(&self) -> bool {
131        self.guardrail_proximity_pct
132            .is_some_and(|pp| pp <= Self::PROXIMITY_PCT)
133    }
134}
135
136impl FrictionLevel {
137    /// Map a state [`Label`] to its default friction level.
138    ///
139    /// This form is capped at L2 and is the right call when the
140    /// caller has no engine risk context — tests, the classifier's
141    /// pure `classify(now)` entrypoint, replay harnesses. Use
142    /// [`FrictionLevel::from_label_and_risk`] from the dispatcher,
143    /// which does see the engine mirror, to reach L3/L4.
144    #[must_use]
145    pub const fn from_label(label: Label) -> Self {
146        match label {
147            Label::Fresh | Label::Steady | Label::Recovery => Self::L0,
148            Label::Elevated | Label::Fatigued => Self::L1,
149            Label::Tilt => Self::L2,
150        }
151    }
152
153    /// Map a `(label, risk)` pair to the full friction level,
154    /// including the M2 L3/L4 escalations.
155    ///
156    /// Escalation is one-way and TILT-gated:
157    /// - `Label::Tilt` + [`RiskContext::halted`] → L4 (refusal).
158    /// - `Label::Tilt` + [`RiskContext::near_guardrail`] → L3.
159    /// - Otherwise, identical to [`Self::from_label`].
160    ///
161    /// L4 beats L3 when both conditions trip — the dead-man switch
162    /// is the stronger signal.
163    #[must_use]
164    pub fn from_label_and_risk(label: Label, risk: RiskContext) -> Self {
165        let base = Self::from_label(label);
166        if matches!(label, Label::Tilt) {
167            if risk.halted {
168                return Self::L4;
169            }
170            if risk.near_guardrail() {
171                return Self::L3;
172            }
173        }
174        base
175    }
176
177    /// Required pause duration before execution.
178    ///
179    /// L3 is **30 s** — the spec's "mandatory pause + re-read the
180    /// exact disclosure phrase" window. L4 is 15 min for the
181    /// pathological case that the operator clears the halt in that
182    /// window; in practice L4 is rendered as a refusal and never
183    /// reaches a timer.
184    #[must_use]
185    pub const fn pause(self) -> Duration {
186        match self {
187            Self::L0 => Duration::ZERO,
188            Self::L1 => Duration::from_secs(3),
189            Self::L2 => Duration::from_secs(10),
190            Self::L3 => Duration::from_secs(30),
191            Self::L4 => Duration::from_secs(15 * 60),
192        }
193    }
194
195    /// Whether the confirmation step requires typing a word/phrase
196    /// rather than a single key. TILT and above switch away from
197    /// single-key `e` to typed confirmations — `execute` at L2, the
198    /// full proximity disclosure at L3 (§6.2, §3.2).
199    #[must_use]
200    pub const fn requires_typed_confirm(self) -> bool {
201        matches!(self, Self::L2 | Self::L3 | Self::L4)
202    }
203
204    /// True when this level is a refusal — the command is dropped,
205    /// no pause can redeem it. Only L4 is a refusal.
206    ///
207    /// This is the load-bearing check for "a tilted operator must
208    /// not reach for a risk-increasing command while the engine is
209    /// already halted". See `RiskContext` and M2_PLAN §3.
210    #[must_use]
211    pub const fn is_refusal(self) -> bool {
212        matches!(self, Self::L4)
213    }
214}
215
216/// A gate applied in front of a command. The generic `D` binds to
217/// `RiskDirection` at the type level so only risk-increasing
218/// commands can be gated.
219#[derive(Debug, Clone, Copy)]
220pub struct FrictionGate<D: GateableDirection> {
221    level: FrictionLevel,
222    _direction: std::marker::PhantomData<D>,
223}
224
225/// Sealed marker trait that lists the directions a gate may apply
226/// to. Only `Increases` implements it. Attempting to parameterize
227/// `FrictionGate` with `Reduces` or `Neutral` fails at compile time.
228pub trait GateableDirection: sealed::Sealed {}
229
230/// Phantom marker type representing `RiskDirection::Increases` at the
231/// type level. Used to parameterize [`FrictionGate`].
232#[derive(Debug, Clone, Copy, PartialEq, Eq)]
233pub struct Increases;
234
235impl sealed::Sealed for Increases {}
236impl GateableDirection for Increases {}
237
238mod sealed {
239    pub trait Sealed {}
240}
241
242impl FrictionGate<Increases> {
243    /// Construct a gate from a friction level.
244    #[must_use]
245    pub const fn new(level: FrictionLevel) -> Self {
246        Self {
247            level,
248            _direction: std::marker::PhantomData,
249        }
250    }
251
252    /// Derive a gate from a state [`Label`].
253    #[must_use]
254    pub const fn for_label(label: Label) -> Self {
255        Self::new(FrictionLevel::from_label(label))
256    }
257
258    #[must_use]
259    pub const fn level(&self) -> FrictionLevel {
260        self.level
261    }
262
263    /// Required pause before execution.
264    #[must_use]
265    pub const fn pause(&self) -> Duration {
266        self.level.pause()
267    }
268
269    /// Whether the confirmation must be typed-word rather than
270    /// single-key.
271    #[must_use]
272    pub const fn requires_typed_confirm(&self) -> bool {
273        self.level.requires_typed_confirm()
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    #[test]
282    fn fresh_steady_recovery_have_no_pause() {
283        for label in [Label::Fresh, Label::Steady, Label::Recovery] {
284            let gate = FrictionGate::<Increases>::for_label(label);
285            assert_eq!(gate.pause(), Duration::ZERO);
286            assert!(!gate.requires_typed_confirm());
287        }
288    }
289
290    #[test]
291    fn elevated_and_fatigued_get_three_seconds() {
292        for label in [Label::Elevated, Label::Fatigued] {
293            let gate = FrictionGate::<Increases>::for_label(label);
294            assert_eq!(gate.pause(), Duration::from_secs(3));
295            assert!(!gate.requires_typed_confirm());
296        }
297    }
298
299    #[test]
300    fn tilt_requires_typed_confirm_and_ten_seconds() {
301        let gate = FrictionGate::<Increases>::for_label(Label::Tilt);
302        assert_eq!(gate.pause(), Duration::from_secs(10));
303        assert!(gate.requires_typed_confirm());
304    }
305
306    // -------------------------------------------------------------
307    // M2 §3: L3/L4 escalation via RiskContext.
308    // -------------------------------------------------------------
309
310    #[test]
311    fn from_label_caps_at_l2_without_risk_context() {
312        for label in [
313            Label::Fresh,
314            Label::Steady,
315            Label::Elevated,
316            Label::Fatigued,
317            Label::Tilt,
318            Label::Recovery,
319        ] {
320            assert!(
321                FrictionLevel::from_label(label) <= FrictionLevel::L2,
322                "from_label({label:?}) escaped the L2 cap — L3/L4 must flow through from_label_and_risk only"
323            );
324        }
325    }
326
327    #[test]
328    fn non_tilt_labels_never_escalate_regardless_of_risk() {
329        let haz = RiskContext {
330            guardrail_proximity_pct: Some(0.1),
331            halted: true,
332        };
333        for label in [
334            Label::Fresh,
335            Label::Steady,
336            Label::Elevated,
337            Label::Fatigued,
338            Label::Recovery,
339        ] {
340            let level = FrictionLevel::from_label_and_risk(label, haz);
341            assert_eq!(
342                level,
343                FrictionLevel::from_label(label),
344                "label={label:?} must not escalate — TILT-gated invariant"
345            );
346        }
347    }
348
349    #[test]
350    fn tilt_plus_halt_escalates_to_l4() {
351        let ctx = RiskContext {
352            guardrail_proximity_pct: None,
353            halted: true,
354        };
355        assert_eq!(
356            FrictionLevel::from_label_and_risk(Label::Tilt, ctx),
357            FrictionLevel::L4
358        );
359    }
360
361    #[test]
362    fn tilt_plus_proximity_escalates_to_l3() {
363        let ctx = RiskContext {
364            guardrail_proximity_pct: Some(0.5),
365            halted: false,
366        };
367        assert_eq!(
368            FrictionLevel::from_label_and_risk(Label::Tilt, ctx),
369            FrictionLevel::L3
370        );
371    }
372
373    #[test]
374    fn tilt_plus_halt_beats_tilt_plus_proximity() {
375        let ctx = RiskContext {
376            guardrail_proximity_pct: Some(0.1),
377            halted: true,
378        };
379        assert_eq!(
380            FrictionLevel::from_label_and_risk(Label::Tilt, ctx),
381            FrictionLevel::L4
382        );
383    }
384
385    #[test]
386    fn tilt_with_distant_proximity_stays_at_l2() {
387        let ctx = RiskContext {
388            guardrail_proximity_pct: Some(RiskContext::PROXIMITY_PCT + 0.01),
389            halted: false,
390        };
391        assert_eq!(
392            FrictionLevel::from_label_and_risk(Label::Tilt, ctx),
393            FrictionLevel::L2
394        );
395    }
396
397    #[test]
398    fn tilt_without_any_risk_signal_stays_at_l2() {
399        assert_eq!(
400            FrictionLevel::from_label_and_risk(Label::Tilt, RiskContext::default()),
401            FrictionLevel::L2
402        );
403    }
404
405    #[test]
406    fn proximity_is_inclusive_at_the_threshold() {
407        let ctx = RiskContext {
408            guardrail_proximity_pct: Some(RiskContext::PROXIMITY_PCT),
409            halted: false,
410        };
411        assert_eq!(
412            FrictionLevel::from_label_and_risk(Label::Tilt, ctx),
413            FrictionLevel::L3,
414            "proximity at exactly the threshold must escalate"
415        );
416    }
417
418    #[test]
419    fn risk_context_from_engine_computes_absolute_distance() {
420        // Use integer-exact pairs so we don't have to reason about
421        // binary-floating-point rounding in the assertion.
422        let ctx = RiskContext::from_engine(Some(4.0), Some(5.0), false);
423        assert_eq!(ctx.guardrail_proximity_pct, Some(1.0));
424        assert!(ctx.near_guardrail());
425
426        let ctx_reversed = RiskContext::from_engine(Some(5.0), Some(4.0), false);
427        assert_eq!(
428            ctx_reversed.guardrail_proximity_pct,
429            Some(1.0),
430            "from_engine must be sign-symmetric — absolute distance only"
431        );
432    }
433
434    #[test]
435    fn risk_context_from_engine_drops_proximity_when_either_field_missing() {
436        let ctx = RiskContext::from_engine(None, Some(5.0), false);
437        assert_eq!(ctx.guardrail_proximity_pct, None);
438        assert!(!ctx.near_guardrail());
439
440        let ctx = RiskContext::from_engine(Some(5.0), None, false);
441        assert_eq!(ctx.guardrail_proximity_pct, None);
442        assert!(!ctx.near_guardrail());
443    }
444
445    #[test]
446    fn l3_pauses_thirty_seconds_and_l4_refuses() {
447        assert_eq!(FrictionLevel::L3.pause(), Duration::from_secs(30));
448        assert!(FrictionLevel::L3.requires_typed_confirm());
449        assert!(!FrictionLevel::L3.is_refusal());
450
451        assert!(FrictionLevel::L4.is_refusal());
452        assert!(FrictionLevel::L4.requires_typed_confirm());
453    }
454
455    /// The risk-asymmetry invariant — Addendum A §6.3.
456    ///
457    /// This doctest must not compile. If someone loosens the trait
458    /// bound on `FrictionGate` to accept `Reduces` or `Neutral`, this
459    /// doctest starts compiling and the CI gate catches it. That is
460    /// the only thing standing between a tired operator and a gated
461    /// `/kill`.
462    ///
463    /// ```compile_fail
464    /// use zero_operator_state::friction::{FrictionGate, FrictionLevel};
465    /// // `Reduces` is not a GateableDirection — this line should fail.
466    /// let _: FrictionGate<zero_operator_state::friction::sealed::Sealed> =
467    ///     FrictionGate::new(FrictionLevel::L0);
468    /// ```
469    fn _compile_fail_doctest_marker() {}
470}