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}