Skip to main content

polydat_core/kernel/
mod.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Polydat runtime kernel: compiled DAG with pull-through evaluation.
5//!
6//! ## Architecture
7//!
8//! ```text
9//! PolydatProgram (Arc, immutable, shared across all fibers)
10//! ┌──────────────────────────────────────────────────────────────┐
11//! │  nodes[]           — Box<dyn PolydatNode> in topological order│
12//! │  wiring[]          — per-node input source tables            │
13//! │  input_defs[]      — coordinates first, then externs          │
14//! │  output_map/list   — name → (node_idx, port_idx), in order   │
15//! │  input_dependents  — per input, the nodes downstream of it   │
16//! │  traversals[]      — the `for` bodies, one program each      │
17//! │  ledger            — the tree's CompileLedger                │
18//! └──────────────────────────────────────────────────────────────┘
19//!
20//! PolydatState (per-fiber, mutable, private — never shared)
21//! ┌──────────────────────────────────────────────────────────────┐
22//! │  EngineCore:                                                 │
23//! │    buffers[][]       — per-node output value slots:          │
24//! │      ┌───────────┐                                           │
25//! │      │ node 0    │ [Value, Value, ...]  (one per output port)│
26//! │      │ node 1    │ [Value]                                   │
27//! │      └───────────┘                                           │
28//! │    node_clean[]      — whether a node's buffers are current  │
29//! │    inputs[]          — current input values, coords + externs│
30//! │    input_defaults[]  — what reset restores                   │
31//! │    shared_cells[]    — cell-bound input slots                 │
32//! │    output_cells[]    — broadcast cells of computed outputs    │
33//! │    input_scratch[]   — temp buffer for node input gathering  │
34//! │    node_scratch[]    — per-node memo space                   │
35//! └──────────────────────────────────────────────────────────────┘
36//!
37//! Evaluation:
38//!   1. kernel.set_inputs(&[cycle])  → writes the coordinates and
39//!                                     marks their dependents unclean
40//!   2. kernel.pull("name")          → walks the output's cone, skips
41//!                                     clean nodes, returns the buffer
42//!
43//! Workload params:
44//!   Numeric and string workload params are injected into the Polydat
45//!   source as constant bindings before compilation. They resolve
46//!   as normal Polydat outputs — no separate globals mechanism needed.
47//! ```
48
49pub mod activation;
50mod api;
51mod api_impl;
52mod bind;
53pub(crate) mod engines;
54pub mod intern;
55pub mod interp;
56mod manifest;
57mod opt;
58mod program;
59mod scope;
60mod state;
61pub mod subcontext;
62pub use activation::{Activation, CursorSlice, TraversalStream};
63pub use bind::{bind_under, propagate_inputs};
64
65pub(crate) use api::SharedKernel;
66pub(crate) use api::internals::KernelInternals;
67pub use api::{
68    Construction, Dataflow, Kernel, KernelProgram, Metadata, ProgramId, WireKey, WriteError,
69};
70pub use engines::*;
71pub use intern::{StaticInterner, static_pair};
72pub use manifest::{ManifestEntry, extract_manifest};
73pub use opt::KernelOptLevel;
74pub use program::*;
75pub use scope::{ScopeCoord, format_scope_coordinate_path};
76pub use state::*;
77
78use crate::ast::Value;
79
80/// Source of a value for a node input port.
81#[derive(Debug, Clone)]
82pub enum WireSource {
83    /// A named input, by index into the unified input array.
84    /// Includes both coordinate inputs and capture inputs.
85    Input(usize),
86    /// Output of another node: `(node_index, output_port_index)`.
87    NodeOutput(usize, usize),
88}
89
90/// Classification of a named input by its evaluation lifecycle.
91///
92/// See `crates/polydat/docs/design/evaluation_model.md` for the lifecycle classification.
93/// The init-binding contract uses this to decide whether a wire
94/// to an `Input(idx)` is effectively-const at scope-init time:
95/// `IterationExtern` slots count as effectively-const (rebound
96/// once per scope activation); `Coordinate` and `ExternalWrite`
97/// slots are dynamic and disqualify any init binding that reaches
98/// them.
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum InputKind {
101    /// Dimensional input declared by `input (cycle: u64, ...: u64)` —
102    /// dynamic, written at every coordinate advance.
103    Coordinate,
104    /// External slot populated by `materialize_wiring_from_outer` from an
105    /// enclosing `for_each` / `for_combinations` clause —
106    /// effectively-const for the duration of one scope activation.
107    IterationExtern,
108    /// External port declared by `extern name: type = default` —
109    /// written by capture extraction during op execution; dynamic
110    /// across cycle boundaries within a stanza.
111    ExternalWrite,
112    /// The captured value of a `const` binding (`__const_<name>`),
113    /// written only when the kernel is initialized ([`ConstInit`]). A
114    /// host write to it is refused (`WriteError::ConstSlot`).
115    Const,
116}
117
118/// How one `const` binding is initialized.
119///
120/// When a kernel is initialized, `source` (the const's own expression,
121/// compiled as the output `__init_<name>`) is evaluated once and its
122/// value written into `slot` (`__const_<name>`), which everything that
123/// reads the const reads. A `None` value falls back to the `fallback`
124/// input, the value a binder copied from the enclosing scope, so a const
125/// that yields nothing leaves the outer binding visible. Nothing
126/// re-evaluates the const afterwards; `Kernel::init` does it again.
127///
128/// The assembler writes these records when it resolves a graph, for
129/// every const whose value is not known at build, and resolves each
130/// name to its index then, so initialization looks nothing up.
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct ConstInit {
133    /// The const's name, and the output that reads it.
134    pub name: String,
135    /// The input slot holding the captured value.
136    pub slot: String,
137    /// The output computing the const's expression.
138    pub source: String,
139    /// The input holding the enclosing scope's value, when the const's
140    /// expression references names.
141    pub fallback: Option<String>,
142    /// Whether this is the computed starting value of a `shared`
143    /// register rather than a const: `slot` is the register's own input,
144    /// and initialization writes it only while nothing has written the
145    /// register, so a scope attached to a register another scope
146    /// declared never seeds it again.
147    pub register: bool,
148    /// `slot`'s index among the kernel's inputs.
149    pub(crate) slot_index: usize,
150    /// `source`'s index among the kernel's outputs.
151    pub(crate) source_index: usize,
152    /// `fallback`'s index among the kernel's inputs.
153    pub(crate) fallback_index: Option<usize>,
154}
155
156/// The text of a caught panic: its `String` or `&str` payload.
157pub(crate) fn panic_message(payload: &Box<dyn std::any::Any + Send>) -> String {
158    if let Some(s) = payload.downcast_ref::<String>() {
159        s.clone()
160    } else if let Some(s) = payload.downcast_ref::<&str>() {
161        s.to_string()
162    } else {
163        "<non-string panic payload>".to_string()
164    }
165}
166
167/// Definition of a named input to the Polydat graph.
168///
169/// All inputs — coordinates, iteration externs, and external-write ports
170/// — are defined uniformly. Coordinates default to `Value::U64(0)`,
171/// captures default to `Value::None` (unset until a capture writes
172/// to them) or to their declared default. The `kind` field carries
173/// the lifecycle classification used by the const-binding contract
174/// (evaluation_model.md, "Const Binding Contract").
175#[derive(Debug, Clone)]
176pub struct InputDef {
177    /// Input name (e.g., "cycle", "username").
178    pub name: String,
179    /// Default value. Coordinates default to U64(0), captures
180    /// to their declared default (or None if unset).
181    pub default: Value,
182    /// The declared port type for this input. Used by the assembler
183    /// for type checking when wiring nodes to this input.
184    pub port_type: crate::ast::PortType,
185    /// Lifecycle classification. The assembler's coordinate inputs
186    /// are `Coordinate`; the DSL compiler sets `IterationExtern` for
187    /// iteration externs and `ExternalWrite` for `extern` ports.
188    pub kind: InputKind,
189    /// Whether the author wrote this input's type or the compiler
190    /// inferred it (input_variance.md §3). An inferred input is *open*:
191    /// the host may write a value of another type, and whether the
192    /// compiler converts it is `CompileOptions::input_variance`'s call.
193    pub type_origin: TypeOrigin,
194    /// For a converted input: the type its consumers read, which is
195    /// what the input reports as its type. The slot itself is `Dyn`
196    /// and holds the value as written; `None` for every other input.
197    pub converts_to: Option<crate::ast::PortType>,
198}
199
200/// How an input's type was established (input_variance.md §3).
201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202pub enum TypeOrigin {
203    /// The author wrote it: `input x: T`, `extern x: T`, `shared x`.
204    /// The input is invariant, and a write of another type is refused.
205    Declared,
206    /// The compiler inferred it: an `input` with no type, or an
207    /// auto-extern typed from the binding that reads it.
208    Inferred,
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use std::collections::HashMap;
215    use std::sync::Arc;
216
217    #[test]
218    fn capture_inputs_persist_across_set_inputs() {
219        // Program with 1 coordinate (cycle) + 2 capture inputs
220        let program = Arc::new(PolydatProgram::with_inputs(
221            vec![],
222            vec![],
223            vec![
224                InputDef {
225                    name: "cycle".into(),
226                    default: Value::U64(0),
227                    port_type: crate::ast::PortType::U64,
228                    kind: InputKind::Coordinate,
229                    type_origin: crate::kernel::TypeOrigin::Declared,
230                    converts_to: None,
231                },
232                InputDef {
233                    name: "balance".into(),
234                    default: Value::F64(0.0),
235                    port_type: crate::ast::PortType::F64,
236                    kind: InputKind::ExternalWrite,
237                    type_origin: crate::kernel::TypeOrigin::Declared,
238                    converts_to: None,
239                },
240                InputDef {
241                    name: "auth_token".into(),
242                    default: Value::Str("anonymous".into()),
243                    port_type: crate::ast::PortType::Str,
244                    kind: InputKind::ExternalWrite,
245                    type_origin: crate::kernel::TypeOrigin::Declared,
246                    converts_to: None,
247                },
248            ],
249            1, // coord_count
250            HashMap::new(),
251            Vec::new(),
252            "",
253            "(test)",
254            crate::kernel::CompileLedger::new(),
255        ));
256        let mut state = program.create_state();
257
258        // Default values for capture inputs
259        assert_eq!(state.get_input(1), Value::F64(0.0));
260        assert_eq!(state.get_input(2), Value::Str("anonymous".into()));
261
262        // Set capture inputs individually
263        state.set_input(1, Value::F64(1234.56));
264        state.set_input(2, Value::Str("token_abc".into()));
265        assert_eq!(state.get_input(1), Value::F64(1234.56));
266        assert_eq!(state.get_input(2), Value::Str("token_abc".into()));
267
268        // Capture inputs persist when coordinates change
269        state.set_inputs(&[42]);
270        assert_eq!(state.get_input(1), Value::F64(1234.56));
271        assert_eq!(state.get_input(2), Value::Str("token_abc".into()));
272    }
273
274    #[test]
275    fn reset_inputs_restores_capture_defaults() {
276        let program = Arc::new(PolydatProgram::with_inputs(
277            vec![],
278            vec![],
279            vec![
280                InputDef {
281                    name: "cycle".into(),
282                    default: Value::U64(0),
283                    port_type: crate::ast::PortType::U64,
284                    kind: InputKind::Coordinate,
285                    type_origin: crate::kernel::TypeOrigin::Declared,
286                    converts_to: None,
287                },
288                InputDef {
289                    name: "token".into(),
290                    default: Value::Str("anon".into()),
291                    port_type: crate::ast::PortType::Str,
292                    kind: InputKind::ExternalWrite,
293                    type_origin: crate::kernel::TypeOrigin::Declared,
294                    converts_to: None,
295                },
296            ],
297            1,
298            HashMap::new(),
299            Vec::new(),
300            "",
301            "(test)",
302            crate::kernel::CompileLedger::new(),
303        ));
304        let mut state = program.create_state();
305
306        state.set_input(1, Value::Str("alice".into()));
307        assert_eq!(state.get_input(1), Value::Str("alice".into()));
308
309        // Reset only capture inputs (from coord_count onward)
310        state.reset_inputs_from(1);
311        assert_eq!(state.get_input(1), Value::Str("anon".into()));
312    }
313
314    #[test]
315    fn invalidate_all_keeps_inputs_and_reset_restores_defaults() {
316        let program = Arc::new(PolydatProgram::with_inputs(
317            vec![],
318            vec![],
319            vec![
320                InputDef {
321                    name: "cycle".into(),
322                    default: Value::U64(0),
323                    port_type: crate::ast::PortType::U64,
324                    kind: InputKind::Coordinate,
325                    type_origin: crate::kernel::TypeOrigin::Declared,
326                    converts_to: None,
327                },
328                InputDef {
329                    name: "token".into(),
330                    default: Value::Str("anon".into()),
331                    port_type: crate::ast::PortType::Str,
332                    kind: InputKind::ExternalWrite,
333                    type_origin: crate::kernel::TypeOrigin::Declared,
334                    converts_to: None,
335                },
336            ],
337            1,
338            HashMap::new(),
339            Vec::new(),
340            "",
341            "(test)",
342            crate::kernel::CompileLedger::new(),
343        ));
344        let mut state = program.create_state();
345
346        state.set_inputs(&[42]);
347        state.set_input(1, Value::Str("alice".into()));
348
349        state.invalidate_all();
350        assert_eq!(state.get_input(0), Value::U64(42));
351        assert_eq!(state.get_input(1), Value::Str("alice".into()));
352        state.reset_inputs_from(0);
353        assert_eq!(state.get_input(0), Value::U64(0));
354        assert_eq!(state.get_input(1), Value::Str("anon".into()));
355    }
356
357    // ---------------------------------------------------------------
358    // WireCost tests: config wire warnings for various DAG shapes
359    // ---------------------------------------------------------------
360
361    /// A test node with one Config wire input and one Data wire input.
362    /// Simulates a node with an expensive LUT that's configured by
363    /// the first input and driven by the second.
364    struct ConfigWireTestNode {
365        meta: crate::ast::NodeMeta,
366    }
367
368    impl ConfigWireTestNode {
369        fn new() -> Self {
370            use crate::ast::{Port, Slot};
371            Self {
372                meta: crate::ast::NodeMeta {
373                    name: "config_test".into(),
374                    outs: vec![Port::u64("output")],
375                    ins: vec![
376                        Slot::Wire(Port::u64("config_param").config()),
377                        Slot::Wire(Port::u64("data_input")),
378                    ],
379                },
380            }
381        }
382    }
383
384    impl crate::ast::PolydatNode for ConfigWireTestNode {
385        fn meta(&self) -> &crate::ast::NodeMeta {
386            &self.meta
387        }
388        fn eval(&self, inputs: &[Value], outputs: &mut [Value]) {
389            let config = inputs[0].as_u64();
390            let data = inputs[1].as_u64();
391            outputs[0] = Value::U64(config.wrapping_add(data));
392        }
393    }
394
395    #[test]
396    fn wire_cost_no_warning_when_config_is_init_time() {
397        // DAG: constant(42) → config_test.config_param
398        //      cycle → hash → config_test.data_input
399        // Config wire fed by init-time constant → no warning
400        use crate::compile::assembly::{PolydatAssembler, WireRef};
401        use crate::dsl::events::CompileEventLog;
402        use crate::library::identity::ConstU64;
403        use crate::library::identity::Identity;
404
405        let mut asm = PolydatAssembler::new(vec!["cycle".into()]);
406        asm.add_node("config_val", Box::new(ConstU64::new(42)), vec![]);
407        asm.add_node(
408            "hashed",
409            Box::new(Identity::new(crate::ast::PortType::U64)),
410            vec![WireRef::input("cycle")],
411        );
412        asm.add_node(
413            "test_node",
414            Box::new(ConfigWireTestNode::new()),
415            vec![WireRef::node("config_val"), WireRef::node("hashed")],
416        );
417        asm.add_output("result", WireRef::node("test_node"));
418
419        let mut log = CompileEventLog::new();
420        let k = asm.compile_with_log(Some(&mut log)).unwrap();
421        let _program = k.into_program();
422
423        // Check: no ConfigWireCycleWarning in events
424        let warnings: Vec<_> = log
425            .events()
426            .iter()
427            .filter(|e| {
428                matches!(
429                    e,
430                    crate::dsl::events::CompileEvent::ConfigWireCycleWarning { .. }
431                )
432            })
433            .collect();
434        assert!(
435            warnings.is_empty(),
436            "no warning expected when config wire is init-time: {warnings:?}"
437        );
438    }
439
440    #[test]
441    fn wire_cost_warning_when_config_is_cycle_time() {
442        // DAG: cycle → hash → config_test.config_param  (BAD: config from cycle)
443        //      cycle → config_test.data_input
444        // Config wire fed by cycle-time node → should warn
445        use crate::compile::assembly::{PolydatAssembler, WireRef};
446        use crate::dsl::events::CompileEventLog;
447        use crate::library::identity::Identity;
448
449        let mut asm = PolydatAssembler::new(vec!["cycle".into()]);
450        asm.add_node(
451            "hashed",
452            Box::new(Identity::new(crate::ast::PortType::U64)),
453            vec![WireRef::input("cycle")],
454        );
455        asm.add_node(
456            "test_node",
457            Box::new(ConfigWireTestNode::new()),
458            vec![
459                WireRef::node("hashed"), // config_param ← cycle-time!
460                WireRef::input("cycle"), // data_input ← cycle
461            ],
462        );
463        asm.add_output("result", WireRef::node("test_node"));
464
465        let mut log = CompileEventLog::new();
466        let _k = asm.compile_with_log(Some(&mut log)).unwrap();
467
468        let warnings: Vec<_> = log
469            .events()
470            .iter()
471            .filter(|e| {
472                matches!(
473                    e,
474                    crate::dsl::events::CompileEvent::ConfigWireCycleWarning { .. }
475                )
476            })
477            .collect();
478        assert_eq!(
479            warnings.len(),
480            1,
481            "expected exactly one config wire warning: {warnings:?}"
482        );
483    }
484
485    /// The same graph under `strict`, on every engine a build can name.
486    ///
487    /// A config wire fed by a cycle-time source warns by default and is
488    /// refused under strict, and both of the assembler's strict checks
489    /// run through one function that every engine's build calls
490    /// (`refuse_strict` → `strict_violation`). The adapter half is
491    /// pinned across engines in `entry_points`; this pins the config
492    /// half, which needs a node with a config-typed wire port and so
493    /// lives beside the one that has it.
494    #[test]
495    fn strict_refuses_a_cycle_fed_config_wire_on_every_engine() {
496        use crate::compile::assembly::{PolydatAssembler, WireRef};
497        use crate::compile::select::{Engine, Provenance};
498        use crate::library::identity::Identity;
499
500        let build = || {
501            let mut asm = PolydatAssembler::new(vec!["cycle".into()]);
502            asm.add_node(
503                "hashed",
504                Box::new(Identity::new(crate::ast::PortType::U64)),
505                vec![WireRef::input("cycle")],
506            );
507            asm.add_node(
508                "test_node",
509                Box::new(ConfigWireTestNode::new()),
510                vec![WireRef::node("hashed"), WireRef::input("cycle")],
511            );
512            asm.add_output("result", WireRef::node("test_node"));
513            asm.set_strict(true);
514            asm
515        };
516
517        let mut engines = vec![
518            Engine::Interpreter(crate::JitMode::Auto),
519            Engine::Closures(Provenance::Auto),
520        ];
521        if cfg!(feature = "jit") {
522            engines.push(Engine::Native(Provenance::Auto));
523            engines.push(Engine::PureNative(Provenance::Auto));
524        }
525        for engine in engines {
526            let outcome = build().compile_with(engine);
527            let err = match outcome {
528                Err(e) => e.to_string(),
529                Ok(_) => panic!("{engine}: strict must refuse a cycle-fed config wire"),
530            };
531            assert!(
532                err.contains("config"),
533                "{engine}: the refusal should name the config wire: {err}"
534            );
535        }
536        // Without strict the same graph builds, warning rather than
537        // refusing: strict is what turns the advice into an error. On
538        // the interpreter, because this node is a test fixture with no
539        // compiled form and the compiled tiers refuse it for that
540        // unrelated reason — which is also why the refusals above are
541        // checked for the word `config` rather than merely being
542        // errors.
543        let mut asm = build();
544        asm.set_strict(false);
545        assert!(
546            asm.compile_with(Engine::Interpreter(crate::JitMode::Auto))
547                .is_ok()
548        );
549    }
550
551    #[test]
552    fn wire_cost_warning_when_config_is_coordinate_direct() {
553        // DAG: cycle → config_test.config_param  (BAD: coordinate direct to config)
554        //      cycle → config_test.data_input
555        use crate::compile::assembly::{PolydatAssembler, WireRef};
556        use crate::dsl::events::CompileEventLog;
557
558        let mut asm = PolydatAssembler::new(vec!["cycle".into()]);
559        asm.add_node(
560            "test_node",
561            Box::new(ConfigWireTestNode::new()),
562            vec![
563                WireRef::input("cycle"), // config_param ← coordinate!
564                WireRef::input("cycle"), // data_input ← cycle
565            ],
566        );
567        asm.add_output("result", WireRef::node("test_node"));
568
569        let mut log = CompileEventLog::new();
570        let _k = asm.compile_with_log(Some(&mut log)).unwrap();
571
572        let warnings: Vec<_> = log
573            .events()
574            .iter()
575            .filter(|e| {
576                matches!(
577                    e,
578                    crate::dsl::events::CompileEvent::ConfigWireCycleWarning { .. }
579                )
580            })
581            .collect();
582        assert_eq!(warnings.len(), 1, "config wire from coordinate should warn");
583    }
584
585    #[test]
586    fn wire_cost_no_warning_data_wire_from_cycle() {
587        // DAG: constant(10) → config_test.config_param (init-time, ok)
588        //      cycle → config_test.data_input           (cycle-time, ok for Data wire)
589        // Only the data wire is cycle-time → no warning
590        use crate::compile::assembly::{PolydatAssembler, WireRef};
591        use crate::dsl::events::CompileEventLog;
592        use crate::library::identity::ConstU64;
593
594        let mut asm = PolydatAssembler::new(vec!["cycle".into()]);
595        asm.add_node("config_val", Box::new(ConstU64::new(10)), vec![]);
596        asm.add_node(
597            "test_node",
598            Box::new(ConfigWireTestNode::new()),
599            vec![
600                WireRef::node("config_val"), // config_param ← constant
601                WireRef::input("cycle"),     // data_input ← cycle (Data wire, ok)
602            ],
603        );
604        asm.add_output("result", WireRef::node("test_node"));
605
606        let mut log = CompileEventLog::new();
607        let _k = asm.compile_with_log(Some(&mut log)).unwrap();
608
609        let warnings: Vec<_> = log
610            .events()
611            .iter()
612            .filter(|e| {
613                matches!(
614                    e,
615                    crate::dsl::events::CompileEvent::ConfigWireCycleWarning { .. }
616                )
617            })
618            .collect();
619        assert!(warnings.is_empty(), "data wire from cycle should not warn");
620    }
621
622    #[test]
623    fn wire_cost_diamond_config_from_init() {
624        // Diamond DAG using two ConfigWireTestNodes:
625        //   constant(5) → inner.config_param ─┐
626        //   constant(3) → inner.data_input    ─┤→ inner.output → outer.config_param
627        //   cycle → hash → outer.data_input
628        // inner is fully init-time → its output feeds outer's config wire → no warning
629        use crate::compile::assembly::{PolydatAssembler, WireRef};
630        use crate::dsl::events::CompileEventLog;
631        use crate::library::identity::ConstU64;
632        use crate::library::identity::Identity;
633
634        let mut asm = PolydatAssembler::new(vec!["cycle".into()]);
635        asm.add_node("a", Box::new(ConstU64::new(5)), vec![]);
636        asm.add_node("b", Box::new(ConstU64::new(3)), vec![]);
637        asm.add_node(
638            "inner",
639            Box::new(ConfigWireTestNode::new()),
640            vec![WireRef::node("a"), WireRef::node("b")],
641        );
642        asm.add_node(
643            "hashed",
644            Box::new(Identity::new(crate::ast::PortType::U64)),
645            vec![WireRef::input("cycle")],
646        );
647        asm.add_node(
648            "outer",
649            Box::new(ConfigWireTestNode::new()),
650            vec![
651                WireRef::node("inner"),  // config_param ← init-time (5+3)
652                WireRef::node("hashed"), // data_input ← cycle-time
653            ],
654        );
655        asm.add_output("result", WireRef::node("outer"));
656
657        let mut log = CompileEventLog::new();
658        let _k = asm.compile_with_log(Some(&mut log)).unwrap();
659
660        // inner's config wire from constant is fine. outer's config wire
661        // from init-time inner output is also fine.
662        let warnings: Vec<_> = log
663            .events()
664            .iter()
665            .filter(|e| {
666                matches!(
667                    e,
668                    crate::dsl::events::CompileEvent::ConfigWireCycleWarning { .. }
669                )
670            })
671            .collect();
672        assert!(
673            warnings.is_empty(),
674            "init-time derived config should not warn: {warnings:?}"
675        );
676    }
677
678    #[test]
679    fn wire_cost_diamond_config_from_mixed() {
680        // Mixed init/cycle feeding config:
681        //   constant(5) → mixer.config_param ─┐
682        //   cycle → mixer.data_input          ─┤→ mixer.output → outer.config_param
683        //   cycle → outer.data_input
684        // mixer depends on cycle → its output is cycle-time → outer's config wire warns
685        use crate::compile::assembly::{PolydatAssembler, WireRef};
686        use crate::dsl::events::CompileEventLog;
687        use crate::library::identity::ConstU64;
688
689        let mut asm = PolydatAssembler::new(vec!["cycle".into()]);
690        asm.add_node("five", Box::new(ConstU64::new(5)), vec![]);
691        asm.add_node(
692            "mixer",
693            Box::new(ConfigWireTestNode::new()),
694            vec![
695                WireRef::node("five"),   // config_param ← init
696                WireRef::input("cycle"), // data_input ← cycle
697            ],
698        );
699        asm.add_node(
700            "outer",
701            Box::new(ConfigWireTestNode::new()),
702            vec![
703                WireRef::node("mixer"),  // config_param ← cycle-tainted!
704                WireRef::input("cycle"), // data_input
705            ],
706        );
707        asm.add_output("result", WireRef::node("outer"));
708
709        let mut log = CompileEventLog::new();
710        let _k = asm.compile_with_log(Some(&mut log)).unwrap();
711
712        let warnings: Vec<_> = log
713            .events()
714            .iter()
715            .filter(|e| {
716                matches!(
717                    e,
718                    crate::dsl::events::CompileEvent::ConfigWireCycleWarning { .. }
719                )
720            })
721            .collect();
722        // outer's config from cycle-tainted mixer should warn.
723        // mixer's config from constant should NOT warn.
724        assert_eq!(
725            warnings.len(),
726            1,
727            "exactly one warning for outer's config: {warnings:?}"
728        );
729    }
730}