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}