fd_policy/
reversibility.rs1use serde::{Deserialize, Serialize};
30use std::str::FromStr;
31
32use crate::decision::{PolicyDecision, PolicyDecisionKind};
33
34pub const RESPONSE_LADDER_ANCHOR: &str = "deepmind-ai-control-roadmap-r1-r3";
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
44#[serde(rename_all = "snake_case")]
45pub enum Reversibility {
46 Reversible,
48 Costly,
50 #[default]
52 Irreversible,
53}
54
55impl Reversibility {
56 pub fn as_str(self) -> &'static str {
58 match self {
59 Reversibility::Reversible => "reversible",
60 Reversibility::Costly => "costly",
61 Reversibility::Irreversible => "irreversible",
62 }
63 }
64
65 pub fn parse(value: &str) -> Self {
69 match value.trim().to_lowercase().as_str() {
70 "reversible" => Reversibility::Reversible,
71 "costly" => Reversibility::Costly,
72 _ => Reversibility::Irreversible,
73 }
74 }
75}
76
77impl FromStr for Reversibility {
78 type Err = std::convert::Infallible;
79 fn from_str(s: &str) -> Result<Self, Self::Err> {
80 Ok(Reversibility::parse(s))
81 }
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
86#[serde(rename_all = "snake_case")]
87pub enum ResponseLevel {
88 AllowAndLog,
90 AllowUnderBudget,
92 RequireApproval,
94}
95
96impl ResponseLevel {
97 pub fn as_str(self) -> &'static str {
99 match self {
100 ResponseLevel::AllowAndLog => "allow_and_log",
101 ResponseLevel::AllowUnderBudget => "allow_under_budget",
102 ResponseLevel::RequireApproval => "require_approval",
103 }
104 }
105
106 pub fn rung(self) -> &'static str {
108 match self {
109 ResponseLevel::AllowAndLog => "R1",
110 ResponseLevel::AllowUnderBudget => "R2",
111 ResponseLevel::RequireApproval => "R3",
112 }
113 }
114
115 pub fn decision_kind(self) -> PolicyDecisionKind {
119 match self {
120 ResponseLevel::AllowAndLog | ResponseLevel::AllowUnderBudget => {
121 PolicyDecisionKind::Allow
122 }
123 ResponseLevel::RequireApproval => PolicyDecisionKind::RequiresApproval,
124 }
125 }
126
127 pub fn to_policy_decision(self, reason: impl Into<String>) -> PolicyDecision {
129 match self {
130 ResponseLevel::AllowAndLog | ResponseLevel::AllowUnderBudget => {
131 PolicyDecision::allow(reason)
132 }
133 ResponseLevel::RequireApproval => PolicyDecision::requires_approval(reason),
134 }
135 }
136}
137
138pub fn graduated_response(rev: Reversibility, budget_has_headroom: bool) -> ResponseLevel {
146 match rev {
147 Reversibility::Reversible => ResponseLevel::AllowAndLog,
148 Reversibility::Costly => {
149 if budget_has_headroom {
150 ResponseLevel::AllowUnderBudget
151 } else {
152 ResponseLevel::RequireApproval
153 }
154 }
155 Reversibility::Irreversible => ResponseLevel::RequireApproval,
156 }
157}
158
159fn restrictiveness(kind: PolicyDecisionKind) -> u8 {
163 match kind {
164 PolicyDecisionKind::Deny => 0,
165 PolicyDecisionKind::RequiresApproval => 1,
166 PolicyDecisionKind::AllowWithWarning => 2,
167 PolicyDecisionKind::Allow => 3,
168 }
169}
170
171pub fn combine(base: PolicyDecisionKind, level: ResponseLevel) -> PolicyDecisionKind {
176 let candidate = level.decision_kind();
177 if restrictiveness(candidate) < restrictiveness(base) {
178 candidate
179 } else {
180 base
181 }
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187
188 #[test]
189 fn default_is_irreversible() {
190 assert_eq!(Reversibility::default(), Reversibility::Irreversible);
191 }
192
193 #[test]
194 fn parse_unknown_defaults_to_irreversible() {
195 assert_eq!(
196 Reversibility::parse("reversible"),
197 Reversibility::Reversible
198 );
199 assert_eq!(Reversibility::parse("COSTLY"), Reversibility::Costly);
200 assert_eq!(
201 Reversibility::parse("irreversible"),
202 Reversibility::Irreversible
203 );
204 assert_eq!(
205 Reversibility::parse("nonsense"),
206 Reversibility::Irreversible
207 );
208 assert_eq!(Reversibility::parse(""), Reversibility::Irreversible);
209 assert_eq!(
210 "garbage".parse::<Reversibility>().unwrap(),
211 Reversibility::Irreversible
212 );
213 }
214
215 #[test]
216 fn ladder_reversible_is_r1_allow_and_log() {
217 assert_eq!(
219 graduated_response(Reversibility::Reversible, true),
220 ResponseLevel::AllowAndLog
221 );
222 assert_eq!(
223 graduated_response(Reversibility::Reversible, false),
224 ResponseLevel::AllowAndLog
225 );
226 }
227
228 #[test]
229 fn ladder_irreversible_is_r3_require_approval() {
230 assert_eq!(
231 graduated_response(Reversibility::Irreversible, true),
232 ResponseLevel::RequireApproval
233 );
234 }
235
236 #[test]
237 fn ladder_costly_flips_to_approval_when_budget_exhausted() {
238 assert_eq!(
240 graduated_response(Reversibility::Costly, true),
241 ResponseLevel::AllowUnderBudget
242 );
243 assert_eq!(
245 graduated_response(Reversibility::Costly, false),
246 ResponseLevel::RequireApproval
247 );
248 }
249
250 #[test]
251 fn response_level_wire_and_rung_labels() {
252 assert_eq!(ResponseLevel::AllowAndLog.as_str(), "allow_and_log");
253 assert_eq!(
254 ResponseLevel::AllowUnderBudget.as_str(),
255 "allow_under_budget"
256 );
257 assert_eq!(ResponseLevel::RequireApproval.as_str(), "require_approval");
258 assert_eq!(ResponseLevel::AllowAndLog.rung(), "R1");
259 assert_eq!(ResponseLevel::AllowUnderBudget.rung(), "R2");
260 assert_eq!(ResponseLevel::RequireApproval.rung(), "R3");
261 }
262
263 #[test]
264 fn to_policy_decision_maps_rungs() {
265 assert!(ResponseLevel::AllowAndLog
266 .to_policy_decision("r1")
267 .is_allowed());
268 assert!(ResponseLevel::AllowUnderBudget
269 .to_policy_decision("r2")
270 .is_allowed());
271 assert!(ResponseLevel::RequireApproval
272 .to_policy_decision("r3")
273 .needs_approval());
274 }
275
276 #[test]
277 fn combine_adds_friction_but_never_loosens() {
278 assert_eq!(
280 combine(PolicyDecisionKind::Allow, ResponseLevel::RequireApproval),
281 PolicyDecisionKind::RequiresApproval
282 );
283 assert_eq!(
285 combine(PolicyDecisionKind::Deny, ResponseLevel::AllowAndLog),
286 PolicyDecisionKind::Deny
287 );
288 assert_eq!(
290 combine(
291 PolicyDecisionKind::RequiresApproval,
292 ResponseLevel::AllowAndLog
293 ),
294 PolicyDecisionKind::RequiresApproval
295 );
296 assert_eq!(
298 combine(PolicyDecisionKind::Allow, ResponseLevel::AllowUnderBudget),
299 PolicyDecisionKind::Allow
300 );
301 }
302
303 #[test]
304 fn serde_snake_case_round_trip() {
305 for r in [
306 Reversibility::Reversible,
307 Reversibility::Costly,
308 Reversibility::Irreversible,
309 ] {
310 let json = serde_json::to_string(&r).unwrap();
311 assert_eq!(json, format!("\"{}\"", r.as_str()));
312 let back: Reversibility = serde_json::from_str(&json).unwrap();
313 assert_eq!(back, r);
314 }
315 }
316}