use std::time::Duration;
use serde::{Deserialize, Serialize};
use zero_operator_state::friction::{FrictionLevel, RiskContext};
use zero_operator_state::label::Label;
use crate::risk::RiskDirection;
pub const TYPED_CONFIRM_WORD: &str = "execute";
pub const FALLBACK_REREAD_PHRASE: &str = "i acknowledge i am approaching a hard guardrail";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum FrictionDecision {
Proceed,
Pause {
#[serde(with = "duration_seconds")]
pause: Duration,
level: FrictionLevel,
},
TypedConfirm {
#[serde(with = "duration_seconds")]
pause: Duration,
level: FrictionLevel,
},
WaitAndReread {
#[serde(with = "duration_seconds")]
pause: Duration,
level: FrictionLevel,
phrase: String,
},
HardStop {
level: FrictionLevel,
reason: String,
},
}
impl FrictionDecision {
#[must_use]
pub const fn level(&self) -> FrictionLevel {
match self {
Self::Proceed => FrictionLevel::L0,
Self::Pause { level, .. }
| Self::TypedConfirm { level, .. }
| Self::WaitAndReread { level, .. }
| Self::HardStop { level, .. } => *level,
}
}
#[must_use]
pub const fn pause(&self) -> Duration {
match self {
Self::Proceed | Self::HardStop { .. } => Duration::ZERO,
Self::Pause { pause, .. }
| Self::TypedConfirm { pause, .. }
| Self::WaitAndReread { pause, .. } => *pause,
}
}
#[must_use]
pub const fn requires_typed_confirm(&self) -> bool {
matches!(self, Self::TypedConfirm { .. } | Self::WaitAndReread { .. })
}
#[must_use]
pub fn confirm_word(&self) -> Option<std::borrow::Cow<'_, str>> {
match self {
Self::TypedConfirm { .. } => Some(std::borrow::Cow::Borrowed(TYPED_CONFIRM_WORD)),
Self::WaitAndReread { phrase, .. } => Some(std::borrow::Cow::Borrowed(phrase.as_str())),
_ => None,
}
}
#[must_use]
pub const fn is_refusal(&self) -> bool {
matches!(self, Self::HardStop { .. })
}
#[must_use]
pub fn refusal_reason(&self) -> Option<&str> {
match self {
Self::HardStop { reason, .. } => Some(reason.as_str()),
_ => None,
}
}
}
#[must_use]
pub const fn decide(direction: RiskDirection, label: Label) -> FrictionDecision {
match direction {
RiskDirection::Reduces | RiskDirection::Neutral => FrictionDecision::Proceed,
RiskDirection::Increases => {
let level = FrictionLevel::from_label(label);
decision_for_level_const(level)
}
}
}
#[must_use]
pub fn decide_with_risk(
direction: RiskDirection,
label: Label,
risk: RiskContext,
halt_reason: Option<&str>,
reread_phrase: Option<String>,
) -> FrictionDecision {
match direction {
RiskDirection::Reduces | RiskDirection::Neutral => FrictionDecision::Proceed,
RiskDirection::Increases => {
let level = FrictionLevel::from_label_and_risk(label, risk);
decision_for_level(level, halt_reason, reread_phrase)
}
}
}
const fn decision_for_level_const(level: FrictionLevel) -> FrictionDecision {
match level {
FrictionLevel::L0 => FrictionDecision::Proceed,
FrictionLevel::L1 => FrictionDecision::Pause {
pause: level.pause(),
level,
},
FrictionLevel::L2 | FrictionLevel::L3 | FrictionLevel::L4 => {
FrictionDecision::TypedConfirm {
pause: level.pause(),
level,
}
}
}
}
fn decision_for_level(
level: FrictionLevel,
halt_reason: Option<&str>,
reread_phrase: Option<String>,
) -> FrictionDecision {
match level {
FrictionLevel::L0 => FrictionDecision::Proceed,
FrictionLevel::L1 => FrictionDecision::Pause {
pause: level.pause(),
level,
},
FrictionLevel::L2 => FrictionDecision::TypedConfirm {
pause: level.pause(),
level,
},
FrictionLevel::L3 => FrictionDecision::WaitAndReread {
pause: level.pause(),
level,
phrase: reread_phrase.unwrap_or_else(|| FALLBACK_REREAD_PHRASE.to_string()),
},
FrictionLevel::L4 => FrictionDecision::HardStop {
level,
reason: halt_reason.map_or_else(|| "engine halted".to_string(), ToOwned::to_owned),
},
}
}
mod duration_seconds {
use serde::{Deserialize, Deserializer, Serializer};
use std::time::Duration;
pub fn serialize<S: Serializer>(d: &Duration, ser: S) -> Result<S::Ok, S::Error> {
ser.serialize_u64(d.as_secs())
}
pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<Duration, D::Error> {
let secs = u64::deserialize(de)?;
Ok(Duration::from_secs(secs))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reduces_never_gated_regardless_of_label() {
for label in [
Label::Fresh,
Label::Steady,
Label::Elevated,
Label::Fatigued,
Label::Tilt,
Label::Recovery,
] {
assert_eq!(
decide(RiskDirection::Reduces, label),
FrictionDecision::Proceed,
"Reduces must never gate — label={label:?}"
);
}
}
#[test]
fn neutral_never_gated_regardless_of_label() {
for label in [
Label::Fresh,
Label::Steady,
Label::Elevated,
Label::Fatigued,
Label::Tilt,
Label::Recovery,
] {
assert_eq!(
decide(RiskDirection::Neutral, label),
FrictionDecision::Proceed,
"Neutral must never gate — label={label:?}"
);
}
}
#[test]
fn increases_fresh_steady_recovery_proceed() {
for label in [Label::Fresh, Label::Steady, Label::Recovery] {
assert_eq!(
decide(RiskDirection::Increases, label),
FrictionDecision::Proceed
);
}
}
#[test]
fn increases_elevated_requires_three_second_pause() {
let d = decide(RiskDirection::Increases, Label::Elevated);
assert_eq!(d.level(), FrictionLevel::L1);
assert_eq!(d.pause(), Duration::from_secs(3));
assert!(!d.requires_typed_confirm());
}
#[test]
fn increases_fatigued_requires_three_second_pause() {
let d = decide(RiskDirection::Increases, Label::Fatigued);
assert_eq!(d.level(), FrictionLevel::L1);
assert_eq!(d.pause(), Duration::from_secs(3));
}
#[test]
fn increases_tilt_requires_typed_confirm() {
let d = decide(RiskDirection::Increases, Label::Tilt);
assert_eq!(d.level(), FrictionLevel::L2);
assert_eq!(d.pause(), Duration::from_secs(10));
assert!(d.requires_typed_confirm());
assert_eq!(d.confirm_word().as_deref(), Some("execute"));
}
#[test]
fn decide_with_risk_reduces_always_proceeds_even_when_halted() {
let ctx = RiskContext {
guardrail_proximity_pct: Some(0.1),
halted: true,
};
let d = decide_with_risk(
RiskDirection::Reduces,
Label::Tilt,
ctx,
Some("global_halt"),
None,
);
assert_eq!(d, FrictionDecision::Proceed);
}
#[test]
fn decide_with_risk_neutral_always_proceeds_even_when_halted() {
let ctx = RiskContext {
guardrail_proximity_pct: None,
halted: true,
};
let d = decide_with_risk(
RiskDirection::Neutral,
Label::Tilt,
ctx,
Some("global_halt"),
None,
);
assert_eq!(d, FrictionDecision::Proceed);
}
#[test]
fn decide_with_risk_tilt_plus_proximity_emits_wait_and_reread() {
let ctx = RiskContext {
guardrail_proximity_pct: Some(0.5),
halted: false,
};
let d = decide_with_risk(
RiskDirection::Increases,
Label::Tilt,
ctx,
None,
Some("drawdown 4.2% — within 0.5pp of 4.7% hard alert".into()),
);
assert_eq!(d.level(), FrictionLevel::L3);
assert_eq!(d.pause(), Duration::from_secs(30));
assert!(d.requires_typed_confirm());
assert_eq!(
d.confirm_word().as_deref(),
Some("drawdown 4.2% — within 0.5pp of 4.7% hard alert"),
"L3 phrase must be the dynamic disclosure, not `execute`"
);
assert!(!d.is_refusal());
}
#[test]
fn decide_with_risk_l3_falls_back_to_fixed_phrase_when_none_supplied() {
let ctx = RiskContext {
guardrail_proximity_pct: Some(0.5),
halted: false,
};
let d = decide_with_risk(RiskDirection::Increases, Label::Tilt, ctx, None, None);
assert_eq!(d.level(), FrictionLevel::L3);
assert_eq!(d.confirm_word().as_deref(), Some(FALLBACK_REREAD_PHRASE));
}
#[test]
fn decide_with_risk_tilt_plus_halt_emits_hard_stop() {
let ctx = RiskContext {
guardrail_proximity_pct: None,
halted: true,
};
let d = decide_with_risk(
RiskDirection::Increases,
Label::Tilt,
ctx,
Some("global_halt"),
None,
);
assert_eq!(d.level(), FrictionLevel::L4);
assert_eq!(d.pause(), Duration::ZERO);
assert!(!d.requires_typed_confirm());
assert_eq!(d.confirm_word(), None);
assert!(d.is_refusal());
assert_eq!(d.refusal_reason(), Some("global_halt"));
}
#[test]
fn decide_with_risk_hard_stop_without_reason_renders_fallback() {
let ctx = RiskContext {
guardrail_proximity_pct: None,
halted: true,
};
let d = decide_with_risk(RiskDirection::Increases, Label::Tilt, ctx, None, None);
assert_eq!(d.refusal_reason(), Some("engine halted"));
}
#[test]
fn decide_with_risk_no_escalation_signal_matches_decide() {
for label in [
Label::Fresh,
Label::Steady,
Label::Elevated,
Label::Fatigued,
Label::Tilt,
Label::Recovery,
] {
for dir in [
RiskDirection::Reduces,
RiskDirection::Neutral,
RiskDirection::Increases,
] {
let plain = decide(dir, label);
let enriched = decide_with_risk(dir, label, RiskContext::default(), None, None);
assert_eq!(
plain, enriched,
"decide/decide_with_risk must agree when risk context is default \
(dir={dir:?}, label={label:?})"
);
}
}
}
#[test]
fn decision_roundtrips_through_json() {
let d = decide(RiskDirection::Increases, Label::Tilt);
let s = serde_json::to_string(&d).expect("to-json");
assert!(s.contains("\"typed_confirm\""));
let back: FrictionDecision = serde_json::from_str(&s).expect("from-json");
assert_eq!(d, back);
assert_eq!(back.confirm_word().as_deref(), Some("execute"));
}
#[test]
fn l3_l4_decisions_roundtrip_through_json() {
let ctx_l3 = RiskContext {
guardrail_proximity_pct: Some(0.5),
halted: false,
};
let l3 = decide_with_risk(
RiskDirection::Increases,
Label::Tilt,
ctx_l3,
None,
Some("dd 4.2% within 0.5pp of 4.7%".into()),
);
let s = serde_json::to_string(&l3).unwrap();
assert!(s.contains("\"wait_and_reread\""));
assert!(s.contains("dd 4.2%"));
let back: FrictionDecision = serde_json::from_str(&s).unwrap();
assert_eq!(l3, back);
let ctx_l4 = RiskContext {
guardrail_proximity_pct: None,
halted: true,
};
let l4 = decide_with_risk(
RiskDirection::Increases,
Label::Tilt,
ctx_l4,
Some("stop_failure_halt"),
None,
);
let s = serde_json::to_string(&l4).unwrap();
assert!(s.contains("\"hard_stop\""));
assert!(s.contains("stop_failure_halt"));
let back: FrictionDecision = serde_json::from_str(&s).unwrap();
assert_eq!(l4, back);
}
}