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}