Skip to main content

behavior_contracts/
plan.rs

1//! plan — execution-plan.md reference implementation.
2//!
3//! run_plan(plan, ops, exec): execute stage groups sequentially, interpret
4//! null-binding skip, Skip propagation, and Error Policy Kind (fail/retry/continue)
5//! to produce a deterministic result tree. Node execution is delegated to `exec`.
6
7use crate::value::Value;
8
9#[derive(Debug, Clone)]
10pub struct ExecutionPlanSpec {
11    pub groups: Vec<Vec<usize>>,
12    pub concurrency: i64,
13}
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum PolicyKind {
17    Fail,
18    Retry,
19    Continue,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum RelationKind {
24    Single,
25    Connection,
26}
27
28#[derive(Debug, Clone)]
29pub struct OpSpec {
30    pub id: String,
31    pub parent: Option<usize>,
32    pub bind_field: Option<String>,
33    pub relation_kind: Option<RelationKind>,
34    /// Raw policy string from the vector (validated at run time so unknown
35    /// policies fail closed with UNKNOWN_POLICY rather than at parse time).
36    pub policy: Option<String>,
37}
38
39/// consumer-supplied node execution outcome (mock).
40#[derive(Debug, Clone)]
41pub enum ExecOutcome {
42    Ok(Value),
43    Error(String),
44}
45
46#[derive(Debug, Clone)]
47enum OpState {
48    Ok(Value),
49    Skipped,
50    // Failed is never stored: fail policy raises immediately.
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum PlanFailureCode {
55    OpFailed,
56    UnknownPolicy,
57    InvalidPlan,
58}
59
60impl PlanFailureCode {
61    pub fn as_str(self) -> &'static str {
62        match self {
63            PlanFailureCode::OpFailed => "OP_FAILED",
64            PlanFailureCode::UnknownPolicy => "UNKNOWN_POLICY",
65            PlanFailureCode::InvalidPlan => "INVALID_PLAN",
66        }
67    }
68}
69
70#[derive(Debug, Clone)]
71pub struct PlanFailure {
72    pub code: PlanFailureCode,
73    pub message: String,
74}
75
76impl std::fmt::Display for PlanFailure {
77    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78        write!(f, "{}: {}", self.code.as_str(), self.message)
79    }
80}
81impl std::error::Error for PlanFailure {}
82
83fn fail<T>(code: PlanFailureCode, message: impl Into<String>) -> Result<T, PlanFailure> {
84    Err(PlanFailure {
85        code,
86        message: message.into(),
87    })
88}
89
90#[derive(Debug)]
91pub struct RunResult {
92    states: Vec<Option<OpState>>,
93    pub executed: Vec<String>,
94    pub skipped: Vec<String>,
95    ops_ref: Vec<(String, Option<RelationKind>)>,
96}
97
98impl RunResult {
99    /// Build the observed result tree (opId → value; Skip → unproduced form).
100    pub fn final_tree(&self) -> Vec<(String, Value)> {
101        let mut tree = Vec::new();
102        for (i, st) in self.states.iter().enumerate() {
103            match st {
104                Some(OpState::Ok(v)) => tree.push((self.ops_ref[i].0.clone(), v.clone())),
105                Some(OpState::Skipped) => tree.push((
106                    self.ops_ref[i].0.clone(),
107                    unproduced_value(self.ops_ref[i].1),
108                )),
109                None => {}
110            }
111        }
112        tree
113    }
114}
115
116fn unproduced_value(kind: Option<RelationKind>) -> Value {
117    if kind == Some(RelationKind::Connection) {
118        Value::Obj(vec![
119            ("items".into(), Value::Arr(vec![])),
120            ("cursor".into(), Value::Null),
121        ])
122    } else {
123        Value::Null
124    }
125}
126
127fn parse_policy(raw: &Option<String>) -> Result<PolicyKind, PlanFailure> {
128    match raw.as_deref() {
129        None | Some("fail") => Ok(PolicyKind::Fail),
130        Some("retry") => Ok(PolicyKind::Retry),
131        Some("continue") => Ok(PolicyKind::Continue),
132        Some(other) => fail(
133            PlanFailureCode::UnknownPolicy,
134            format!("unknown policy kind: {other}"),
135        ),
136    }
137}
138
139/// Run the plan. `exec(op, bound_value)` returns the node outcome (mock).
140pub fn run_plan<F>(
141    plan: Option<&ExecutionPlanSpec>,
142    ops: &[OpSpec],
143    mut exec: F,
144) -> Result<RunResult, PlanFailure>
145where
146    F: FnMut(&OpSpec, Option<&Value>) -> ExecOutcome,
147{
148    let n = ops.len();
149    let mut states: Vec<Option<OpState>> = vec![None; n];
150    let mut executed_idx: Vec<usize> = Vec::new();
151    let mut skipped_idx: Vec<usize> = Vec::new();
152
153    let stages: Vec<Vec<usize>> = match plan {
154        Some(p) => p.groups.clone(),
155        None => (0..n).map(|i| vec![i]).collect(),
156    };
157
158    validate_coverage(&stages, n)?;
159
160    for stage in &stages {
161        let mut ordered = stage.clone();
162        ordered.sort_unstable(); // determinism: concurrency does not change results
163        for &i in &ordered {
164            let st = run_op(
165                i,
166                &ops[i],
167                &states,
168                &mut exec,
169                &mut executed_idx,
170                &mut skipped_idx,
171            )?;
172            states[i] = Some(st);
173        }
174    }
175
176    executed_idx.sort_unstable();
177    skipped_idx.sort_unstable();
178
179    Ok(RunResult {
180        executed: executed_idx.iter().map(|&i| ops[i].id.clone()).collect(),
181        skipped: skipped_idx.iter().map(|&i| ops[i].id.clone()).collect(),
182        ops_ref: ops
183            .iter()
184            .map(|o| (o.id.clone(), o.relation_kind))
185            .collect(),
186        states,
187    })
188}
189
190#[allow(clippy::too_many_arguments)]
191fn run_op<F>(
192    i: usize,
193    op: &OpSpec,
194    states: &[Option<OpState>],
195    exec: &mut F,
196    executed_idx: &mut Vec<usize>,
197    skipped_idx: &mut Vec<usize>,
198) -> Result<OpState, PlanFailure>
199where
200    F: FnMut(&OpSpec, Option<&Value>) -> ExecOutcome,
201{
202    let policy = parse_policy(&op.policy)?; // fail-closed on unknown
203
204    let mut bound_value: Option<Value> = None;
205    if let Some(pidx) = op.parent {
206        match states.get(pidx).and_then(|s| s.as_ref()) {
207            Some(OpState::Ok(pv)) => {
208                if let Some(field) = &op.bind_field {
209                    let bound = pv.obj_get(field);
210                    match bound {
211                        None | Some(Value::Null) => {
212                            skipped_idx.push(i);
213                            return Ok(OpState::Skipped);
214                        }
215                        Some(b) => bound_value = Some(b.clone()),
216                    }
217                }
218            }
219            _ => {
220                // parent skipped / failed / not produced → skip (chain)
221                skipped_idx.push(i);
222                return Ok(OpState::Skipped);
223            }
224        }
225    }
226
227    let outcome = exec(op, bound_value.as_ref());
228    executed_idx.push(i);
229
230    match outcome {
231        ExecOutcome::Ok(v) => Ok(OpState::Ok(v)),
232        ExecOutcome::Error(e) => match policy {
233            PolicyKind::Fail => fail(
234                PlanFailureCode::OpFailed,
235                format!("operation '{}' failed under 'fail' policy: {e}", op.id),
236            ),
237            PolicyKind::Retry => fail(
238                PlanFailureCode::OpFailed,
239                format!(
240                    "operation '{}' failed under 'retry' policy (exhausted): {e}",
241                    op.id
242                ),
243            ),
244            PolicyKind::Continue => {
245                // failed op produces no Port → treat as unproduced/skip downstream.
246                skipped_idx.push(i);
247                executed_idx.pop(); // ran but produced no Port
248                Ok(OpState::Skipped)
249            }
250        },
251    }
252}
253
254fn validate_coverage(stages: &[Vec<usize>], n: usize) -> Result<(), PlanFailure> {
255    let mut seen = std::collections::HashSet::new();
256    for stage in stages {
257        for &i in stage {
258            if i >= n {
259                return fail(
260                    PlanFailureCode::InvalidPlan,
261                    format!("stage index {i} out of range [0,{n})"),
262                );
263            }
264            if !seen.insert(i) {
265                return fail(
266                    PlanFailureCode::InvalidPlan,
267                    format!("operation {i} appears in more than one stage"),
268                );
269            }
270        }
271    }
272    if seen.len() != n {
273        return fail(
274            PlanFailureCode::InvalidPlan,
275            format!(
276                "plan does not cover all {n} operations (covered {})",
277                seen.len()
278            ),
279        );
280    }
281    Ok(())
282}