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