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/// Element Error Policy Kind (scp-error.md "The Element Error Policy"). A map node property:
40/// whether an element's Failure becomes the map's Component Failure (`Error`, the default) or the
41/// failing element is dropped and the rest proceed (`Skip`, order preserved).
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum ElementPolicyKind {
44    Error,
45    Skip,
46}
47
48impl ElementPolicyKind {
49    /// Parse a wire kind. `None` for an unrecognized kind — the caller fails closed.
50    pub fn parse(s: &str) -> Option<ElementPolicyKind> {
51        match s {
52            "error" => Some(ElementPolicyKind::Error),
53            "skip" => Some(ElementPolicyKind::Skip),
54            _ => None,
55        }
56    }
57}
58
59#[derive(Debug, Clone)]
60pub struct OpSpec {
61    pub id: String,
62    pub parent: Option<usize>,
63    pub bind_field: Option<String>,
64    pub relation_kind: Option<RelationKind>,
65    /// Raw policy string from the vector (validated at run time so unknown
66    /// policies fail closed with UNKNOWN_POLICY rather than at parse time).
67    pub policy: Option<String>,
68}
69
70// ── Error Value (the structured error value — scp-error.md "The Error Value") ────
71/// What went wrong (a closed set). An unrecognized kind fails closed rather than degrading
72/// to a default.
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum ErrorKind {
75    /// A value is present but its wire type does not match the declared type.
76    TypeMismatch,
77    /// A required field is absent or null.
78    MissingField,
79    /// A numeric value is outside the representable range of its declared type.
80    Overflow,
81}
82
83impl ErrorKind {
84    pub fn as_str(self) -> &'static str {
85        match self {
86            ErrorKind::TypeMismatch => "typeMismatch",
87            ErrorKind::MissingField => "missingField",
88            ErrorKind::Overflow => "overflow",
89        }
90    }
91
92    /// Parse a wire kind. `None` for an unrecognized kind — the caller fails closed.
93    pub fn parse(s: &str) -> Option<ErrorKind> {
94        match s {
95            "typeMismatch" => Some(ErrorKind::TypeMismatch),
96            "missingField" => Some(ErrorKind::MissingField),
97            "overflow" => Some(ErrorKind::Overflow),
98            _ => None,
99        }
100    }
101}
102
103/// The structured, recoverable payload a Failure carries (scp-error.md).
104///
105/// The **leaf produces** this; the runtime only transports it — it never synthesizes or infers a
106/// field. Every field except `kind` is optional: absent means *not applicable*, never *unknown,
107/// defaulted*.
108///
109/// `expected_type` is a PortableType in **Portable Type Notation** (`portable_type_notation`), not a
110/// serialized type object: it is a rendering of a statically declared type, so a codegen target bakes
111/// it as a literal and nothing walks a type at runtime.
112#[derive(Debug, Clone, PartialEq, Eq, Default)]
113pub struct ErrorDetail {
114    pub kind: Option<ErrorKind>,
115    /// The declaring model / component.
116    pub model: Option<String>,
117    /// The offending field / port.
118    pub field: Option<String>,
119    /// The declared type, in Portable Type Notation.
120    pub expected_type: Option<String>,
121    /// The wire type actually observed (the producer's own vocabulary).
122    pub actual_wire_type: Option<String>,
123    /// The offending value, stringified (never re-parsed to recover a type).
124    pub raw_value: Option<String>,
125    /// Caller context (e.g. the item key that located the datum). Ordered for determinism.
126    pub context: std::collections::BTreeMap<String, String>,
127}
128
129/// consumer-supplied node execution outcome (mock).
130///
131/// A failure carries human-facing text plus the structured, recoverable `ErrorDetail` the leaf
132/// produced (scp-error.md). `None` = the failure is not about a datum.
133#[derive(Debug, Clone)]
134pub enum ExecOutcome {
135    Ok(Value),
136    Error(String, Option<Box<ErrorDetail>>),
137}
138
139impl ExecOutcome {
140    /// A failure with no structured payload.
141    pub fn error(message: impl Into<String>) -> ExecOutcome {
142        ExecOutcome::Error(message.into(), None)
143    }
144}
145
146#[derive(Debug, Clone)]
147enum OpState {
148    Ok(Value),
149    Skipped,
150    // Failed is never stored: fail policy raises immediately.
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub enum PlanFailureCode {
155    OpFailed,
156    UnknownPolicy,
157    InvalidPlan,
158}
159
160impl PlanFailureCode {
161    pub fn as_str(self) -> &'static str {
162        match self {
163            PlanFailureCode::OpFailed => "OP_FAILED",
164            PlanFailureCode::UnknownPolicy => "UNKNOWN_POLICY",
165            PlanFailureCode::InvalidPlan => "INVALID_PLAN",
166        }
167    }
168}
169
170#[derive(Debug, Clone)]
171pub struct PlanFailure {
172    pub code: PlanFailureCode,
173    pub message: String,
174    /// The leaf-produced structured payload (scp-error.md "The Error Value"). Transported
175    /// verbatim across the Policy Kind interpretation — never synthesized here. Boxed: it is an
176    /// error-path-only payload and inlining it would bloat every `Result` on the hot path.
177    pub detail: Option<Box<ErrorDetail>>,
178}
179
180impl std::fmt::Display for PlanFailure {
181    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182        write!(f, "{}: {}", self.code.as_str(), self.message)
183    }
184}
185impl std::error::Error for PlanFailure {}
186
187fn fail<T>(code: PlanFailureCode, message: impl Into<String>) -> Result<T, PlanFailure> {
188    fail_detail(code, message, None)
189}
190
191fn fail_detail<T>(
192    code: PlanFailureCode,
193    message: impl Into<String>,
194    detail: Option<Box<ErrorDetail>>,
195) -> Result<T, PlanFailure> {
196    Err(PlanFailure {
197        code,
198        detail,
199        message: message.into(),
200    })
201}
202
203#[derive(Debug)]
204pub struct RunResult {
205    states: Vec<Option<OpState>>,
206    pub executed: Vec<String>,
207    pub skipped: Vec<String>,
208    ops_ref: Vec<(String, Option<RelationKind>)>,
209}
210
211impl RunResult {
212    /// Build the observed result tree (opId → value; Skip → unproduced form).
213    pub fn final_tree(&self) -> Vec<(String, Value)> {
214        let mut tree = Vec::new();
215        for (i, st) in self.states.iter().enumerate() {
216            match st {
217                Some(OpState::Ok(v)) => tree.push((self.ops_ref[i].0.clone(), v.clone())),
218                Some(OpState::Skipped) => tree.push((
219                    self.ops_ref[i].0.clone(),
220                    unproduced_value(self.ops_ref[i].1),
221                )),
222                None => {}
223            }
224        }
225        tree
226    }
227}
228
229fn unproduced_value(kind: Option<RelationKind>) -> Value {
230    if kind == Some(RelationKind::Connection) {
231        Value::Obj(vec![
232            ("items".into(), Value::Arr(vec![])),
233            ("cursor".into(), Value::Null),
234        ])
235    } else {
236        Value::Null
237    }
238}
239
240fn parse_policy(raw: &Option<String>) -> Result<PolicyKind, PlanFailure> {
241    match raw.as_deref() {
242        None | Some("fail") => Ok(PolicyKind::Fail),
243        Some("retry") => Ok(PolicyKind::Retry),
244        Some("continue") => Ok(PolicyKind::Continue),
245        Some(other) => fail(
246            PlanFailureCode::UnknownPolicy,
247            format!("unknown policy kind: {other}"),
248        ),
249    }
250}
251
252/// Run the plan. `exec(op, bound_value)` returns the node outcome (mock).
253pub fn run_plan<F>(
254    plan: Option<&ExecutionPlanSpec>,
255    ops: &[OpSpec],
256    mut exec: F,
257) -> Result<RunResult, PlanFailure>
258where
259    F: FnMut(&OpSpec, Option<&Value>) -> ExecOutcome,
260{
261    let n = ops.len();
262    let mut states: Vec<Option<OpState>> = vec![None; n];
263    let mut executed_idx: Vec<usize> = Vec::new();
264    let mut skipped_idx: Vec<usize> = Vec::new();
265
266    let stages: Vec<Vec<usize>> = match plan {
267        Some(p) => p.groups.clone(),
268        None => (0..n).map(|i| vec![i]).collect(),
269    };
270
271    validate_coverage(&stages, n)?;
272
273    for stage in &stages {
274        let mut ordered = stage.clone();
275        ordered.sort_unstable(); // determinism: concurrency does not change results
276        for &i in &ordered {
277            let st = run_op(
278                i,
279                &ops[i],
280                &states,
281                &mut exec,
282                &mut executed_idx,
283                &mut skipped_idx,
284            )?;
285            states[i] = Some(st);
286        }
287    }
288
289    executed_idx.sort_unstable();
290    skipped_idx.sort_unstable();
291
292    Ok(RunResult {
293        executed: executed_idx.iter().map(|&i| ops[i].id.clone()).collect(),
294        skipped: skipped_idx.iter().map(|&i| ops[i].id.clone()).collect(),
295        ops_ref: ops
296            .iter()
297            .map(|o| (o.id.clone(), o.relation_kind))
298            .collect(),
299        states,
300    })
301}
302
303/// Bounded parallel variant of [`run_plan`] (bc#23).
304///
305/// Deterministic commit protocol (execution-plan.md §4.1), per stage:
306/// 1. preflight (ascending index): policy validation, skip decision, and bindField
307///    binding are derived from the settled prior-stage states (stage members are
308///    mutually independent — §1).
309/// 2. dispatch (ascending index, bounded): exec of runnable members runs on scoped
310///    worker threads, at most `plan.concurrency` in flight.
311/// 3. commit (ascending index): outcomes are interpreted in declaration order, so
312///    the failure identity (code + message) matches [`run_plan`] exactly.
313///
314/// The only observable difference from [`run_plan`] is speculative dispatch: when a
315/// member's interpretation fails, later members of the same stage may already have
316/// been executed (same allowance as graphddb's historical thread-overlap).
317///
318/// A consumer shipping a plan with `concurrency > 1` declares that `exec` may be
319/// invoked from that many threads concurrently (ship `concurrency: 1` otherwise —
320/// that, single-member stages, and intra-stage parent/child dependencies all fall
321/// back to sequential dispatch with unchanged results).
322pub fn run_plan_parallel<F>(
323    plan: Option<&ExecutionPlanSpec>,
324    ops: &[OpSpec],
325    exec: F,
326) -> Result<RunResult, PlanFailure>
327where
328    F: Fn(&OpSpec, Option<&Value>) -> ExecOutcome + Sync,
329{
330    let n = ops.len();
331    let mut states: Vec<Option<OpState>> = vec![None; n];
332    let mut executed_idx: Vec<usize> = Vec::new();
333    let mut skipped_idx: Vec<usize> = Vec::new();
334
335    let stages: Vec<Vec<usize>> = match plan {
336        Some(p) => p.groups.clone(),
337        None => (0..n).map(|i| vec![i]).collect(),
338    };
339    let concurrency = plan.map(|p| p.concurrency).unwrap_or(1);
340
341    validate_coverage(&stages, n)?;
342
343    for stage in &stages {
344        let mut ordered = stage.clone();
345        ordered.sort_unstable();
346
347        let has_intra_deps = ordered
348            .iter()
349            .any(|&i| matches!(ops[i].parent, Some(p) if ordered.contains(&p)));
350
351        if concurrency <= 1 || ordered.len() <= 1 || has_intra_deps {
352            // Sequential fallback (§4.1) — identical per-op flow to run_plan.
353            for &i in &ordered {
354                let st = run_op(
355                    i,
356                    &ops[i],
357                    &states,
358                    &mut |op: &OpSpec, b: Option<&Value>| exec(op, b),
359                    &mut executed_idx,
360                    &mut skipped_idx,
361                )?;
362                states[i] = Some(st);
363            }
364            continue;
365        }
366
367        // ── parallel path ────────────────────────────────────────────────────
368        let pres: Vec<Preflight> = ordered
369            .iter()
370            .map(|&i| preflight_op(&ops[i], &states))
371            .collect();
372
373        // Jobs = runnable members in declaration order; a shared cursor hands them
374        // out in that order to at most `concurrency` scoped worker threads.
375        let jobs: Vec<usize> = (0..ordered.len())
376            .filter(|&k| matches!(pres[k], Preflight::Exec { .. }))
377            .collect();
378        let workers = (concurrency.max(1) as usize).min(jobs.len());
379        let cursor = std::sync::atomic::AtomicUsize::new(0);
380        let slots: Vec<std::sync::Mutex<Option<ExecOutcome>>> = (0..ordered.len())
381            .map(|_| std::sync::Mutex::new(None))
382            .collect();
383
384        std::thread::scope(|scope| {
385            for _ in 0..workers {
386                scope.spawn(|| loop {
387                    let j = cursor.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
388                    if j >= jobs.len() {
389                        break;
390                    }
391                    let k = jobs[j];
392                    let i = ordered[k];
393                    let bound = match &pres[k] {
394                        Preflight::Exec { bound, .. } => bound.as_ref(),
395                        _ => unreachable!(),
396                    };
397                    let outcome = exec(&ops[i], bound);
398                    *slots[k].lock().unwrap() = Some(outcome);
399                });
400            }
401        }); // worker panics propagate here (like sequential exec panics)
402
403        // Commit (ascending index): declaration-order interpretation.
404        for (k, &i) in ordered.iter().enumerate() {
405            match &pres[k] {
406                Preflight::InvalidPolicy(msg) => {
407                    return fail(PlanFailureCode::UnknownPolicy, msg.clone())
408                }
409                Preflight::Skip => {
410                    skipped_idx.push(i);
411                    states[i] = Some(OpState::Skipped);
412                }
413                Preflight::Exec { policy, .. } => {
414                    let outcome = slots[k]
415                        .lock()
416                        .unwrap()
417                        .take()
418                        .expect("dispatched op must have an outcome");
419                    let st = interpret_outcome(
420                        i,
421                        &ops[i],
422                        *policy,
423                        outcome,
424                        &mut executed_idx,
425                        &mut skipped_idx,
426                    )?;
427                    states[i] = Some(st);
428                }
429            }
430        }
431    }
432
433    executed_idx.sort_unstable();
434    skipped_idx.sort_unstable();
435
436    Ok(RunResult {
437        executed: executed_idx.iter().map(|&i| ops[i].id.clone()).collect(),
438        skipped: skipped_idx.iter().map(|&i| ops[i].id.clone()).collect(),
439        ops_ref: ops
440            .iter()
441            .map(|o| (o.id.clone(), o.relation_kind))
442            .collect(),
443        states,
444    })
445}
446
447/// Pure pre-exec decision for one op (policy validation → skip decision →
448/// bindField binding), shared by the sequential and parallel paths. The order
449/// mirrors the sequential run_op exactly: the policy check precedes the skip
450/// decision (UNKNOWN_POLICY fires even for ops that would be skipped).
451enum Preflight {
452    InvalidPolicy(String),
453    Skip,
454    Exec {
455        policy: PolicyKind,
456        bound: Option<Value>,
457    },
458}
459
460fn preflight_op(op: &OpSpec, states: &[Option<OpState>]) -> Preflight {
461    let policy = match parse_policy(&op.policy) {
462        Ok(p) => p,
463        Err(e) => return Preflight::InvalidPolicy(e.message), // fail-closed on unknown
464    };
465
466    let mut bound_value: Option<Value> = None;
467    if let Some(pidx) = op.parent {
468        match states.get(pidx).and_then(|s| s.as_ref()) {
469            Some(OpState::Ok(pv)) => {
470                if let Some(field) = &op.bind_field {
471                    let bound = pv.obj_get(field);
472                    match bound {
473                        None | Some(Value::Null) => return Preflight::Skip,
474                        Some(b) => bound_value = Some(b.clone()),
475                    }
476                }
477            }
478            _ => {
479                // parent skipped / failed / not produced → skip (chain)
480                return Preflight::Skip;
481            }
482        }
483    }
484    Preflight::Exec {
485        policy,
486        bound: bound_value,
487    }
488}
489
490/// Post-exec interpretation (executed bookkeeping → Policy Kind), shared by both
491/// paths so failure messages stay identical.
492fn interpret_outcome(
493    i: usize,
494    op: &OpSpec,
495    policy: PolicyKind,
496    outcome: ExecOutcome,
497    executed_idx: &mut Vec<usize>,
498    skipped_idx: &mut Vec<usize>,
499) -> Result<OpState, PlanFailure> {
500    executed_idx.push(i);
501
502    match outcome {
503        ExecOutcome::Ok(v) => Ok(OpState::Ok(v)),
504        // The leaf-produced detail rides across the Policy Kind interpretation unchanged
505        // (scp-error.md: the runtime transports the Error Value, it does not synthesize it).
506        ExecOutcome::Error(e, detail) => match policy {
507            PolicyKind::Fail => fail_detail(
508                PlanFailureCode::OpFailed,
509                format!("operation '{}' failed under 'fail' policy: {e}", op.id),
510                detail,
511            ),
512            PolicyKind::Retry => fail_detail(
513                PlanFailureCode::OpFailed,
514                format!(
515                    "operation '{}' failed under 'retry' policy (exhausted): {e}",
516                    op.id
517                ),
518                detail,
519            ),
520            PolicyKind::Continue => {
521                // failed op produces no Port → treat as unproduced/skip downstream.
522                skipped_idx.push(i);
523                executed_idx.pop(); // ran but produced no Port
524                Ok(OpState::Skipped)
525            }
526        },
527    }
528}
529
530fn run_op<F>(
531    i: usize,
532    op: &OpSpec,
533    states: &[Option<OpState>],
534    exec: &mut F,
535    executed_idx: &mut Vec<usize>,
536    skipped_idx: &mut Vec<usize>,
537) -> Result<OpState, PlanFailure>
538where
539    F: FnMut(&OpSpec, Option<&Value>) -> ExecOutcome,
540{
541    let (policy, bound_value) = match preflight_op(op, states) {
542        Preflight::InvalidPolicy(msg) => return fail(PlanFailureCode::UnknownPolicy, msg),
543        Preflight::Skip => {
544            skipped_idx.push(i);
545            return Ok(OpState::Skipped);
546        }
547        Preflight::Exec { policy, bound } => (policy, bound),
548    };
549
550    let outcome = exec(op, bound_value.as_ref());
551    interpret_outcome(i, op, policy, outcome, executed_idx, skipped_idx)
552}
553
554fn validate_coverage(stages: &[Vec<usize>], n: usize) -> Result<(), PlanFailure> {
555    let mut seen = std::collections::HashSet::new();
556    for stage in stages {
557        for &i in stage {
558            if i >= n {
559                return fail(
560                    PlanFailureCode::InvalidPlan,
561                    format!("stage index {i} out of range [0,{n})"),
562                );
563            }
564            if !seen.insert(i) {
565                return fail(
566                    PlanFailureCode::InvalidPlan,
567                    format!("operation {i} appears in more than one stage"),
568                );
569            }
570        }
571    }
572    if seen.len() != n {
573        return fail(
574            PlanFailureCode::InvalidPlan,
575            format!(
576                "plan does not cover all {n} operations (covered {})",
577                seen.len()
578            ),
579        );
580    }
581    Ok(())
582}