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//!
7//! Bounded parallel stage execution (bc#23, execution-plan.md §4.1):
8//! [`run_plan_parallel`] additionally dispatches the members of a stage on scoped
9//! worker threads, bounded by `plan.concurrency`, while COMMITTING interpretation
10//! (Skip / Policy Kind / Failure) in ascending index order — the observed result
11//! (tree / executed / skipped / failure code+message) is byte-identical to
12//! [`run_plan`]. It requires `exec: Fn + Sync` (callable from multiple threads),
13//! which is why it is a separate, additive entry point: [`run_plan`] keeps the
14//! `FnMut` seam (and stays sequential — a conforming §4.1 fallback) so existing
15//! consumers, including [`crate::behavior::run_behavior`]'s stateful exec closure,
16//! are untouched.
17
18use crate::value::Value;
19
20#[derive(Debug, Clone)]
21pub struct ExecutionPlanSpec {
22    pub groups: Vec<Vec<usize>>,
23    pub concurrency: i64,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum PolicyKind {
28    Fail,
29    Retry,
30    Continue,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum RelationKind {
35    Single,
36    Connection,
37}
38
39#[derive(Debug, Clone)]
40pub struct OpSpec {
41    pub id: String,
42    pub parent: Option<usize>,
43    pub bind_field: Option<String>,
44    pub relation_kind: Option<RelationKind>,
45    /// Raw policy string from the vector (validated at run time so unknown
46    /// policies fail closed with UNKNOWN_POLICY rather than at parse time).
47    pub policy: Option<String>,
48}
49
50/// consumer-supplied node execution outcome (mock).
51#[derive(Debug, Clone)]
52pub enum ExecOutcome {
53    Ok(Value),
54    Error(String),
55}
56
57#[derive(Debug, Clone)]
58enum OpState {
59    Ok(Value),
60    Skipped,
61    // Failed is never stored: fail policy raises immediately.
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum PlanFailureCode {
66    OpFailed,
67    UnknownPolicy,
68    InvalidPlan,
69}
70
71impl PlanFailureCode {
72    pub fn as_str(self) -> &'static str {
73        match self {
74            PlanFailureCode::OpFailed => "OP_FAILED",
75            PlanFailureCode::UnknownPolicy => "UNKNOWN_POLICY",
76            PlanFailureCode::InvalidPlan => "INVALID_PLAN",
77        }
78    }
79}
80
81#[derive(Debug, Clone)]
82pub struct PlanFailure {
83    pub code: PlanFailureCode,
84    pub message: String,
85}
86
87impl std::fmt::Display for PlanFailure {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        write!(f, "{}: {}", self.code.as_str(), self.message)
90    }
91}
92impl std::error::Error for PlanFailure {}
93
94fn fail<T>(code: PlanFailureCode, message: impl Into<String>) -> Result<T, PlanFailure> {
95    Err(PlanFailure {
96        code,
97        message: message.into(),
98    })
99}
100
101#[derive(Debug)]
102pub struct RunResult {
103    states: Vec<Option<OpState>>,
104    pub executed: Vec<String>,
105    pub skipped: Vec<String>,
106    ops_ref: Vec<(String, Option<RelationKind>)>,
107}
108
109impl RunResult {
110    /// Build the observed result tree (opId → value; Skip → unproduced form).
111    pub fn final_tree(&self) -> Vec<(String, Value)> {
112        let mut tree = Vec::new();
113        for (i, st) in self.states.iter().enumerate() {
114            match st {
115                Some(OpState::Ok(v)) => tree.push((self.ops_ref[i].0.clone(), v.clone())),
116                Some(OpState::Skipped) => tree.push((
117                    self.ops_ref[i].0.clone(),
118                    unproduced_value(self.ops_ref[i].1),
119                )),
120                None => {}
121            }
122        }
123        tree
124    }
125}
126
127fn unproduced_value(kind: Option<RelationKind>) -> Value {
128    if kind == Some(RelationKind::Connection) {
129        Value::Obj(vec![
130            ("items".into(), Value::Arr(vec![])),
131            ("cursor".into(), Value::Null),
132        ])
133    } else {
134        Value::Null
135    }
136}
137
138fn parse_policy(raw: &Option<String>) -> Result<PolicyKind, PlanFailure> {
139    match raw.as_deref() {
140        None | Some("fail") => Ok(PolicyKind::Fail),
141        Some("retry") => Ok(PolicyKind::Retry),
142        Some("continue") => Ok(PolicyKind::Continue),
143        Some(other) => fail(
144            PlanFailureCode::UnknownPolicy,
145            format!("unknown policy kind: {other}"),
146        ),
147    }
148}
149
150/// Run the plan. `exec(op, bound_value)` returns the node outcome (mock).
151pub fn run_plan<F>(
152    plan: Option<&ExecutionPlanSpec>,
153    ops: &[OpSpec],
154    mut exec: F,
155) -> Result<RunResult, PlanFailure>
156where
157    F: FnMut(&OpSpec, Option<&Value>) -> ExecOutcome,
158{
159    let n = ops.len();
160    let mut states: Vec<Option<OpState>> = vec![None; n];
161    let mut executed_idx: Vec<usize> = Vec::new();
162    let mut skipped_idx: Vec<usize> = Vec::new();
163
164    let stages: Vec<Vec<usize>> = match plan {
165        Some(p) => p.groups.clone(),
166        None => (0..n).map(|i| vec![i]).collect(),
167    };
168
169    validate_coverage(&stages, n)?;
170
171    for stage in &stages {
172        let mut ordered = stage.clone();
173        ordered.sort_unstable(); // determinism: concurrency does not change results
174        for &i in &ordered {
175            let st = run_op(
176                i,
177                &ops[i],
178                &states,
179                &mut exec,
180                &mut executed_idx,
181                &mut skipped_idx,
182            )?;
183            states[i] = Some(st);
184        }
185    }
186
187    executed_idx.sort_unstable();
188    skipped_idx.sort_unstable();
189
190    Ok(RunResult {
191        executed: executed_idx.iter().map(|&i| ops[i].id.clone()).collect(),
192        skipped: skipped_idx.iter().map(|&i| ops[i].id.clone()).collect(),
193        ops_ref: ops
194            .iter()
195            .map(|o| (o.id.clone(), o.relation_kind))
196            .collect(),
197        states,
198    })
199}
200
201/// Bounded parallel variant of [`run_plan`] (bc#23).
202///
203/// Deterministic commit protocol (execution-plan.md §4.1), per stage:
204/// 1. preflight (ascending index): policy validation, skip decision, and bindField
205///    binding are derived from the settled prior-stage states (stage members are
206///    mutually independent — §1).
207/// 2. dispatch (ascending index, bounded): exec of runnable members runs on scoped
208///    worker threads, at most `plan.concurrency` in flight.
209/// 3. commit (ascending index): outcomes are interpreted in declaration order, so
210///    the failure identity (code + message) matches [`run_plan`] exactly.
211///
212/// The only observable difference from [`run_plan`] is speculative dispatch: when a
213/// member's interpretation fails, later members of the same stage may already have
214/// been executed (same allowance as graphddb's historical thread-overlap).
215///
216/// A consumer shipping a plan with `concurrency > 1` declares that `exec` may be
217/// invoked from that many threads concurrently (ship `concurrency: 1` otherwise —
218/// that, single-member stages, and intra-stage parent/child dependencies all fall
219/// back to sequential dispatch with unchanged results).
220pub fn run_plan_parallel<F>(
221    plan: Option<&ExecutionPlanSpec>,
222    ops: &[OpSpec],
223    exec: F,
224) -> Result<RunResult, PlanFailure>
225where
226    F: Fn(&OpSpec, Option<&Value>) -> ExecOutcome + Sync,
227{
228    let n = ops.len();
229    let mut states: Vec<Option<OpState>> = vec![None; n];
230    let mut executed_idx: Vec<usize> = Vec::new();
231    let mut skipped_idx: Vec<usize> = Vec::new();
232
233    let stages: Vec<Vec<usize>> = match plan {
234        Some(p) => p.groups.clone(),
235        None => (0..n).map(|i| vec![i]).collect(),
236    };
237    let concurrency = plan.map(|p| p.concurrency).unwrap_or(1);
238
239    validate_coverage(&stages, n)?;
240
241    for stage in &stages {
242        let mut ordered = stage.clone();
243        ordered.sort_unstable();
244
245        let has_intra_deps = ordered
246            .iter()
247            .any(|&i| matches!(ops[i].parent, Some(p) if ordered.contains(&p)));
248
249        if concurrency <= 1 || ordered.len() <= 1 || has_intra_deps {
250            // Sequential fallback (§4.1) — identical per-op flow to run_plan.
251            for &i in &ordered {
252                let st = run_op(
253                    i,
254                    &ops[i],
255                    &states,
256                    &mut |op: &OpSpec, b: Option<&Value>| exec(op, b),
257                    &mut executed_idx,
258                    &mut skipped_idx,
259                )?;
260                states[i] = Some(st);
261            }
262            continue;
263        }
264
265        // ── parallel path ────────────────────────────────────────────────────
266        let pres: Vec<Preflight> = ordered
267            .iter()
268            .map(|&i| preflight_op(&ops[i], &states))
269            .collect();
270
271        // Jobs = runnable members in declaration order; a shared cursor hands them
272        // out in that order to at most `concurrency` scoped worker threads.
273        let jobs: Vec<usize> = (0..ordered.len())
274            .filter(|&k| matches!(pres[k], Preflight::Exec { .. }))
275            .collect();
276        let workers = (concurrency.max(1) as usize).min(jobs.len());
277        let cursor = std::sync::atomic::AtomicUsize::new(0);
278        let slots: Vec<std::sync::Mutex<Option<ExecOutcome>>> = (0..ordered.len())
279            .map(|_| std::sync::Mutex::new(None))
280            .collect();
281
282        std::thread::scope(|scope| {
283            for _ in 0..workers {
284                scope.spawn(|| loop {
285                    let j = cursor.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
286                    if j >= jobs.len() {
287                        break;
288                    }
289                    let k = jobs[j];
290                    let i = ordered[k];
291                    let bound = match &pres[k] {
292                        Preflight::Exec { bound, .. } => bound.as_ref(),
293                        _ => unreachable!(),
294                    };
295                    let outcome = exec(&ops[i], bound);
296                    *slots[k].lock().unwrap() = Some(outcome);
297                });
298            }
299        }); // worker panics propagate here (like sequential exec panics)
300
301        // Commit (ascending index): declaration-order interpretation.
302        for (k, &i) in ordered.iter().enumerate() {
303            match &pres[k] {
304                Preflight::InvalidPolicy(msg) => {
305                    return fail(PlanFailureCode::UnknownPolicy, msg.clone())
306                }
307                Preflight::Skip => {
308                    skipped_idx.push(i);
309                    states[i] = Some(OpState::Skipped);
310                }
311                Preflight::Exec { policy, .. } => {
312                    let outcome = slots[k]
313                        .lock()
314                        .unwrap()
315                        .take()
316                        .expect("dispatched op must have an outcome");
317                    let st = interpret_outcome(
318                        i,
319                        &ops[i],
320                        *policy,
321                        outcome,
322                        &mut executed_idx,
323                        &mut skipped_idx,
324                    )?;
325                    states[i] = Some(st);
326                }
327            }
328        }
329    }
330
331    executed_idx.sort_unstable();
332    skipped_idx.sort_unstable();
333
334    Ok(RunResult {
335        executed: executed_idx.iter().map(|&i| ops[i].id.clone()).collect(),
336        skipped: skipped_idx.iter().map(|&i| ops[i].id.clone()).collect(),
337        ops_ref: ops
338            .iter()
339            .map(|o| (o.id.clone(), o.relation_kind))
340            .collect(),
341        states,
342    })
343}
344
345/// Pure pre-exec decision for one op (policy validation → skip decision →
346/// bindField binding), shared by the sequential and parallel paths. The order
347/// mirrors the sequential run_op exactly: the policy check precedes the skip
348/// decision (UNKNOWN_POLICY fires even for ops that would be skipped).
349enum Preflight {
350    InvalidPolicy(String),
351    Skip,
352    Exec {
353        policy: PolicyKind,
354        bound: Option<Value>,
355    },
356}
357
358fn preflight_op(op: &OpSpec, states: &[Option<OpState>]) -> Preflight {
359    let policy = match parse_policy(&op.policy) {
360        Ok(p) => p,
361        Err(e) => return Preflight::InvalidPolicy(e.message), // fail-closed on unknown
362    };
363
364    let mut bound_value: Option<Value> = None;
365    if let Some(pidx) = op.parent {
366        match states.get(pidx).and_then(|s| s.as_ref()) {
367            Some(OpState::Ok(pv)) => {
368                if let Some(field) = &op.bind_field {
369                    let bound = pv.obj_get(field);
370                    match bound {
371                        None | Some(Value::Null) => return Preflight::Skip,
372                        Some(b) => bound_value = Some(b.clone()),
373                    }
374                }
375            }
376            _ => {
377                // parent skipped / failed / not produced → skip (chain)
378                return Preflight::Skip;
379            }
380        }
381    }
382    Preflight::Exec {
383        policy,
384        bound: bound_value,
385    }
386}
387
388/// Post-exec interpretation (executed bookkeeping → Policy Kind), shared by both
389/// paths so failure messages stay identical.
390fn interpret_outcome(
391    i: usize,
392    op: &OpSpec,
393    policy: PolicyKind,
394    outcome: ExecOutcome,
395    executed_idx: &mut Vec<usize>,
396    skipped_idx: &mut Vec<usize>,
397) -> Result<OpState, PlanFailure> {
398    executed_idx.push(i);
399
400    match outcome {
401        ExecOutcome::Ok(v) => Ok(OpState::Ok(v)),
402        ExecOutcome::Error(e) => match policy {
403            PolicyKind::Fail => fail(
404                PlanFailureCode::OpFailed,
405                format!("operation '{}' failed under 'fail' policy: {e}", op.id),
406            ),
407            PolicyKind::Retry => fail(
408                PlanFailureCode::OpFailed,
409                format!(
410                    "operation '{}' failed under 'retry' policy (exhausted): {e}",
411                    op.id
412                ),
413            ),
414            PolicyKind::Continue => {
415                // failed op produces no Port → treat as unproduced/skip downstream.
416                skipped_idx.push(i);
417                executed_idx.pop(); // ran but produced no Port
418                Ok(OpState::Skipped)
419            }
420        },
421    }
422}
423
424fn run_op<F>(
425    i: usize,
426    op: &OpSpec,
427    states: &[Option<OpState>],
428    exec: &mut F,
429    executed_idx: &mut Vec<usize>,
430    skipped_idx: &mut Vec<usize>,
431) -> Result<OpState, PlanFailure>
432where
433    F: FnMut(&OpSpec, Option<&Value>) -> ExecOutcome,
434{
435    let (policy, bound_value) = match preflight_op(op, states) {
436        Preflight::InvalidPolicy(msg) => return fail(PlanFailureCode::UnknownPolicy, msg),
437        Preflight::Skip => {
438            skipped_idx.push(i);
439            return Ok(OpState::Skipped);
440        }
441        Preflight::Exec { policy, bound } => (policy, bound),
442    };
443
444    let outcome = exec(op, bound_value.as_ref());
445    interpret_outcome(i, op, policy, outcome, executed_idx, skipped_idx)
446}
447
448fn validate_coverage(stages: &[Vec<usize>], n: usize) -> Result<(), PlanFailure> {
449    let mut seen = std::collections::HashSet::new();
450    for stage in stages {
451        for &i in stage {
452            if i >= n {
453                return fail(
454                    PlanFailureCode::InvalidPlan,
455                    format!("stage index {i} out of range [0,{n})"),
456                );
457            }
458            if !seen.insert(i) {
459                return fail(
460                    PlanFailureCode::InvalidPlan,
461                    format!("operation {i} appears in more than one stage"),
462                );
463            }
464        }
465    }
466    if seen.len() != n {
467        return fail(
468            PlanFailureCode::InvalidPlan,
469            format!(
470                "plan does not cover all {n} operations (covered {})",
471                seen.len()
472            ),
473        );
474    }
475    Ok(())
476}