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[]    — cell-bound `shared` 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#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct ConstInit {
129    /// The const's name, and the output that reads it.
130    pub name: String,
131    /// The input slot holding the captured value.
132    pub slot: String,
133    /// The output computing the const's expression.
134    pub source: String,
135    /// The input holding the enclosing scope's value, when the const's
136    /// expression references names.
137    pub fallback: Option<String>,
138}
139
140/// The text of a caught panic: its `String` or `&str` payload.
141pub(crate) fn panic_message(payload: &Box<dyn std::any::Any + Send>) -> String {
142    if let Some(s) = payload.downcast_ref::<String>() {
143        s.clone()
144    } else if let Some(s) = payload.downcast_ref::<&str>() {
145        s.to_string()
146    } else {
147        "<non-string panic payload>".to_string()
148    }
149}
150
151/// Definition of a named input to the Polydat graph.
152///
153/// All inputs — coordinates, iteration externs, and external-write ports
154/// — are defined uniformly. Coordinates default to `Value::U64(0)`,
155/// captures default to `Value::None` (unset until a capture writes
156/// to them) or to their declared default. The `kind` field carries
157/// the lifecycle classification used by the init-binding contract
158/// (see SRD 11 §"Init Binding Contract").
159#[derive(Debug, Clone)]
160pub struct InputDef {
161    /// Input name (e.g., "cycle", "username").
162    pub name: String,
163    /// Default value. Coordinates default to U64(0), captures
164    /// to their declared default (or None if unset).
165    pub default: Value,
166    /// The declared port type for this input. Used by the assembler
167    /// for type checking when wiring nodes to this input.
168    pub port_type: crate::ast::PortType,
169    /// Lifecycle classification. The assembler's coordinate inputs
170    /// are `Coordinate`; the DSL compiler sets `IterationExtern` for
171    /// iteration externs and `ExternalWrite` for `extern` ports.
172    pub kind: InputKind,
173    /// Whether the author wrote this input's type or the compiler
174    /// inferred it (input_variance.md §3). An inferred input is *open*:
175    /// the host may write a value of another type, and whether the
176    /// compiler converts it is `CompileOptions::input_variance`'s call.
177    pub type_origin: TypeOrigin,
178    /// For a converted input: the type its consumers read, which is
179    /// what the input reports as its type. The slot itself is `Dyn`
180    /// and holds the value as written; `None` for every other input.
181    pub converts_to: Option<crate::ast::PortType>,
182}
183
184/// How an input's type was established (input_variance.md §3).
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186pub enum TypeOrigin {
187    /// The author wrote it: `input x: T`, `extern x: T`, `shared x`.
188    /// The input is invariant, and a write of another type is refused.
189    Declared,
190    /// The compiler inferred it: an `input` with no type, or an
191    /// auto-extern typed from the binding that reads it.
192    Inferred,
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use std::collections::HashMap;
199    use std::sync::Arc;
200
201    #[test]
202    fn capture_inputs_persist_across_set_inputs() {
203        // Program with 1 coordinate (cycle) + 2 capture inputs
204        let program = Arc::new(PolydatProgram::with_inputs(
205            vec![],
206            vec![],
207            vec![
208                InputDef {
209                    name: "cycle".into(),
210                    default: Value::U64(0),
211                    port_type: crate::ast::PortType::U64,
212                    kind: InputKind::Coordinate,
213                    type_origin: crate::kernel::TypeOrigin::Declared,
214                    converts_to: None,
215                },
216                InputDef {
217                    name: "balance".into(),
218                    default: Value::F64(0.0),
219                    port_type: crate::ast::PortType::F64,
220                    kind: InputKind::ExternalWrite,
221                    type_origin: crate::kernel::TypeOrigin::Declared,
222                    converts_to: None,
223                },
224                InputDef {
225                    name: "auth_token".into(),
226                    default: Value::Str("anonymous".into()),
227                    port_type: crate::ast::PortType::Str,
228                    kind: InputKind::ExternalWrite,
229                    type_origin: crate::kernel::TypeOrigin::Declared,
230                    converts_to: None,
231                },
232            ],
233            1, // coord_count
234            HashMap::new(),
235            Vec::new(),
236            "",
237            "(test)",
238            crate::kernel::CompileLedger::new(),
239        ));
240        let mut state = program.create_state();
241
242        // Default values for capture inputs
243        assert_eq!(state.get_input(1), Value::F64(0.0));
244        assert_eq!(state.get_input(2), Value::Str("anonymous".into()));
245
246        // Set capture inputs individually
247        state.set_input(1, Value::F64(1234.56));
248        state.set_input(2, Value::Str("token_abc".into()));
249        assert_eq!(state.get_input(1), Value::F64(1234.56));
250        assert_eq!(state.get_input(2), Value::Str("token_abc".into()));
251
252        // Capture inputs persist when coordinates change
253        state.set_inputs(&[42]);
254        assert_eq!(state.get_input(1), Value::F64(1234.56));
255        assert_eq!(state.get_input(2), Value::Str("token_abc".into()));
256    }
257
258    #[test]
259    fn reset_inputs_restores_capture_defaults() {
260        let program = Arc::new(PolydatProgram::with_inputs(
261            vec![],
262            vec![],
263            vec![
264                InputDef {
265                    name: "cycle".into(),
266                    default: Value::U64(0),
267                    port_type: crate::ast::PortType::U64,
268                    kind: InputKind::Coordinate,
269                    type_origin: crate::kernel::TypeOrigin::Declared,
270                    converts_to: None,
271                },
272                InputDef {
273                    name: "token".into(),
274                    default: Value::Str("anon".into()),
275                    port_type: crate::ast::PortType::Str,
276                    kind: InputKind::ExternalWrite,
277                    type_origin: crate::kernel::TypeOrigin::Declared,
278                    converts_to: None,
279                },
280            ],
281            1,
282            HashMap::new(),
283            Vec::new(),
284            "",
285            "(test)",
286            crate::kernel::CompileLedger::new(),
287        ));
288        let mut state = program.create_state();
289
290        state.set_input(1, Value::Str("alice".into()));
291        assert_eq!(state.get_input(1), Value::Str("alice".into()));
292
293        // Reset only capture inputs (from coord_count onward)
294        state.reset_inputs_from(1);
295        assert_eq!(state.get_input(1), Value::Str("anon".into()));
296    }
297
298    #[test]
299    fn invalidate_all_keeps_inputs_and_reset_restores_defaults() {
300        let program = Arc::new(PolydatProgram::with_inputs(
301            vec![],
302            vec![],
303            vec![
304                InputDef {
305                    name: "cycle".into(),
306                    default: Value::U64(0),
307                    port_type: crate::ast::PortType::U64,
308                    kind: InputKind::Coordinate,
309                    type_origin: crate::kernel::TypeOrigin::Declared,
310                    converts_to: None,
311                },
312                InputDef {
313                    name: "token".into(),
314                    default: Value::Str("anon".into()),
315                    port_type: crate::ast::PortType::Str,
316                    kind: InputKind::ExternalWrite,
317                    type_origin: crate::kernel::TypeOrigin::Declared,
318                    converts_to: None,
319                },
320            ],
321            1,
322            HashMap::new(),
323            Vec::new(),
324            "",
325            "(test)",
326            crate::kernel::CompileLedger::new(),
327        ));
328        let mut state = program.create_state();
329
330        state.set_inputs(&[42]);
331        state.set_input(1, Value::Str("alice".into()));
332
333        state.invalidate_all();
334        assert_eq!(state.get_input(0), Value::U64(42));
335        assert_eq!(state.get_input(1), Value::Str("alice".into()));
336        state.reset_inputs_from(0);
337        assert_eq!(state.get_input(0), Value::U64(0));
338        assert_eq!(state.get_input(1), Value::Str("anon".into()));
339    }
340
341    // ---------------------------------------------------------------
342    // WireCost tests: config wire warnings for various DAG shapes
343    // ---------------------------------------------------------------
344
345    /// A test node with one Config wire input and one Data wire input.
346    /// Simulates a node with an expensive LUT that's configured by
347    /// the first input and driven by the second.
348    struct ConfigWireTestNode {
349        meta: crate::ast::NodeMeta,
350    }
351
352    impl ConfigWireTestNode {
353        fn new() -> Self {
354            use crate::ast::{Port, Slot};
355            Self {
356                meta: crate::ast::NodeMeta {
357                    name: "config_test".into(),
358                    outs: vec![Port::u64("output")],
359                    ins: vec![
360                        Slot::Wire(Port::u64("config_param").config()),
361                        Slot::Wire(Port::u64("data_input")),
362                    ],
363                },
364            }
365        }
366    }
367
368    impl crate::ast::PolydatNode for ConfigWireTestNode {
369        fn meta(&self) -> &crate::ast::NodeMeta {
370            &self.meta
371        }
372        fn eval(&self, inputs: &[Value], outputs: &mut [Value]) {
373            let config = inputs[0].as_u64();
374            let data = inputs[1].as_u64();
375            outputs[0] = Value::U64(config.wrapping_add(data));
376        }
377    }
378
379    #[test]
380    fn wire_cost_no_warning_when_config_is_init_time() {
381        // DAG: constant(42) → config_test.config_param
382        //      cycle → hash → config_test.data_input
383        // Config wire fed by init-time constant → no warning
384        use crate::compile::assembly::{PolydatAssembler, WireRef};
385        use crate::dsl::events::CompileEventLog;
386        use crate::library::identity::ConstU64;
387        use crate::library::identity::Identity;
388
389        let mut asm = PolydatAssembler::new(vec!["cycle".into()]);
390        asm.add_node("config_val", Box::new(ConstU64::new(42)), vec![]);
391        asm.add_node(
392            "hashed",
393            Box::new(Identity::new(crate::ast::PortType::U64)),
394            vec![WireRef::input("cycle")],
395        );
396        asm.add_node(
397            "test_node",
398            Box::new(ConfigWireTestNode::new()),
399            vec![WireRef::node("config_val"), WireRef::node("hashed")],
400        );
401        asm.add_output("result", WireRef::node("test_node"));
402
403        let mut log = CompileEventLog::new();
404        let k = asm.compile_with_log(Some(&mut log)).unwrap();
405        let _program = k.into_program();
406
407        // Check: no ConfigWireCycleWarning in events
408        let warnings: Vec<_> = log
409            .events()
410            .iter()
411            .filter(|e| {
412                matches!(
413                    e,
414                    crate::dsl::events::CompileEvent::ConfigWireCycleWarning { .. }
415                )
416            })
417            .collect();
418        assert!(
419            warnings.is_empty(),
420            "no warning expected when config wire is init-time: {warnings:?}"
421        );
422    }
423
424    #[test]
425    fn wire_cost_warning_when_config_is_cycle_time() {
426        // DAG: cycle → hash → config_test.config_param  (BAD: config from cycle)
427        //      cycle → config_test.data_input
428        // Config wire fed by cycle-time node → should warn
429        use crate::compile::assembly::{PolydatAssembler, WireRef};
430        use crate::dsl::events::CompileEventLog;
431        use crate::library::identity::Identity;
432
433        let mut asm = PolydatAssembler::new(vec!["cycle".into()]);
434        asm.add_node(
435            "hashed",
436            Box::new(Identity::new(crate::ast::PortType::U64)),
437            vec![WireRef::input("cycle")],
438        );
439        asm.add_node(
440            "test_node",
441            Box::new(ConfigWireTestNode::new()),
442            vec![
443                WireRef::node("hashed"), // config_param ← cycle-time!
444                WireRef::input("cycle"), // data_input ← cycle
445            ],
446        );
447        asm.add_output("result", WireRef::node("test_node"));
448
449        let mut log = CompileEventLog::new();
450        let _k = asm.compile_with_log(Some(&mut log)).unwrap();
451
452        let warnings: Vec<_> = log
453            .events()
454            .iter()
455            .filter(|e| {
456                matches!(
457                    e,
458                    crate::dsl::events::CompileEvent::ConfigWireCycleWarning { .. }
459                )
460            })
461            .collect();
462        assert_eq!(
463            warnings.len(),
464            1,
465            "expected exactly one config wire warning: {warnings:?}"
466        );
467    }
468
469    /// The same graph under `strict`, on every engine a build can name.
470    ///
471    /// A config wire fed by a cycle-time source warns by default and is
472    /// refused under strict, and both of the assembler's strict checks
473    /// run through one function that every engine's build calls
474    /// (`refuse_strict` → `strict_violation`). The adapter half is
475    /// pinned across engines in `entry_points`; this pins the config
476    /// half, which needs a node with a config-typed wire port and so
477    /// lives beside the one that has it (F-H3).
478    #[test]
479    fn strict_refuses_a_cycle_fed_config_wire_on_every_engine() {
480        use crate::compile::assembly::{PolydatAssembler, WireRef};
481        use crate::compile::select::{Engine, Provenance};
482        use crate::library::identity::Identity;
483
484        let build = || {
485            let mut asm = PolydatAssembler::new(vec!["cycle".into()]);
486            asm.add_node(
487                "hashed",
488                Box::new(Identity::new(crate::ast::PortType::U64)),
489                vec![WireRef::input("cycle")],
490            );
491            asm.add_node(
492                "test_node",
493                Box::new(ConfigWireTestNode::new()),
494                vec![WireRef::node("hashed"), WireRef::input("cycle")],
495            );
496            asm.add_output("result", WireRef::node("test_node"));
497            asm.set_strict(true);
498            asm
499        };
500
501        let mut engines = vec![
502            Engine::Interpreter(crate::JitMode::Auto),
503            Engine::Closures(Provenance::Auto),
504        ];
505        if cfg!(feature = "jit") {
506            engines.push(Engine::Native(Provenance::Auto));
507            engines.push(Engine::PureNative(Provenance::Auto));
508        }
509        for engine in engines {
510            let outcome = build().compile_with(engine);
511            let err = match outcome {
512                Err(e) => e.to_string(),
513                Ok(_) => panic!("{engine}: strict must refuse a cycle-fed config wire"),
514            };
515            assert!(
516                err.contains("config"),
517                "{engine}: the refusal should name the config wire: {err}"
518            );
519        }
520        // Without strict the same graph builds, warning rather than
521        // refusing: strict is what turns the advice into an error. On
522        // the interpreter, because this node is a test fixture with no
523        // compiled form and the compiled tiers refuse it for that
524        // unrelated reason — which is also why the refusals above are
525        // checked for the word `config` rather than merely being
526        // errors.
527        let mut asm = build();
528        asm.set_strict(false);
529        assert!(
530            asm.compile_with(Engine::Interpreter(crate::JitMode::Auto))
531                .is_ok()
532        );
533    }
534
535    #[test]
536    fn wire_cost_warning_when_config_is_coordinate_direct() {
537        // DAG: cycle → config_test.config_param  (BAD: coordinate direct to config)
538        //      cycle → config_test.data_input
539        use crate::compile::assembly::{PolydatAssembler, WireRef};
540        use crate::dsl::events::CompileEventLog;
541
542        let mut asm = PolydatAssembler::new(vec!["cycle".into()]);
543        asm.add_node(
544            "test_node",
545            Box::new(ConfigWireTestNode::new()),
546            vec![
547                WireRef::input("cycle"), // config_param ← coordinate!
548                WireRef::input("cycle"), // data_input ← cycle
549            ],
550        );
551        asm.add_output("result", WireRef::node("test_node"));
552
553        let mut log = CompileEventLog::new();
554        let _k = asm.compile_with_log(Some(&mut log)).unwrap();
555
556        let warnings: Vec<_> = log
557            .events()
558            .iter()
559            .filter(|e| {
560                matches!(
561                    e,
562                    crate::dsl::events::CompileEvent::ConfigWireCycleWarning { .. }
563                )
564            })
565            .collect();
566        assert_eq!(warnings.len(), 1, "config wire from coordinate should warn");
567    }
568
569    #[test]
570    fn wire_cost_no_warning_data_wire_from_cycle() {
571        // DAG: constant(10) → config_test.config_param (init-time, ok)
572        //      cycle → config_test.data_input           (cycle-time, ok for Data wire)
573        // Only the data wire is cycle-time → no warning
574        use crate::compile::assembly::{PolydatAssembler, WireRef};
575        use crate::dsl::events::CompileEventLog;
576        use crate::library::identity::ConstU64;
577
578        let mut asm = PolydatAssembler::new(vec!["cycle".into()]);
579        asm.add_node("config_val", Box::new(ConstU64::new(10)), vec![]);
580        asm.add_node(
581            "test_node",
582            Box::new(ConfigWireTestNode::new()),
583            vec![
584                WireRef::node("config_val"), // config_param ← constant
585                WireRef::input("cycle"),     // data_input ← cycle (Data wire, ok)
586            ],
587        );
588        asm.add_output("result", WireRef::node("test_node"));
589
590        let mut log = CompileEventLog::new();
591        let _k = asm.compile_with_log(Some(&mut log)).unwrap();
592
593        let warnings: Vec<_> = log
594            .events()
595            .iter()
596            .filter(|e| {
597                matches!(
598                    e,
599                    crate::dsl::events::CompileEvent::ConfigWireCycleWarning { .. }
600                )
601            })
602            .collect();
603        assert!(warnings.is_empty(), "data wire from cycle should not warn");
604    }
605
606    #[test]
607    fn wire_cost_diamond_config_from_init() {
608        // Diamond DAG using two ConfigWireTestNodes:
609        //   constant(5) → inner.config_param ─┐
610        //   constant(3) → inner.data_input    ─┤→ inner.output → outer.config_param
611        //   cycle → hash → outer.data_input
612        // inner is fully init-time → its output feeds outer's config wire → no warning
613        use crate::compile::assembly::{PolydatAssembler, WireRef};
614        use crate::dsl::events::CompileEventLog;
615        use crate::library::identity::ConstU64;
616        use crate::library::identity::Identity;
617
618        let mut asm = PolydatAssembler::new(vec!["cycle".into()]);
619        asm.add_node("a", Box::new(ConstU64::new(5)), vec![]);
620        asm.add_node("b", Box::new(ConstU64::new(3)), vec![]);
621        asm.add_node(
622            "inner",
623            Box::new(ConfigWireTestNode::new()),
624            vec![WireRef::node("a"), WireRef::node("b")],
625        );
626        asm.add_node(
627            "hashed",
628            Box::new(Identity::new(crate::ast::PortType::U64)),
629            vec![WireRef::input("cycle")],
630        );
631        asm.add_node(
632            "outer",
633            Box::new(ConfigWireTestNode::new()),
634            vec![
635                WireRef::node("inner"),  // config_param ← init-time (5+3)
636                WireRef::node("hashed"), // data_input ← cycle-time
637            ],
638        );
639        asm.add_output("result", WireRef::node("outer"));
640
641        let mut log = CompileEventLog::new();
642        let _k = asm.compile_with_log(Some(&mut log)).unwrap();
643
644        // inner's config wire from constant is fine. outer's config wire
645        // from init-time inner output is also fine.
646        let warnings: Vec<_> = log
647            .events()
648            .iter()
649            .filter(|e| {
650                matches!(
651                    e,
652                    crate::dsl::events::CompileEvent::ConfigWireCycleWarning { .. }
653                )
654            })
655            .collect();
656        assert!(
657            warnings.is_empty(),
658            "init-time derived config should not warn: {warnings:?}"
659        );
660    }
661
662    #[test]
663    fn wire_cost_diamond_config_from_mixed() {
664        // Mixed init/cycle feeding config:
665        //   constant(5) → mixer.config_param ─┐
666        //   cycle → mixer.data_input          ─┤→ mixer.output → outer.config_param
667        //   cycle → outer.data_input
668        // mixer depends on cycle → its output is cycle-time → outer's config wire warns
669        use crate::compile::assembly::{PolydatAssembler, WireRef};
670        use crate::dsl::events::CompileEventLog;
671        use crate::library::identity::ConstU64;
672
673        let mut asm = PolydatAssembler::new(vec!["cycle".into()]);
674        asm.add_node("five", Box::new(ConstU64::new(5)), vec![]);
675        asm.add_node(
676            "mixer",
677            Box::new(ConfigWireTestNode::new()),
678            vec![
679                WireRef::node("five"),   // config_param ← init
680                WireRef::input("cycle"), // data_input ← cycle
681            ],
682        );
683        asm.add_node(
684            "outer",
685            Box::new(ConfigWireTestNode::new()),
686            vec![
687                WireRef::node("mixer"),  // config_param ← cycle-tainted!
688                WireRef::input("cycle"), // data_input
689            ],
690        );
691        asm.add_output("result", WireRef::node("outer"));
692
693        let mut log = CompileEventLog::new();
694        let _k = asm.compile_with_log(Some(&mut log)).unwrap();
695
696        let warnings: Vec<_> = log
697            .events()
698            .iter()
699            .filter(|e| {
700                matches!(
701                    e,
702                    crate::dsl::events::CompileEvent::ConfigWireCycleWarning { .. }
703                )
704            })
705            .collect();
706        // outer's config from cycle-tainted mixer should warn.
707        // mixer's config from constant should NOT warn.
708        assert_eq!(
709            warnings.len(),
710            1,
711            "exactly one warning for outer's config: {warnings:?}"
712        );
713    }
714}