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/// artifact 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 artifact produced by steps run so far, keyed by their `produces` name.
37 ///
38 /// Named `artifacts` rather than `slots` because IR 0.7 uses "slot" for a named position in
39 /// prompt text with alternative wordings — a different thing entirely. The spec has always
40 /// called these artifacts: `on_flag`'s target is documented as a dotted `artifact.field`.
41 pub artifacts: HashMap<String, String>,
42 /// Every step's outcome recorded so far, keyed by step name.
43 pub outcomes: HashMap<String, StepOutcome>,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(tag = "kind", rename_all = "lowercase")]
48pub enum ConditionalDecision {
49 Run,
50 Skip,
51 Repair { target: StepIdentity },
52}
53
54/// Default bound on repair attempts. Not (yet) an IR field — `max_attempts` is not part of the
55/// schema either back-end reads — so this mirrors conditional.ts's back-end-local runtime constant.
56pub const DEFAULT_MAX_REPAIR_ATTEMPTS: usize = 1;
57
58/// Resolve a dotted `artifact.field.nested` path against the parsed JSON of the named artifact.
59/// Any failure along the way (artifact absent, not JSON, path doesn't resolve) reads as
60/// `false`/absent — a guard never fails on a shape mismatch, it just doesn't fire.
61fn read_flag(artifacts: &HashMap<String, String>, target: &str) -> bool {
62 let mut parts = target.split('.');
63 let Some(artifact_name) = parts.next() else {
64 return false;
65 };
66 let Some(raw) = artifacts.get(artifact_name) else {
67 return false;
68 };
69 let Ok(mut cur) = serde_json::from_str::<serde_json::Value>(raw) else {
70 return false;
71 };
72 for key in parts {
73 let serde_json::Value::Object(map) = &cur else {
74 return false;
75 };
76 match map.get(key) {
77 Some(v) => cur = v.clone(),
78 None => return false,
79 }
80 }
81 matches!(cur, serde_json::Value::Bool(true))
82}
83
84/// Evaluate a guard's truth value directly (R2 — guarded-skip). Does not special-case the R1 repair
85/// shape; callers that need to distinguish repair-fold from a plain `on_failure` skip should use
86/// [`classify_conditional_step`] instead.
87pub fn evaluate_guard(when: &WhenGuard, state: &GuardState) -> Result<bool, DispatchError> {
88 match when.guard.as_str() {
89 "on_failure" => Ok(state.outcomes.get(&when.target) == Some(&StepOutcome::Failure)),
90 "on_flag" => Ok(read_flag(&state.artifacts, &when.target)),
91 "on_missing" => Ok(!state.artifacts.contains_key(&when.target)),
92 // The compiler validates guard names against the closed vocabulary before this IR ever
93 // reaches a back-end (core/src/compile.rs); an unrecognized value here means a hand-edited
94 // or future-versioned IR slipped through — loud-fail rather than silently treating it as false.
95 other => Err(DispatchError::new(format!(
96 "unknown guard '{other}' (closed vocabulary: on_failure, on_flag, on_missing)"
97 ))),
98 }
99}
100
101/// Structural test for the R1 repair shape: an `on_failure` guard whose target is `preceding_step`,
102/// where that step's sole produced artifact is also consumed by the conditional step. Returns the
103/// target's identity when the shape matches, else `None` (falls back to R2 guarded-skip).
104pub fn repair_fold_target(
105 when: &WhenGuard,
106 consumes: &[String],
107 preceding_step: Option<&StepIdentity>,
108) -> Option<StepIdentity> {
109 let preceding = preceding_step?;
110 if when.guard != "on_failure" || when.target != preceding.name {
111 return None;
112 }
113 let produces = preceding.produces.as_ref()?;
114 if !consumes.iter().any(|c| c == produces) {
115 return None;
116 }
117 Some(preceding.clone())
118}
119
120/// The single entry point a staged executor would use to decide what a conditional step does next:
121/// fold into a bounded repair turn (R1), run (R2 guard true), or skip (R2 guard false / R1 target
122/// didn't fail).
123pub fn classify_conditional_step(
124 when: &WhenGuard,
125 consumes: &[String],
126 preceding_step: Option<&StepIdentity>,
127 state: &GuardState,
128) -> Result<ConditionalDecision, DispatchError> {
129 if let Some(target) = repair_fold_target(when, consumes, preceding_step) {
130 return Ok(
131 if state.outcomes.get(&target.name) == Some(&StepOutcome::Failure) {
132 ConditionalDecision::Repair { target }
133 } else {
134 ConditionalDecision::Skip
135 },
136 );
137 }
138 Ok(if evaluate_guard(when, state)? {
139 ConditionalDecision::Run
140 } else {
141 ConditionalDecision::Skip
142 })
143}
144
145/// Drive a bounded repair loop: call `attempt_failed` up to `max_attempts` times (1-indexed),
146/// stopping at the first attempt that reports success. Mirrors conditional.ts's `runRepairLoop`:
147/// returns `(recovered, attempts)` and never loops past `max_attempts` — exhaustion is reported back
148/// (`recovered = false`) for the caller to loud-fail, not swallowed here.
149pub fn run_repair_loop(
150 max_attempts: usize,
151 mut attempt_failed: impl FnMut(usize) -> bool,
152) -> (bool, usize) {
153 for attempt in 1..=max_attempts {
154 if !attempt_failed(attempt) {
155 return (true, attempt);
156 }
157 }
158 (false, max_attempts)
159}