Skip to main content

polaris_graph/
node.rs

1//! Node types for graphs.
2//!
3//! Nodes are the vertices in a graph, representing units of computation
4//! or control flow decisions.
5
6use crate::graph::Graph;
7use crate::predicate::BoxedPredicate;
8use core::any::Any;
9use hashbrown::{HashMap, HashSet};
10use polaris_system::plugin::{IntoScheduleIds, ScheduleId};
11use polaris_system::resource::LocalResource;
12use polaris_system::system::{BoxedSystem, ErasedSystem, IntoSystem};
13use std::any::TypeId;
14use std::fmt;
15use std::marker::PhantomData;
16use std::sync::Arc;
17use std::time::Duration;
18
19/// Unique identifier for a node in the graph.
20///
21/// Node IDs are generated using nanoid, providing globally unique identifiers
22/// that don't require coordination between graph instances. This enables
23/// merging graphs without ID collision handling.
24///
25/// Internally uses `Arc<str>` for cheap cloning (reference count bump only).
26///
27/// # Examples
28///
29/// ```
30/// use polaris_graph::NodeId;
31///
32/// // Auto-generated unique ID
33/// let id = NodeId::new();
34/// assert!(!id.as_str().is_empty());
35///
36/// // From a known string (useful in tests)
37/// let id = NodeId::from_string("my_node");
38/// assert_eq!(id.as_str(), "my_node");
39///
40/// // IDs are always unique
41/// assert_ne!(NodeId::new(), NodeId::new());
42/// ```
43#[derive(Debug, Clone, PartialEq, Eq, Hash)]
44pub struct NodeId(Arc<str>);
45
46impl LocalResource for NodeId {}
47
48impl NodeId {
49    /// Creates a new node ID with a unique nanoid.
50    #[must_use]
51    pub fn new() -> Self {
52        Self(nanoid::nanoid!(8).into())
53    }
54
55    /// Creates a node ID from a specific string value.
56    ///
57    /// This is primarily useful for testing or when restoring serialized graphs.
58    #[must_use]
59    pub fn from_string(id: impl Into<Arc<str>>) -> Self {
60        Self(id.into())
61    }
62
63    /// Returns the ID as a string slice.
64    #[must_use]
65    pub fn as_str(&self) -> &str {
66        &self.0
67    }
68}
69
70impl Default for NodeId {
71    fn default() -> Self {
72        Self::new()
73    }
74}
75
76impl fmt::Display for NodeId {
77    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78        write!(f, "node_{}", self.0)
79    }
80}
81
82impl IntoIterator for NodeId {
83    type Item = NodeId;
84    type IntoIter = std::iter::Once<NodeId>;
85
86    fn into_iter(self) -> Self::IntoIter {
87        std::iter::once(self)
88    }
89}
90
91/// A node in the graph.
92///
93/// Each node represents either a computation unit (system) or a control flow
94/// construct (decision, loop, parallel execution).
95///
96/// # Examples
97///
98/// Nodes are created through the [`Graph`] builder API rather than directly:
99///
100/// ```
101/// use polaris_graph::Graph;
102///
103/// async fn reason() -> i32 { 1 }
104/// async fn act() -> i32 { 2 }
105///
106/// let mut graph = Graph::new();
107/// graph.add_system(reason).add_system(act);
108///
109/// // Access nodes after construction
110/// for node in graph.nodes() {
111///     let _id = node.id();
112///     let _name = node.name();
113/// }
114/// ```
115#[derive(Debug)]
116#[non_exhaustive]
117pub enum Node {
118    /// Executes a system function.
119    System(SystemNode),
120    /// Routes flow based on predicate (binary branch).
121    Decision(DecisionNode),
122    /// Routes flow based on discriminator (multi-way branch).
123    Switch(SwitchNode),
124    /// Executes multiple paths of subgraphs concurrently.
125    /// The parallel node is both the entry and exit point — after all branches
126    /// complete, execution continues from the parallel node's outgoing edge.
127    Parallel(ParallelNode),
128    /// Repeats subgraph until termination condition.
129    Loop(LoopNode),
130    /// Executes an embedded graph with a configurable context boundary.
131    Scope(ScopeNode),
132}
133
134impl Node {
135    /// Returns the node's ID.
136    #[must_use]
137    pub fn id(&self) -> NodeId {
138        match self {
139            Node::System(n) => n.id.clone(),
140            Node::Decision(n) => n.id.clone(),
141            Node::Switch(n) => n.id.clone(),
142            Node::Parallel(n) => n.id.clone(),
143            Node::Loop(n) => n.id.clone(),
144            Node::Scope(n) => n.id.clone(),
145        }
146    }
147
148    /// Returns the node's name.
149    #[must_use]
150    pub fn name(&self) -> &'static str {
151        match self {
152            Node::System(n) => n.name(),
153            Node::Decision(n) => n.name,
154            Node::Switch(n) => n.name,
155            Node::Parallel(n) => n.name,
156            Node::Loop(n) => n.name,
157            Node::Scope(n) => n.name,
158        }
159    }
160}
161
162/// Retry policy for system nodes that may fail transiently.
163///
164/// When a system fails and has a retry policy, the executor retries
165/// according to the policy before routing to error/timeout handlers.
166///
167/// # Examples
168///
169/// ```
170/// use polaris_graph::RetryPolicy;
171/// use std::time::Duration;
172///
173/// // Fixed delay: retry up to 3 times with 100ms between attempts
174/// let fixed = RetryPolicy::fixed(3, Duration::from_millis(100));
175/// assert_eq!(fixed.max_retries(), 3);
176/// assert_eq!(fixed.delay_for_attempt(0), Duration::from_millis(100));
177/// assert_eq!(fixed.delay_for_attempt(2), Duration::from_millis(100));
178///
179/// // Exponential backoff: 100ms, 200ms, 400ms, ... capped at 1s
180/// let expo = RetryPolicy::exponential(5, Duration::from_millis(100))
181///     .with_max_delay(Duration::from_secs(1));
182/// assert_eq!(expo.delay_for_attempt(0), Duration::from_millis(100));
183/// assert_eq!(expo.delay_for_attempt(3), Duration::from_millis(800));
184/// assert_eq!(expo.delay_for_attempt(4), Duration::from_secs(1)); // capped
185/// ```
186#[derive(Debug, Clone)]
187pub enum RetryPolicy {
188    /// Fixed delay between retries.
189    Fixed {
190        /// Maximum number of retry attempts (not counting the initial attempt).
191        max_retries: usize,
192        /// Delay between attempts.
193        delay: Duration,
194    },
195    /// Exponential backoff between retries.
196    Exponential {
197        /// Maximum number of retry attempts (not counting the initial attempt).
198        max_retries: usize,
199        /// Delay before the first retry.
200        initial_delay: Duration,
201        /// Maximum delay between retries (caps the exponential growth).
202        max_delay: Option<Duration>,
203    },
204}
205
206impl RetryPolicy {
207    /// Creates a fixed-delay retry policy.
208    #[must_use]
209    pub fn fixed(max_retries: usize, delay: Duration) -> Self {
210        RetryPolicy::Fixed { max_retries, delay }
211    }
212
213    /// Creates an exponential backoff retry policy.
214    #[must_use]
215    pub fn exponential(max_retries: usize, initial_delay: Duration) -> Self {
216        RetryPolicy::Exponential {
217            max_retries,
218            initial_delay,
219            max_delay: None,
220        }
221    }
222
223    /// Sets the maximum delay (for exponential backoff).
224    ///
225    /// Has no effect on [`Fixed`](RetryPolicy::Fixed) policies.
226    #[must_use]
227    pub fn with_max_delay(mut self, max_delay: Duration) -> Self {
228        if let RetryPolicy::Exponential {
229            max_delay: ref mut md,
230            ..
231        } = self
232        {
233            *md = Some(max_delay);
234        }
235        self
236    }
237
238    /// Returns the maximum number of retry attempts.
239    #[must_use]
240    pub fn max_retries(&self) -> usize {
241        match self {
242            RetryPolicy::Fixed { max_retries, .. }
243            | RetryPolicy::Exponential { max_retries, .. } => *max_retries,
244        }
245    }
246
247    /// Returns the delay for the given attempt number (0-indexed).
248    ///
249    /// Attempt 0 is the delay before the first retry (after the initial attempt fails).
250    #[must_use]
251    pub fn delay_for_attempt(&self, attempt: usize) -> Duration {
252        match self {
253            RetryPolicy::Fixed { delay, .. } => *delay,
254            RetryPolicy::Exponential {
255                initial_delay,
256                max_delay,
257                ..
258            } => {
259                // 2^attempt, saturating on overflow (attempt >= 32)
260                let multiplier = 1u32.checked_shl(attempt as u32);
261                let delay = if let Some(m) = multiplier {
262                    initial_delay.saturating_mul(m)
263                } else {
264                    max_delay.unwrap_or(Duration::MAX)
265                };
266                if let Some(cap) = max_delay {
267                    delay.min(*cap)
268                } else {
269                    delay
270                }
271            }
272        }
273    }
274}
275
276/// A node that executes a system function.
277///
278/// This is the most common node type, wrapping an async system function
279/// that performs computation (LLM calls, tool invocations, etc.).
280///
281/// # Examples
282///
283/// System nodes are typically created through the [`Graph`] builder API:
284///
285/// ```
286/// use polaris_graph::Graph;
287///
288/// async fn call_llm() -> String { String::new() }
289/// async fn parse_response() -> i32 { 42 }
290///
291/// let mut graph = Graph::new();
292/// graph
293///     .add_system(call_llm)
294///     .add_system(parse_response);
295/// ```
296///
297/// For low-level construction:
298///
299/// ```
300/// use polaris_graph::node::SystemNode;
301/// use polaris_system::system::IntoSystem;
302///
303/// async fn my_system() -> i32 { 42 }
304///
305/// let node = SystemNode::new(my_system.into_system());
306/// assert!(node.name().contains("my_system"));
307/// ```
308pub struct SystemNode {
309    /// Unique identifier for this node.
310    pub id: NodeId,
311    /// The boxed system to execute.
312    pub system: BoxedSystem,
313    /// Optional timeout for this system's execution.
314    /// If set and exceeded, the executor will follow any timeout edge if present.
315    pub timeout: Option<Duration>,
316    /// Optional retry policy for transient failures.
317    pub retry_policy: Option<RetryPolicy>,
318    /// Custom schedules attached to this system node.
319    /// System lifecycle events are re-emitted on these schedules,
320    /// allowing hooks to subscribe to events for this system only.
321    pub schedules: Vec<ScheduleId>,
322}
323
324impl SystemNode {
325    /// Creates a new system node from any type implementing [`ErasedSystem`].
326    #[must_use]
327    pub fn new<S: ErasedSystem>(system: S) -> Self {
328        Self {
329            id: NodeId::new(),
330            system: Box::new(system),
331            timeout: None,
332            retry_policy: None,
333            schedules: Vec::new(),
334        }
335    }
336
337    /// Creates a new system node from an already-boxed system.
338    #[must_use]
339    pub fn new_boxed(system: BoxedSystem) -> Self {
340        Self {
341            id: NodeId::new(),
342            system,
343            timeout: None,
344            retry_policy: None,
345            schedules: Vec::new(),
346        }
347    }
348
349    /// Sets the timeout for this system node.
350    #[must_use]
351    pub fn with_timeout(mut self, timeout: Duration) -> Self {
352        self.timeout = Some(timeout);
353        self
354    }
355
356    /// Sets the custom schedules for this system node.
357    #[must_use]
358    pub fn with_schedules(mut self, schedules: Vec<ScheduleId>) -> Self {
359        self.schedules = schedules;
360        self
361    }
362
363    /// Returns the system's name for debugging and tracing.
364    #[must_use]
365    pub fn name(&self) -> &'static str {
366        self.system.name()
367    }
368
369    /// Returns the [`TypeId`] of this system's output type.
370    #[must_use]
371    pub fn output_type_id(&self) -> TypeId {
372        self.system.output_type_id()
373    }
374
375    /// Returns the output type name for error messages.
376    #[must_use]
377    pub fn output_type_name(&self) -> &'static str {
378        self.system.output_type_name()
379    }
380}
381
382impl fmt::Debug for SystemNode {
383    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
384        f.debug_struct("SystemNode")
385            .field("id", &self.id)
386            .field("name", &self.name())
387            .field("output_type", &self.output_type_name())
388            .field("schedules", &self.schedules)
389            .finish()
390    }
391}
392
393/// A node that routes flow based on a boolean predicate.
394///
395/// Decision nodes implement binary branching: if the predicate returns true,
396/// flow continues to the "true" branch; otherwise to the "false" branch.
397///
398/// # Examples
399///
400/// Decision nodes are created through the [`Graph`] builder API:
401///
402/// ```
403/// use polaris_graph::Graph;
404///
405/// #[derive(PartialEq)]
406/// enum Action { UseTool, Respond }
407/// struct ReasoningResult { action: Action }
408///
409/// async fn use_tool() -> i32 { 1 }
410/// async fn respond() -> i32 { 2 }
411///
412/// let mut graph = Graph::new();
413/// graph.add_conditional_branch::<ReasoningResult, _, _, _>(
414///     "needs_tool",
415///     |result| result.action == Action::UseTool,
416///     |g| { g.add_system(use_tool); },
417///     |g| { g.add_system(respond); },
418/// );
419/// ```
420pub struct DecisionNode {
421    /// Unique identifier for this node.
422    pub id: NodeId,
423    /// Human-readable name for debugging and tracing.
424    pub name: &'static str,
425    /// The predicate that determines which branch to take.
426    pub predicate: Option<BoxedPredicate>,
427    /// Node ID for the true branch.
428    pub true_branch: Option<NodeId>,
429    /// Node ID for the false branch.
430    pub false_branch: Option<NodeId>,
431}
432
433impl DecisionNode {
434    /// Creates a new decision node.
435    #[must_use]
436    pub fn new(name: &'static str) -> Self {
437        Self {
438            id: NodeId::new(),
439            name,
440            predicate: None,
441            true_branch: None,
442            false_branch: None,
443        }
444    }
445
446    /// Creates a new decision node with a predicate.
447    #[must_use]
448    pub fn with_predicate(name: &'static str, predicate: BoxedPredicate) -> Self {
449        Self {
450            id: NodeId::new(),
451            name,
452            predicate: Some(predicate),
453            true_branch: None,
454            false_branch: None,
455        }
456    }
457}
458
459impl fmt::Debug for DecisionNode {
460    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
461        f.debug_struct("DecisionNode")
462            .field("id", &self.id)
463            .field("name", &self.name)
464            .field("has_predicate", &self.predicate.is_some())
465            .field("true_branch", &self.true_branch)
466            .field("false_branch", &self.false_branch)
467            .finish()
468    }
469}
470
471/// A node that routes flow based on a discriminator value (multi-way branch).
472///
473/// Switch nodes generalize decision nodes to handle multiple cases,
474/// similar to a match/switch statement.
475///
476/// # Examples
477///
478/// Switch nodes are created through the [`Graph`] builder API:
479///
480/// ```
481/// use polaris_graph::Graph;
482///
483/// struct RouterOutput { action: &'static str }
484///
485/// async fn use_tool() -> i32 { 1 }
486/// async fn respond() -> i32 { 2 }
487/// async fn handle_unknown() -> i32 { 3 }
488///
489/// let mut graph = Graph::new();
490/// graph.add_switch::<RouterOutput, _, _, _>(
491///     "route_action",
492///     |output| output.action,
493///     vec![
494///         ("tool", Box::new(|g: &mut Graph| { g.add_system(use_tool); })
495///             as Box<dyn FnOnce(&mut Graph)>),
496///         ("respond", Box::new(|g: &mut Graph| { g.add_system(respond); })
497///             as Box<dyn FnOnce(&mut Graph)>),
498///     ],
499///     Some(Box::new(|g: &mut Graph| { g.add_system(handle_unknown); })),
500/// );
501/// ```
502pub struct SwitchNode {
503    /// Unique identifier for this node.
504    pub id: NodeId,
505    /// Human-readable name for debugging and tracing.
506    pub name: &'static str,
507    /// The discriminator that determines which case to take.
508    pub discriminator: Option<crate::predicate::BoxedDiscriminator>,
509    /// Node IDs for each case, keyed by case name.
510    pub cases: Vec<(&'static str, NodeId)>,
511    /// Default case if no match.
512    pub default: Option<NodeId>,
513}
514
515impl SwitchNode {
516    /// Creates a new switch node.
517    #[must_use]
518    pub fn new(name: &'static str) -> Self {
519        Self {
520            id: NodeId::new(),
521            name,
522            discriminator: None,
523            cases: Vec::new(),
524            default: None,
525        }
526    }
527
528    /// Creates a new switch node with a discriminator.
529    #[must_use]
530    pub fn with_discriminator(
531        name: &'static str,
532        discriminator: crate::predicate::BoxedDiscriminator,
533    ) -> Self {
534        Self {
535            id: NodeId::new(),
536            name,
537            discriminator: Some(discriminator),
538            cases: Vec::new(),
539            default: None,
540        }
541    }
542}
543
544impl fmt::Debug for SwitchNode {
545    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
546        f.debug_struct("SwitchNode")
547            .field("id", &self.id)
548            .field("name", &self.name)
549            .field("has_discriminator", &self.discriminator.is_some())
550            .field("cases", &self.cases)
551            .field("default", &self.default)
552            .finish()
553    }
554}
555
556/// A node that executes multiple paths concurrently.
557///
558/// Parallel nodes fork execution into multiple branches that run
559/// simultaneously. After all branches complete, outputs are merged
560/// and execution continues from the parallel node's outgoing edge.
561///
562/// # Examples
563///
564/// Parallel nodes are created through the [`Graph`] builder API:
565///
566/// ```
567/// use polaris_graph::Graph;
568///
569/// async fn fetch_user() -> String { String::new() }
570/// async fn fetch_orders() -> Vec<i32> { vec![] }
571/// async fn fetch_preferences() -> bool { true }
572///
573/// let mut graph = Graph::new();
574/// graph.add_parallel("gather_data", [
575///     |g: &mut Graph| { g.add_system(fetch_user); },
576///     |g: &mut Graph| { g.add_system(fetch_orders); },
577///     |g: &mut Graph| { g.add_system(fetch_preferences); },
578/// ]);
579/// ```
580#[derive(Debug)]
581pub struct ParallelNode {
582    /// Unique identifier for this node.
583    pub id: NodeId,
584    /// Human-readable name for debugging and tracing.
585    pub name: &'static str,
586    /// Node IDs for each parallel branch entry point.
587    pub branches: Vec<NodeId>,
588}
589
590impl ParallelNode {
591    /// Creates a new parallel node.
592    #[must_use]
593    pub fn new(name: &'static str) -> Self {
594        Self {
595            id: NodeId::new(),
596            name,
597            branches: Vec::new(),
598        }
599    }
600}
601
602/// A node that repeats a subgraph until a termination condition.
603///
604/// Loop nodes implement iterative execution patterns, repeating the
605/// loop body until a termination predicate returns true or max iterations
606/// is reached.
607///
608/// # Examples
609///
610/// Loop nodes are created through the [`Graph`] builder API:
611///
612/// ```
613/// use polaris_graph::Graph;
614///
615/// struct LoopState { done: bool }
616///
617/// async fn iterate() -> LoopState { LoopState { done: false } }
618///
619/// // With a termination predicate
620/// let mut graph = Graph::new();
621/// graph.add_loop::<LoopState, _, _>(
622///     "work_loop",
623///     |state| state.done,
624///     |g| { g.add_system(iterate); },
625/// );
626/// ```
627///
628/// With a fixed iteration count:
629///
630/// ```
631/// use polaris_graph::Graph;
632///
633/// async fn attempt() -> i32 { 1 }
634///
635/// let mut graph = Graph::new();
636/// graph.add_loop_n("retry", 5, |g| {
637///     g.add_system(attempt);
638/// });
639/// ```
640pub struct LoopNode {
641    /// Unique identifier for this node.
642    pub id: NodeId,
643    /// Human-readable name for debugging and tracing.
644    pub name: &'static str,
645    /// The termination predicate (loop exits when this returns true).
646    pub termination: Option<BoxedPredicate>,
647    /// Maximum number of iterations (safety limit).
648    pub max_iterations: Option<usize>,
649    /// Entry point of the loop body.
650    pub body_entry: Option<NodeId>,
651}
652
653impl LoopNode {
654    /// Creates a new loop node.
655    #[must_use]
656    pub fn new(name: &'static str) -> Self {
657        Self {
658            id: NodeId::new(),
659            name,
660            termination: None,
661            max_iterations: None,
662            body_entry: None,
663        }
664    }
665
666    /// Creates a new loop node with a termination predicate.
667    #[must_use]
668    pub fn with_termination(name: &'static str, termination: BoxedPredicate) -> Self {
669        Self {
670            id: NodeId::new(),
671            name,
672            termination: Some(termination),
673            max_iterations: None,
674            body_entry: None,
675        }
676    }
677
678    /// Creates a new loop node with a maximum iteration count.
679    #[must_use]
680    pub fn with_max_iterations(name: &'static str, max_iterations: usize) -> Self {
681        Self {
682            id: NodeId::new(),
683            name,
684            termination: None,
685            max_iterations: Some(max_iterations),
686            body_entry: None,
687        }
688    }
689}
690
691impl fmt::Debug for LoopNode {
692    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
693        f.debug_struct("LoopNode")
694            .field("id", &self.id)
695            .field("name", &self.name)
696            .field("has_termination", &self.termination.is_some())
697            .field("max_iterations", &self.max_iterations)
698            .field("body_entry", &self.body_entry)
699            .finish()
700    }
701}
702
703// ─────────────────────────────────────────────────────────────────────────────
704// Scope Node
705// ─────────────────────────────────────────────────────────────────────────────
706
707/// Type-erased clone function used by the `forward` crossing.
708///
709/// Returns `None` on downcast failure (should never happen in practice —
710/// `TypeId` is verified before invocation).
711pub(crate) type CloneFn = fn(&dyn Any) -> Option<Box<dyn Any + Send + Sync>>;
712
713/// Per-resource crossing strategy used when entering a scope boundary.
714///
715/// Each variant corresponds to a positive builder verb on [`ContextPolicy`]
716/// (`share`, `forward`, `fork`, `forward_fresh`). The negative `exclude` verb
717/// is tracked separately on [`ContextPolicy::excludes`].
718#[derive(Clone)]
719pub(crate) enum CrossingAction {
720    /// Reachable via the parent chain — no copy. Translates to an entry in
721    /// the child's [`ParentFilter`](polaris_system::param::ParentFilter).
722    Share,
723    /// Cloned from the parent's local scope at scope entry.
724    Forward(CloneFn),
725    /// Forked from the parent's local scope via [`ForkStrategy::fork`].
726    ///
727    /// [`ForkStrategy::fork`]: polaris_system::resource::ForkStrategy::fork
728    Fork(CloneFn),
729    /// Re-instantiated at scope entry from the resource's registered factory.
730    ForwardFresh,
731}
732
733impl PartialEq for CrossingAction {
734    /// Variants compare by tag only — `Forward` and `Fork` deliberately
735    /// ignore their inner [`CloneFn`] pointers because Rust does not
736    /// guarantee fn-pointer addresses are unique across codegen units.
737    /// Two crossings with the same verb on the same `T` are stored once
738    /// per [`TypeId`], so equality only needs to distinguish verb shape.
739    fn eq(&self, other: &Self) -> bool {
740        matches!(
741            (self, other),
742            (Self::Share, Self::Share)
743                | (Self::Forward(_), Self::Forward(_))
744                | (Self::Fork(_), Self::Fork(_))
745                | (Self::ForwardFresh, Self::ForwardFresh)
746        )
747    }
748}
749
750impl Eq for CrossingAction {}
751
752impl fmt::Debug for CrossingAction {
753    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
754        match self {
755            Self::Share => f.write_str("Share"),
756            Self::Forward(_) => f.write_str("Forward"),
757            Self::Fork(_) => f.write_str("Fork"),
758            Self::ForwardFresh => f.write_str("ForwardFresh"),
759        }
760    }
761}
762
763/// Per-resource decision recorded on a [`ContextPolicy`].
764#[derive(Debug, Clone, PartialEq, Eq)]
765pub(crate) struct ResourceCrossing {
766    pub(crate) type_id: TypeId,
767    pub(crate) type_name: &'static str,
768    pub(crate) action: CrossingAction,
769}
770
771/// High-level summary of a [`ContextPolicy`]'s scope-boundary mode.
772///
773/// `ContextMode` collapses the verb composition on a [`ContextPolicy`] into
774/// a coarse classification suitable for hooks and middleware that don't need
775/// the per-resource detail. Each policy maps to exactly one mode:
776///
777/// | Mode | Trigger |
778/// |---|---|
779/// | [`Shared`](Self::Shared) | [`ContextPolicy::shared`] |
780/// | [`Inherit`](Self::Inherit) | [`share_rest`](ContextPolicy::share_rest) on a [`ContextPolicy::new`] policy |
781/// | [`Isolated`](Self::Isolated) | a [`ContextPolicy::new`] policy without `share_rest` |
782///
783/// The [`Display`](fmt::Display) rendering is the capitalized variant name —
784/// `Shared` / `Inherit` / `Isolated` — and is interpolated into
785/// [`GraphEvent`](crate::hooks::events::GraphEvent) scope logs, so it is part of the
786/// observable surface that downstream log consumers may match against.
787///
788/// # Examples
789///
790/// ```
791/// use polaris_graph::ContextMode;
792///
793/// assert_eq!(ContextMode::Inherit.to_string(), "Inherit");
794/// ```
795#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
796#[non_exhaustive]
797pub enum ContextMode {
798    /// No boundary — the scope reuses the parent context
799    /// ([`ContextPolicy::shared`]).
800    Shared,
801    /// Selective sharing — `share_rest()` lets the child see the parent
802    /// chain by default, with optional per-type `exclude::<T>()` overrides.
803    Inherit,
804    /// Pure isolation or per-resource crossings only — the child sees
805    /// globals plus explicitly forwarded/forked/fresh locals, and any
806    /// types listed via `share::<T>()`.
807    Isolated,
808}
809
810impl fmt::Display for ContextMode {
811    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
812        match self {
813            ContextMode::Shared => f.write_str("Shared"),
814            ContextMode::Inherit => f.write_str("Inherit"),
815            ContextMode::Isolated => f.write_str("Isolated"),
816        }
817    }
818}
819
820/// Per-resource policy controlling which resources cross a scope boundary,
821/// and how each one crosses.
822///
823/// Construct with [`ContextPolicy::new`] (empty: nothing crosses unless added)
824/// or [`ContextPolicy::shared`] (no boundary at all, equivalent to running the
825/// inner graph inline). Compose by chaining per-resource verbs:
826///
827/// | Verb | Mechanism | Requires of `T` | Use when |
828/// |---|---|---|---|
829/// | [`share`](Self::share) | Child reads via parent chain | nothing | Read-only access; large or expensive-to-clone |
830/// | [`forward`](Self::forward) | `Clone::clone` into child's local | `T: Clone` | Small mutable resource; child needs its own copy |
831/// | [`fork`](Self::fork) | [`ForkStrategy::fork`] into child's local | `T: ForkStrategy` | Stateful resource with non-`Clone` semantics |
832/// | [`forward_fresh`](Self::forward_fresh) | Re-invoke `T`'s registered factory | factory registered via `Server::register_local` | Resource that should start clean (counters, scratchpads) |
833/// | [`exclude`](Self::exclude) | Suppress any earlier verb / [`share_rest`](Self::share_rest) | nothing | Opt one resource out of the catch-all |
834/// | [`share_rest`](Self::share_rest) | Apply `share` to every resource not otherwise mentioned | nothing | Common "mostly inherit, with a few overrides" case |
835///
836/// Verbs are applied in declaration order; later verbs override earlier ones
837/// for the same `T`. [`share_rest`](Self::share_rest) only applies to types
838/// not otherwise named.
839///
840/// # Panics
841///
842/// Calling any verb on a policy constructed via [`shared()`](Self::shared)
843/// panics. `shared()` denotes "no boundary at all" — there is no child context
844/// to apply per-resource decisions to, so attempting to compose verbs onto it
845/// is a programmer error.
846///
847/// # Examples
848///
849/// ```
850/// use polaris_graph::ContextPolicy;
851/// use polaris_system::resource::LocalResource;
852///
853/// #[derive(Clone, Default)]
854/// struct Config;
855/// impl LocalResource for Config {}
856///
857/// // No boundary at all — same context.
858/// let _ = ContextPolicy::shared();
859///
860/// // Strict isolation — only Config crosses, by clone.
861/// let _ = ContextPolicy::new().forward::<Config>();
862///
863/// // Mostly inherit, override one resource.
864/// let _ = ContextPolicy::new()
865///     .forward::<Config>()
866///     .share_rest();
867/// ```
868///
869/// [`ForkStrategy::fork`]: polaris_system::resource::ForkStrategy::fork
870///
871/// See [`ScopeNode`] — the node that carries this policy — for how the executor
872/// applies these verbs at scope entry, and the *Execution Context — Scope* /
873/// *Graph — Scope* reference docs for the narrative walkthrough.
874///
875/// Note: `ContextPolicy` derives `PartialEq`/`Eq` (sufficient for tests and
876/// equality checks). Equality is **verb-shape** equality, not behavioral
877/// equality: the per-resource crossing compares by verb kind only and ignores
878/// the inner clone/fork closure, so two policies that `forward::<T>()` with
879/// distinct closures compare equal. It does **not** derive `Hash` because the
880/// underlying `HashMap`/`HashSet` storage and the cached
881/// [`ParentFilter`](polaris_system::param::ParentFilter) do not
882/// implement `Hash`.
883#[derive(Debug, Clone, PartialEq, Eq)]
884pub struct ContextPolicy {
885    /// High-level scope-boundary mode (kept in sync with `share_rest` and the
886    /// constructor used).
887    pub(crate) mode: ContextMode,
888    /// Positive per-resource crossings (`share`, `forward`, `fork`, `forward_fresh`).
889    /// Disjoint from [`Self::excludes`].
890    pub(crate) crossings: HashMap<TypeId, ResourceCrossing>,
891    /// Resources opted out of any catch-all (e.g. `share_rest`).
892    /// Disjoint from [`Self::crossings`].
893    pub(crate) excludes: HashSet<TypeId>,
894    /// When `true`, resources not in `crossings`/`excludes` are reachable via
895    /// the parent chain.
896    pub(crate) share_rest: bool,
897    /// Cached [`ParentFilter`](polaris_system::param::ParentFilter) — rebuilt
898    /// eagerly whenever a verb mutates the policy. Avoids per-call allocation
899    /// in the executor and validation hot paths.
900    ///
901    /// Held behind an [`Arc`] so each scope entry (including every iteration of
902    /// a loop wrapping the scope) shares the cached filter with a
903    /// reference-count bump instead of cloning its `HashSet`.
904    pub(crate) cached_parent_filter: Arc<polaris_system::param::ParentFilter>,
905}
906
907impl ContextPolicy {
908    /// Empty per-resource policy: no resources cross unless explicitly added.
909    ///
910    /// The child context is created fresh and only sees globals plus whatever
911    /// is added via [`share`](Self::share), [`forward`](Self::forward),
912    /// [`fork`](Self::fork), [`forward_fresh`](Self::forward_fresh), or
913    /// [`share_rest`](Self::share_rest). This is the right default for
914    /// sandbox-style scopes.
915    #[must_use]
916    #[expect(
917        clippy::new_without_default,
918        reason = "ContextPolicy intentionally omits Default — `new()` and `shared()` make the boundary choice explicit"
919    )]
920    pub fn new() -> Self {
921        Self {
922            mode: ContextMode::Isolated,
923            crossings: HashMap::new(),
924            excludes: HashSet::new(),
925            share_rest: false,
926            // Pure-isolation default: parent chain hides every local; globals
927            // still flow. `ParentFilter::default()` is `AllowAllExcept(empty)`
928            // — the wrong starting point — so we set `AllowOnly(empty)`
929            // explicitly here.
930            cached_parent_filter: Arc::new(polaris_system::param::ParentFilter::allow_only([])),
931        }
932    }
933
934    /// No-boundary policy — the scope reuses the parent context.
935    ///
936    /// Reads, writes, and outputs all flow through the parent. Per-resource
937    /// verbs are rejected (see [`ContextPolicy`] panics).
938    #[must_use]
939    pub fn shared() -> Self {
940        Self {
941            mode: ContextMode::Shared,
942            crossings: HashMap::new(),
943            excludes: HashSet::new(),
944            share_rest: false,
945            // Unused for `shared` policies (no child context is built), but
946            // keep a sensible value: an empty `AllowOnly` mirrors `new()`.
947            cached_parent_filter: Arc::new(polaris_system::param::ParentFilter::allow_only([])),
948        }
949    }
950
951    /// Make `T` reachable in the child via the parent chain — zero copy.
952    ///
953    /// The child does not own a copy; reads of `T` walk up to the parent.
954    /// Use this for read-only access to large or expensive-to-clone resources
955    /// (e.g. tool registries, system prompts).
956    ///
957    /// # Panics
958    ///
959    /// Panics if called on [`ContextPolicy::shared`] — see type-level docs.
960    #[must_use]
961    pub fn share<T: LocalResource>(mut self) -> Self {
962        self.assert_not_shared::<T>("share");
963        self.set_crossing::<T>(CrossingAction::Share);
964        self.refresh_parent_filter();
965        self
966    }
967
968    /// Clone `T` from the parent's local scope into the child at scope entry.
969    ///
970    /// The clone is one-way — mutations in the child do not propagate back.
971    /// If the scope sits inside a loop, each iteration clones the resource.
972    ///
973    /// # Panics
974    ///
975    /// Panics if called on [`ContextPolicy::shared`] — see type-level docs.
976    #[must_use]
977    pub fn forward<T: LocalResource + Clone>(mut self) -> Self {
978        self.assert_not_shared::<T>("forward");
979        self.set_crossing::<T>(CrossingAction::Forward(|any| {
980            Some(Box::new(any.downcast_ref::<T>()?.clone()))
981        }));
982        self.refresh_parent_filter();
983        self
984    }
985
986    /// Fork `T` from the parent into the child via [`ForkStrategy::fork`].
987    ///
988    /// Use this when domain semantics differ from `Clone` — e.g. fresh-empty
989    /// stores, `Arc`-shared atomics, or child trace spans.
990    ///
991    /// # Panics
992    ///
993    /// Panics if called on [`ContextPolicy::shared`] — see type-level docs.
994    ///
995    /// [`ForkStrategy::fork`]: polaris_system::resource::ForkStrategy::fork
996    #[must_use]
997    pub fn fork<T>(mut self) -> Self
998    where
999        T: polaris_system::resource::ForkStrategy,
1000    {
1001        self.assert_not_shared::<T>("fork");
1002        self.set_crossing::<T>(CrossingAction::Fork(|any| {
1003            Some(Box::new(any.downcast_ref::<T>()?.fork()))
1004        }));
1005        self.refresh_parent_filter();
1006        self
1007    }
1008
1009    /// Instantiate a fresh `T` in the child via the resource's registered factory.
1010    ///
1011    /// `T` must have been registered with the server via `register_local(...)`
1012    /// — the factory is captured on the resource entry and re-invoked at scope
1013    /// entry. If no factory is registered for `T` anywhere in the parent
1014    /// hierarchy, scope execution fails with a clear error.
1015    ///
1016    /// # Panics
1017    ///
1018    /// Panics if called on [`ContextPolicy::shared`] — see type-level docs.
1019    #[must_use]
1020    pub fn forward_fresh<T: LocalResource>(mut self) -> Self {
1021        self.assert_not_shared::<T>("forward_fresh");
1022        self.set_crossing::<T>(CrossingAction::ForwardFresh);
1023        self.refresh_parent_filter();
1024        self
1025    }
1026
1027    /// Suppress any earlier verb and any catch-all [`share_rest`](Self::share_rest)
1028    /// for this resource.
1029    ///
1030    /// Combine with [`share_rest`](Self::share_rest) to opt one resource out of
1031    /// the catch-all.
1032    ///
1033    /// # Panics
1034    ///
1035    /// Panics if called on [`ContextPolicy::shared`] — see type-level docs.
1036    #[must_use]
1037    pub fn exclude<T: LocalResource>(mut self) -> Self {
1038        self.assert_not_shared::<T>("exclude");
1039        self.crossings.remove(&TypeId::of::<T>());
1040        self.excludes.insert(TypeId::of::<T>());
1041        self.refresh_parent_filter();
1042        self
1043    }
1044
1045    /// Apply [`share`](Self::share) to every resource not otherwise named.
1046    ///
1047    /// Conventionally the last call in a chain. Combines naturally with
1048    /// [`exclude`](Self::exclude) for a "mostly inherit, with a few overrides"
1049    /// pattern.
1050    ///
1051    /// # Panics
1052    ///
1053    /// Panics if called on [`ContextPolicy::shared`] — see type-level docs.
1054    #[must_use]
1055    pub fn share_rest(mut self) -> Self {
1056        assert!(
1057            !matches!(self.mode, ContextMode::Shared),
1058            "share_rest() is not valid on ContextPolicy::shared() — \
1059             use ContextPolicy::new() to compose per-resource verbs",
1060        );
1061        self.share_rest = true;
1062        self.mode = ContextMode::Inherit;
1063        self.refresh_parent_filter();
1064        self
1065    }
1066
1067    fn assert_not_shared<T: ?Sized>(&self, verb: &'static str) {
1068        assert!(
1069            !matches!(self.mode, ContextMode::Shared),
1070            "{verb}::<{}>() is not valid on ContextPolicy::shared() — \
1071             use ContextPolicy::new() to compose per-resource verbs",
1072            core::any::type_name::<T>(),
1073        );
1074    }
1075
1076    /// Records a positive crossing for `T`, removing any prior `exclude`.
1077    fn set_crossing<T: 'static>(&mut self, action: CrossingAction) {
1078        let type_id = TypeId::of::<T>();
1079        self.excludes.remove(&type_id);
1080        self.crossings.insert(
1081            type_id,
1082            ResourceCrossing {
1083                type_id,
1084                type_name: core::any::type_name::<T>(),
1085                action,
1086            },
1087        );
1088    }
1089
1090    /// Rebuilds the cached [`ParentFilter`] from the current verb composition.
1091    ///
1092    /// Called from the tail of every mutating verb method. Cheap (one
1093    /// allocation per mutation) and avoids repeated rebuilding at execution
1094    /// time.
1095    fn refresh_parent_filter(&mut self) {
1096        use polaris_system::param::ParentFilter;
1097        self.cached_parent_filter = Arc::new(if self.share_rest {
1098            ParentFilter::allow_all_except(self.excludes.iter().copied())
1099        } else {
1100            ParentFilter::allow_only(
1101                self.crossings
1102                    .values()
1103                    .filter(|c| matches!(c.action, CrossingAction::Share))
1104                    .map(|c| c.type_id),
1105            )
1106        });
1107    }
1108
1109    /// Returns the high-level [`ContextMode`] for this policy.
1110    ///
1111    /// `Shared` for [`ContextPolicy::shared`], `Inherit` when
1112    /// [`share_rest`](Self::share_rest) is set, otherwise `Isolated`.
1113    #[must_use]
1114    pub fn mode(&self) -> ContextMode {
1115        self.mode
1116    }
1117
1118    /// Returns whether this policy is the no-boundary form
1119    /// ([`ContextPolicy::shared`]).
1120    #[must_use]
1121    pub fn is_shared(&self) -> bool {
1122        matches!(self.mode, ContextMode::Shared)
1123    }
1124
1125    /// Returns the per-resource crossing decisions on this policy.
1126    pub(crate) fn crossings(&self) -> impl Iterator<Item = &ResourceCrossing> {
1127        self.crossings.values()
1128    }
1129
1130    /// Looks up the crossing action for a specific type.
1131    #[cfg(test)]
1132    pub(crate) fn crossing_for(&self, type_id: TypeId) -> Option<&ResourceCrossing> {
1133        self.crossings.get(&type_id)
1134    }
1135
1136    /// Returns the cached [`ParentFilter`](polaris_system::param::ParentFilter)
1137    /// for this policy.
1138    ///
1139    /// The filter governs which resource types are reachable through the
1140    /// parent chain in the child context. For pure-isolation policies (no
1141    /// `share` / `share_rest`) the filter allows only the explicitly shared
1142    /// types — globals still flow through.
1143    ///
1144    /// Production code clones the cached filter cheaply via
1145    /// [`parent_filter_arc`](Self::parent_filter_arc); this borrowing accessor
1146    /// exists for equality assertions in tests.
1147    #[cfg(test)]
1148    pub(crate) fn parent_filter(&self) -> &polaris_system::param::ParentFilter {
1149        &self.cached_parent_filter
1150    }
1151
1152    /// Returns a cheap (reference-counted) handle to the cached
1153    /// [`ParentFilter`](polaris_system::param::ParentFilter).
1154    ///
1155    /// Used by the executor and validation paths to hand the filter to
1156    /// [`SystemContext::child_filtered`](polaris_system::param::SystemContext::child_filtered)
1157    /// at each scope entry without cloning the underlying set — a scope inside
1158    /// a loop pays only a reference-count bump per iteration.
1159    pub(crate) fn parent_filter_arc(&self) -> Arc<polaris_system::param::ParentFilter> {
1160        Arc::clone(&self.cached_parent_filter)
1161    }
1162}
1163
1164/// A node that executes an embedded graph with a configurable context boundary.
1165///
1166/// The embedded graph is a self-contained directed graph that is executed as a
1167/// single unit within the parent graph. The [`ContextPolicy`] controls how the
1168/// parent's [`SystemContext`](polaris_system::param::SystemContext) is shared
1169/// with the embedded graph.
1170///
1171/// From the parent graph's perspective, the scope node is a single opaque node —
1172/// execution enters the scope, runs the embedded graph to completion, and exits
1173/// from the scope's outgoing edge.
1174///
1175/// Unlike decision/loop/parallel nodes, the embedded graph's nodes are NOT merged
1176/// into the parent. The `ScopeNode` holds the [`Graph`] as a field.
1177///
1178/// # Examples
1179///
1180/// Scope nodes are created through the [`Graph`] builder API:
1181///
1182/// ```
1183/// use polaris_graph::{Graph, ContextPolicy};
1184///
1185/// async fn gather_info() -> String { String::new() }
1186/// async fn summarize() -> String { String::new() }
1187///
1188/// // Build an inner graph for the sub-agent
1189/// let mut research = Graph::new();
1190/// research.add_system(gather_info).add_system(summarize);
1191///
1192/// // Embed it as a scope that chain-reads parent resources.
1193/// let mut graph = Graph::new();
1194/// graph.add_scope("research", research, ContextPolicy::new().share_rest());
1195/// ```
1196#[derive(Debug)]
1197pub struct ScopeNode {
1198    /// Unique identifier for this node.
1199    pub id: NodeId,
1200    /// Human-readable name for debugging and tracing.
1201    pub name: &'static str,
1202    /// The embedded graph to execute.
1203    pub(crate) graph: Graph,
1204    /// Context sharing policy.
1205    pub(crate) context_policy: ContextPolicy,
1206}
1207
1208impl ScopeNode {
1209    /// Creates a new scope node.
1210    #[must_use]
1211    pub fn new(name: &'static str, graph: Graph, context_policy: ContextPolicy) -> Self {
1212        Self {
1213            id: NodeId::new(),
1214            name,
1215            graph,
1216            context_policy,
1217        }
1218    }
1219
1220    /// Returns a reference to the embedded graph.
1221    #[must_use]
1222    pub fn graph(&self) -> &Graph {
1223        &self.graph
1224    }
1225
1226    /// Returns a reference to the context policy.
1227    #[must_use]
1228    pub fn context_policy(&self) -> &ContextPolicy {
1229        &self.context_policy
1230    }
1231}
1232
1233// ─────────────────────────────────────────────────────────────────────────────
1234// IntoSystemNode
1235// ─────────────────────────────────────────────────────────────────────────────
1236
1237/// Converts a type into the components needed for a [`SystemNode`].
1238///
1239/// Enables `add_system` to accept both bare systems and
1240/// `(custom_schedules, system)` tuples.
1241pub trait IntoSystemNode<Marker> {
1242    /// Converts into a boxed system and its custom schedules.
1243    fn into_system_node(self) -> (BoxedSystem, Vec<ScheduleId>);
1244}
1245
1246/// Marker for bare system nodes.
1247pub struct NodeMarker<M>(PhantomData<M>);
1248
1249/// Marker for system nodes with custom schedules attached.
1250pub struct ScheduledNodeMarker<M>(PhantomData<M>);
1251
1252impl<S, M> IntoSystemNode<NodeMarker<M>> for S
1253where
1254    S: IntoSystem<M>,
1255    S::System: 'static,
1256{
1257    fn into_system_node(self) -> (BoxedSystem, Vec<ScheduleId>) {
1258        (Box::new(self.into_system()), Vec::new())
1259    }
1260}
1261
1262impl<Sch, S, M> IntoSystemNode<ScheduledNodeMarker<M>> for (Sch, S)
1263where
1264    Sch: IntoScheduleIds,
1265    S: IntoSystem<M>,
1266    S::System: 'static,
1267{
1268    fn into_system_node(self) -> (BoxedSystem, Vec<ScheduleId>) {
1269        (Box::new(self.1.into_system()), Sch::schedule_ids())
1270    }
1271}
1272
1273#[cfg(test)]
1274mod tests {
1275    use super::*;
1276    use polaris_system::plugin::Schedule;
1277    use polaris_system::system::IntoSystem;
1278
1279    // Test system functions
1280    async fn test_system() -> String {
1281        "hello".to_string()
1282    }
1283
1284    async fn sys_fn() -> i32 {
1285        42
1286    }
1287
1288    #[test]
1289    fn node_id_uniqueness() {
1290        // Generated IDs should be unique
1291        let id1 = NodeId::new();
1292        let id2 = NodeId::new();
1293        assert_ne!(id1, id2);
1294    }
1295
1296    #[test]
1297    fn system_node_creation() {
1298        let system = test_system.into_system();
1299        let node = SystemNode::new(system);
1300        // ID is auto-generated, just check it exists
1301        assert!(!node.id.as_str().is_empty());
1302        assert!(node.name().contains("test_system"));
1303    }
1304
1305    #[test]
1306    fn node_enum_accessors() {
1307        let system = Node::System(SystemNode::new(sys_fn.into_system()));
1308        assert!(!system.id().as_str().is_empty());
1309        assert!(system.name().contains("sys_fn"));
1310
1311        let decision = Node::Decision(DecisionNode::new("dec"));
1312        assert!(!decision.id().as_str().is_empty());
1313        assert_eq!(decision.name(), "dec");
1314    }
1315
1316    #[test]
1317    fn system_node_preserves_type_info() {
1318        let system = sys_fn.into_system();
1319        let node = SystemNode::new(system);
1320
1321        assert_eq!(node.output_type_id(), TypeId::of::<i32>());
1322        assert!(node.output_type_name().contains("i32"));
1323    }
1324
1325    #[test]
1326    fn retry_policy_fixed_delay() {
1327        let policy = RetryPolicy::fixed(3, Duration::from_millis(100));
1328        assert_eq!(policy.max_retries(), 3);
1329        assert_eq!(policy.delay_for_attempt(0), Duration::from_millis(100));
1330        assert_eq!(policy.delay_for_attempt(1), Duration::from_millis(100));
1331        assert_eq!(policy.delay_for_attempt(2), Duration::from_millis(100));
1332    }
1333
1334    #[test]
1335    fn retry_policy_exponential_delay() {
1336        let policy = RetryPolicy::exponential(4, Duration::from_millis(100));
1337        assert_eq!(policy.max_retries(), 4);
1338        assert_eq!(policy.delay_for_attempt(0), Duration::from_millis(100));
1339        assert_eq!(policy.delay_for_attempt(1), Duration::from_millis(200));
1340        assert_eq!(policy.delay_for_attempt(2), Duration::from_millis(400));
1341        assert_eq!(policy.delay_for_attempt(3), Duration::from_millis(800));
1342    }
1343
1344    #[test]
1345    fn retry_policy_exponential_with_max_delay() {
1346        let policy = RetryPolicy::exponential(4, Duration::from_millis(100))
1347            .with_max_delay(Duration::from_millis(300));
1348        assert_eq!(policy.delay_for_attempt(0), Duration::from_millis(100));
1349        assert_eq!(policy.delay_for_attempt(1), Duration::from_millis(200));
1350        // 400ms capped to 300ms
1351        assert_eq!(policy.delay_for_attempt(2), Duration::from_millis(300));
1352        // 800ms capped to 300ms
1353        assert_eq!(policy.delay_for_attempt(3), Duration::from_millis(300));
1354    }
1355
1356    #[test]
1357    fn retry_policy_with_max_delay_no_effect_on_fixed() {
1358        let policy = RetryPolicy::fixed(2, Duration::from_millis(100))
1359            .with_max_delay(Duration::from_millis(50));
1360        // with_max_delay has no effect on Fixed
1361        assert_eq!(policy.delay_for_attempt(0), Duration::from_millis(100));
1362    }
1363
1364    struct MarkerA;
1365    impl Schedule for MarkerA {}
1366
1367    struct MarkerB;
1368    impl Schedule for MarkerB {}
1369
1370    #[test]
1371    fn into_system_node_bare() {
1372        let (_, schedules) = sys_fn.into_system_node();
1373        assert!(schedules.is_empty());
1374    }
1375
1376    #[test]
1377    fn into_system_node_single_schedule() {
1378        let (_, schedules) = (MarkerA, sys_fn).into_system_node();
1379        assert_eq!(schedules.len(), 1);
1380        assert_eq!(schedules[0], ScheduleId::of::<MarkerA>());
1381    }
1382
1383    #[test]
1384    fn into_system_node_multi_schedules() {
1385        let (_, schedules) = ((MarkerA, MarkerB), sys_fn).into_system_node();
1386        assert_eq!(schedules.len(), 2);
1387        assert_eq!(schedules[0], ScheduleId::of::<MarkerA>());
1388        assert_eq!(schedules[1], ScheduleId::of::<MarkerB>());
1389    }
1390
1391    #[test]
1392    fn system_node_with_schedules() {
1393        let node = SystemNode::new(sys_fn.into_system()).with_schedules(vec![
1394            ScheduleId::of::<MarkerA>(),
1395            ScheduleId::of::<MarkerB>(),
1396        ]);
1397        assert_eq!(node.schedules.len(), 2);
1398        assert_eq!(node.schedules[0], ScheduleId::of::<MarkerA>());
1399        assert_eq!(node.schedules[1], ScheduleId::of::<MarkerB>());
1400    }
1401
1402    // ─────────────────────────────────────────────────────────────────────────
1403    // ContextPolicy tests
1404    // ─────────────────────────────────────────────────────────────────────────
1405
1406    use polaris_system::param::ParentFilter;
1407    use polaris_system::resource::{ForkStrategy, LocalResource};
1408
1409    #[derive(Clone, Default)]
1410    struct TestRes;
1411    impl LocalResource for TestRes {}
1412
1413    #[derive(Default)]
1414    struct ForkRes;
1415    impl LocalResource for ForkRes {}
1416    impl ForkStrategy for ForkRes {
1417        fn fork(&self) -> Self {
1418            ForkRes
1419        }
1420    }
1421
1422    #[test]
1423    fn context_policy_shared_no_boundary() {
1424        let policy = ContextPolicy::shared();
1425        assert!(policy.is_shared());
1426        assert_eq!(policy.mode(), ContextMode::Shared);
1427        assert!(policy.crossings().next().is_none());
1428    }
1429
1430    #[test]
1431    fn context_policy_new_is_isolated_by_default() {
1432        let policy = ContextPolicy::new();
1433        assert!(!policy.is_shared());
1434        assert_eq!(policy.mode(), ContextMode::Isolated);
1435        assert_eq!(*policy.parent_filter(), ParentFilter::allow_only([]));
1436    }
1437
1438    #[test]
1439    fn share_rest_yields_child_with_allow_all() {
1440        let policy = ContextPolicy::new().share_rest();
1441        assert_eq!(policy.mode(), ContextMode::Inherit);
1442        assert_eq!(*policy.parent_filter(), ParentFilter::allow_all_except([]));
1443    }
1444
1445    #[test]
1446    fn share_specific_type_yields_child_with_allow_only() {
1447        let policy = ContextPolicy::new().share::<TestRes>();
1448        assert_eq!(
1449            *policy.parent_filter(),
1450            ParentFilter::allow_only([TypeId::of::<TestRes>()])
1451        );
1452    }
1453
1454    #[test]
1455    fn share_then_share_rest_keeps_share_and_yields_allow_all() {
1456        // Under `share_rest`, an explicit `share::<T>()` is redundant — but
1457        // the policy must not lose the share or produce the wrong parent
1458        // filter. We assert both: the parent filter is `AllowAllExcept([])`
1459        // (i.e. unrestricted), and the share crossing is still recorded.
1460        let policy = ContextPolicy::new().share::<TestRes>().share_rest();
1461        assert_eq!(policy.mode(), ContextMode::Inherit);
1462        assert_eq!(*policy.parent_filter(), ParentFilter::allow_all_except([]));
1463        let crossing = policy
1464            .crossing_for(TypeId::of::<TestRes>())
1465            .expect("share crossing should be retained");
1466        assert!(matches!(crossing.action, CrossingAction::Share));
1467    }
1468
1469    #[test]
1470    fn forward_records_clone_strategy() {
1471        let policy = ContextPolicy::new().forward::<TestRes>();
1472        let crossing = policy.crossing_for(TypeId::of::<TestRes>()).unwrap();
1473        assert!(matches!(crossing.action, CrossingAction::Forward(_)));
1474    }
1475
1476    #[test]
1477    fn fork_records_fork_strategy() {
1478        let policy = ContextPolicy::new().fork::<ForkRes>();
1479        let crossing = policy.crossing_for(TypeId::of::<ForkRes>()).unwrap();
1480        assert!(matches!(crossing.action, CrossingAction::Fork(_)));
1481    }
1482
1483    #[test]
1484    fn forward_fresh_records_factory_strategy() {
1485        let policy = ContextPolicy::new().forward_fresh::<TestRes>();
1486        let crossing = policy.crossing_for(TypeId::of::<TestRes>()).unwrap();
1487        assert!(matches!(crossing.action, CrossingAction::ForwardFresh));
1488    }
1489
1490    #[test]
1491    fn exclude_in_share_rest_filters_parent() {
1492        let policy = ContextPolicy::new().share_rest().exclude::<TestRes>();
1493        let filter = policy.parent_filter();
1494        assert!(!filter.allows(TypeId::of::<TestRes>()));
1495        // share_rest still allows other types through.
1496        struct Other;
1497        assert!(filter.allows(TypeId::of::<Other>()));
1498    }
1499
1500    #[test]
1501    fn exclude_clears_prior_positive_verb() {
1502        // .share().exclude() must remove the share — last verb wins, and
1503        // exclude must not leave a stale entry in `crossings`.
1504        let policy = ContextPolicy::new().share::<TestRes>().exclude::<TestRes>();
1505        assert!(policy.crossing_for(TypeId::of::<TestRes>()).is_none());
1506        // `exclude` alone (without share_rest) doesn't change parent_filter
1507        // — allow_only stays empty — but we do want excludes tracked for
1508        // share_rest interactions.
1509        let policy_with_rest = ContextPolicy::new()
1510            .share::<TestRes>()
1511            .exclude::<TestRes>()
1512            .share_rest();
1513        let filter = policy_with_rest.parent_filter();
1514        assert!(!filter.allows(TypeId::of::<TestRes>()));
1515    }
1516
1517    #[test]
1518    fn positive_verb_clears_prior_exclude() {
1519        // .exclude().share() must clear the exclude — last verb wins.
1520        let policy = ContextPolicy::new().exclude::<TestRes>().share::<TestRes>();
1521        let crossing = policy.crossing_for(TypeId::of::<TestRes>()).unwrap();
1522        assert!(matches!(crossing.action, CrossingAction::Share));
1523        assert_eq!(
1524            *policy.parent_filter(),
1525            ParentFilter::allow_only([TypeId::of::<TestRes>()])
1526        );
1527    }
1528
1529    #[test]
1530    fn forward_clears_prior_exclude() {
1531        // .exclude().forward() must clear the exclude — last verb wins for the
1532        // clone verbs too, mirroring `positive_verb_clears_prior_exclude` for
1533        // `share`.
1534        let policy = ContextPolicy::new()
1535            .exclude::<TestRes>()
1536            .forward::<TestRes>();
1537        let crossing = policy.crossing_for(TypeId::of::<TestRes>()).unwrap();
1538        assert!(matches!(crossing.action, CrossingAction::Forward(_)));
1539        assert!(!policy.excludes.contains(&TypeId::of::<TestRes>()));
1540        // `forward` copies into the child rather than chain-reading, so it does
1541        // not widen the parent filter — allow_only stays empty.
1542        assert_eq!(*policy.parent_filter(), ParentFilter::allow_only([]));
1543    }
1544
1545    #[test]
1546    fn later_verb_overrides_earlier_for_same_type() {
1547        let policy = ContextPolicy::new().forward::<TestRes>().share::<TestRes>();
1548        let crossing = policy.crossing_for(TypeId::of::<TestRes>()).unwrap();
1549        assert!(matches!(crossing.action, CrossingAction::Share));
1550    }
1551
1552    #[test]
1553    fn context_mode_display_renders_each_variant() {
1554        // The `Display` rendering is part of the observable surface — it is
1555        // interpolated into `GraphEvent` scope logs that downstream consumers
1556        // may match against — so pin all three variants, not just `Inherit`.
1557        assert_eq!(ContextMode::Shared.to_string(), "Shared");
1558        assert_eq!(ContextMode::Inherit.to_string(), "Inherit");
1559        assert_eq!(ContextMode::Isolated.to_string(), "Isolated");
1560    }
1561
1562    #[test]
1563    fn equality_is_verb_shape_not_closure_identity() {
1564        // The documented contract (see the `ContextPolicy` type docs):
1565        // equality is *verb-shape* equality, not closure identity.
1566        // `forward::<T>()` builds a fresh clone closure on each call, so the
1567        // two policies below hold distinct `CloneFn` pointers — yet they must
1568        // compare equal because the crossing compares by verb kind only.
1569        assert_eq!(
1570            ContextPolicy::new().forward::<TestRes>(),
1571            ContextPolicy::new().forward::<TestRes>(),
1572        );
1573        assert_eq!(
1574            ContextPolicy::new().fork::<ForkRes>(),
1575            ContextPolicy::new().fork::<ForkRes>(),
1576        );
1577        assert_eq!(
1578            ContextPolicy::new().share::<TestRes>(),
1579            ContextPolicy::new().share::<TestRes>(),
1580        );
1581
1582        // Different verbs on the same type are not equal...
1583        assert_ne!(
1584            ContextPolicy::new().forward::<TestRes>(),
1585            ContextPolicy::new().share::<TestRes>(),
1586        );
1587
1588        // ...and the same verb on different types is not equal.
1589        #[derive(Clone, Default)]
1590        struct OtherRes;
1591        impl LocalResource for OtherRes {}
1592        assert_ne!(
1593            ContextPolicy::new().forward::<TestRes>(),
1594            ContextPolicy::new().forward::<OtherRes>(),
1595        );
1596    }
1597
1598    #[test]
1599    #[should_panic(expected = "is not valid on ContextPolicy::shared()")]
1600    fn shared_plus_share_panics() {
1601        let _ = ContextPolicy::shared().share::<TestRes>();
1602    }
1603
1604    #[test]
1605    #[should_panic(expected = "is not valid on ContextPolicy::shared()")]
1606    fn shared_plus_forward_panics() {
1607        let _ = ContextPolicy::shared().forward::<TestRes>();
1608    }
1609
1610    #[test]
1611    #[should_panic(expected = "is not valid on ContextPolicy::shared()")]
1612    fn shared_plus_forward_fresh_panics() {
1613        let _ = ContextPolicy::shared().forward_fresh::<TestRes>();
1614    }
1615
1616    #[test]
1617    #[should_panic(expected = "is not valid on ContextPolicy::shared()")]
1618    fn shared_plus_fork_panics() {
1619        let _ = ContextPolicy::shared().fork::<ForkRes>();
1620    }
1621
1622    #[test]
1623    #[should_panic(expected = "is not valid on ContextPolicy::shared()")]
1624    fn shared_plus_exclude_panics() {
1625        let _ = ContextPolicy::shared().exclude::<TestRes>();
1626    }
1627
1628    #[test]
1629    #[should_panic(expected = "is not valid on ContextPolicy::shared()")]
1630    fn shared_plus_share_rest_panics() {
1631        let _ = ContextPolicy::shared().share_rest();
1632    }
1633
1634    #[test]
1635    fn scope_node_accessors() {
1636        let inner = Graph::new();
1637        let scope = ScopeNode {
1638            id: NodeId::new(),
1639            name: "test_scope",
1640            graph: inner,
1641            context_policy: ContextPolicy::shared(),
1642        };
1643        let node = Node::Scope(scope);
1644        assert_eq!(node.name(), "test_scope");
1645        assert!(!node.id().as_str().is_empty());
1646    }
1647}