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
229/// 未生成 relation 値の SSoT: single→Null / connection→空 connection {items:[],cursor:null}。
230/// behavior.rs(writeUnproduced の whole-node skip / map.into の per-element guard-skip)もこれを読む。
231pub(crate) fn unproduced_value(kind: Option<RelationKind>) -> Value {
232    if kind == Some(RelationKind::Connection) {
233        Value::Obj(vec![
234            ("items".into(), Value::Arr(vec![])),
235            ("cursor".into(), Value::Null),
236        ])
237    } else {
238        Value::Null
239    }
240}
241
242fn parse_policy(raw: &Option<String>) -> Result<PolicyKind, PlanFailure> {
243    match raw.as_deref() {
244        None | Some("fail") => Ok(PolicyKind::Fail),
245        Some("retry") => Ok(PolicyKind::Retry),
246        Some("continue") => Ok(PolicyKind::Continue),
247        Some(other) => fail(
248            PlanFailureCode::UnknownPolicy,
249            format!("unknown policy kind: {other}"),
250        ),
251    }
252}
253
254/// Run the plan. `exec(op, bound_value)` returns the node outcome (mock).
255pub fn run_plan<F>(
256    plan: Option<&ExecutionPlanSpec>,
257    ops: &[OpSpec],
258    mut exec: F,
259) -> Result<RunResult, PlanFailure>
260where
261    F: FnMut(&OpSpec, Option<&Value>) -> ExecOutcome,
262{
263    let n = ops.len();
264    let mut states: Vec<Option<OpState>> = vec![None; n];
265    let mut executed_idx: Vec<usize> = Vec::new();
266    let mut skipped_idx: Vec<usize> = Vec::new();
267
268    let stages: Vec<Vec<usize>> = match plan {
269        Some(p) => p.groups.clone(),
270        None => (0..n).map(|i| vec![i]).collect(),
271    };
272
273    validate_coverage(&stages, n)?;
274
275    for stage in &stages {
276        let mut ordered = stage.clone();
277        ordered.sort_unstable(); // determinism: concurrency does not change results
278        for &i in &ordered {
279            let st = run_op(
280                i,
281                &ops[i],
282                &states,
283                &mut exec,
284                &mut executed_idx,
285                &mut skipped_idx,
286            )?;
287            states[i] = Some(st);
288        }
289    }
290
291    executed_idx.sort_unstable();
292    skipped_idx.sort_unstable();
293
294    Ok(RunResult {
295        executed: executed_idx.iter().map(|&i| ops[i].id.clone()).collect(),
296        skipped: skipped_idx.iter().map(|&i| ops[i].id.clone()).collect(),
297        ops_ref: ops
298            .iter()
299            .map(|o| (o.id.clone(), o.relation_kind))
300            .collect(),
301        states,
302    })
303}
304
305/// Bounded parallel variant of [`run_plan`] (bc#23).
306///
307/// Deterministic commit protocol (execution-plan.md §4.1), per stage:
308/// 1. preflight (ascending index): policy validation, skip decision, and bindField
309///    binding are derived from the settled prior-stage states (stage members are
310///    mutually independent — §1).
311/// 2. dispatch (ascending index, bounded): exec of runnable members runs on scoped
312///    worker threads, at most `plan.concurrency` in flight.
313/// 3. commit (ascending index): outcomes are interpreted in declaration order, so
314///    the failure identity (code + message) matches [`run_plan`] exactly.
315///
316/// The only observable difference from [`run_plan`] is speculative dispatch: when a
317/// member's interpretation fails, later members of the same stage may already have
318/// been executed (same allowance as graphddb's historical thread-overlap).
319///
320/// A consumer shipping a plan with `concurrency > 1` declares that `exec` may be
321/// invoked from that many threads concurrently (ship `concurrency: 1` otherwise —
322/// that, single-member stages, and intra-stage parent/child dependencies all fall
323/// back to sequential dispatch with unchanged results).
324pub fn run_plan_parallel<F>(
325    plan: Option<&ExecutionPlanSpec>,
326    ops: &[OpSpec],
327    exec: F,
328) -> Result<RunResult, PlanFailure>
329where
330    F: Fn(&OpSpec, Option<&Value>) -> ExecOutcome + Sync,
331{
332    let n = ops.len();
333    let mut states: Vec<Option<OpState>> = vec![None; n];
334    let mut executed_idx: Vec<usize> = Vec::new();
335    let mut skipped_idx: Vec<usize> = Vec::new();
336
337    let stages: Vec<Vec<usize>> = match plan {
338        Some(p) => p.groups.clone(),
339        None => (0..n).map(|i| vec![i]).collect(),
340    };
341    let concurrency = plan.map(|p| p.concurrency).unwrap_or(1);
342
343    validate_coverage(&stages, n)?;
344
345    for stage in &stages {
346        let mut ordered = stage.clone();
347        ordered.sort_unstable();
348
349        let has_intra_deps = ordered
350            .iter()
351            .any(|&i| matches!(ops[i].parent, Some(p) if ordered.contains(&p)));
352
353        if concurrency <= 1 || ordered.len() <= 1 || has_intra_deps {
354            // Sequential fallback (§4.1) — identical per-op flow to run_plan.
355            for &i in &ordered {
356                let st = run_op(
357                    i,
358                    &ops[i],
359                    &states,
360                    &mut |op: &OpSpec, b: Option<&Value>| exec(op, b),
361                    &mut executed_idx,
362                    &mut skipped_idx,
363                )?;
364                states[i] = Some(st);
365            }
366            continue;
367        }
368
369        // ── parallel path ────────────────────────────────────────────────────
370        let pres: Vec<Preflight> = ordered
371            .iter()
372            .map(|&i| preflight_op(&ops[i], &states))
373            .collect();
374
375        // Jobs = runnable members in declaration order; a shared cursor hands them
376        // out in that order to at most `concurrency` scoped worker threads.
377        let jobs: Vec<usize> = (0..ordered.len())
378            .filter(|&k| matches!(pres[k], Preflight::Exec { .. }))
379            .collect();
380        let workers = (concurrency.max(1) as usize).min(jobs.len());
381        let cursor = std::sync::atomic::AtomicUsize::new(0);
382        let slots: Vec<std::sync::Mutex<Option<ExecOutcome>>> = (0..ordered.len())
383            .map(|_| std::sync::Mutex::new(None))
384            .collect();
385
386        std::thread::scope(|scope| {
387            for _ in 0..workers {
388                scope.spawn(|| loop {
389                    let j = cursor.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
390                    if j >= jobs.len() {
391                        break;
392                    }
393                    let k = jobs[j];
394                    let i = ordered[k];
395                    let bound = match &pres[k] {
396                        Preflight::Exec { bound, .. } => bound.as_ref(),
397                        _ => unreachable!(),
398                    };
399                    let outcome = exec(&ops[i], bound);
400                    *slots[k].lock().unwrap() = Some(outcome);
401                });
402            }
403        }); // worker panics propagate here (like sequential exec panics)
404
405        // Commit (ascending index): declaration-order interpretation.
406        for (k, &i) in ordered.iter().enumerate() {
407            match &pres[k] {
408                Preflight::InvalidPolicy(msg) => {
409                    return fail(PlanFailureCode::UnknownPolicy, msg.clone())
410                }
411                Preflight::Skip => {
412                    skipped_idx.push(i);
413                    states[i] = Some(OpState::Skipped);
414                }
415                Preflight::Exec { policy, .. } => {
416                    let outcome = slots[k]
417                        .lock()
418                        .unwrap()
419                        .take()
420                        .expect("dispatched op must have an outcome");
421                    let st = interpret_outcome(
422                        i,
423                        &ops[i],
424                        *policy,
425                        outcome,
426                        &mut executed_idx,
427                        &mut skipped_idx,
428                    )?;
429                    states[i] = Some(st);
430                }
431            }
432        }
433    }
434
435    executed_idx.sort_unstable();
436    skipped_idx.sort_unstable();
437
438    Ok(RunResult {
439        executed: executed_idx.iter().map(|&i| ops[i].id.clone()).collect(),
440        skipped: skipped_idx.iter().map(|&i| ops[i].id.clone()).collect(),
441        ops_ref: ops
442            .iter()
443            .map(|o| (o.id.clone(), o.relation_kind))
444            .collect(),
445        states,
446    })
447}
448
449/// Pure pre-exec decision for one op (policy validation → skip decision →
450/// bindField binding), shared by the sequential and parallel paths. The order
451/// mirrors the sequential run_op exactly: the policy check precedes the skip
452/// decision (UNKNOWN_POLICY fires even for ops that would be skipped).
453enum Preflight {
454    InvalidPolicy(String),
455    Skip,
456    Exec {
457        policy: PolicyKind,
458        bound: Option<Value>,
459    },
460}
461
462fn preflight_op(op: &OpSpec, states: &[Option<OpState>]) -> Preflight {
463    let policy = match parse_policy(&op.policy) {
464        Ok(p) => p,
465        Err(e) => return Preflight::InvalidPolicy(e.message), // fail-closed on unknown
466    };
467
468    let mut bound_value: Option<Value> = None;
469    if let Some(pidx) = op.parent {
470        match states.get(pidx).and_then(|s| s.as_ref()) {
471            Some(OpState::Ok(pv)) => {
472                if let Some(field) = &op.bind_field {
473                    let bound = pv.obj_get(field);
474                    match bound {
475                        None | Some(Value::Null) => return Preflight::Skip,
476                        Some(b) => bound_value = Some(b.clone()),
477                    }
478                }
479            }
480            _ => {
481                // parent skipped / failed / not produced → skip (chain)
482                return Preflight::Skip;
483            }
484        }
485    }
486    Preflight::Exec {
487        policy,
488        bound: bound_value,
489    }
490}
491
492/// Post-exec interpretation (executed bookkeeping → Policy Kind), shared by both
493/// paths so failure messages stay identical.
494fn interpret_outcome(
495    i: usize,
496    op: &OpSpec,
497    policy: PolicyKind,
498    outcome: ExecOutcome,
499    executed_idx: &mut Vec<usize>,
500    skipped_idx: &mut Vec<usize>,
501) -> Result<OpState, PlanFailure> {
502    executed_idx.push(i);
503
504    match outcome {
505        ExecOutcome::Ok(v) => Ok(OpState::Ok(v)),
506        // The leaf-produced detail rides across the Policy Kind interpretation unchanged
507        // (scp-error.md: the runtime transports the Error Value, it does not synthesize it).
508        ExecOutcome::Error(e, detail) => match policy {
509            PolicyKind::Fail => fail_detail(
510                PlanFailureCode::OpFailed,
511                format!("operation '{}' failed under 'fail' policy: {e}", op.id),
512                detail,
513            ),
514            PolicyKind::Retry => fail_detail(
515                PlanFailureCode::OpFailed,
516                format!(
517                    "operation '{}' failed under 'retry' policy (exhausted): {e}",
518                    op.id
519                ),
520                detail,
521            ),
522            PolicyKind::Continue => {
523                // failed op produces no Port → treat as unproduced/skip downstream.
524                skipped_idx.push(i);
525                executed_idx.pop(); // ran but produced no Port
526                Ok(OpState::Skipped)
527            }
528        },
529    }
530}
531
532fn run_op<F>(
533    i: usize,
534    op: &OpSpec,
535    states: &[Option<OpState>],
536    exec: &mut F,
537    executed_idx: &mut Vec<usize>,
538    skipped_idx: &mut Vec<usize>,
539) -> Result<OpState, PlanFailure>
540where
541    F: FnMut(&OpSpec, Option<&Value>) -> ExecOutcome,
542{
543    let (policy, bound_value) = match preflight_op(op, states) {
544        Preflight::InvalidPolicy(msg) => return fail(PlanFailureCode::UnknownPolicy, msg),
545        Preflight::Skip => {
546            skipped_idx.push(i);
547            return Ok(OpState::Skipped);
548        }
549        Preflight::Exec { policy, bound } => (policy, bound),
550    };
551
552    let outcome = exec(op, bound_value.as_ref());
553    interpret_outcome(i, op, policy, outcome, executed_idx, skipped_idx)
554}
555
556fn validate_coverage(stages: &[Vec<usize>], n: usize) -> Result<(), PlanFailure> {
557    let mut seen = std::collections::HashSet::new();
558    for stage in stages {
559        for &i in stage {
560            if i >= n {
561                return fail(
562                    PlanFailureCode::InvalidPlan,
563                    format!("stage index {i} out of range [0,{n})"),
564                );
565            }
566            if !seen.insert(i) {
567                return fail(
568                    PlanFailureCode::InvalidPlan,
569                    format!("operation {i} appears in more than one stage"),
570                );
571            }
572        }
573    }
574    if seen.len() != n {
575        return fail(
576            PlanFailureCode::InvalidPlan,
577            format!(
578                "plan does not cover all {n} operations (covered {})",
579                seen.len()
580            ),
581        );
582    }
583    Ok(())
584}