warble_claude_code/conditional.rs
1//! Deterministic realization of a `conditional` step's closed-vocabulary `when` guard — mirrors
2//! `dispatcher/claude-agent-sdk/src/conditional.ts` field-for-field (see that module's doc for the
3//! full R1 repair-fold-into-loop / R2 guarded-skip design).
4//!
5//! This module is deliberately **not** wired into this back-end's actual static-file emission
6//! (`emit.rs`): the Claude Code CLI target has no deterministic runtime, so its conditional/repair
7//! behavior is entirely textual — the live CLI agent judges the guard emergently from the emitted
8//! prompt text (see `ir.rs`'s `LlmCall::when` doc comment), not from an evaluator like this one.
9//! No existing profile calls into this module; it exists solely so the two back-ends' understanding
10//! of the SAME closed guard vocabulary can be held to a shared conformance fixture
11//! (`dispatcher/conformance-fixtures/conditional.json`, asserted against here and against
12//! `conditional.ts`) — a semantic drift between them fails whichever suite moved.
13
14use crate::error::DispatchError;
15use crate::ir::WhenGuard;
16use serde::{Deserialize, Serialize};
17use std::collections::HashMap;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "lowercase")]
21pub enum StepOutcome {
22 Success,
23 Failure,
24}
25
26/// The subset of a step's identity this module needs: its name (an `on_failure` target) and the
27/// slot its output would land in (an `on_failure`-repair shape also requires it to be consumed).
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct StepIdentity {
30 pub name: String,
31 pub produces: Option<String>,
32}
33
34#[derive(Debug, Clone, Default)]
35pub struct GuardState {
36 /// Every slot value produced by steps run so far, keyed by their `produces` name.
37 pub slots: HashMap<String, String>,
38 /// Every step's outcome recorded so far, keyed by step name.
39 pub outcomes: HashMap<String, StepOutcome>,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(tag = "kind", rename_all = "lowercase")]
44pub enum ConditionalDecision {
45 Run,
46 Skip,
47 Repair { target: StepIdentity },
48}
49
50/// Default bound on repair attempts. Not (yet) an IR field — `max_attempts` is not part of the
51/// schema either back-end reads — so this mirrors conditional.ts's back-end-local runtime constant.
52pub const DEFAULT_MAX_REPAIR_ATTEMPTS: usize = 1;
53
54/// Resolve a dotted `slot.field.nested` path against the parsed JSON of `slots[slot]`. Any failure
55/// along the way (slot absent, not JSON, path doesn't resolve) reads as `false`/absent — a guard
56/// never fails on a shape mismatch, it just doesn't fire.
57fn read_flag(slots: &HashMap<String, String>, target: &str) -> bool {
58 let mut parts = target.split('.');
59 let Some(slot_name) = parts.next() else {
60 return false;
61 };
62 let Some(raw) = slots.get(slot_name) else {
63 return false;
64 };
65 let Ok(mut cur) = serde_json::from_str::<serde_json::Value>(raw) else {
66 return false;
67 };
68 for key in parts {
69 let serde_json::Value::Object(map) = &cur else {
70 return false;
71 };
72 match map.get(key) {
73 Some(v) => cur = v.clone(),
74 None => return false,
75 }
76 }
77 matches!(cur, serde_json::Value::Bool(true))
78}
79
80/// Evaluate a guard's truth value directly (R2 — guarded-skip). Does not special-case the R1 repair
81/// shape; callers that need to distinguish repair-fold from a plain `on_failure` skip should use
82/// [`classify_conditional_step`] instead.
83pub fn evaluate_guard(when: &WhenGuard, state: &GuardState) -> Result<bool, DispatchError> {
84 match when.guard.as_str() {
85 "on_failure" => Ok(state.outcomes.get(&when.target) == Some(&StepOutcome::Failure)),
86 "on_flag" => Ok(read_flag(&state.slots, &when.target)),
87 "on_missing" => Ok(!state.slots.contains_key(&when.target)),
88 // The compiler validates guard names against the closed vocabulary before this IR ever
89 // reaches a back-end (core/src/compile.rs); an unrecognized value here means a hand-edited
90 // or future-versioned IR slipped through — loud-fail rather than silently treating it as false.
91 other => Err(DispatchError::new(format!(
92 "unknown guard '{other}' (closed vocabulary: on_failure, on_flag, on_missing)"
93 ))),
94 }
95}
96
97/// Structural test for the R1 repair shape: an `on_failure` guard whose target is `preceding_step`,
98/// where that step's sole produced artifact is also consumed by the conditional step. Returns the
99/// target's identity when the shape matches, else `None` (falls back to R2 guarded-skip).
100pub fn repair_fold_target(
101 when: &WhenGuard,
102 consumes: &[String],
103 preceding_step: Option<&StepIdentity>,
104) -> Option<StepIdentity> {
105 let preceding = preceding_step?;
106 if when.guard != "on_failure" || when.target != preceding.name {
107 return None;
108 }
109 let produces = preceding.produces.as_ref()?;
110 if !consumes.iter().any(|c| c == produces) {
111 return None;
112 }
113 Some(preceding.clone())
114}
115
116/// The single entry point a staged executor would use to decide what a conditional step does next:
117/// fold into a bounded repair turn (R1), run (R2 guard true), or skip (R2 guard false / R1 target
118/// didn't fail).
119pub fn classify_conditional_step(
120 when: &WhenGuard,
121 consumes: &[String],
122 preceding_step: Option<&StepIdentity>,
123 state: &GuardState,
124) -> Result<ConditionalDecision, DispatchError> {
125 if let Some(target) = repair_fold_target(when, consumes, preceding_step) {
126 return Ok(
127 if state.outcomes.get(&target.name) == Some(&StepOutcome::Failure) {
128 ConditionalDecision::Repair { target }
129 } else {
130 ConditionalDecision::Skip
131 },
132 );
133 }
134 Ok(if evaluate_guard(when, state)? {
135 ConditionalDecision::Run
136 } else {
137 ConditionalDecision::Skip
138 })
139}
140
141/// Drive a bounded repair loop: call `attempt_failed` up to `max_attempts` times (1-indexed),
142/// stopping at the first attempt that reports success. Mirrors conditional.ts's `runRepairLoop`:
143/// returns `(recovered, attempts)` and never loops past `max_attempts` — exhaustion is reported back
144/// (`recovered = false`) for the caller to loud-fail, not swallowed here.
145pub fn run_repair_loop(
146 max_attempts: usize,
147 mut attempt_failed: impl FnMut(usize) -> bool,
148) -> (bool, usize) {
149 for attempt in 1..=max_attempts {
150 if !attempt_failed(attempt) {
151 return (true, attempt);
152 }
153 }
154 (false, max_attempts)
155}