Skip to main content

nmbrs_runtime/
scene_tree.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Scene tree — the runtime hierarchy as it's surfaced to renderers.
5//!
6//! Distinct from [`crate::scope_tree::ScopeTree`]: the scope tree
7//! mirrors the static scenario AST 1:1 (one node per `ScenarioNode`),
8//! while the *scene* tree is what's actually shown to the user —
9//! `for_each` iterations are unrolled into per-iteration phase
10//! children under a single `for_each` scope header, and any phases
11//! that aren't reachable until runtime resolution still appear under
12//! a fallback parent.
13//!
14//! Renderers (TUI, web API, post-run summary) walk this tree by
15//! parent / children pointers rather than by depth tags, so:
16//!
17//! - Per-scope status aggregation (`for_each` is "running" if any
18//!   child phase is running) becomes a tree walk.
19//! - Web `GET /api/scope-tree` can serialize the structure directly.
20//! - TUI features that want collapse / expand / scope-level summary
21//!   have the structural information they need.
22//!
23//! Status carried here is the small lifecycle enum (`PhaseStatus`).
24//! Renderers that want richer per-phase metrics (the TUI's
25//! `PhaseSummary` with sparkline buffer, percentiles, etc.) keep a
26//! parallel side-map keyed by [`SceneNodeId`] — the scene tree
27//! stays cheap to clone and serialize.
28
29use serde::{Deserialize, Serialize};
30use std::sync::{Arc, Mutex, RwLock};
31
32/// Process-wide handle to the running session's scene tree.
33///
34/// Published by the runner after `pre_map_tree` builds the
35/// initial pending shape; lifecycle hooks (phase start / complete
36/// / fail) mutate the same tree in place. Out-of-band consumers
37/// (web API, post-run summary, future scripting hooks) read a
38/// snapshot via [`current`] without depending on the observer
39/// surface.
40///
41/// `Mutex<Option<...>>` rather than `OnceLock<...>` so the
42/// integration-test harness can re-run the runner from the same
43/// test binary without cross-contamination — a `OnceLock` would
44/// pin the first run's tree for the lifetime of the process,
45/// and any subsequent runner invocation would see the wrong
46/// phase identities. Production runs only install once, so the
47/// "first-write-wins" production semantics are preserved by the
48/// runner's call sites, not by the storage shape.
49static GLOBAL_TREE: Mutex<Option<Arc<RwLock<SceneTree>>>> = Mutex::new(None);
50
51/// Install the session's scene tree. Replaces any previously-
52/// installed tree (e.g. from a prior in-process runner
53/// invocation by the integration-test harness). The runner
54/// itself only installs once per session, so production
55/// behaviour is unchanged.
56pub fn install_global(tree: SceneTree) -> Arc<RwLock<SceneTree>> {
57    let arc = Arc::new(RwLock::new(tree));
58    // SRD-88: inside an execution scope, install into THAT execution's tree
59    // (so its lifecycle mutations stay isolated); outside any scope, the
60    // process-global default (single-run / CLI / tests — A1).
61    if !crate::execution_context::install_scene_tree(arc.clone()) {
62        *GLOBAL_TREE.lock().unwrap_or_else(|e| e.into_inner()) = Some(arc.clone());
63    }
64    arc
65}
66
67/// The active scene-tree handle: the current execution's (task-local) if
68/// scoped + installed, else the process-global. SRD-88 A1: outside any
69/// execution scope this is exactly `GLOBAL_TREE`.
70fn active_handle() -> Option<Arc<RwLock<SceneTree>>> {
71    crate::execution_context::current_scene_tree().or_else(|| {
72        GLOBAL_TREE
73            .lock()
74            .unwrap_or_else(|e| e.into_inner())
75            .clone()
76    })
77}
78
79/// Snapshot the active scene tree, if installed. Returns
80/// `None` outside an active session — e.g. standalone `nmbrs web`.
81pub fn current() -> Option<SceneTree> {
82    active_handle().and_then(|t| t.read().ok().map(|g| g.clone()))
83}
84
85/// Apply a mutation to the active tree, if installed. No-op when
86/// no session has published one. Used by the runner's lifecycle
87/// emit sites so the tree mirrors the observer's view.
88pub fn with_global_mut<F: FnOnce(&mut SceneTree)>(f: F) {
89    if let Some(arc) = active_handle()
90        && let Ok(mut g) = arc.write()
91    {
92        f(&mut g);
93    }
94}
95
96/// Read-only access to the active scene tree, if installed.
97/// Returns `None` when no session has published one. Mirrors
98/// [`with_global_mut`] for callers that just need to inspect
99/// (e.g. SRD-77 execution-end disposition computation).
100pub fn with_global<R, F: FnOnce(&SceneTree) -> R>(f: F) -> Option<R> {
101    active_handle().and_then(|a| a.read().ok().map(|g| f(&g)))
102}
103
104/// Stable index into [`SceneTree::nodes`]. Indices never change for
105/// a given tree instance; renderers can hold onto them across
106/// status updates.
107pub type SceneNodeId = usize;
108
109/// What kind of node this scene entry represents.
110#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
111pub enum NodeKind {
112    /// Synthetic root above all top-level scenario entries.
113    /// Has no display analogue — its children are rendered as
114    /// the scenario's top-level nodes.
115    Root,
116    /// An executable phase with a Pending → Running → Completed
117    /// lifecycle.
118    Phase,
119    /// A grouping header (`for_each`, `for_combinations`,
120    /// `do_while`, `do_until`, or a phase-level `for_each` lift).
121    /// No own lifecycle — its aggregate status is computed from
122    /// its descendants by [`SceneTree::aggregate_status`].
123    Scope,
124}
125
126/// Phase lifecycle state. Only carries meaning on `Phase` nodes;
127/// `Scope` nodes always start (and stay) `Pending`, with their
128/// effective status derived from descendants.
129#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
130pub enum PhaseStatus {
131    Pending,
132    Running,
133    Completed,
134    Failed(String),
135}
136
137/// One node in the scene tree.
138#[derive(Clone, Debug, Serialize, Deserialize)]
139pub struct SceneNode {
140    pub id: SceneNodeId,
141    pub parent: Option<SceneNodeId>,
142    pub children: Vec<SceneNodeId>,
143    pub depth: usize,
144    pub kind: NodeKind,
145    /// For `Phase`: the phase name. For `Scope`: a description
146    /// like `"for_each color=red"` or `"do_while empty"`.
147    pub name: String,
148    /// For `Phase`: dimensional labels (e.g. `"k=10, table=fknn"`).
149    /// For `Scope`: empty (the description is in `name`).
150    pub labels: String,
151    pub status: PhaseStatus,
152    pub op_count: usize,
153    pub duration_secs: Option<f64>,
154    /// For `Phase`: the ordered list of op template names in this
155    /// phase's stanza (one entry per `ParsedOp`). Empty for
156    /// `Scope` and `Root`. Populated at pre-map time so the TUI's
157    /// scenario view can drill into a phase and show its ops
158    /// without having to reach back into the workload model.
159    #[serde(default)]
160    pub op_names: Vec<String>,
161    /// Names *defined* at this scope: own bindings, iter vars,
162    /// and externs that the scope's specs / op templates
163    /// reference. Excludes inherited cascade-propagation names
164    /// (workload params auto-injected at intermediate scopes
165    /// solely so descendants see them). Populated from the
166    /// scope's installed kernel via
167    /// `program.own_output_names()`. Empty for `Root`. Used by
168    /// the TUI / dryrun renderer to show "what's defined here"
169    /// without listing every name that's merely visible.
170    #[serde(default)]
171    pub own_names: Vec<String>,
172    /// 1-based sequence number assigned to **Phase** nodes at
173    /// pre-map time, in DFS order. `None` for `Scope` and
174    /// `Root` entries.
175    ///
176    /// The TUI shows this as `[N/total]` next to the phase name
177    /// and as `phase X/Y` in the header counter, so the
178    /// operator can at any moment see which step of the planned
179    /// scenario is in flight relative to the whole. The
180    /// numbering is stable for the lifetime of one session
181    /// (assigned once during pre-map), so a UI that displays
182    /// "phase 47" on screen N and "phase 48" on screen N+1 is
183    /// always referring to the same two phases — not a fresh
184    /// re-numbering per draw.
185    #[serde(default)]
186    pub seq: Option<usize>,
187    /// Fully-qualified structural location of this node in the
188    /// workload YAML — outer-first chain of scenarios,
189    /// for_each/for_combinations clauses, do-loops, and
190    /// (terminal) the phase name itself. Populated for
191    /// `Phase` nodes; ancestor `Scope` nodes carry partial
192    /// paths (everything down to but not including the phase
193    /// name).
194    ///
195    /// Used by the checkpoint resume planner — `yaml_path`
196    /// plus the leaf-first coord-path string is the
197    /// per-phase identity tuple that decides whether a saved
198    /// checkpoint entry applies to a freshly-pre-mapped
199    /// phase. See SRD-44 §"Phase identity".
200    #[serde(default)]
201    pub yaml_path: Vec<crate::checkpoint::PathSegment>,
202    /// SRD-76 — structured terminal-state record. `None`
203    /// while the phase is pending or running; populated
204    /// exactly once at phase end by the executor's
205    /// `set_phase_outcome` call. Carries the per-phase
206    /// `PhaseStatus`, wall-clock duration, and the
207    /// chronological error list.
208    ///
209    /// Co-existence with the legacy `status` /
210    /// `duration_secs` fields: the legacy fields stay as
211    /// the load-bearing renderer surface (TUI / stderr
212    /// observer / scene-tree-prints) until Push 4 lands
213    /// the new readouts that read this slot directly.
214    /// `set_phase_outcome` keeps the two in sync; new
215    /// consumers should read from here.
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub outcome: Option<crate::phase_outcome::PhaseOutcome>,
218    /// Whether this node is active for execution under the
219    /// session's `phases=<pattern>` filter (default true). Set
220    /// by the planning walker before any phase runs:
221    /// - Phase nodes get `active = pattern.is_match(name)` (or
222    ///   `true` when no pattern is set).
223    /// - Scope nodes are `active` iff any descendant phase is
224    ///   active; otherwise they're elided from the execution
225    ///   walk along with their subtree.
226    ///
227    /// Inactive nodes are still in the tree (so coordinate
228    /// chains, scope-init kernels, and parent context stay
229    /// intact for active siblings under the same scope) but
230    /// the executor skips their phase activation.
231    #[serde(default = "default_active")]
232    pub active: bool,
233}
234
235fn default_active() -> bool {
236    true
237}
238
239/// The scene tree itself. `nodes[0]` is always the synthetic root.
240#[derive(Clone, Debug, Serialize, Deserialize)]
241pub struct SceneTree {
242    pub nodes: Vec<SceneNode>,
243}
244
245impl Default for SceneTree {
246    fn default() -> Self {
247        Self::new()
248    }
249}
250
251impl SceneTree {
252    /// Build an empty tree containing just the synthetic root.
253    pub fn new() -> Self {
254        let mut t = Self { nodes: Vec::new() };
255        t.nodes.push(SceneNode {
256            id: 0,
257            parent: None,
258            children: Vec::new(),
259            depth: 0,
260            kind: NodeKind::Root,
261            name: String::new(),
262            labels: String::new(),
263            status: PhaseStatus::Pending,
264            op_count: 0,
265            duration_secs: None,
266            op_names: Vec::new(),
267            own_names: Vec::new(),
268            seq: None,
269            yaml_path: Vec::new(),
270            outcome: None,
271            active: true,
272        });
273        t
274    }
275
276    /// Index of the synthetic root.
277    pub fn root(&self) -> SceneNodeId {
278        0
279    }
280
281    /// Append a node under `parent` and return its id.
282    ///
283    /// **Idempotent by `(parent, kind, name)`**: if a child with
284    /// the same kind and name already exists under `parent`, its
285    /// id is returned and no new node is created. Per SRD 18b
286    /// §"Single Walker Contract" point 1, the same walker runs
287    /// once at depth=Phase to populate the tree (so subsequent
288    /// `resume_plan` / `declare_scene_tree_phases` /
289    /// `pre_map_pending_uses` reads see a populated tree) and
290    /// once at the configured execution depth to run cycles —
291    /// re-encountering nodes pushed by the first walk must be a
292    /// no-op, not a duplicate insertion.
293    ///
294    /// `Phase` nodes are auto-assigned a 1-based sequence number
295    /// in insertion order (see [`SceneNode::seq`]); since the
296    /// walker pushes phases in DFS-of-the-scenario-tree order,
297    /// the resulting numbers match the order in which the
298    /// runtime executes them.
299    pub fn push(
300        &mut self,
301        parent: SceneNodeId,
302        kind: NodeKind,
303        name: impl Into<String>,
304        labels: impl Into<String>,
305    ) -> SceneNodeId {
306        let name: String = name.into();
307        let labels: String = labels.into();
308        // Find-or-create: scan `parent`'s children for an
309        // existing match on (kind, name). Matches are returned
310        // unchanged — the second walk pass re-encounters every
311        // node from the first pass and must not duplicate.
312        if let Some(&existing) = self.nodes[parent]
313            .children
314            .iter()
315            .find(|&&c| self.nodes[c].kind == kind && self.nodes[c].name == name)
316        {
317            return existing;
318        }
319        let id = self.nodes.len();
320        let depth = self.nodes[parent].depth + 1;
321        let seq = match kind {
322            NodeKind::Phase => {
323                let count = self
324                    .nodes
325                    .iter()
326                    .filter(|n| n.kind == NodeKind::Phase)
327                    .count();
328                Some(count + 1)
329            }
330            _ => None,
331        };
332        self.nodes.push(SceneNode {
333            id,
334            parent: Some(parent),
335            children: Vec::new(),
336            depth,
337            kind,
338            name,
339            labels,
340            status: PhaseStatus::Pending,
341            op_count: 0,
342            duration_secs: None,
343            op_names: Vec::new(),
344            own_names: Vec::new(),
345            seq,
346            yaml_path: Vec::new(),
347            outcome: None,
348            active: true,
349        });
350        self.nodes[parent].children.push(id);
351        id
352    }
353
354    /// Set the structural YAML path for a node. Called by the
355    /// pre-map walker as it descends through scenarios /
356    /// for_each / for_combinations / do-loops, so each Scope
357    /// and Phase node carries the full chain from the workload
358    /// root down to its declaration site. Used by the
359    /// checkpoint resume planner to identify phases across
360    /// runs (per SRD-44 §"Phase identity").
361    pub fn set_yaml_path(&mut self, id: SceneNodeId, path: Vec<crate::checkpoint::PathSegment>) {
362        if id < self.nodes.len() {
363            self.nodes[id].yaml_path = path;
364        }
365    }
366
367    /// Total number of `Phase` entries in the tree. Equal to the
368    /// largest assigned `seq` value once the tree is fully built.
369    pub fn total_phases(&self) -> usize {
370        self.nodes
371            .iter()
372            .filter(|n| n.kind == NodeKind::Phase)
373            .count()
374    }
375
376    /// Apply a phase-name filter to the tree. Phase nodes whose
377    /// `name` does not match `pattern` are marked `active=false`;
378    /// Scope nodes inherit `active=false` iff every phase
379    /// descendant under them was filtered out. The synthetic
380    /// root is always active. When `pattern` is `None`, every
381    /// node stays active.
382    ///
383    /// Inactive subtrees stay in the tree so the executor's
384    /// planning walk still constructs the scope-init kernels
385    /// the active branches inherit from — only execution is
386    /// skipped at the leaves.
387    pub fn apply_phase_filter(
388        &mut self,
389        pattern: Option<&crate::phase_filter::PhasePattern>,
390    ) -> PhaseFilterStats {
391        let mut stats = PhaseFilterStats::default();
392        if pattern.is_none() {
393            stats.matched = self.total_phases();
394            return stats;
395        }
396        let pat = pattern.unwrap();
397        // Pass 1: phases get their own match decision.
398        for n in self.nodes.iter_mut() {
399            if n.kind == NodeKind::Phase {
400                n.active = pat.is_match(&n.name);
401                stats.total += 1;
402                if n.active {
403                    stats.matched += 1;
404                }
405            }
406        }
407        // Pass 2: scope nodes (and the root) are active iff any
408        // descendant phase is active. Walk bottom-up by
409        // processing nodes in reverse-id order — children
410        // always have higher ids than parents (the tree's
411        // append-only construction guarantees this).
412        let n = self.nodes.len();
413        for i in (0..n).rev() {
414            if matches!(self.nodes[i].kind, NodeKind::Phase) {
415                continue;
416            }
417            let kids = self.nodes[i].children.clone();
418            let any_active = kids.iter().any(|c| self.nodes[*c].active);
419            self.nodes[i].active = any_active;
420        }
421        // Root stays active when at least one phase matched
422        // — but if zero matched we leave it inactive so the
423        // executor short-circuits cleanly with no work done.
424        stats
425    }
426
427    /// Whether the phase node at `id` should be executed under
428    /// the active phase filter. Convenience for the executor's
429    /// per-phase dispatch site.
430    pub fn is_phase_active(&self, id: SceneNodeId) -> bool {
431        self.nodes.get(id).map(|n| n.active).unwrap_or(false)
432    }
433}
434
435/// Counters returned by [`SceneTree::apply_phase_filter`] so the
436/// runner can log how many phases the filter selected vs the
437/// total available.
438#[derive(Default, Debug, Clone, Copy)]
439pub struct PhaseFilterStats {
440    pub matched: usize,
441    pub total: usize,
442}
443
444impl SceneTree {
445    /// Set the op-template names for a phase node. Called at
446    /// pre-map time once the workload model has been resolved so
447    /// the TUI can drill into a phase and show its stanza
448    /// elements.
449    pub fn set_phase_op_names(&mut self, id: SceneNodeId, names: Vec<String>) {
450        if id < self.nodes.len() {
451            self.nodes[id].op_names = names;
452        }
453    }
454
455    /// Set the scope-local "own names" — names defined at this
456    /// scope vs. inherited via cascade. See
457    /// [`SceneNode::own_names`]. Called at pre-map time from
458    /// the scope kernel's `program.own_output_names()`.
459    pub fn set_own_names(&mut self, id: SceneNodeId, names: Vec<String>) {
460        if id < self.nodes.len() {
461            self.nodes[id].own_names = names;
462        }
463    }
464
465    /// DFS walk from the root, yielding every node in display
466    /// order. The synthetic root itself is included as the first
467    /// item; renderers filter on `kind == Root` to skip it.
468    pub fn dfs(&self) -> DfsIter<'_> {
469        DfsIter {
470            tree: self,
471            stack: vec![0],
472        }
473    }
474
475    /// DFS yielding only `Phase`-kind nodes, in the same order
476    /// the flat pre-map vector used to produce.
477    pub fn dfs_phases(&self) -> impl Iterator<Item = &SceneNode> {
478        self.dfs().filter(|n| n.kind == NodeKind::Phase)
479    }
480
481    /// First phase node matching `(name, status)`. Used by
482    /// observer callbacks to bind a `phase_starting` event to the
483    /// next pending phase, then `phase_completed` to its running
484    /// counterpart.
485    ///
486    /// Matching is **structural-order**, not label-based: pre-map
487    /// (`executor::pre_map_recursive`) and runtime
488    /// (`executor::execute_node` → `dispatch_comprehension`) walk
489    /// the scenario tree in the same DFS order, so the *i*-th
490    /// runtime invocation of phase `name` always corresponds to
491    /// the *i*-th pre-mapped phase node by `name`. That lets us
492    /// avoid forcing pre-map's coordinate-path label string to
493    /// match runtime's `format_scope_coordinate_path` output
494    /// byte-for-byte — historically a fragile coupling that
495    /// silently degraded to the "push under root" fallback when
496    /// any workload-param vs. iter-var distinction shifted (e.g.
497    /// `optimize_for_values` vs. `optimize_for`).
498    ///
499    /// `labels` was the legacy match key; preserved on the
500    /// signature so callers don't have to change, but only used
501    /// now if the order-based lookup misses (which shouldn't
502    /// happen — surface as a warning if it does).
503    pub fn find_phase(
504        &self,
505        name: &str,
506        _labels: &str,
507        want: Option<&PhaseStatus>,
508    ) -> Option<SceneNodeId> {
509        self.dfs_phases()
510            .find(|n| n.name == name && want.is_none_or(|w| &n.status == w))
511            .map(|n| n.id)
512    }
513
514    // ── id-based lifecycle flips (SRD-100 P1c) ──────────────────
515    //
516    // The canonical lifecycle-routing primitives: flip the node at
517    // a dispatch-time [`SceneNodeId`] directly, no DFS-order match.
518    // Under concurrent dispatch two phases sharing a `name` race in
519    // `find_phase` (it ignores labels and matches first-by-status),
520    // mis-attributing status / op_count / duration / outcome to the
521    // wrong node. The executor allocates each phase's node id at
522    // dispatch and threads it through the observer lifecycle so the
523    // flip lands on THIS phase's node regardless of sibling timing.
524
525    /// Mark the phase node at `id` as running. No-op if `id` is out
526    /// of range (defensive against a stale/foreign id).
527    pub fn set_phase_running_at(&mut self, id: SceneNodeId, op_count: usize) {
528        if let Some(n) = self.nodes.get_mut(id) {
529            n.status = PhaseStatus::Running;
530            n.op_count = op_count;
531        }
532    }
533
534    /// Mark the phase node at `id` as completed with `duration_secs`.
535    /// Detach `id` from its parent's child list, removing it from every
536    /// tree walk (display folds, replay tree). The node's allocation and
537    /// id remain valid — ids are stable — it is simply unreachable.
538    /// Used by the `skipped_phases=elide|prune` display modes to drop
539    /// fully-gated-off phases from the completed tree.
540    pub fn remove_node(&mut self, id: SceneNodeId) {
541        let Some(parent) = self.nodes.get(id).and_then(|n| n.parent) else {
542            return;
543        };
544        if let Some(p) = self.nodes.get_mut(parent) {
545            p.children.retain(|c| *c != id);
546        }
547    }
548
549    pub fn set_phase_completed_at(&mut self, id: SceneNodeId, duration_secs: f64) {
550        if let Some(n) = self.nodes.get_mut(id) {
551            n.status = PhaseStatus::Completed;
552            n.duration_secs = Some(duration_secs);
553        }
554    }
555
556    /// Mark the phase node at `id` as failed with `error`.
557    pub fn set_phase_failed_at(&mut self, id: SceneNodeId, error: &str) {
558        if let Some(n) = self.nodes.get_mut(id) {
559            n.status = PhaseStatus::Failed(error.to_string());
560        }
561    }
562
563    /// Mark a phase as running. By-name convenience delegating to
564    /// [`Self::set_phase_running_at`] via [`Self::find_phase`] —
565    /// used by tests and non-concurrent by-name call sites. The
566    /// production lifecycle path threads the dispatch-time id
567    /// (P1c) and calls the `_at` form directly.
568    pub fn set_phase_running(&mut self, name: &str, labels: &str, op_count: usize) {
569        if let Some(id) = self.find_phase(name, labels, Some(&PhaseStatus::Pending)) {
570            self.set_phase_running_at(id, op_count);
571        }
572    }
573
574    /// Mark a phase as completed. By-name convenience (see
575    /// [`Self::set_phase_running`]).
576    pub fn set_phase_completed(&mut self, name: &str, labels: &str, duration_secs: f64) {
577        if let Some(id) = self.find_phase(name, labels, Some(&PhaseStatus::Running)) {
578            self.set_phase_completed_at(id, duration_secs);
579        }
580    }
581
582    /// Mark a phase as failed. By-name convenience matching the
583    /// first phase with the given (name, labels) regardless of
584    /// status — failure can arrive while the phase is still
585    /// pending in the rare case of pre-flight resolution errors.
586    pub fn set_phase_failed(&mut self, name: &str, labels: &str, error: &str) {
587        if let Some(id) = self.find_phase(name, labels, None) {
588            self.set_phase_failed_at(id, error);
589        }
590    }
591
592    /// SRD-76 — install the structured terminal outcome on
593    /// a phase node. Mirrors the legacy `status` /
594    /// `duration_secs` fields so existing renderers see
595    /// consistent state (the legacy fields stay
596    /// load-bearing until SRD-76 Push 4 lands the new
597    /// readouts that read `outcome` directly).
598    ///
599    /// Matches the first phase with the given
600    /// `(name, labels)` regardless of current status — the
601    /// outcome can arrive on a Pending phase if pre-flight
602    /// failed before the running transition, on a Running
603    /// phase at normal completion, and idempotency in the
604    /// rare double-install case (debug-asserted off in
605    /// release builds).
606    pub fn set_phase_outcome(
607        &mut self,
608        name: &str,
609        labels: &str,
610        outcome: crate::phase_outcome::PhaseOutcome,
611    ) {
612        let Some(id) = self.find_phase(name, labels, None) else {
613            return;
614        };
615        self.set_phase_outcome_at(id, outcome);
616    }
617
618    /// SRD-100 P1c — install the structured outcome on the phase
619    /// node at `id` directly (dispatch-time-keyed, race-safe under
620    /// concurrent same-name phases). The by-name
621    /// [`Self::set_phase_outcome`] delegates here after a
622    /// [`Self::find_phase`] lookup.
623    pub fn set_phase_outcome_at(
624        &mut self,
625        id: SceneNodeId,
626        outcome: crate::phase_outcome::PhaseOutcome,
627    ) {
628        let Some(n) = self.nodes.get_mut(id) else {
629            return;
630        };
631        // Overwrite-on-re-run matches the legacy `status` /
632        // `duration_secs` fields: comprehension iterations
633        // re-use the same SceneNode for each tuple of the
634        // for_each, so the LATEST outcome wins for the
635        // realtime display. Sqlite persistence (SRD-76
636        // Push 3) writes per-iteration rows keyed by
637        // (phase_name, phase_labels, ended_at_nanos) so the
638        // full history is preserved in the structured
639        // store; the in-memory carrier is the most-recent
640        // snapshot only.
641        // Keep the tree's lifecycle field in sync so renderers
642        // reading it see consistent state. Validity is the axis
643        // that matters here (SRD-82 Part 1): a trustworthy result
644        // — completed, skipped, or re-usable partial progress —
645        // renders Completed; an untrustworthy one renders Failed
646        // with the first error message.
647        let lifecycle = match outcome.validity {
648            crate::phase_outcome::Validity::Succeeded => PhaseStatus::Completed,
649            crate::phase_outcome::Validity::Failed => {
650                let msg = outcome
651                    .first_error_message()
652                    .unwrap_or("unknown error")
653                    .to_string();
654                PhaseStatus::Failed(msg)
655            }
656        };
657        n.status = lifecycle;
658        if outcome.duration_secs > 0.0 {
659            n.duration_secs = Some(outcome.duration_secs);
660        }
661        n.outcome = Some(outcome);
662    }
663
664    /// SRD-76 — project every phase's outcome onto the
665    /// session-wide pass/fail axis. Walks every phase node
666    /// that has been populated with a `PhaseOutcome`;
667    /// returns [`SessionDisposition::Failure`] when any
668    /// phase's outcome carries `Validity::Failed`,
669    /// [`SessionDisposition::Success`] otherwise. Phases
670    /// that never ran (still Pending at session end)
671    /// contribute nothing — interrupted-mid-run is not a
672    /// failure per SRD-76 §"SessionDisposition".
673    pub fn session_disposition(&self) -> crate::phase_outcome::SessionDisposition {
674        let any_failed = self
675            .nodes
676            .iter()
677            .filter(|n| matches!(n.kind, NodeKind::Phase))
678            .filter_map(|n| n.outcome.as_ref())
679            .any(|o| o.is_failure());
680        if any_failed {
681            crate::phase_outcome::SessionDisposition::Failure
682        } else {
683            crate::phase_outcome::SessionDisposition::Success
684        }
685    }
686
687    /// SRD-76 — iterate every phase node's structured
688    /// outcome in DFS order. Used by the (Push 3) sqlite
689    /// persister and the (Push 5) replay rehydrator. Skips
690    /// phases that never reached terminal state.
691    /// True when the phase node already carries a recorded
692    /// [`crate::phase_outcome::PhaseOutcome`]. The `run_phase`
693    /// chokepoint uses this to detect an early config-resolution
694    /// failure (an `Outcome::failed()` returned before the failure
695    /// epilogue ran) so it can route it through the visible
696    /// surfaces exactly once.
697    pub fn phase_outcome_present_at(&self, id: SceneNodeId) -> bool {
698        self.nodes
699            .get(id)
700            .map(|n| n.outcome.is_some())
701            .unwrap_or(false)
702    }
703
704    pub fn iter_phase_outcomes(&self) -> impl Iterator<Item = &crate::phase_outcome::PhaseOutcome> {
705        self.nodes
706            .iter()
707            .filter(|n| matches!(n.kind, NodeKind::Phase))
708            .filter_map(|n| n.outcome.as_ref())
709    }
710
711    /// Effective status for a `Scope` (or `Root`) node, computed
712    /// by walking descendants:
713    /// - any descendant `Failed` → Failed
714    /// - any descendant `Running` → Running
715    /// - all descendant phases `Completed` → Completed
716    /// - else → Pending
717    pub fn aggregate_status(&self, id: SceneNodeId) -> PhaseStatus {
718        let n = &self.nodes[id];
719        if n.kind == NodeKind::Phase {
720            return n.status.clone();
721        }
722        let mut seen_phase = false;
723        let mut all_completed = true;
724        let mut any_running = false;
725        let mut first_failure: Option<String> = None;
726        for &child in &n.children {
727            let cs = self.aggregate_status(child);
728            match cs {
729                PhaseStatus::Failed(e) => {
730                    if first_failure.is_none() {
731                        first_failure = Some(e);
732                    }
733                    all_completed = false;
734                }
735                PhaseStatus::Running => {
736                    any_running = true;
737                    all_completed = false;
738                }
739                PhaseStatus::Pending => {
740                    all_completed = false;
741                }
742                PhaseStatus::Completed => {}
743            }
744            if self.nodes[child].kind == NodeKind::Phase || self.descendants_contain_phase(child) {
745                seen_phase = true;
746            }
747        }
748        if let Some(e) = first_failure {
749            return PhaseStatus::Failed(e);
750        }
751        if any_running {
752            return PhaseStatus::Running;
753        }
754        if seen_phase && all_completed {
755            return PhaseStatus::Completed;
756        }
757        PhaseStatus::Pending
758    }
759
760    fn descendants_contain_phase(&self, id: SceneNodeId) -> bool {
761        let n = &self.nodes[id];
762        if n.kind == NodeKind::Phase {
763            return true;
764        }
765        n.children
766            .iter()
767            .any(|&c| self.descendants_contain_phase(c))
768    }
769
770    /// Total count of `Phase`-kind nodes in the tree.
771    pub fn phase_count(&self) -> usize {
772        self.dfs_phases().count()
773    }
774}
775
776/// Indent prefix (single-space repeats) for log lines whose
777/// visual nesting should match the **executing** phase's scope
778/// depth. Empty string when no scene tree is installed.
779///
780/// One char per level — deep scenario trees can stack 5+
781/// levels of nesting; a 2-char indent burns 10+ columns of
782/// screen real estate before any content lands.
783///
784/// Used by emit sites that fire from inside a phase's
785/// execution (polling-op progress, activity-end DONE summary,
786/// relevancy stats, the errorhandler / metrics-diag log
787/// bridges) so they nest under the phase's startup line in
788/// tui=terminal output.
789///
790/// SRD-100 P1c — the depth comes from the **ambient executing
791/// phase** ([`crate::execution_context::current_phase_node`], a
792/// task-local set by `run_phase` and carried across fiber spawns
793/// by `propagate`), so under concurrency each phase's emit nests
794/// under ITS OWN depth. The historical "first `Running` in DFS
795/// order" guess — which mis-indented a poll line to a concurrent
796/// sibling — remains only as the fallback for emitters with no
797/// phase task-local (the metrics scheduler thread and other
798/// genuinely cross-phase sinks, where "which phase" is undefined).
799pub fn running_phase_indent() -> String {
800    let Some(tree) = current() else {
801        return String::new();
802    };
803    if let Some(id) = crate::execution_context::current_phase_node()
804        && let Some(n) = tree.nodes.get(id)
805    {
806        return " ".repeat(n.depth.saturating_sub(1));
807    }
808    tree.dfs_phases()
809        .find(|n| matches!(n.status, PhaseStatus::Running))
810        .map(|n| " ".repeat(n.depth.saturating_sub(1)))
811        .unwrap_or_default()
812}
813
814/// Depth-first iterator over a [`SceneTree`].
815pub struct DfsIter<'a> {
816    tree: &'a SceneTree,
817    stack: Vec<SceneNodeId>,
818}
819
820impl<'a> Iterator for DfsIter<'a> {
821    type Item = &'a SceneNode;
822
823    fn next(&mut self) -> Option<Self::Item> {
824        let id = self.stack.pop()?;
825        let node = &self.tree.nodes[id];
826        for &c in node.children.iter().rev() {
827            self.stack.push(c);
828        }
829        Some(node)
830    }
831}
832
833#[cfg(test)]
834mod tests {
835    use super::*;
836
837    fn build_simple() -> SceneTree {
838        let mut t = SceneTree::new();
839        let s = t.push(t.root(), NodeKind::Scope, "for_each x=1", "");
840        let _ = t.push(s, NodeKind::Phase, "p", "x=1");
841        let _ = t.push(s, NodeKind::Phase, "q", "x=1");
842        let s2 = t.push(t.root(), NodeKind::Scope, "for_each x=2", "");
843        let _ = t.push(s2, NodeKind::Phase, "p", "x=2");
844        let _ = t.push(s2, NodeKind::Phase, "q", "x=2");
845        t
846    }
847
848    #[test]
849    fn dfs_yields_all_in_display_order() {
850        let t = build_simple();
851        let names: Vec<&str> = t.dfs().map(|n| n.name.as_str()).collect();
852        assert_eq!(
853            names,
854            vec!["", "for_each x=1", "p", "q", "for_each x=2", "p", "q"]
855        );
856    }
857
858    #[test]
859    fn dfs_phases_skips_root_and_scopes() {
860        let t = build_simple();
861        let names: Vec<&str> = t.dfs_phases().map(|n| n.name.as_str()).collect();
862        assert_eq!(names, vec!["p", "q", "p", "q"]);
863    }
864
865    #[test]
866    fn find_pending_then_running_progresses_through_iterations() {
867        let mut t = build_simple();
868        // First (p, x=1) Pending → Running → Completed.
869        t.set_phase_running("p", "x=1", 3);
870        let n = t
871            .find_phase("p", "x=1", Some(&PhaseStatus::Running))
872            .unwrap();
873        assert_eq!(t.nodes[n].op_count, 3);
874        t.set_phase_completed("p", "x=1", 0.5);
875        // The next pending (p, x=2) is now matchable.
876        t.set_phase_running("p", "x=2", 5);
877        let n2 = t
878            .find_phase("p", "x=2", Some(&PhaseStatus::Running))
879            .unwrap();
880        assert_ne!(n, n2);
881        assert_eq!(t.nodes[n2].op_count, 5);
882    }
883
884    /// SRD-100 §12 — when two same-named phases are **distinct nodes**
885    /// (scenario-level `for_each`, `for_combinations`, nesting — each
886    /// cell pushed under its OWN per-iter scope), their status /
887    /// op_count / duration must attribute to the CORRECT node. The
888    /// dispatch-time [`SceneNodeId`] keys each flip directly; the
889    /// legacy by-name [`SceneTree::find_phase`] (first-pending-by-DFS,
890    /// labels ignored) races and mis-attributes when completion order
891    /// differs from dispatch order. This pins the `_at` flips against
892    /// that race: x=2 completes BEFORE x=1, yet each node keeps its
893    /// own numbers (a `find_phase(.., Running)` lookup would have
894    /// recorded x=2's 10.0s onto the first-DFS node, p[x=1]).
895    ///
896    /// NOTE the scope boundary: `build_simple` puts each `p` under a
897    /// SEPARATE scope, so they ARE distinct nodes. Flat phase-level
898    /// `for_each` / optimize sweeps push every cell under ONE scope and
899    /// collapse to a single node (see
900    /// [`same_name_cells_under_one_parent_alias_to_one_node`]) — the
901    /// threaded id cannot disambiguate those; node distinctness for that
902    /// topology is a separate concern from this routing fix.
903    #[test]
904    fn id_based_flips_attribute_to_correct_node_under_reordered_completion() {
905        let mut t = build_simple();
906        // The two same-named "p" cells: p[x=1] is first in DFS,
907        // p[x=2] the second. `find_phase` ignores labels, so locate
908        // them structurally by DFS position.
909        let p_x1 = t.dfs_phases().filter(|n| n.name == "p").next().unwrap().id;
910        let p_x2 = t.dfs_phases().filter(|n| n.name == "p").nth(1).unwrap().id;
911        assert_ne!(p_x1, p_x2);
912
913        // Both cells start running (dispatch order x=1 then x=2).
914        t.set_phase_running_at(p_x1, 3);
915        t.set_phase_running_at(p_x2, 7);
916
917        // Completion arrives in REVERSED order: x=2 finishes first.
918        t.set_phase_completed_at(p_x2, 10.0);
919        t.set_phase_completed_at(p_x1, 5.0);
920
921        // Each node carries ITS OWN op_count + duration + status.
922        assert_eq!(t.nodes[p_x1].op_count, 3);
923        assert_eq!(t.nodes[p_x2].op_count, 7);
924        assert_eq!(t.nodes[p_x1].duration_secs, Some(5.0));
925        assert_eq!(t.nodes[p_x2].duration_secs, Some(10.0));
926        assert_eq!(t.nodes[p_x1].status, PhaseStatus::Completed);
927        assert_eq!(t.nodes[p_x2].status, PhaseStatus::Completed);
928    }
929
930    /// SRD-100 §12 — structured outcomes (SRD-76 carrier) install on
931    /// the dispatch-time node, not a same-named sibling. Mirrors the
932    /// duration race for the `set_phase_outcome_at` path.
933    #[test]
934    fn id_based_outcome_install_targets_the_dispatch_node() {
935        use crate::phase_outcome::{PhaseIdentity, PhaseOutcome};
936        let mut t = build_simple();
937        let p_x1 = t.dfs_phases().filter(|n| n.name == "p").next().unwrap().id;
938        let p_x2 = t.dfs_phases().filter(|n| n.name == "p").nth(1).unwrap().id;
939        t.set_phase_running_at(p_x1, 1);
940        t.set_phase_running_at(p_x2, 1);
941        // Install x=2's outcome first (reversed completion order).
942        t.set_phase_outcome_at(
943            p_x2,
944            PhaseOutcome::completed(PhaseIdentity::new("p", "x=2"), 9.0),
945        );
946        t.set_phase_outcome_at(
947            p_x1,
948            PhaseOutcome::completed(PhaseIdentity::new("p", "x=1"), 4.0),
949        );
950        assert_eq!(t.nodes[p_x1].duration_secs, Some(4.0));
951        assert_eq!(t.nodes[p_x2].duration_secs, Some(9.0));
952        assert!(t.nodes[p_x1].outcome.is_some());
953        assert!(t.nodes[p_x2].outcome.is_some());
954    }
955
956    /// SRD-100 P1c invariant — [`SceneTree::push`] is find-or-create by
957    /// `(parent, kind, name)`, **labels ignored**, so same-name cells pushed
958    /// under ONE parent collapse to a single node id. This is the reason the
959    /// comprehension dispatcher (phase-level `for_each`) wraps each cell in
960    /// its OWN per-iter scope before pushing the phase (executor.rs
961    /// `dispatch_comprehension`): distinctness comes from a distinct PARENT,
962    /// not from label-keying the phase (which would re-introduce the §4
963    /// byte-exact label-identity coupling). Pinning the primitive's
964    /// idempotency so that contract is explicit and a regression in it would
965    /// surface here, not as a silent same-name collapse downstream.
966    #[test]
967    fn same_name_cells_under_one_parent_alias_to_one_node() {
968        let mut t = SceneTree::new();
969        let s = t.push(t.root(), NodeKind::Scope, "phase.for_each x", "");
970        let c1 = t.push(s, NodeKind::Phase, "p", "x=1");
971        let c2 = t.push(s, NodeKind::Phase, "p", "x=2");
972        assert_eq!(
973            c1, c2,
974            "push is idempotent by name — flat for_each / sweep cells collapse"
975        );
976        // Contrast: distinct PARENTS yield distinct ids (the topology the
977        // attribution tests above rely on).
978        let s2 = t.push(t.root(), NodeKind::Scope, "phase.for_each y", "");
979        let c3 = t.push(s2, NodeKind::Phase, "p", "y=1");
980        assert_ne!(
981            c1, c3,
982            "same name under a DIFFERENT parent is a distinct node"
983        );
984    }
985
986    #[test]
987    fn aggregate_status_walks_descendants() {
988        let mut t = build_simple();
989        // No phases moved yet — aggregate is Pending.
990        assert_eq!(t.aggregate_status(t.root()), PhaseStatus::Pending);
991        // Mark every phase Completed → root aggregates to Completed.
992        for (name, labels) in [("p", "x=1"), ("q", "x=1"), ("p", "x=2"), ("q", "x=2")] {
993            t.set_phase_running(name, labels, 1);
994            t.set_phase_completed(name, labels, 0.1);
995        }
996        assert_eq!(t.aggregate_status(t.root()), PhaseStatus::Completed);
997    }
998
999    #[test]
1000    fn aggregate_propagates_failure() {
1001        let mut t = build_simple();
1002        t.set_phase_running("p", "x=1", 1);
1003        t.set_phase_failed("p", "x=1", "boom");
1004        let s = t.aggregate_status(t.root());
1005        assert!(
1006            matches!(s, PhaseStatus::Failed(ref e) if e == "boom"),
1007            "got {s:?}"
1008        );
1009    }
1010
1011    #[test]
1012    fn aggregate_running_when_any_running() {
1013        let mut t = build_simple();
1014        t.set_phase_running("p", "x=1", 1);
1015        assert_eq!(t.aggregate_status(t.root()), PhaseStatus::Running);
1016    }
1017
1018    /// SRD-76 — `set_phase_outcome` installs the
1019    /// structured outcome AND mirrors the terminal state
1020    /// onto the legacy `status` field so existing
1021    /// renderers stay consistent. `iter_phase_outcomes`
1022    /// surfaces every populated outcome in DFS order.
1023    #[test]
1024    fn set_phase_outcome_installs_structured_and_mirrors_legacy() {
1025        use crate::phase_outcome::{PhaseIdentity, PhaseOutcome};
1026        let mut t = build_simple();
1027        t.set_phase_running("p", "x=1", 1);
1028        let outcome = PhaseOutcome::completed(PhaseIdentity::new("p", "x=1"), 2.5);
1029        t.set_phase_outcome("p", "x=1", outcome.clone());
1030        let phase_id = t.find_phase("p", "x=1", None).expect("phase found");
1031        let n = &t.nodes[phase_id];
1032        assert_eq!(n.outcome.as_ref(), Some(&outcome));
1033        assert_eq!(n.status, PhaseStatus::Completed);
1034        assert_eq!(n.duration_secs, Some(2.5));
1035        // iter_phase_outcomes returns exactly the installed one
1036        let outcomes: Vec<_> = t.iter_phase_outcomes().collect();
1037        assert_eq!(outcomes.len(), 1);
1038        assert_eq!(outcomes[0], &outcome);
1039    }
1040
1041    /// SRD-76 — installing a `Failed` outcome maps the
1042    /// first error's message onto the legacy
1043    /// `PhaseStatus::Failed(String)` so the existing
1044    /// status-line renderer continues to print the
1045    /// reason without code changes.
1046    #[test]
1047    fn failed_outcome_legacy_status_carries_first_error_message() {
1048        use crate::phase_outcome::{PhaseErrorDetail, PhaseIdentity, PhaseOutcome};
1049        let mut t = build_simple();
1050        t.set_phase_running("p", "x=1", 1);
1051        let outcome = PhaseOutcome::failed(
1052            PhaseIdentity::new("p", "x=1"),
1053            142.7,
1054            vec![PhaseErrorDetail {
1055                class: "poll_timeout".into(),
1056                message: "deadline reached after 14400s".into(),
1057                op_name: None,
1058                cycle: None,
1059                op_template: None,
1060                op_resolved: None,
1061                at_nanos: 1_000,
1062                retryable: false,
1063            }],
1064        );
1065        t.set_phase_outcome("p", "x=1", outcome);
1066        let phase_id = t.find_phase("p", "x=1", None).expect("phase found");
1067        match &t.nodes[phase_id].status {
1068            PhaseStatus::Failed(msg) => assert_eq!(msg, "deadline reached after 14400s"),
1069            other => panic!("expected Failed, got {other:?}"),
1070        }
1071    }
1072
1073    /// SRD-76 — `session_disposition` is `Failure` iff
1074    /// any populated outcome is `PhaseStatus::Failed`.
1075    /// Phases that never reached terminal state
1076    /// contribute nothing (interrupted ≠ failed).
1077    #[test]
1078    fn session_disposition_failure_when_any_phase_failed() {
1079        use crate::phase_outcome::{
1080            PhaseErrorDetail, PhaseIdentity, PhaseOutcome, SessionDisposition,
1081        };
1082        let mut t = build_simple();
1083        t.set_phase_outcome(
1084            "p",
1085            "x=1",
1086            PhaseOutcome::completed(PhaseIdentity::new("p", "x=1"), 1.0),
1087        );
1088        // Still all-success — only one outcome installed, and it's Completed.
1089        assert_eq!(t.session_disposition(), SessionDisposition::Success);
1090
1091        // Install a Failed outcome — disposition flips.
1092        t.set_phase_outcome(
1093            "q",
1094            "x=1",
1095            PhaseOutcome::failed(
1096                PhaseIdentity::new("q", "x=1"),
1097                0.5,
1098                vec![PhaseErrorDetail {
1099                    class: "BindError".into(),
1100                    message: "bad".into(),
1101                    op_name: None,
1102                    cycle: None,
1103                    op_template: None,
1104                    op_resolved: None,
1105                    at_nanos: 0,
1106                    retryable: false,
1107                }],
1108            ),
1109        );
1110        assert_eq!(t.session_disposition(), SessionDisposition::Failure);
1111    }
1112
1113    /// SRD-76 — a session where no phase ran (all
1114    /// Pending) is `Success`. Interrupted-before-anything
1115    /// shouldn't masquerade as a failure.
1116    #[test]
1117    fn session_disposition_success_when_no_phase_ran() {
1118        use crate::phase_outcome::SessionDisposition;
1119        let t = build_simple();
1120        assert_eq!(t.session_disposition(), SessionDisposition::Success);
1121    }
1122
1123    /// SRD-76 — Skipped and CursorSuspended are
1124    /// non-failures at the session level. Both
1125    /// contribute `Success` even without any
1126    /// `Completed` siblings.
1127    #[test]
1128    fn session_disposition_skipped_and_cursor_suspended_are_success() {
1129        use crate::phase_outcome::{PhaseIdentity, PhaseOutcome, SessionDisposition};
1130        let mut t = build_simple();
1131        t.set_phase_outcome(
1132            "p",
1133            "x=1",
1134            PhaseOutcome::skipped(PhaseIdentity::new("p", "x=1")),
1135        );
1136        // Interrupted+Succeeded — re-usable partial progress (the
1137        // retired CursorSuspended collapses here, SRD-82 Part 1).
1138        t.set_phase_outcome(
1139            "q",
1140            "x=1",
1141            PhaseOutcome::interrupted(PhaseIdentity::new("q", "x=1"), 0.5, None),
1142        );
1143        assert_eq!(t.session_disposition(), SessionDisposition::Success);
1144    }
1145}