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