Skip to main content

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}