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}