polydat_core/dsl/compile.rs
1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! DSL-to-assembly bridge: compile a parsed Polydat AST into a runtime kernel.
5//!
6//! Walks the AST, resolves function names to node constructors, wires
7//! the `PolydatAssembler`, and produces the interpreter's
8//! `PolydatKernel` or any engine's boxed `Kernel`.
9
10use std::path::{Path, PathBuf};
11
12use crate::compile::assembly::{PolydatAssembler, WireRef};
13use crate::dsl::ast::*;
14use crate::dsl::lexer;
15use crate::dsl::parser;
16use crate::kernel::PolydatKernel;
17
18use crate::dsl::error::DiagnosticReport;
19use crate::dsl::validate::{collect_references, validate_ast};
20
21use std::collections::HashSet;
22
23use super::modules::ResolvedModule;
24
25/// Typed error ontology for the embedded-evaluation surface.
26///
27/// Per `expression_engine.md` §6 Error Ontology (under
28/// `crates/polydat/docs/design/`), every failure mode the embedding
29/// surface can produce maps to one of these variants. Hosts
30/// pattern-match on the variant to drive UX, recovery, or logging
31/// without parsing message strings.
32///
33/// The embedding surfaces (`eval_const_expr*`, the typed surfaces,
34/// `interpolate_via_kernel`) return this type; the `compile_polydat*`
35/// entry points return `String` errors, and `From<EmbeddingError>
36/// for String` bridges the two.
37#[derive(Debug, Clone)]
38pub enum EmbeddingError {
39 /// Text could not be parsed as polydat expression source.
40 /// The lexer or parser rejected the input before any
41 /// semantic analysis.
42 Parse {
43 /// The source text.
44 source: String,
45 /// The lexer's or parser's message.
46 message: String,
47 /// The byte offset of the error, when known.
48 position: Option<usize>,
49 },
50
51 /// A `{name}` placeholder in the text had no matching
52 /// binding in the kernel chain. Produced by
53 /// `interpolate_via_kernel` only.
54 UnresolvedPlaceholder {
55 /// The placeholder's name.
56 name: String,
57 /// The source text.
58 source: String,
59 },
60
61 /// The expression's upstream cone reaches a dynamic input,
62 /// but the requested evaluation surface requires
63 /// effectively-const lifecycle. Produced by
64 /// `eval_const_expr` (directly or via the two-step
65 /// composition).
66 LifecycleMismatch {
67 /// The source text.
68 source: String,
69 /// The dynamic inputs the cone reaches.
70 dynamic_inputs: Vec<String>,
71 },
72
73 /// A node mentioned in the expression is not registered
74 /// in the runtime. Includes a suggested alternative when
75 /// the name is close to a known node.
76 UnknownNode {
77 /// The unknown node's name.
78 name: String,
79 /// The source text.
80 source: String,
81 /// A registered name close to it, if any.
82 suggestion: Option<String>,
83 },
84
85 /// The expression's wire chain has a type mismatch that
86 /// auto-adapters cannot heal. Produced by the assembly
87 /// pass during compilation.
88 TypeMismatch {
89 /// The producing node.
90 from_node: String,
91 /// Its output type.
92 from_type: crate::ast::PortType,
93 /// The consuming node.
94 to_node: String,
95 /// The type its port requires.
96 to_type: crate::ast::PortType,
97 /// The source text.
98 source: String,
99 },
100
101 /// A node's `eval` panicked during scope-init evaluation.
102 /// The kernel's `catch_unwind` boundary captured the
103 /// panic; the message is the panic payload's
104 /// human-readable form.
105 NodeEvalPanic {
106 /// The node that panicked.
107 node_name: String,
108 /// The panic's message.
109 message: String,
110 /// The source text.
111 source: String,
112 },
113
114 /// A `Value::None` propagated to the expression's output
115 /// where a concrete value was required. Produced by a
116 /// `HostType::from_value` conversion that meets `Value::None`,
117 /// or by a host's own strict accessor (`as_bool` on
118 /// `Value::None`, etc.). See SRD-74.
119 NonePropagated {
120 /// The accessor the host called.
121 accessor: &'static str,
122 /// The source text.
123 source: String,
124 },
125}
126
127impl std::fmt::Display for EmbeddingError {
128 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129 match self {
130 EmbeddingError::Parse {
131 source,
132 message,
133 position,
134 } => match position {
135 Some(p) => write!(f, "parse error at position {p} in '{source}': {message}"),
136 None => write!(f, "parse error in '{source}': {message}"),
137 },
138 EmbeddingError::UnresolvedPlaceholder { name, source } => write!(
139 f,
140 "unresolved placeholder '{{{name}}}' in '{source}' — \
141 no matching binding in the kernel chain"
142 ),
143 EmbeddingError::LifecycleMismatch {
144 source,
145 dynamic_inputs,
146 } => write!(
147 f,
148 "not a const expression: '{source}' depends on runtime inputs ({})",
149 dynamic_inputs.join(", ")
150 ),
151 EmbeddingError::UnknownNode {
152 name,
153 source,
154 suggestion,
155 } => match suggestion {
156 Some(sug) => write!(
157 f,
158 "unknown function: '{name}' in '{source}'\n\n Did you mean '{sug}'?"
159 ),
160 None => write!(
161 f,
162 "unknown function: '{name}' in '{source}'\n\n \
163 This function is not registered in the Polydat function library."
164 ),
165 },
166 EmbeddingError::TypeMismatch {
167 from_node,
168 from_type,
169 to_node,
170 to_type,
171 source,
172 } => {
173 write!(
174 f,
175 "type mismatch in '{source}': cannot connect \
176 {from_type:?} output of '{from_node}' to {to_type:?} \
177 input of '{to_node}'"
178 )
179 }
180 EmbeddingError::NodeEvalPanic {
181 node_name,
182 message,
183 source,
184 } => write!(
185 f,
186 "node-eval panic in '{source}' (node '{node_name}'): {message}"
187 ),
188 EmbeddingError::NonePropagated { accessor, source } => write!(
189 f,
190 "Value::None propagated to '{source}'; \
191 host called strict accessor `{accessor}`. \
192 Use a non-strict accessor (`try_as_*`) or surface the None to the user."
193 ),
194 }
195 }
196}
197
198impl std::error::Error for EmbeddingError {}
199
200/// Renders the error as its message for callers on the
201/// `Result<_, String>` entry points.
202impl From<EmbeddingError> for String {
203 fn from(e: EmbeddingError) -> String {
204 e.to_string()
205 }
206}
207
208/// Embedded standard library modules, compiled into the binary.
209///
210/// Each entry is (filename, source). Multiple modules per file —
211/// each top-level binding is a separate module, resolved by name.
212/// Searched as the final fallback after the source directory and
213/// `CompileOptions::lib_paths` (the binary's `--lib`).
214pub(super) static STDLIB_MODULES: &[(&str, &str)] = &[
215 (
216 "hashing.polydat",
217 include_str!("../../stdlib/hashing.polydat"),
218 ),
219 (
220 "strings.polydat",
221 include_str!("../../stdlib/strings.polydat"),
222 ),
223 (
224 "identity.polydat",
225 include_str!("../../stdlib/identity.polydat"),
226 ),
227 (
228 "distributions.polydat",
229 include_str!("../../stdlib/distributions.polydat"),
230 ),
231 (
232 "latency.polydat",
233 include_str!("../../stdlib/latency.polydat"),
234 ),
235 (
236 "timeseries.polydat",
237 include_str!("../../stdlib/timeseries.polydat"),
238 ),
239 ("waves.polydat", include_str!("../../stdlib/waves.polydat")),
240 (
241 "fourier.polydat",
242 include_str!("../../stdlib/fourier.polydat"),
243 ),
244 (
245 "modeling.polydat",
246 include_str!("../../stdlib/modeling.polydat"),
247 ),
248];
249
250/// Return the embedded standard library module sources.
251pub fn stdlib_sources() -> &'static [(&'static str, &'static str)] {
252 STDLIB_MODULES
253}
254
255/// Compile a `.polydat` source string under the default options, on the
256/// engine those options name — which is [`Engine::default`](crate::Engine::default),
257/// the most native form the build has.
258///
259/// This is the way in. The kernel comes back as `dyn Kernel`, which is
260/// the surface every use of a kernel goes through, and the engine is a
261/// value the options carry rather than a branch in the code: a caller
262/// that wants a different one sets `options.engine` and calls
263/// [`compile_polydat_kernel_with_options`], rather than calling a
264/// differently-named function.
265///
266/// Naming an engine is for the callers whose *subject* is the engine:
267///
268/// - [`compile_polydat_interpreter`] for the interpreter's concrete
269/// kernel, when a test or diagnostic needs its own internals, or when
270/// it is being used as the semantic oracle a differential test
271/// compares a compiled engine against. That is a real need, and it
272/// says so by name — it used to be what this entry point quietly
273/// returned, which meant a host got the slowest engine by asking for
274/// none.
275/// - [`compile_polydat_with`] to walk the tiers with one source.
276pub fn compile_polydat(source: &str) -> Result<Box<dyn crate::Kernel>, crate::KernelError> {
277 compile_polydat_kernel(source)
278}
279
280/// [`compile_polydat`] returning the interpreter's concrete kernel.
281///
282/// This is the carve-out from the rule that kernels are used through
283/// the [`Kernel`](crate::Kernel) trait, and it is narrow on purpose:
284/// the concrete type carries the interpreter's implementation detail
285/// (`program()`, `lookup()`, `state()`, `build_subscope()`, the
286/// constant and wire readers), which a test asserting on that detail
287/// and a diagnostic reporting it both need and nothing else should
288/// reach for. Driving a kernel — coordinates, externs, cursors,
289/// evaluation, reads, traversals — is the trait's, on every engine
290/// including this one.
291pub fn compile_polydat_interpreter(source: &str) -> Result<PolydatKernel, crate::KernelError> {
292 compile_polydat_interpreter_with_options(source, &CompileOptions::default(), None)
293}
294
295/// Parse Polydat source into the program tree, the form every
296/// transform reads and rewrites.
297///
298/// This is the first of the three steps a host takes when it shapes a
299/// program before running it: parse, transform, compile. A host with
300/// nothing to change calls a `compile_polydat*` entry point instead
301/// and never sees a tree; a host that injects a value
302/// ([`transform::assign_values`](super::transform::assign_values)),
303/// adds tiles it built from what it holds
304/// ([`transform::add_tiles`](super::transform::add_tiles)), or rewrites
305/// a definition in place applies as many of those as it means to the
306/// one tree and compiles it once with
307/// [`compile_ast_with_engine`] or
308/// [`compile_ast_interpreter_with_options`].
309///
310/// [`parse_polydat_with_tile_defaults`] is the same parse for a host
311/// that sets the hole delimiters a tile body is read with.
312pub fn parse_polydat(source: &str) -> Result<PolydatFile, crate::KernelError> {
313 let tokens = super::lexer::lex(source).map_err(crate::KernelError::Source)?;
314 super::parser::parse(tokens).map_err(crate::KernelError::Source)
315}
316
317/// [`parse_polydat`] with the hole delimiters and directive sigil a
318/// tile body that declares none of its own is read with.
319///
320/// These belong to the parse and not to a later rewrite: a tile body
321/// is raw text until it is read, so what counts as a hole has to be
322/// settled before there are pieces for a transform to address.
323pub fn parse_polydat_with_tile_defaults(
324 source: &str,
325 defaults: &super::ast::TileOptions,
326) -> Result<PolydatFile, crate::KernelError> {
327 let tokens = super::lexer::lex(source).map_err(crate::KernelError::Source)?;
328 super::parser::parse_with_tile_defaults(tokens, defaults).map_err(crate::KernelError::Source)
329}
330
331/// Compile Polydat source to an assembler (not yet compiled to a kernel).
332///
333/// Returns the `PolydatAssembler` with every node and wire in place,
334/// the graph a host may extend by hand before building it on any
335/// engine: [`PolydatAssembler::compile_kernel`] for the default engine,
336/// [`PolydatAssembler::compile_with`] for a named one,
337/// [`PolydatAssembler::compile`] for the interpreter's concrete kernel.
338/// An assembler carries no traversal, so a program with a `for`
339/// statement is refused here; the kernel entry points compile it.
340pub fn compile_polydat_to_assembler(source: &str) -> Result<PolydatAssembler, crate::KernelError> {
341 compile_polydat_to_assembler_with(source, &CompileOptions::default())
342}
343
344/// [`compile_polydat_to_assembler`] with the options the kernel entry
345/// points take: a source directory for relative imports, library
346/// directories, required outputs, strict typing, a diagnostic context,
347/// and a cursor limit. The assembler it returns is the graph
348/// [`compile_polydat_interpreter_with_options`] would compile from the same source
349/// and options, ready for any engine.
350pub fn compile_polydat_to_assembler_with(
351 source: &str,
352 options: &CompileOptions,
353) -> Result<PolydatAssembler, crate::KernelError> {
354 let tokens = super::lexer::lex(source).map_err(crate::KernelError::Source)?;
355 let ast = super::parser::parse(tokens).map_err(crate::KernelError::Source)?;
356 let mut prepared = Prepared::new(source, &ast, options, None);
357 let (compiler, filter) = prepared.parts();
358 compiler
359 .assemble_parent(&ast, filter)
360 .map_err(crate::KernelError::Source)
361}
362
363/// Compile one selected scalar output into the conservative perfect-ordinal
364/// Tier-1 SIMD executor.
365///
366/// This is an explicit execution surface: ordinary [`compile_polydat`] and
367/// `PolydatKernel::pull` remain scalar-cycle APIs. `driving_input` is normally
368/// a cursor projection such as `base__ordinal`; `output` names the only result
369/// drained by the batch executor.
370#[cfg(feature = "jit")]
371pub fn compile_polydat_tier1_simd_ordinal(
372 source: &str,
373 driving_input: &str,
374 output: &str,
375) -> Result<crate::compile::simd_tier1::Tier1SimdExecutor, String> {
376 compile_polydat_to_assembler(source)
377 .map_err(|e| e.to_string())?
378 .try_compile_tier1_simd_ordinal(driving_input, output)
379 .map_err(|error| error.to_string())
380}
381
382/// `const name := expr` declares a side-effect-carrying compile-time
383/// computation: download a dataset, prebuffer a facet, register a
384/// resource, etc. The user's signal that they want it evaluated is
385/// the `const` keyword itself, not a downstream wire reference. Yet
386/// the assembler's DCE pass walks back from the requested-outputs
387/// set and prunes anything not in that ancestry, which silently
388/// removes const bindings whose result nothing reads.
389///
390/// This helper extends a caller-supplied `required_outputs` list
391/// with every `const` binding target in the source. Two effects:
392/// the assembler keeps those nodes during DCE, and constant
393/// folding then evaluates them once at compile time — running the
394/// side effect exactly once, before any dispatch.
395///
396/// Plain bindings (`name := ...`) are *not* added; they only run
397/// when consumed. Modules and other statements are likewise not
398/// auto-promoted.
399fn extend_required_with_const_bindings(
400 required_outputs: &[String],
401 ast: &crate::dsl::ast::PolydatFile,
402) -> Vec<String> {
403 let mut out: Vec<String> = required_outputs.to_vec();
404 for stmt in &ast.statements {
405 if let crate::dsl::ast::Statement::Binding(b) = stmt
406 && b.modifier.is_const()
407 {
408 for name in &b.targets {
409 if !out.iter().any(|n| n == name) {
410 out.push(name.clone());
411 }
412 }
413 }
414 }
415 out
416}
417
418/// RAII guard that sets the data-file base directory (see
419/// [`crate::library::datafile::set_data_base_dir`]) for the duration of
420/// a synchronous compile and restores the previous value on drop, so
421/// nested compiles unwind cleanly.
422struct DataBaseDirGuard(Option<PathBuf>);
423
424impl DataBaseDirGuard {
425 fn set(dir: &Path) -> Self {
426 DataBaseDirGuard(crate::library::datafile::set_data_base_dir(Some(
427 dir.to_path_buf(),
428 )))
429 }
430}
431
432impl Drop for DataBaseDirGuard {
433 fn drop(&mut self) {
434 crate::library::datafile::set_data_base_dir(self.0.take());
435 }
436}
437
438/// The options every entry point compiles under. A host that names
439/// none gets the defaults: no source directory, no library paths,
440/// every binding an output, lax typing, the default context label,
441/// no cursor limit, and the most compiled engine this build has.
442///
443/// `strict` refuses what lax compilation warns about, on every engine:
444/// an implicit type coercion, a config wire fed from a cycle-time
445/// source, a nondeterministic node no `volatile` output acknowledges, a
446/// binding nothing reads, an undeclared coordinate, and a positional
447/// module argument.
448///
449/// `engine` is a **preference**, and the only place a caller expresses
450/// one. Leaving it alone is the normative path: [`Engine::default`](crate::Engine::default) is
451/// the most native form the build offers (native code with the `jit`
452/// feature, the closure tier without), so a host that never mentions
453/// the field still gets compiled code, and gets faster code for free
454/// when a build gains a tier. Naming an engine is for testing,
455/// measurement, and demonstration — a differential test that wants the
456/// interpreter as the reference, a bench that walks the tiers. A
457/// preference the build cannot realize is refused
458/// ([`crate::KernelError::Refused`]) rather than silently replaced, and
459/// every kernel reports what it actually runs through
460/// [`crate::Kernel::engine`].
461#[derive(Debug, Default, Clone)]
462pub struct CompileOptions {
463 /// The directory relative data-file paths resolve against.
464 pub source_dir: Option<PathBuf>,
465 /// Library search paths, tried after the source directory and before the embedded standard library.
466 pub lib_paths: Vec<PathBuf>,
467 /// The outputs to keep; every output when empty.
468 pub required_outputs: Vec<String>,
469 /// Whether to enforce strict validation.
470 pub strict: bool,
471 /// The diagnostic context label, such as a file name.
472 pub context: String,
473 /// A limit on every cursor's extent, if any.
474 pub cursor_limit: Option<u64>,
475 /// The compile ledger to record this program tree in: a host that
476 /// holds one charges the compile to it; `None` mints a fresh one,
477 /// read back through the kernel's `ledger()`.
478 pub ledger: Option<std::sync::Arc<crate::kernel::CompileLedger>>,
479 /// The engine to build on. Defaults to [`Engine::default`](crate::Engine::default), the
480 /// most native form this build has; see the type's documentation
481 /// for when to set it and what happens when it cannot be realized.
482 pub engine: crate::Engine,
483}
484
485/// Compile Polydat source into the interpreter's kernel under
486/// `options`, recording pragma and assembly events in `log` when one is
487/// given: the interpreter-typed entry point every other interpreter form
488/// reduces to. [`compile_polydat_with_engine`] is the same compile on
489/// any engine.
490pub fn compile_polydat_interpreter_with_options(
491 source: &str,
492 options: &CompileOptions,
493 mut log: Option<&mut super::events::CompileEventLog>,
494) -> Result<PolydatKernel, crate::KernelError> {
495 let tokens = lexer::lex(source).map_err(crate::KernelError::Source)?;
496 let ast = parser::parse(tokens).map_err(crate::KernelError::Source)?;
497 if let Some(log) = log.as_deref_mut() {
498 log.push(super::events::CompileEvent::Parsed {
499 statements: ast.statements.len(),
500 });
501 }
502 compile_ast_interpreter_with_options(&ast, source, options, log)
503}
504
505/// [`compile_polydat_interpreter_with_options`] for an already parsed, possibly
506/// transformed, program. `source` is the text the program was parsed
507/// from and is used for diagnostics only.
508pub fn compile_ast_interpreter_with_options(
509 ast: &PolydatFile,
510 source: &str,
511 options: &CompileOptions,
512 mut log: Option<&mut super::events::CompileEventLog>,
513) -> Result<PolydatKernel, crate::KernelError> {
514 let mut prepared = Prepared::new(source, ast, options, log.as_deref_mut());
515 let (compiler, filter) = prepared.parts();
516 // This path returns the interpreter's concrete kernel, so the
517 // options' engine preference can only say how much of the graph is
518 // fused into native cones. A preference naming the interpreter is
519 // honoured with its mode; any other preference is a caller asking a
520 // concrete-typed entry point for an engine it cannot return, and it
521 // gets the interpreter under the default cone mode. A caller that
522 // means the preference calls `compile_polydat_kernel_with_options`,
523 // which can return whichever engine the options name.
524 let cones = match options.engine {
525 crate::Engine::Interpreter(mode) => mode,
526 _ => crate::JitMode::Auto,
527 };
528 compiler.compile_interpreter(ast, filter, log, cones)
529}
530
531/// [`compile_polydat_interpreter_with_options`] under the default options, with the
532/// compile event log: the same kernel [`compile_polydat`] builds, with
533/// every pragma, assembly, fold, and tile event recorded.
534pub fn compile_polydat_interpreter_with_log(
535 source: &str,
536 log: &mut super::events::CompileEventLog,
537) -> Result<PolydatKernel, crate::KernelError> {
538 compile_polydat_interpreter_with_options(source, &CompileOptions::default(), Some(log))
539}
540
541/// Record one event per pragma in `set`: `PragmaAcknowledged`
542/// (advisory) for `strict_types`/`strict_values`/`strict`,
543/// `UnknownPragma` (warning) for the rest. Forward-compatible: an
544/// unknown pragma never blocks compilation.
545///
546/// Called from `Prepared::new` for every entry point given a log;
547/// the set comes from `pragmas::collect_from_ast`.
548pub(crate) fn record_pragma_events(
549 set: &super::pragmas::PragmaSet,
550 log: &mut super::events::CompileEventLog,
551) {
552 use super::events::CompileEvent;
553 for entry in &set.entries {
554 let known = matches!(
555 entry.name.as_str(),
556 "strict_types" | "strict_values" | "strict"
557 );
558 if known {
559 log.push(CompileEvent::PragmaAcknowledged {
560 name: entry.name.clone(),
561 line: entry.line,
562 });
563 } else {
564 log.push(CompileEvent::UnknownPragma {
565 name: entry.name.clone(),
566 line: entry.line,
567 });
568 }
569 }
570}
571
572/// Compile with full diagnostics: errors, warnings, suggestions, on the
573/// default engine.
574///
575/// Returns `(Ok(kernel), report)` on success with possible warnings,
576/// or `(Err(()), report)` on failure with errors. The report always
577/// contains all diagnostics. The program the report describes is the
578/// program the kernel runs: the same compile every entry point makes.
579pub fn compile_polydat_checked(
580 source: &str,
581) -> (Result<Box<dyn crate::Kernel>, ()>, DiagnosticReport) {
582 let mut report = DiagnosticReport::new(source);
583
584 let tokens = match lexer::lex(source) {
585 Ok(t) => t,
586 Err(e) => {
587 report.error(crate::dsl::lexer::Span { line: 1, col: 1 }, e);
588 return (Err(()), report);
589 }
590 };
591
592 let ast = match parser::parse(tokens) {
593 Ok(a) => a,
594 Err(e) => {
595 report.error(crate::dsl::lexer::Span { line: 1, col: 1 }, e);
596 return (Err(()), report);
597 }
598 };
599
600 // Validate the AST before compiling
601 validate_ast(&ast, &mut report);
602
603 if report.has_errors() {
604 return (Err(()), report);
605 }
606
607 match compile_ast_with_engine(
608 &ast,
609 source,
610 &CompileOptions::default(),
611 None,
612 crate::Engine::default(),
613 ) {
614 Ok(kernel) => (Ok(kernel), report),
615 Err(e) => {
616 report.error(crate::dsl::lexer::Span { line: 1, col: 1 }, e.to_string());
617 (Err(()), report)
618 }
619 }
620}
621
622/// Cache of constant-expression results keyed by source text. A const
623/// expression compiles with no inputs, so its value is a pure function
624/// of its text; caching is exact. Bounded so a pathological caller
625/// cannot grow it without limit. This is what keeps repeated evaluation
626/// of the same range, list, or predicate text compile-free (SRD 113
627/// §5.2).
628static CONST_EXPR_CACHE: std::sync::OnceLock<
629 std::sync::Mutex<std::collections::HashMap<String, crate::ast::Value>>,
630> = std::sync::OnceLock::new();
631const CONST_EXPR_CACHE_CAP: usize = 8192;
632
633/// Evaluate a constant expression by compiling it as a one-binding
634/// program: what a comprehension source such as `partitions("*\/4", 1000)`
635/// goes through. Cached by source text, so the same text compiles once
636/// per process (SRD 113 §5.2). An expression that reaches a dynamic
637/// input is a lifecycle error. The compile, when there is one, is
638/// recorded in a ledger of its own; [`eval_const_expr_for`] charges
639/// it to a tree's.
640///
641/// # Examples
642///
643/// ```
644/// use polydat::dsl::compile::eval_const_expr;
645/// let v = eval_const_expr("4 * 4").unwrap();
646/// assert_eq!(v.as_u64(), 16); // both int literals → u64_mul
647/// let v = eval_const_expr("4.0 * 4.0").unwrap();
648/// assert_eq!(v.as_f64(), 16.0); // both float literals → f64_mul
649/// ```
650pub fn eval_const_expr(source: &str) -> Result<crate::ast::Value, EmbeddingError> {
651 eval_const_expr_for(source, &crate::kernel::CompileLedger::new())
652}
653
654/// [`eval_const_expr`] with its compile, when the text is not cached,
655/// recorded in `ledger`: what a traversal source or predicate that has
656/// to compile charges to the tree that opened it.
657pub fn eval_const_expr_for(
658 source: &str,
659 ledger: &std::sync::Arc<crate::kernel::CompileLedger>,
660) -> Result<crate::ast::Value, EmbeddingError> {
661 let cache =
662 CONST_EXPR_CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
663 if let Ok(map) = cache.lock()
664 && let Some(v) = map.get(source)
665 {
666 return Ok(v.clone());
667 }
668 let result = eval_const_expr_uncached(source, ledger);
669 if let Ok(v) = &result
670 && let Ok(mut map) = cache.lock()
671 {
672 if map.len() >= CONST_EXPR_CACHE_CAP {
673 map.clear();
674 }
675 map.insert(source.to_string(), v.clone());
676 }
677 result
678}
679
680fn eval_const_expr_uncached(
681 source: &str,
682 ledger: &std::sync::Arc<crate::kernel::CompileLedger>,
683) -> Result<crate::ast::Value, EmbeddingError> {
684 let wrapped = format!("\nout := {source}");
685 let source_owned = source.to_string();
686 let options = CompileOptions {
687 ledger: Some(ledger.clone()),
688 ..CompileOptions::default()
689 };
690 // Constant-folding inside `compile_polydat` invokes node `eval`
691 // for inputs-free DAGs, so any node that panics on bad data
692 // (e.g. `handle_of(&Value::None)` after a failed
693 // `dataset_open`) would unwind out past this function and
694 // crash any caller that doesn't itself catch panics. The
695 // kernel's `engines::eval_node` enriches node-eval panics
696 // with their provenance string; that string is what we
697 // extract.
698 let source_for_panic = source_owned.clone();
699 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(
700 move || -> Result<crate::ast::Value, EmbeddingError> {
701 let kernel = compile_polydat_interpreter_with_options(&wrapped, &options, None)
702 .map_err(|e| classify_compile_error(&source_owned, e))?;
703 kernel.get_constant("out").cloned().ok_or_else(|| {
704 // The expression compiled but did not fold, which
705 // means it reads something that is not known until
706 // the workload runs. The kernel's inputs are those
707 // things, and naming them is the whole point of the
708 // variant.
709 EmbeddingError::LifecycleMismatch {
710 source: source_owned.clone(),
711 dynamic_inputs: kernel.input_names(),
712 }
713 })
714 },
715 ));
716 match result {
717 Ok(r) => r,
718 Err(payload) => Err(EmbeddingError::NodeEvalPanic {
719 node_name: "(unknown)".to_string(),
720 message: panic_payload_message(&payload),
721 source: source_for_panic,
722 }),
723 }
724}
725
726/// Classify a compile failure into a typed [`EmbeddingError`].
727///
728/// The assembler's own errors are structured, so the fields are
729/// carried across rather than reconstructed: a wiring type mismatch
730/// keeps the two node names and the two types the assembler already
731/// knows. The DSL front end reports in strings, so the one shape a
732/// host acts on — an unregistered function — is read out of the
733/// message, and its suggestion comes from the registry rather than
734/// being dropped. Anything else keeps the compiler's own message.
735fn classify_compile_error(source: &str, err: crate::KernelError) -> EmbeddingError {
736 use crate::KernelError;
737 use crate::compile::assembly::AssemblyError;
738 if let KernelError::Assembly(AssemblyError::TypeMismatch {
739 from_node,
740 from_type,
741 to_node,
742 to_type,
743 ..
744 }) = err
745 {
746 return EmbeddingError::TypeMismatch {
747 from_node,
748 from_type,
749 to_node,
750 to_type,
751 source: source.to_string(),
752 };
753 }
754 let msg = err.to_string();
755 // The factory and the diagnostic pass both write "unknown
756 // function: '<name>'", the factory with the compiler's context
757 // ahead of it, so the marker is searched for rather than
758 // stripped from the front.
759 const UNKNOWN: &str = "unknown function: '";
760 if let Some(at) = msg.find(UNKNOWN)
761 && let Some(end) = msg[at + UNKNOWN.len()..].find('\'')
762 {
763 let name = msg[at + UNKNOWN.len()..][..end].to_string();
764 let suggestion = crate::dsl::registry::suggest_function(&name).map(str::to_string);
765 return EmbeddingError::UnknownNode {
766 name,
767 source: source.to_string(),
768 suggestion,
769 };
770 }
771 EmbeddingError::Parse {
772 source: source.to_string(),
773 message: msg,
774 position: None,
775 }
776}
777
778// ───── Typed embedding surface (γ-4) ─────
779
780/// Host-facing type that polydat can return from the typed
781/// embedding surfaces. The trait declares the polydat
782/// `PortType` the Rust type corresponds to and the conversion
783/// from the returned [`crate::ast::Value`] back to the host
784/// type.
785///
786/// Hosts that want compile-time type alignment use the typed
787/// surfaces ([`eval_const_expr_typed`] /
788/// [`eval_kernel_bound_typed`]) and let the type parameter
789/// drive the contract. The fall-back is the untyped surface
790/// (`eval_const_expr`) which returns a raw [`crate::ast::Value`]
791/// for hosts to coerce themselves.
792///
793/// See expression_engine.md §5.3.
794pub trait HostType: Sized {
795 /// The `PortType` that polydat compares the expression's
796 /// output type against. Used for compile-time / construction-
797 /// time type-mismatch detection.
798 fn target_port_type() -> crate::ast::PortType;
799
800 /// Convert a polydat [`crate::ast::Value`] into the host Rust
801 /// type. Returns a typed [`EmbeddingError::TypeMismatch`] when
802 /// the value cannot be represented as the host type; the impls
803 /// accept the lossless widenings (`U64` → `bool`/`f64`, scalars
804 /// → `String`).
805 fn from_value(v: crate::ast::Value) -> Result<Self, EmbeddingError>;
806}
807
808impl HostType for bool {
809 fn target_port_type() -> crate::ast::PortType {
810 crate::ast::PortType::Bool
811 }
812 fn from_value(v: crate::ast::Value) -> Result<Self, EmbeddingError> {
813 match v {
814 crate::ast::Value::Bool(b) => Ok(b),
815 crate::ast::Value::U64(n) => Ok(n != 0),
816 crate::ast::Value::None => Err(EmbeddingError::NonePropagated {
817 accessor: "HostType::<bool>::from_value",
818 source: "<typed-embedding result>".to_string(),
819 }),
820 other => Err(EmbeddingError::TypeMismatch {
821 from_node: "<expression-output>".to_string(),
822 from_type: other.port_type(),
823 to_node: "<host-target>".to_string(),
824 to_type: crate::ast::PortType::Bool,
825 source: "<typed-embedding result>".to_string(),
826 }),
827 }
828 }
829}
830
831impl HostType for u64 {
832 fn target_port_type() -> crate::ast::PortType {
833 crate::ast::PortType::U64
834 }
835 fn from_value(v: crate::ast::Value) -> Result<Self, EmbeddingError> {
836 match v {
837 crate::ast::Value::U64(n) => Ok(n),
838 crate::ast::Value::None => Err(EmbeddingError::NonePropagated {
839 accessor: "HostType::<u64>::from_value",
840 source: "<typed-embedding result>".to_string(),
841 }),
842 other => Err(EmbeddingError::TypeMismatch {
843 from_node: "<expression-output>".to_string(),
844 from_type: other.port_type(),
845 to_node: "<host-target>".to_string(),
846 to_type: crate::ast::PortType::U64,
847 source: "<typed-embedding result>".to_string(),
848 }),
849 }
850 }
851}
852
853impl HostType for f64 {
854 fn target_port_type() -> crate::ast::PortType {
855 crate::ast::PortType::F64
856 }
857 fn from_value(v: crate::ast::Value) -> Result<Self, EmbeddingError> {
858 match v {
859 crate::ast::Value::F64(n) => Ok(n),
860 crate::ast::Value::U64(n) => Ok(n as f64),
861 crate::ast::Value::None => Err(EmbeddingError::NonePropagated {
862 accessor: "HostType::<f64>::from_value",
863 source: "<typed-embedding result>".to_string(),
864 }),
865 other => Err(EmbeddingError::TypeMismatch {
866 from_node: "<expression-output>".to_string(),
867 from_type: other.port_type(),
868 to_node: "<host-target>".to_string(),
869 to_type: crate::ast::PortType::F64,
870 source: "<typed-embedding result>".to_string(),
871 }),
872 }
873 }
874}
875
876impl HostType for String {
877 fn target_port_type() -> crate::ast::PortType {
878 crate::ast::PortType::Str
879 }
880 fn from_value(v: crate::ast::Value) -> Result<Self, EmbeddingError> {
881 match v {
882 crate::ast::Value::Str(s) => Ok(s.to_string()),
883 crate::ast::Value::U64(n) => Ok(n.to_string()),
884 crate::ast::Value::F64(n) => Ok(n.to_string()),
885 crate::ast::Value::Bool(b) => Ok(b.to_string()),
886 crate::ast::Value::None => Err(EmbeddingError::NonePropagated {
887 accessor: "HostType::<String>::from_value",
888 source: "<typed-embedding result>".to_string(),
889 }),
890 other => Err(EmbeddingError::TypeMismatch {
891 from_node: "<expression-output>".to_string(),
892 from_type: other.port_type(),
893 to_node: "<host-target>".to_string(),
894 to_type: crate::ast::PortType::Str,
895 source: "<typed-embedding result>".to_string(),
896 }),
897 }
898 }
899}
900
901/// Const-fold the expression and convert the typed `Value`
902/// into the host's requested Rust type. Compile-time type
903/// alignment per expression_engine.md §5.3 + E5 + E7.
904///
905/// `T` must implement [`HostType`]. The expression's output
906/// `PortType` is compared against `T::target_port_type()`;
907/// matching types pass through directly to
908/// [`HostType::from_value`]. Mismatched types invoke the γ-6
909/// **return-path boundary adapter**: the catalog
910/// (`crate::compile::assembly::auto_adapter`) is consulted
911/// to heal the mismatch when possible. Only when no
912/// catalog entry exists for the (output_type, target_type)
913/// pair does this surface return
914/// `EmbeddingError::TypeMismatch`.
915///
916/// Pairs with [`eval_kernel_bound_typed`] for the
917/// kernel-bound (post-interpolation) case.
918pub fn eval_const_expr_typed<T: HostType>(source: &str) -> Result<T, EmbeddingError> {
919 let value = eval_const_expr(source)?;
920 let value_type = value.port_type();
921 let target_type = T::target_port_type();
922 if value_type == target_type {
923 return T::from_value(value);
924 }
925 // γ-6 return-path adapter: try the catalog before
926 // surfacing TypeMismatch.
927 if let Some(adapter) = crate::compile::assembly::auto_adapter(value_type, target_type) {
928 let inputs = vec![value];
929 let mut outputs = vec![crate::ast::Value::None];
930 adapter.eval(&inputs, &mut outputs);
931 return T::from_value(outputs.remove(0));
932 }
933 // No catalog entry — surface as typed error.
934 Err(EmbeddingError::TypeMismatch {
935 from_node: "<expression-output>".to_string(),
936 from_type: value_type,
937 to_node: "<host-target>".to_string(),
938 to_type: target_type,
939 source: source.to_string(),
940 })
941}
942
943/// Two-step: interpolate placeholders against `scope`, then
944/// const-fold + type-convert. The canonical pattern for
945/// kernel-bound typed embedding per expression_engine.md
946/// §3.2 + §5.3.
947///
948/// `scope` is anything names resolve in: a kernel of any engine, a
949/// [`Layered`](crate::kernel::interp::Layered) view of a tuple over
950/// one, or the empty scope. It used to be `&PolydatKernel`, so a host
951/// holding a compiled kernel had to compile its program again on the
952/// interpreter to read a binding through here.
953pub fn eval_kernel_bound_typed<T: HostType>(
954 text: &str,
955 scope: &dyn crate::kernel::interp::Lookup,
956) -> Result<T, EmbeddingError> {
957 let interpolated = crate::kernel::interp::interpolate_via_kernel(text, scope)?;
958 eval_const_expr_typed::<T>(&interpolated)
959}
960
961/// Strict-mode variant of [`eval_const_expr_typed`].
962///
963/// Rejects type mismatches whose only catalog adapter is
964/// **lossy** (e.g., `F64 → U64` truncation, `U64 → Bool`
965/// boolean coercion). Hosts that want guaranteed-lossless
966/// value passage opt into this surface per
967/// `expression_engine.md` §5.1.3 (opt-in strict contract).
968///
969/// The "lossy" classification is per
970/// [`is_lossless_adapter`] below; the function returns
971/// `false` for catalog entries that change the value's
972/// information content (truncation, narrowing, boolean
973/// projection).
974pub fn eval_const_expr_typed_strict<T: HostType>(source: &str) -> Result<T, EmbeddingError> {
975 let value = eval_const_expr(source)?;
976 let value_type = value.port_type();
977 let target_type = T::target_port_type();
978 if value_type == target_type {
979 return T::from_value(value);
980 }
981 if !is_lossless_adapter(value_type, target_type) {
982 return Err(EmbeddingError::TypeMismatch {
983 from_node: "<expression-output>".to_string(),
984 from_type: value_type,
985 to_node: "<host-target>".to_string(),
986 to_type: target_type,
987 source: source.to_string(),
988 });
989 }
990 if let Some(adapter) = crate::compile::assembly::auto_adapter(value_type, target_type) {
991 let inputs = vec![value];
992 let mut outputs = vec![crate::ast::Value::None];
993 adapter.eval(&inputs, &mut outputs);
994 return T::from_value(outputs.remove(0));
995 }
996 Err(EmbeddingError::TypeMismatch {
997 from_node: "<expression-output>".to_string(),
998 from_type: value_type,
999 to_node: "<host-target>".to_string(),
1000 to_type: target_type,
1001 source: source.to_string(),
1002 })
1003}
1004
1005/// Strict-mode kernel-bound variant. Composes
1006/// [`crate::kernel::interp::interpolate_via_kernel`] with
1007/// [`eval_const_expr_typed_strict`].
1008pub fn eval_kernel_bound_typed_strict<T: HostType>(
1009 text: &str,
1010 scope: &dyn crate::kernel::interp::Lookup,
1011) -> Result<T, EmbeddingError> {
1012 let interpolated = crate::kernel::interp::interpolate_via_kernel(text, scope)?;
1013 eval_const_expr_typed_strict::<T>(&interpolated)
1014}
1015
1016/// Whether a conversion from one port type to another keeps the
1017/// value: whether every number `from` can carry is a number `to`
1018/// can carry.
1019///
1020/// The answer is read off the two types' own numeric domains
1021/// ([`crate::ast::PortType::numeric_domain`]) rather than looked up in a table of
1022/// pairs. A table has to be kept in step with the adapter catalog by
1023/// hand, and was not: it named eleven pairs where the catalog has
1024/// well over a hundred, so `U8 → U64` was refused as lossy, and it
1025/// called `U64 → F64` and `I64 → F64` lossless where both round above
1026/// `2^53`.
1027///
1028/// Rendering to `Str` keeps the value for the types that have a
1029/// numeric domain, since each of those renders with a round-trip
1030/// `Display`. Every other conversion — into `Bytes`, `Json`, a
1031/// vector, `Ext` — is out of the scalar world and is not claimed
1032/// lossless here, whatever the catalog can do with it.
1033///
1034/// Strict-mode embedding surfaces use this to gate which catalog
1035/// adapters they will invoke.
1036pub fn is_lossless_adapter(from: crate::ast::PortType, to: crate::ast::PortType) -> bool {
1037 use crate::ast::PortType;
1038 if from == to {
1039 return true;
1040 }
1041 let Some(f) = from.numeric_domain() else {
1042 return false;
1043 };
1044 if to == PortType::Str {
1045 return true;
1046 }
1047 to.numeric_domain().is_some_and(|t| f.fits_in(t))
1048}
1049
1050// ───── End typed embedding surface ─────
1051
1052/// Best-effort extraction of a human message from a
1053/// `catch_unwind` payload. The kernel's `enrich_eval_panic`
1054/// re-raises with a `String` payload, so the common case is one
1055/// line of context-bearing text; fall through to a sentinel for
1056/// non-string payloads (rare — third-party panic with a custom
1057/// payload type).
1058fn panic_payload_message(payload: &Box<dyn std::any::Any + Send>) -> String {
1059 if let Some(s) = payload.downcast_ref::<&str>() {
1060 (*s).to_string()
1061 } else if let Some(s) = payload.downcast_ref::<String>() {
1062 s.clone()
1063 } else {
1064 "<non-string panic payload>".to_string()
1065 }
1066}
1067
1068/// Run one of the assembler's str-to-typed coercion nodes over a string
1069/// literal at compile time, turning the node's panic diagnostic into a
1070/// compile error.
1071fn coerce_string_literal(
1072 node: Box<dyn crate::ast::PolydatNode>,
1073 s: &str,
1074) -> Result<crate::ast::Value, String> {
1075 use crate::ast::Value;
1076 // The coercion node reports a bad value by panicking with its
1077 // diagnostic. Silence the default hook so the diagnostic surfaces
1078 // once, as the compile error, rather than also on stderr.
1079 let hook = std::panic::take_hook();
1080 std::panic::set_hook(Box::new(|_| {}));
1081 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1082 let mut out = [Value::None];
1083 node.eval(&[Value::Str(s.into())], &mut out);
1084 out[0].clone()
1085 }));
1086 std::panic::set_hook(hook);
1087 result.map_err(|e| coercion_panic_message(&e))
1088}
1089
1090fn coercion_panic_message(payload: &Box<dyn std::any::Any + Send>) -> String {
1091 if let Some(s) = payload.downcast_ref::<&str>() {
1092 (*s).to_string()
1093 } else if let Some(s) = payload.downcast_ref::<String>() {
1094 s.clone()
1095 } else {
1096 "string value could not be coerced to the declared type".to_string()
1097 }
1098}
1099
1100/// Evaluate an `extern name: type = default` default expression
1101/// to a typed `Value`. Accepts literal forms only (`IntLit`,
1102/// `FloatLit`, `StringLit`, plus identifiers `true`/`false` for
1103/// `bool` ports). A string literal fuses to the declared type
1104/// through the same `StrToU64`/`StrToF64`/`StrToBool` coercions
1105/// the assembler inserts. Non-literal expressions are rejected
1106/// with a clear error; complex defaults belong in a binding, not
1107/// on the extern declaration.
1108fn evaluate_default_expr(
1109 expr: &crate::dsl::ast::Expr,
1110 port_type: crate::ast::PortType,
1111) -> Result<crate::ast::Value, String> {
1112 use crate::ast::{PortType, Value};
1113 use crate::dsl::ast::Expr;
1114 match (expr, port_type) {
1115 (Expr::IntLit(v, _), PortType::U64) => Ok(Value::U64(*v)),
1116 (Expr::IntLit(v, _), PortType::F64) => Ok(Value::F64(*v as f64)),
1117 (Expr::FloatLit(v, _), PortType::F64) => Ok(Value::F64(*v)),
1118 (Expr::StringLit(s, _), PortType::Str) => Ok(Value::Str(s.as_str().into())),
1119 (Expr::Ident(name, _), PortType::Bool) if name == "true" => Ok(Value::Bool(true)),
1120 (Expr::Ident(name, _), PortType::Bool) if name == "false" => Ok(Value::Bool(false)),
1121 // A string literal default fuses to the declared type through the
1122 // same coercions the assembler inserts when a str wire feeds a
1123 // typed port. This is what lets a host inject `name=value` text
1124 // as a program transform and leave typing to the program.
1125 (Expr::StringLit(s, _), PortType::U64) => {
1126 coerce_string_literal(Box::new(crate::library::convert::StrToU64::new()), s)
1127 }
1128 (Expr::StringLit(s, _), PortType::F64) => {
1129 coerce_string_literal(Box::new(crate::library::convert::StrToF64::new()), s)
1130 }
1131 (Expr::StringLit(s, _), PortType::Bool) => {
1132 coerce_string_literal(Box::new(crate::library::convert::StrToBool::new()), s)
1133 }
1134 _ => Err(format!(
1135 "default expression must be a literal of type {port_type:?}; got {expr:?}"
1136 )),
1137 }
1138}
1139
1140/// Try to fold a `shared X := <expr>` initializer to a typed
1141/// `(Value, PortType)`. Returns `Some` for literal forms (the
1142/// shareable-cell case); returns `None` for non-literal
1143/// expressions (which keep the ordinary binding shape — the
1144/// `shared` keyword carries metadata only and the binding has
1145/// no cross-scope mutability today).
1146///
1147/// Literal-init shared bindings compile to an input slot +
1148/// passthrough output, so `materialize_wiring_from_outer` can wire a
1149/// `SharedCell` between this slot and inner kernels' matching
1150/// inputs. Non-literal shared bindings retain the
1151/// computation-node shape; full cross-scope mutability for
1152/// those is future work (see scope_model.md §6.2 "Concurrent
1153/// semantics").
1154fn try_fold_shared_init(
1155 expr: &crate::dsl::ast::Expr,
1156) -> Option<(crate::ast::Value, crate::ast::PortType)> {
1157 use crate::ast::{PortType, Value};
1158 use crate::dsl::ast::Expr;
1159 match expr {
1160 Expr::IntLit(v, _) => Some((Value::U64(*v), PortType::U64)),
1161 Expr::FloatLit(v, _) => Some((Value::F64(*v), PortType::F64)),
1162 Expr::StringLit(s, _) => Some((Value::Str(s.as_str().into()), PortType::Str)),
1163 Expr::Ident(name, _) if name == "true" => Some((Value::Bool(true), PortType::Bool)),
1164 Expr::Ident(name, _) if name == "false" => Some((Value::Bool(false), PortType::Bool)),
1165 _ => None,
1166 }
1167}
1168
1169/// Apply the optional `shared name: type := …` annotation
1170/// (scope_model.md §"Type stability") to the folded `(value, type)`:
1171/// the annotation PINS the cell's type for life, winning over literal
1172/// inference. An integer literal widens to an f64-annotated cell (the
1173/// natural authoring, `shared m: f64 := 1`); any other mismatch is a
1174/// compile error at the declaration — not a runtime surprise.
1175fn apply_shared_type_annotation(
1176 name: &str,
1177 annotation: Option<&String>,
1178 init_value: crate::ast::Value,
1179 port_type: crate::ast::PortType,
1180) -> Result<(crate::ast::Value, crate::ast::PortType), String> {
1181 let Some(t) = annotation else {
1182 return Ok((init_value, port_type));
1183 };
1184 let annotated = crate::ast::PortType::from_keyword(t).ok_or_else(|| {
1185 format!(
1186 "shared binding '{name}': unknown type `{t}` in annotation. \
1187 Recognised types: u64, f64, str, bool."
1188 )
1189 })?;
1190 if annotated == port_type {
1191 Ok((init_value, annotated))
1192 } else if port_type == crate::ast::PortType::U64 && annotated == crate::ast::PortType::F64 {
1193 let widened = match init_value {
1194 crate::ast::Value::U64(v) => crate::ast::Value::F64(v as f64),
1195 other => other,
1196 };
1197 Ok((widened, annotated))
1198 } else {
1199 Err(format!(
1200 "shared binding '{name}: {t}': the initializer is {port_type:?}, \
1201 which doesn't match the annotated type. A cell keeps ONE type \
1202 for life — make the initializer match the annotation."
1203 ))
1204 }
1205}
1206
1207/// Extract an integer literal from a positional argument. Returns None
1208/// for named args, non-int-literal positional args, or any other form.
1209fn positional_int_lit(arg: &crate::dsl::ast::Arg) -> Option<u64> {
1210 match arg {
1211 crate::dsl::ast::Arg::Positional(crate::dsl::ast::Expr::IntLit(v, _)) => Some(*v),
1212 _ => None,
1213 }
1214}
1215
1216/// Collect the declared port type of every `input <name>: <type>`
1217/// declaration in the file (bare and tuple forms both lower to one
1218/// `InputDecl` per name). An unrecognised or absent type keyword is
1219/// omitted, leaving the assembler's `U64` default in force.
1220fn declared_input_types(
1221 file: &PolydatFile,
1222) -> std::collections::HashMap<String, crate::ast::PortType> {
1223 let mut types = std::collections::HashMap::new();
1224 for stmt in &file.statements {
1225 if let Statement::InputDecl(d) = stmt
1226 && let Some(ty) = &d.ty
1227 && let Some(pt) = crate::ast::PortType::from_keyword(ty)
1228 {
1229 types.insert(d.name.clone(), pt);
1230 }
1231 }
1232 types
1233}
1234
1235/// Extract a string literal from an optional positional argument.
1236/// Re-exported for cursor-sugar handlers in node modules that
1237/// validate string-literal-only constructor args.
1238pub fn positional_str_lit(arg: Option<&crate::dsl::ast::Arg>) -> Option<String> {
1239 match arg? {
1240 crate::dsl::ast::Arg::Positional(crate::dsl::ast::Expr::StringLit(s, _)) => Some(s.clone()),
1241 _ => None,
1242 }
1243}
1244
1245pub(super) struct Compiler {
1246 pub(super) input_names: Vec<String>,
1247 /// Track all named outputs so we can expose them.
1248 pub(super) all_names: Vec<String>,
1249 /// Auto-generated node counter for desugared intermediates.
1250 pub(super) anon_counter: usize,
1251 /// Directory for module resolution (search for .polydat files).
1252 pub(super) source_dir: Option<PathBuf>,
1253 /// Additional library directories for module resolution.
1254 ///
1255 /// Searched after `source_dir` but before the embedded stdlib.
1256 /// Populated from `CompileOptions::lib_paths` (the binary's
1257 /// `--lib`).
1258 pub(super) polydat_lib_paths: Vec<PathBuf>,
1259 /// Cache of already-resolved module ASTs: module_name → (inputs, statements).
1260 pub(super) module_cache: std::collections::HashMap<String, ResolvedModule>,
1261 /// When true, enforce strict validation.
1262 pub(super) strict: bool,
1263 /// Original source text, attached to compiled programs for diagnostics.
1264 source_text: String,
1265 /// Source schemas collected during compilation.
1266 pub(super) cursor_schemas: Vec<crate::iteration::source::SourceSchema>,
1267 /// Deferred cursor extent resolutions: each entry maps a cursor
1268 /// schema index to the aux output names that, once folded, give
1269 /// the range's start and end values. These are resolved after the
1270 /// kernel compiles by reading `get_constant()` for each name.
1271 pub(super) deferred_extents: Vec<DeferredExtent>,
1272 /// Optional limit applied to all cursors (from `limit` activity param).
1273 pub(super) cursor_limit: Option<u64>,
1274 /// Diagnostic context label.
1275 context_label: String,
1276 /// Module-level pragmas extracted from the source. Drive the
1277 /// assembler's `strict_types` / `strict_values` flags
1278 /// (SRD 15 §"Module-Level Pragmas" + §"Strict Wire Mode").
1279 pub(super) pragmas: super::pragmas::PragmaSet,
1280 /// LHS binding name currently being compiled, if any. Used as a
1281 /// prefix for auto-generated anonymous node names so type-mismatch
1282 /// errors point at the user-level binding (`overscan__anon_3`)
1283 /// instead of an opaque counter (`__anon_14`).
1284 pub(super) current_binding: Option<String>,
1285 /// Tiles lowered so far in this compile, in order, so later tiles
1286 /// can splice earlier ones (SRD 114 §5.5).
1287 pub(super) tiles: Vec<super::ast::TileDef>,
1288 /// Producer bindings seen so far, so tile projections over a
1289 /// producer can type their elements.
1290 pub(super) producers_seen: Vec<super::traversal::Producer>,
1291 /// Events raised while lowering, handed to the compile event log:
1292 /// one `TileHoleTyped` per hole (SRD 114 §4.4), so `explain tiles`
1293 /// can show how each hole was typed and encoded, one
1294 /// `ComprehensionWarning` per degenerate composition (§5.8), and the
1295 /// steps of this compile that report themselves (a binding resolved,
1296 /// a module inlined, an output declared), merged into the log after
1297 /// the parent assembles.
1298 pub(super) pending_events: Vec<super::events::CompileEvent>,
1299 /// The compile ledger of the tree being compiled: the root's, handed
1300 /// to every body compiler and to the assembler of every program.
1301 pub(super) ledger: std::sync::Arc<crate::kernel::CompileLedger>,
1302}
1303
1304/// Records a cursor whose `range(...)` bounds reference const
1305/// expressions (e.g., `vector_count("example:default")`) rather than
1306/// integer literals. The expressions are compiled as auxiliary outputs
1307/// and the extent is resolved after kernel compilation by querying the
1308/// constant values.
1309pub(super) struct DeferredExtent {
1310 /// Index into `cursor_schemas` whose extent needs resolution.
1311 pub schema_idx: usize,
1312 /// Name of the aux output that, when folded, gives the start value.
1313 pub start_output: String,
1314 /// Name of the aux output that, when folded, gives the end value.
1315 pub end_output: String,
1316}
1317
1318impl Compiler {
1319 /// The comprehension validation mode of this compile
1320 /// (comprehension_forms.md §5.8): a strict compile refuses a
1321 /// degenerate composition, a lax one warns about it.
1322 pub(super) fn validation_mode(&self) -> crate::iteration::comprehension::Mode {
1323 if self.strict {
1324 crate::iteration::comprehension::Mode::Strict
1325 } else {
1326 crate::iteration::comprehension::Mode::Permissive
1327 }
1328 }
1329
1330 /// The scope a context-free source evaluates in during this
1331 /// compile (comprehension_forms.md §10.7.0): no name resolves, and
1332 /// what has to compile is charged to the program tree's ledger.
1333 pub(super) fn source_scope(&self) -> crate::kernel::interp::NoScope {
1334 crate::kernel::interp::NoScope::charged_to(self.ledger.clone())
1335 }
1336
1337 pub(super) fn with_lib_paths(
1338 source_dir: Option<PathBuf>,
1339 polydat_lib_paths: Vec<PathBuf>,
1340 strict: bool,
1341 ) -> Self {
1342 Self {
1343 input_names: Vec::new(),
1344 all_names: Vec::new(),
1345 anon_counter: 0,
1346 source_dir,
1347 polydat_lib_paths,
1348 module_cache: std::collections::HashMap::new(),
1349 strict,
1350 source_text: String::new(),
1351 context_label: "(polydat)".into(),
1352 cursor_schemas: Vec::new(),
1353 deferred_extents: Vec::new(),
1354 cursor_limit: None,
1355 pragmas: super::pragmas::PragmaSet::default(),
1356 current_binding: None,
1357 tiles: Vec::new(),
1358 producers_seen: Vec::new(),
1359 pending_events: Vec::new(),
1360 ledger: crate::kernel::CompileLedger::new(),
1361 }
1362 }
1363
1364 /// Process a source declaration: create input ports for projections,
1365 /// passthrough nodes, and record the schema.
1366 fn process_cursor(
1367 &mut self,
1368 asm: &mut PolydatAssembler,
1369 decl: &crate::dsl::ast::CursorDecl,
1370 ) -> Result<(), String> {
1371 let source_name = &decl.name;
1372
1373 // Cursor-sugar dispatch: any node module can register a
1374 // handler that recognizes a non-`range` constructor (e.g.
1375 // `vectordata_base("ds", "label_00")`) and rewrites it into
1376 // a synthetic `range(...)` plus a list of aux bindings to
1377 // emit after input ports are wired. The core stays
1378 // generic — nothing here knows that vectordata exists.
1379 // See `dsl::cursor_sugar` for the registry mechanism.
1380 let sugar = crate::dsl::cursor_sugar::dispatch(source_name, &decl.constructor)?;
1381 let effective_constructor = match &sugar {
1382 Some(s) => s.effective_constructor.clone(),
1383 None => decl.constructor.clone(),
1384 };
1385
1386 // All sources get an "ordinal" projection.
1387 let mut projections = vec![("ordinal".to_string(), crate::ast::PortType::U64)];
1388
1389 // Determine extent from constructor args. Three cases per arg:
1390 // 1. Integer literal → use directly
1391 // 2. Other const-foldable expression (e.g. `vector_count("...")`)
1392 // → compile as an aux output and resolve after kernel compiles
1393 // 3. Arg references runtime state → no extent available
1394 //
1395 // Immediate-literal cases produce a concrete extent here.
1396 // Deferred cases push a DeferredExtent record; the outer compile
1397 // routine reads the folded values after compilation and updates
1398 // the schema's extent in place.
1399 let mut deferred: Option<(Option<u64>, String, Option<u64>, String)> = None;
1400 let mut cursor_kind_for_decl: crate::iteration::source::CursorKind =
1401 crate::iteration::source::CursorKind::Range;
1402 let extent = match &effective_constructor {
1403 // ── until_*(...) — extending cursors ────────────────
1404 // Recognise every cursor function whose constructor
1405 // declares an extending policy. The shape of each is:
1406 // until_FAMILY(base, ...policy_args[, delta])
1407 // where `base` is the initial extent / pass size and
1408 // policy_args carry the family's stop-condition
1409 // parameters. An optional final `delta` overrides the
1410 // extension step size (defaults to `base`).
1411 //
1412 // Recognised families:
1413 // until_elapsed(base, min_ms[, delta])
1414 // until_passes(base, min_passes[, delta])
1415 // until_count(base, min_count[, delta])
1416 // until_elapsed_and_passes(base, min_ms, min_passes[, delta])
1417 // until_elapsed_or_passes(base, min_ms, min_passes[, delta])
1418 //
1419 // Common shape: emit `base` as the cursor's `end` aux
1420 // output, `start` as a literal 0, and each policy arg
1421 // as a named aux output the runtime pulls at phase
1422 // setup. The CursorKind variant carries the output
1423 // names so the executor knows how to build the policy.
1424 crate::dsl::ast::Expr::Call(call)
1425 if matches!(
1426 call.func.as_str(),
1427 "until_elapsed"
1428 | "until_passes"
1429 | "until_count"
1430 | "until_elapsed_and_passes"
1431 | "until_elapsed_or_passes"
1432 ) =>
1433 {
1434 let family = call.func.as_str();
1435 let expected = match family {
1436 "until_elapsed" | "until_passes" | "until_count" => (2usize, 3usize),
1437 "until_elapsed_and_passes" | "until_elapsed_or_passes" => (3, 4),
1438 _ => unreachable!(),
1439 };
1440 let n = call.args.len();
1441 if n < expected.0 || n > expected.1 {
1442 return Err(format!(
1443 "cursor '{source_name}': `{family}` takes {}-{} args, got {n}",
1444 expected.0, expected.1,
1445 ));
1446 }
1447 // Common: base, start, end aux outputs.
1448 let base_literal = positional_int_lit(&call.args[0]);
1449 let base_name = format!("__cursor_extent_{source_name}_end");
1450 let start_name = format!("__cursor_extent_{source_name}_start");
1451 let _ = self.compile_binding(
1452 asm,
1453 std::slice::from_ref(&start_name),
1454 &crate::dsl::ast::Expr::IntLit(0, decl.span),
1455 );
1456 if let crate::dsl::ast::Arg::Positional(expr) = &call.args[0] {
1457 self.compile_binding(asm, std::slice::from_ref(&base_name), expr)
1458 .map_err(|e| {
1459 format!("cursor '{source_name}': failed to compile {family} base: {e}")
1460 })?;
1461 }
1462 // Helper closure: compile a positional arg as a
1463 // named aux output. Returns the name on success.
1464 let mut compile_aux = |idx: usize, suffix: &str| -> Result<String, String> {
1465 let out_name = format!("__cursor_{suffix}_{source_name}");
1466 if let crate::dsl::ast::Arg::Positional(expr) = &call.args[idx] {
1467 self.compile_binding(asm, std::slice::from_ref(&out_name), expr)
1468 .map_err(|e| {
1469 format!(
1470 "cursor '{source_name}': failed to compile \
1471 {family} arg {idx}: {e}"
1472 )
1473 })?;
1474 }
1475 Ok(out_name)
1476 };
1477 // Family-specific arg layout.
1478 cursor_kind_for_decl = match family {
1479 "until_elapsed" => {
1480 let min_ms_name = compile_aux(1, "min_ms")?;
1481 let delta_output = if n == 3 {
1482 Some(compile_aux(2, "delta")?)
1483 } else {
1484 None
1485 };
1486 crate::iteration::source::CursorKind::ExtendingTimed {
1487 min_ms_output: min_ms_name,
1488 delta_output,
1489 }
1490 }
1491 "until_passes" => {
1492 let min_passes_name = compile_aux(1, "min_passes")?;
1493 let delta_output = if n == 3 {
1494 Some(compile_aux(2, "delta")?)
1495 } else {
1496 None
1497 };
1498 crate::iteration::source::CursorKind::ExtendingPasses {
1499 min_passes_output: min_passes_name,
1500 delta_output,
1501 }
1502 }
1503 "until_count" => {
1504 let min_count_name = compile_aux(1, "min_count")?;
1505 let delta_output = if n == 3 {
1506 Some(compile_aux(2, "delta")?)
1507 } else {
1508 None
1509 };
1510 crate::iteration::source::CursorKind::ExtendingCount {
1511 min_count_output: min_count_name,
1512 delta_output,
1513 }
1514 }
1515 "until_elapsed_and_passes" => {
1516 let min_ms_name = compile_aux(1, "min_ms")?;
1517 let min_passes_name = compile_aux(2, "min_passes")?;
1518 let delta_output = if n == 4 {
1519 Some(compile_aux(3, "delta")?)
1520 } else {
1521 None
1522 };
1523 crate::iteration::source::CursorKind::ExtendingElapsedAndPasses {
1524 min_ms_output: min_ms_name,
1525 min_passes_output: min_passes_name,
1526 delta_output,
1527 }
1528 }
1529 "until_elapsed_or_passes" => {
1530 let min_ms_name = compile_aux(1, "min_ms")?;
1531 let min_passes_name = compile_aux(2, "min_passes")?;
1532 let delta_output = if n == 4 {
1533 Some(compile_aux(3, "delta")?)
1534 } else {
1535 None
1536 };
1537 crate::iteration::source::CursorKind::ExtendingElapsedOrPasses {
1538 min_ms_output: min_ms_name,
1539 min_passes_output: min_passes_name,
1540 delta_output,
1541 }
1542 }
1543 _ => unreachable!(),
1544 };
1545 deferred = Some((Some(0), start_name, base_literal, base_name));
1546 base_literal
1547 }
1548 crate::dsl::ast::Expr::Call(call) if call.func == "range" && call.args.len() >= 2 => {
1549 let start_literal = positional_int_lit(&call.args[0]);
1550 let end_literal = positional_int_lit(&call.args[1]);
1551
1552 match (start_literal, end_literal) {
1553 // Both literal — compute directly. We also emit
1554 // the start/end as named final bindings so the
1555 // comprehension `all(<cursor>)` form (SRD-18c)
1556 // can resolve them uniformly with the deferred
1557 // (non-literal) case below.
1558 (Some(s), Some(e)) => {
1559 let start_name = format!("__cursor_extent_{source_name}_start");
1560 let end_name = format!("__cursor_extent_{source_name}_end");
1561 let s_lit = crate::dsl::ast::Expr::IntLit(s, decl.span);
1562 let e_lit = crate::dsl::ast::Expr::IntLit(e, decl.span);
1563 let _ = self.compile_binding(asm, &[start_name], &s_lit);
1564 let _ = self.compile_binding(asm, &[end_name], &e_lit);
1565 Some(e.saturating_sub(s))
1566 }
1567 // At least one non-literal — compile as aux outputs.
1568 _ => {
1569 let start_name = format!("__cursor_extent_{source_name}_start");
1570 let end_name = format!("__cursor_extent_{source_name}_end");
1571 // Compile each arg as a named auxiliary output. Errors
1572 // are returned so the user sees them — silently
1573 // dropping them would leave extent=None and produce
1574 // a phase that runs zero cycles with no explanation.
1575 if let crate::dsl::ast::Arg::Positional(expr) = &call.args[0] {
1576 self.compile_binding(asm, std::slice::from_ref(&start_name), expr)
1577 .map_err(|e| {
1578 format!(
1579 "cursor '{source_name}': failed to compile range start: {e}"
1580 )
1581 })?;
1582 }
1583 if let crate::dsl::ast::Arg::Positional(expr) = &call.args[1] {
1584 self.compile_binding(asm, std::slice::from_ref(&end_name), expr)
1585 .map_err(|e| {
1586 format!(
1587 "cursor '{source_name}': failed to compile range end: {e}"
1588 )
1589 })?;
1590 }
1591 deferred = Some((start_literal, start_name, end_literal, end_name));
1592 None
1593 }
1594 }
1595 }
1596 _ => None,
1597 };
1598
1599 // Create input ports and passthrough nodes for each projection.
1600 for (field_name, port_type) in &projections {
1601 let input_name = format!("{source_name}__{field_name}");
1602 let default_value = match port_type {
1603 crate::ast::PortType::U64 => crate::ast::Value::U64(0),
1604 crate::ast::PortType::F64 => crate::ast::Value::F64(0.0),
1605 _ => crate::ast::Value::None,
1606 };
1607
1608 // Cursor projection slots are written by cursor advance
1609 // every cycle — dynamic for init-contract purposes.
1610 asm.add_input(
1611 &input_name,
1612 default_value,
1613 *port_type,
1614 crate::kernel::InputKind::ExternalWrite,
1615 );
1616 self.input_names.push(input_name.clone());
1617
1618 let passthrough = Box::new(crate::library::identity::PortPassthrough::new(
1619 &input_name,
1620 *port_type,
1621 ));
1622 let node_name = format!("{source_name}__{field_name}");
1623 asm.add_node(&node_name, passthrough, vec![WireRef::input(&input_name)]);
1624 asm.add_output(&node_name, WireRef::node(&node_name));
1625 }
1626
1627 // Apply any aux bindings the sugar handler asked for.
1628 // Bindings whose `projection` is `Some` are also published
1629 // as cursor projections — both pinned on the schema and
1630 // exposed as kernel outputs the runtime can read.
1631 if let Some(sugar) = sugar {
1632 for aux in sugar.aux_bindings {
1633 self.compile_binding(asm, std::slice::from_ref(&aux.name), &aux.value)
1634 .map_err(|e| {
1635 format!(
1636 "cursor '{source_name}': failed to compile aux binding '{}': {e}",
1637 aux.name,
1638 )
1639 })?;
1640 if let Some((field, port_type)) = aux.projection {
1641 projections.push((field, port_type));
1642 asm.add_output(&aux.name, WireRef::node(&aux.name));
1643 }
1644 }
1645 }
1646
1647 // If a limit is set, insert a limit() node that shadows the cursor wire.
1648 // The limit node is a visible, documented passthrough that clamps extent.
1649 let effective_extent = if let Some(limit_val) = self.cursor_limit {
1650 let limit_node_name = format!("{source_name}__limit");
1651 let ordinal_wire = format!("{source_name}__ordinal");
1652 asm.add_node(
1653 &limit_node_name,
1654 Box::new(crate::library::context::Limit::new(limit_val)),
1655 vec![WireRef::node(&ordinal_wire)],
1656 );
1657 // Shadow the ordinal output with the limited version
1658 asm.add_output(&ordinal_wire, WireRef::node(&limit_node_name));
1659
1660 // Clamp extent
1661 extent.map(|e| e.min(limit_val)).or(Some(limit_val))
1662 } else {
1663 extent
1664 };
1665
1666 let schema_idx = self.cursor_schemas.len();
1667 let extent_outputs = deferred
1668 .as_ref()
1669 .map(|(_, start, _, end)| (start.clone(), end.clone()));
1670
1671 // SRD 71: if the cursor decl carries an `over <expr>`
1672 // clause, set up two pieces of plumbing:
1673 //
1674 // 1. An auxiliary output `<source>__over_raw` carrying
1675 // the raw expression value (typically a string spec
1676 // or a workload-param-typed value). The executor
1677 // pulls this at phase setup to determine the
1678 // narrowing range.
1679 //
1680 // 2. An input slot + passthrough output `<source>__cursor`
1681 // of type `Ext` — this is the field-access wire that
1682 // workload authors reference as `<source>.cursor`. At
1683 // phase setup the executor resolves the raw value to
1684 // a concrete `Partition` and writes it into this slot,
1685 // so downstream nodes (`mod_in`, `cardinality`, etc.)
1686 // can consume it as a `Partition`-typed wire.
1687 let mut partitions: Option<Vec<crate::iteration::cursor_partition::Partition>> = None;
1688 let partition_output = if let Some(over_expr) = decl.over.as_ref() {
1689 let raw_name = format!("__cursor_{source_name}_over_raw");
1690 self.compile_binding(asm, std::slice::from_ref(&raw_name), over_expr)
1691 .map_err(|e| {
1692 format!("cursor '{source_name}': failed to compile `over` expression: {e}")
1693 })?;
1694 // The wire is a spec string or a partition-typed external
1695 // (for_traversal.md §5, §7): any other type is refused here,
1696 // not at the first activation.
1697 if let Some(ty) = asm.output_type(&raw_name)
1698 && !matches!(ty, crate::ast::PortType::Str | crate::ast::PortType::Ext)
1699 {
1700 return Err(format!(
1701 "cursor '{source_name}': `over` names a {ty:?} wire; expected a spec string or a partition-typed value"
1702 ));
1703 }
1704 // A literal spec over a known extent resolves now
1705 // (engines.md §3.5): the schema carries the
1706 // partitions for the host, and a clause that denotes
1707 // exactly one partition seeds the cursor's slots, so the
1708 // program runs on every engine with no host call. A clause
1709 // that denotes several leaves the choice to the host or
1710 // the traversal runtime, as before.
1711 if let (crate::dsl::ast::Expr::StringLit(spec, _), Some(extent)) =
1712 (over_expr, effective_extent)
1713 {
1714 let open = !matches!(
1715 cursor_kind_for_decl,
1716 crate::iteration::source::CursorKind::Range
1717 );
1718 let parts = crate::iteration::cursor_partition::resolve_over(
1719 &crate::ast::Value::Str(spec.as_str().into()),
1720 extent,
1721 open,
1722 )
1723 .map_err(|e| format!("cursor '{source_name}': `over \"{spec}\"`: {e}"))?;
1724 partitions = Some(parts);
1725 }
1726 let seeded: Option<crate::iteration::cursor_partition::Partition> =
1727 partitions.as_ref().filter(|p| p.len() == 1).map(|p| p[0]);
1728 // Allocate the resolved-Partition input slot. Its default
1729 // is the one partition the clause denotes, or `Value::None`
1730 // until the host or the traversal runtime narrows it.
1731 let cursor_input_name = format!("{source_name}__cursor");
1732 asm.add_input(
1733 &cursor_input_name,
1734 seeded.map_or(crate::ast::Value::None, crate::ast::Value::from_partition),
1735 crate::ast::PortType::Ext,
1736 crate::kernel::InputKind::ExternalWrite,
1737 );
1738 self.input_names.push(cursor_input_name.clone());
1739 let passthrough = Box::new(crate::library::identity::PortPassthrough::new(
1740 &cursor_input_name,
1741 crate::ast::PortType::Ext,
1742 ));
1743 asm.add_node(
1744 &cursor_input_name,
1745 passthrough,
1746 vec![WireRef::input(&cursor_input_name)],
1747 );
1748 asm.add_output(&cursor_input_name, WireRef::node(&cursor_input_name));
1749 // SRD 71 §"Cursor metadata wires": scalar projections
1750 // of the resolved partition, as plain typed slots —
1751 // `<source>.cursor.idx` and friends parse as chained
1752 // field access and flatten onto these wires. The
1753 // executor writes them alongside the Ext slot at
1754 // phase setup; defaults here cover the no-narrowing
1755 // case (idx 0, count 1, full-extent pcts; the
1756 // ordinal pair is patched by the executor once the
1757 // cursor's extent is known).
1758 use crate::ast::{PortType, Value};
1759 let scalar_slots: [(&str, Value, PortType); 6] = match seeded {
1760 Some(p) => [
1761 ("idx", Value::U64(p.idx), PortType::U64),
1762 ("partition_count", Value::U64(p.count.max(1)), PortType::U64),
1763 ("start_pct", Value::F64(p.start_pct), PortType::F64),
1764 ("end_pct", Value::F64(p.end_pct), PortType::F64),
1765 ("start_ordinal", Value::U64(p.start_ord), PortType::U64),
1766 ("end_ordinal", Value::U64(p.end_ord), PortType::U64),
1767 ],
1768 None => [
1769 ("idx", Value::U64(0), PortType::U64),
1770 ("partition_count", Value::U64(1), PortType::U64),
1771 ("start_pct", Value::F64(0.0), PortType::F64),
1772 ("end_pct", Value::F64(100.0), PortType::F64),
1773 ("start_ordinal", Value::U64(0), PortType::U64),
1774 ("end_ordinal", Value::U64(0), PortType::U64),
1775 ],
1776 };
1777 for (field, default, port_type) in scalar_slots {
1778 let slot = format!("{cursor_input_name}__{field}");
1779 asm.add_input(
1780 &slot,
1781 default,
1782 port_type,
1783 crate::kernel::InputKind::ExternalWrite,
1784 );
1785 self.input_names.push(slot.clone());
1786 let pass = Box::new(crate::library::identity::PortPassthrough::new(
1787 &slot, port_type,
1788 ));
1789 asm.add_node(&slot, pass, vec![WireRef::input(&slot)]);
1790 asm.add_output(&slot, WireRef::node(&slot));
1791 }
1792 Some(raw_name)
1793 } else {
1794 None
1795 };
1796
1797 self.cursor_schemas
1798 .push(crate::iteration::source::SourceSchema {
1799 name: source_name.clone(),
1800 projections,
1801 extent: effective_extent,
1802 extent_outputs,
1803 extent_limit: self.cursor_limit,
1804 cursor_kind: cursor_kind_for_decl.clone(),
1805 partition_output,
1806 partitions,
1807 });
1808
1809 // Record deferred extent resolution if the range bounds are not
1810 // both literals. Post-compile, the outer compile routine will
1811 // query the aux outputs' folded constants and update this
1812 // schema's extent in place.
1813 if let Some((_start_lit, start_output, _end_lit, end_output)) = deferred {
1814 self.deferred_extents.push(DeferredExtent {
1815 schema_idx,
1816 start_output,
1817 end_output,
1818 });
1819 }
1820 Ok(())
1821 }
1822
1823 /// The interpreter's kernel of `file` as its concrete type: the one
1824 /// compile path with the interpreter's build, keeping the outputs in
1825 /// `filter` (every output when `None`) and recording events in `log`.
1826 /// The parent's AST is retained as program metadata for the subscope
1827 /// synthesizer.
1828 pub(super) fn compile_interpreter(
1829 &mut self,
1830 file: &PolydatFile,
1831 filter: Option<&[String]>,
1832 log: Option<&mut super::events::CompileEventLog>,
1833 cones: crate::JitMode,
1834 ) -> Result<PolydatKernel, crate::KernelError> {
1835 let (mut kernel, parent) = compile_file_with(self, file, filter, log, |mut asm, log| {
1836 asm.set_jit_mode(cones);
1837 asm.compile_with_log(log).map_err(crate::KernelError::from)
1838 })?;
1839 kernel.set_ast(std::sync::Arc::new(parent));
1840 Ok(kernel)
1841 }
1842
1843 /// The output type of a generator expression used as a comprehension
1844 /// source (SRD 113 §3.3): compile `__probe := <expr>` on its own and
1845 /// read the port type. Shared by `for` bodies and tile projections.
1846 pub(super) fn probe_element_type(&self, expr: &str) -> Result<crate::ast::PortType, String> {
1847 let src = format!("input cycle: u64\n__probe := {expr}\n");
1848 let tokens = lexer::lex(&src)?;
1849 let ast = parser::parse(tokens)?;
1850 let mut probe_compiler = Compiler::with_lib_paths(
1851 self.source_dir.clone(),
1852 self.polydat_lib_paths.clone(),
1853 false,
1854 );
1855 probe_compiler.source_text = src.clone();
1856 probe_compiler.context_label = format!("{} (element probe)", self.context_label);
1857 probe_compiler.module_cache = self.module_cache.clone();
1858 // Assembly answers this. The probe used to compile a whole
1859 // kernel under `JitMode::Auto` — wire resolution, the constant
1860 // fold, cone extraction, native codegen, a state — to read one
1861 // output's declared port type, which the assembler knows as
1862 // soon as the node is registered.
1863 let asm = probe_compiler.assemble_parent(&ast, None)?;
1864 asm.output_type("__probe")
1865 .ok_or_else(|| "probe produced no output".to_string())
1866 }
1867
1868 /// Lower each `for` statement's body to a child program, typed from
1869 /// its comprehension and the parent's manifest (SRD 113 §3.3, §4).
1870 fn compile_traversals(
1871 &mut self,
1872 for_stmts: &[super::ast::ForStmt],
1873 producers: &[super::traversal::Producer],
1874 type_of: &dyn Fn(&str) -> Option<crate::ast::PortType>,
1875 ) -> Result<Vec<super::traversal::Traversal>, String> {
1876 use super::traversal::{
1877 Traversal, child_file, element_types, resolve_source_with, warning_events,
1878 };
1879 let mut out = Vec::with_capacity(for_stmts.len());
1880 for f in for_stmts {
1881 let (comprehension, warnings) = resolve_source_with(
1882 &f.source,
1883 producers,
1884 self.validation_mode(),
1885 &self.source_scope(),
1886 )?;
1887 self.pending_events
1888 .extend(warning_events(&f.source, &warnings));
1889 let mut probe = |expr: &str| self.probe_element_type(expr);
1890 let elements = element_types(&comprehension, &mut probe).map_err(|e| {
1891 format!(
1892 "`for {}` at line {}, col {}: {e}",
1893 f.source.to_text(),
1894 f.span.line,
1895 f.span.col
1896 )
1897 })?;
1898 let (child, cascade) = child_file(f, &comprehension, &elements, type_of)?;
1899 let mut child_compiler = Compiler::with_lib_paths(
1900 self.source_dir.clone(),
1901 self.polydat_lib_paths.clone(),
1902 self.strict,
1903 );
1904 // The body sees every module the parent resolved, its own
1905 // definitions included, wherever it compiles.
1906 child_compiler.module_cache = self.module_cache.clone();
1907 // The body's program is one of the tree's.
1908 child_compiler.ledger = self.ledger.clone();
1909 child_compiler.source_text = super::pprint::pp_file(&child);
1910 child_compiler.context_label = format!(
1911 "{} :: for {} (line {}, col {})",
1912 self.context_label,
1913 f.source.to_text(),
1914 f.span.line,
1915 f.span.col
1916 );
1917 child_compiler.cursor_limit = self.cursor_limit;
1918 child_compiler.pragmas = self.pragmas.clone();
1919 let child_kernel = child_compiler
1920 .compile_interpreter(&child, None, None, crate::JitMode::Auto)
1921 .map_err(|e| {
1922 format!(
1923 "`for {}` at line {}, col {}: body failed to compile: {e}",
1924 f.source.to_text(),
1925 f.span.line,
1926 f.span.col
1927 )
1928 })?;
1929 self.pending_events
1930 .append(&mut child_compiler.pending_events);
1931 let body = super::traversal::BodySource {
1932 file: child,
1933 source_text: child_compiler.source_text.clone(),
1934 source_dir: self.source_dir.clone(),
1935 lib_paths: self.polydat_lib_paths.clone(),
1936 strict: self.strict,
1937 context_label: child_compiler.context_label.clone(),
1938 cursor_limit: self.cursor_limit,
1939 pragmas: self.pragmas.clone(),
1940 modules: self.module_cache.clone(),
1941 programs: std::sync::Mutex::new(std::collections::HashMap::new()),
1942 ledger: self.ledger.clone(),
1943 };
1944 out.push(Traversal {
1945 span: f.span,
1946 source_text: f.source.to_text(),
1947 comprehension,
1948 elements,
1949 cascade,
1950 program: child_kernel.into_program(),
1951 body: std::sync::Arc::new(body),
1952 });
1953 }
1954 Ok(out)
1955 }
1956
1957 /// Compile a traversal body on `engine` (engine parity, step 8): the
1958 /// same child file and compiler settings the parent used for the
1959 /// interpreter's program, through the assembler, its own `for`
1960 /// statements and producers included.
1961 /// A body of this program's, from its lowered source: the
1962 /// settings this compiler carries, so the body compiles the way
1963 /// the program around it does — its source directory and library
1964 /// paths, its strict flag, its pragmas, the modules it has
1965 /// resolved, and the tree's compile ledger.
1966 ///
1967 /// A `for` body gets these because `compile_traversals` builds its
1968 /// `BodySource` here; a tile's projection body used to travel as
1969 /// text and get none of them.
1970 pub(super) fn body_source_for(
1971 &self,
1972 source: &str,
1973 context_label: &str,
1974 ) -> Result<super::traversal::BodySource, String> {
1975 let file = super::lexer::lex(source).and_then(super::parser::parse)?;
1976 Ok(super::traversal::BodySource::from_parts(
1977 file,
1978 source.to_string(),
1979 self.source_dir.clone(),
1980 self.polydat_lib_paths.clone(),
1981 self.strict,
1982 format!("{} :: {context_label}", self.context_label),
1983 self.cursor_limit,
1984 self.pragmas.clone(),
1985 self.module_cache.clone(),
1986 self.ledger.clone(),
1987 ))
1988 }
1989
1990 pub(super) fn compile_body_on(
1991 body: &super::traversal::BodySource,
1992 engine: crate::Engine,
1993 ) -> Result<Box<dyn crate::Kernel>, crate::KernelError> {
1994 let _data_base = body.source_dir.as_deref().map(DataBaseDirGuard::set);
1995 let mut compiler =
1996 Compiler::with_lib_paths(body.source_dir.clone(), body.lib_paths.clone(), body.strict);
1997 compiler.source_text = body.source_text.clone();
1998 compiler.context_label = body.context_label.clone();
1999 compiler.cursor_limit = body.cursor_limit;
2000 compiler.pragmas = body.pragmas.clone();
2001 compiler.module_cache = body.modules.clone();
2002 compiler.ledger = body.ledger.clone();
2003 compile_file_on_engine(&mut compiler, &body.file, None, engine, None)
2004 }
2005
2006 /// Assemble the parent program: inputs and their passthroughs,
2007 /// externs, bindings, cursors, tiles, and the output set. Every
2008 /// entry point builds its assembler here, so a kernel and an
2009 /// assembler from the same source are the same graph.
2010 fn assemble_parent(
2011 &mut self,
2012 file: &PolydatFile,
2013 required_outputs: Option<&[String]>,
2014 ) -> Result<PolydatAssembler, String> {
2015 self.register_local_modules(file);
2016 // First pass: collect explicit `input` declarations, dedup by name.
2017 for stmt in &file.statements {
2018 if let Statement::InputDecl(d) = stmt
2019 && !self.input_names.iter().any(|n| n == &d.name)
2020 {
2021 self.input_names.push(d.name.clone());
2022 }
2023 }
2024
2025 // Input declaration check: error in strict mode (modules, .polydat files)
2026 if self.input_names.is_empty() && self.strict {
2027 return Err(
2028 "strict mode: no `input` declaration — add `input <name>: <type>` \
2029 (or the tuple form `input (a: u64, b: f64)`) to declare graph \
2030 inputs explicitly"
2031 .into(),
2032 );
2033 }
2034
2035 // If no explicit inputs, infer from unbound references
2036 if self.input_names.is_empty() {
2037 let defined: HashSet<String> = file
2038 .statements
2039 .iter()
2040 .flat_map(|stmt| match stmt {
2041 Statement::Binding(b) => b.targets.clone(),
2042 Statement::ModuleDef(m) => vec![m.name.clone()],
2043 Statement::ExternPort(p) => vec![p.name.clone()],
2044 Statement::InputDecl(_) => vec![],
2045 Statement::Cursor(_) => vec![],
2046 Statement::Pragma { .. } => vec![],
2047 Statement::For(_) => vec![],
2048 Statement::Tile(t) => vec![t.name.clone()],
2049 })
2050 .collect();
2051
2052 let mut referenced: HashSet<String> = HashSet::new();
2053 for stmt in &file.statements {
2054 let expr = match stmt {
2055 Statement::InputDecl(_)
2056 | Statement::ModuleDef(_)
2057 | Statement::ExternPort(_)
2058 | Statement::Cursor(_)
2059 | Statement::Pragma { .. }
2060 | Statement::For(_)
2061 | Statement::Tile(_) => continue,
2062 Statement::Binding(b) => &b.value,
2063 };
2064 collect_references(expr, &mut referenced);
2065 }
2066
2067 let mut inferred: Vec<String> = referenced
2068 .into_iter()
2069 .filter(|name| !defined.contains(name))
2070 .collect();
2071 inferred.sort();
2072 self.input_names = inferred;
2073 }
2074
2075 // Zero inferred inputs means all bindings are constants — valid.
2076
2077 let mut asm = PolydatAssembler::new(self.input_names.clone());
2078 asm.ledger = self.ledger.clone();
2079 for (name, ty) in declared_input_types(file) {
2080 asm.set_input_type(&name, ty);
2081 }
2082
2083 // Auto-expose every declared input as a passthrough output
2084 // (parity with `extern`). See `compile()` for the same wiring.
2085 for input_name in self.input_names.clone() {
2086 // Mirror the input's (now correctly-typed) slot so the
2087 // auto-exposed output carries the declared type, not U64.
2088 let port_type = asm
2089 .input_type(&input_name)
2090 .unwrap_or(crate::ast::PortType::U64);
2091 let passthrough = Box::new(crate::library::identity::PortPassthrough::new(
2092 &input_name,
2093 port_type,
2094 ));
2095 let passthrough_name = format!("__port_{input_name}");
2096 asm.add_node(
2097 &passthrough_name,
2098 passthrough,
2099 vec![WireRef::input(&input_name)],
2100 );
2101 asm.add_output(&input_name, WireRef::node(&passthrough_name));
2102 }
2103
2104 // Second pass: process all bindings into the assembler
2105 for stmt in &file.statements {
2106 match stmt {
2107 Statement::InputDecl(_) => {}
2108 Statement::Binding(b) => {
2109 // `shared X := <literal>` compiles to an input
2110 // slot + passthrough output, so
2111 // `materialize_wiring_from_outer` can wire a
2112 // `SharedCell` for cross-scope mutability (SRD-16
2113 // §"Mutability Rules: Shared Mutable"). Non-literal
2114 // inits and tuple-target shared bindings are
2115 // rejected on every entry point: the cell needs a
2116 // single, well-defined initial value, and a
2117 // computation-shaped RHS doesn't have one. See
2118 // SRD-16 §"Non-literal `shared` initializers".
2119 if b.modifier == BindingModifier::SHARED {
2120 if b.targets.len() != 1 {
2121 return Err(format!(
2122 "shared binding must be single-target, not tuple unpack \
2123 ({}). Declare each target separately if a shared cell \
2124 is intended.",
2125 b.targets.join(", "),
2126 ));
2127 }
2128 let name = &b.targets[0];
2129 let (init_value, port_type) =
2130 try_fold_shared_init(&b.value).ok_or_else(|| {
2131 format!(
2132 "shared binding '{name}' requires a literal initial value \
2133 (number, string, true/false). Computed and cycle-dependent \
2134 expressions don't have a well-defined single init for the \
2135 shared cell. See SRD-16 §\"Non-literal `shared` initializers\"."
2136 )
2137 })?;
2138 let (init_value, port_type) = apply_shared_type_annotation(
2139 name,
2140 b.type_annotation.as_ref(),
2141 init_value,
2142 port_type,
2143 )?;
2144 asm.add_input(
2145 name,
2146 init_value,
2147 port_type,
2148 crate::kernel::InputKind::ExternalWrite,
2149 );
2150 self.input_names.push(name.clone());
2151 let passthrough = Box::new(crate::library::identity::PortPassthrough::new(
2152 name, port_type,
2153 ));
2154 let passthrough_name = format!("__port_{name}");
2155 asm.add_node(&passthrough_name, passthrough, vec![WireRef::input(name)]);
2156 asm.add_output(name, WireRef::node(&passthrough_name));
2157 asm.set_output_modifier(name, BindingModifier::SHARED);
2158 continue;
2159 }
2160 self.compile_binding(&mut asm, &b.targets, &b.value)?;
2161 // Every target that now names a node reports what it
2162 // resolved to: a call, an operator, or a literal alike.
2163 for target in &b.targets {
2164 if let Some(node_type) = asm.node_type_of(target) {
2165 self.pending_events.push(
2166 super::events::CompileEvent::BindingResolved {
2167 name: target.clone(),
2168 node_type,
2169 },
2170 );
2171 }
2172 }
2173 if b.modifier != BindingModifier::NONE {
2174 for target in &b.targets {
2175 asm.set_output_modifier(target, b.modifier);
2176 }
2177 }
2178 // SRD-74 P2: auto-extern const targets whose RHS
2179 // references at least one name. See the parallel
2180 // block in `compile()` for rationale — makes
2181 // `const NAME := <expr>` a conditional shadow when
2182 // its RHS could fold to None, while leaving
2183 // pure-literal consts (SRD-13f Gate 2 iter-vars)
2184 // alone.
2185 if b.modifier.is_const() {
2186 let rhs_has_refs = {
2187 let mut refs = std::collections::HashSet::new();
2188 crate::dsl::validate::collect_references(&b.value, &mut refs);
2189 !refs.is_empty()
2190 };
2191 for target in &b.targets {
2192 asm.mark_const_output(target);
2193 if rhs_has_refs && !asm.input_names().contains(&target.as_str()) {
2194 // The binding's right-hand side was
2195 // compiled just above, so its node is
2196 // in the assembler and carries the
2197 // resolved output `PortType` the
2198 // auto-extern slot should take. That
2199 // covers every shape, including the
2200 // ones a pass over the surface AST
2201 // cannot see through — `select_str`,
2202 // `format_u64`, a nested call.
2203 let Some(inferred) = asm.output_type(target.as_str()) else {
2204 return Err(format!(
2205 "internal error: the const binding `{target}` was \
2206 just compiled, so the assembler should carry its \
2207 output type"
2208 ));
2209 };
2210 asm.add_input(
2211 target.as_str(),
2212 crate::ast::Value::None,
2213 inferred,
2214 crate::kernel::InputKind::IterationExtern,
2215 );
2216 }
2217 }
2218 }
2219 }
2220 Statement::ModuleDef(_) => {}
2221 Statement::ExternPort(port) => {
2222 // Mirror `compile()`: same kind classification —
2223 // a default expression marks this as a capture
2224 // port (dynamic); no default marks it as an
2225 // iteration extern (effectively-const at
2226 // scope-init time).
2227 let port_type = crate::ast::PortType::from_keyword(port.typ.as_str())
2228 .ok_or_else(|| {
2229 format!(
2230 "extern '{}': unknown polydat type keyword '{}'. \
2231 Canonical keywords are emitted by PortType::to_keyword \
2232 (one per PortType variant).",
2233 port.name, port.typ,
2234 )
2235 })?;
2236 let (default_value, kind) = match &port.default {
2237 Some(expr) => {
2238 let v = evaluate_default_expr(expr, port_type)
2239 .map_err(|e| format!("extern '{}' default: {e}", port.name,))?;
2240 (v, crate::kernel::InputKind::ExternalWrite)
2241 }
2242 None => (
2243 crate::ast::Value::None,
2244 crate::kernel::InputKind::IterationExtern,
2245 ),
2246 };
2247 asm.add_input(&port.name, default_value, port_type, kind);
2248 self.input_names.push(port.name.clone());
2249 let passthrough = Box::new(crate::library::identity::PortPassthrough::new(
2250 &port.name, port_type,
2251 ));
2252 let passthrough_name = format!("__port_{}", port.name);
2253 asm.add_node(
2254 &passthrough_name,
2255 passthrough,
2256 vec![crate::compile::assembly::WireRef::input(&port.name)],
2257 );
2258 asm.add_output(
2259 &port.name,
2260 crate::compile::assembly::WireRef::node(&passthrough_name),
2261 );
2262 }
2263 Statement::Cursor(decl) => {
2264 self.process_cursor(&mut asm, decl)?;
2265 }
2266 Statement::Pragma { .. } => {}
2267 Statement::For(f) => {
2268 return Err(format!(
2269 "`for {}` at line {}, col {}: {}",
2270 f.source.to_text(),
2271 f.span.line,
2272 f.span.col,
2273 "a `for` traversal compiles through `compile_polydat` and runs through `PolydatKernel::traverse`; the assembler entry point builds one program and cannot carry a traversal (docs/design/for_traversal.md §5)"
2274 ));
2275 }
2276 Statement::Tile(t) => {
2277 self.compile_tile(&mut asm, t)?;
2278 }
2279 }
2280 }
2281
2282 // Unused binding check: defer to kernel-level check in fold_init_constants_impl.
2283 // The kernel has the full wiring graph and can accurately determine which
2284 // nodes have no downstream consumers. The compiler can't do this reliably
2285 // because it doesn't track inter-binding wire dependencies.
2286
2287 // Expose outputs: only the required set, or all if no filter.
2288 // Cursor extent aux outputs (`__cursor_extent_*`) must always be
2289 // exposed regardless of the filter — they are queried by the
2290 // post-compile deferred extent resolution and would otherwise be
2291 // pruned by DCE, leaving the cursor extent unresolved.
2292 match required_outputs {
2293 Some(required) => {
2294 // SRD-13f Push D / SRD-44: `volatile` bindings stay
2295 // exposed as outputs even when the caller's
2296 // required list doesn't mention them. The author
2297 // declared the wire as volatile to mark it as
2298 // non-deterministic across invocations — losing
2299 // it from the output set (DCE) would also lose
2300 // the "exclude from program identity" guarantee,
2301 // because the lifecycle classifier would no
2302 // longer find a volatile output pointing at the
2303 // producing node.
2304 let mut required_owned: Vec<String> = required.to_vec();
2305 for stmt in &file.statements {
2306 if let crate::dsl::ast::Statement::Binding(b) = stmt
2307 && b.modifier.is_volatile()
2308 {
2309 for t in &b.targets {
2310 if !required_owned.iter().any(|n| n == t) {
2311 required_owned.push(t.clone());
2312 }
2313 }
2314 }
2315 }
2316 for name in &required_owned {
2317 if self.all_names.contains(name) {
2318 self.pending_events
2319 .push(super::events::CompileEvent::OutputDeclared {
2320 name: name.clone(),
2321 });
2322 }
2323 if self.all_names.contains(name) {
2324 asm.add_output(name, WireRef::node(name));
2325 }
2326 }
2327 for deferred in &self.deferred_extents {
2328 if self.all_names.contains(&deferred.start_output) {
2329 asm.add_output(
2330 &deferred.start_output,
2331 WireRef::node(&deferred.start_output),
2332 );
2333 }
2334 if self.all_names.contains(&deferred.end_output) {
2335 asm.add_output(&deferred.end_output, WireRef::node(&deferred.end_output));
2336 }
2337 }
2338 // Always preserve `__cursor_extent_*` auxiliary
2339 // outputs — they're consumed by the comprehension
2340 // `all(<cursor>)` form (SRD-18c §"Layer 3") and
2341 // also by the post-compile deferred-extent
2342 // resolution above. DCE-ing them would leave the
2343 // cursor's extent unresolvable to descendant scopes.
2344 let pruned_aux: Vec<String> = self
2345 .all_names
2346 .iter()
2347 .filter(|n| n.starts_with("__cursor_extent_"))
2348 .cloned()
2349 .collect();
2350 for name in pruned_aux {
2351 asm.add_output(&name, WireRef::node(&name));
2352 }
2353 }
2354 None => {
2355 for name in &self.all_names {
2356 self.pending_events
2357 .push(super::events::CompileEvent::OutputDeclared { name: name.clone() });
2358 asm.add_output(name, WireRef::node(name));
2359 }
2360 }
2361 }
2362
2363 asm.set_context(&self.source_text, &self.context_label);
2364 // The strictness pragmas reach every kernel built from this
2365 // assembler, on every engine and on every entry point.
2366 asm.set_strict_wires(self.pragmas.strict_types(), self.pragmas.strict_values());
2367 asm.set_strict(self.strict);
2368 // The cursors, with their partitions resolved at build, reach
2369 // every kernel built from this assembler (engines.md §3.5).
2370 asm.set_cursor_schemas(self.cursor_schemas.clone());
2371 Ok(asm)
2372 }
2373}
2374
2375// ── The one entry point (engines.md §3.5) ──────────────────
2376
2377/// Compile `source` for `engine`: the interpreter, the closure tier,
2378/// the hybrid kernel, or pure native code. Every engine accepts every
2379/// program the interpreter accepts, or refuses it with a reason
2380/// ([`crate::KernelError::Refused`]); a host drives the result through
2381/// [`crate::Kernel`] without knowing which engine it holds. The
2382/// `compile_polydat_kernel*` and `compile_polydat_checked` entry
2383/// points are this on `Engine::default()`; `compile_polydat`,
2384/// `compile_polydat_interpreter_with_options`, and the deprecated forms build
2385/// the interpreter's kernel.
2386pub fn compile_polydat_with(
2387 source: &str,
2388 engine: crate::Engine,
2389) -> Result<Box<dyn crate::Kernel>, crate::KernelError> {
2390 compile_polydat_with_engine(source, engine, &CompileOptions::default(), None)
2391}
2392
2393/// [`compile_polydat_with`] on [`Engine::default`](crate::Engine::default):
2394/// compiled code, with the JIT where the build has it.
2395pub fn compile_polydat_kernel(source: &str) -> Result<Box<dyn crate::Kernel>, crate::KernelError> {
2396 compile_polydat_with(source, crate::Engine::default())
2397}
2398
2399/// [`compile_polydat_kernel`] with the kernel path's options (source
2400/// directory, library paths, required outputs, strict typing, the
2401/// error context label, the cursor limit, and the engine preference)
2402/// and the compile event log.
2403///
2404/// This is the entry point the engine preference is read from: it
2405/// builds on `options.engine`, which defaults to the most native form
2406/// the build has, so a host that never sets the field gets compiled
2407/// code without naming one.
2408pub fn compile_polydat_kernel_with_options(
2409 source: &str,
2410 options: &CompileOptions,
2411 log: Option<&mut super::events::CompileEventLog>,
2412) -> Result<Box<dyn crate::Kernel>, crate::KernelError> {
2413 compile_polydat_with_engine(source, options.engine, options, log)
2414}
2415
2416/// [`compile_polydat_with`] with the kernel path's options (source
2417/// directory, library paths, required outputs, strict typing, the
2418/// error context label, the cursor limit) and the compile event log.
2419/// On the interpreter this is the whole kernel path, traversals
2420/// included; on a compiled engine the assembler entry point followed
2421/// by [`PolydatAssembler::compile_engine_with_log`].
2422///
2423/// The `engine` argument is a per-call override of `options.engine`,
2424/// for a caller that holds one options value and walks the tiers with
2425/// it. A caller that has no such need expresses the preference once, in
2426/// the options, and calls [`compile_polydat_kernel_with_options`].
2427pub fn compile_polydat_with_engine(
2428 source: &str,
2429 engine: crate::Engine,
2430 options: &CompileOptions,
2431 mut log: Option<&mut super::events::CompileEventLog>,
2432) -> Result<Box<dyn crate::Kernel>, crate::KernelError> {
2433 use crate::KernelError;
2434 let tokens = super::lexer::lex(source).map_err(KernelError::Source)?;
2435 let ast = super::parser::parse(tokens).map_err(KernelError::Source)?;
2436 if let Some(log) = log.as_deref_mut() {
2437 log.push(super::events::CompileEvent::Parsed {
2438 statements: ast.statements.len(),
2439 });
2440 }
2441 compile_ast_with_engine(&ast, source, options, log, engine)
2442}
2443
2444/// [`compile_polydat_with_engine`] from a parsed file: the parent
2445/// compiles on `engine` through the assembler, and each `for` body
2446/// compiles once for the interpreter as the traversal's record and on
2447/// any engine at activation (engine parity, step 8).
2448pub fn compile_ast_with_engine(
2449 ast: &PolydatFile,
2450 source: &str,
2451 options: &CompileOptions,
2452 mut log: Option<&mut super::events::CompileEventLog>,
2453 engine: crate::Engine,
2454) -> Result<Box<dyn crate::Kernel>, crate::KernelError> {
2455 let mut prepared = Prepared::new(source, ast, options, log.as_deref_mut());
2456 let (compiler, filter) = prepared.parts();
2457 compile_file_on_engine(compiler, ast, filter, engine, log)
2458}
2459
2460/// Everything an entry point sets up before a program assembles: the
2461/// compiler under its options, the outputs to keep, and the data-file
2462/// base directory for the compile's duration. One prologue for every
2463/// entry point, so the options mean the same thing whichever one
2464/// carries them.
2465struct Prepared {
2466 compiler: Compiler,
2467 required: Vec<String>,
2468 _data_base: Option<DataBaseDirGuard>,
2469}
2470
2471impl Prepared {
2472 fn new(
2473 source: &str,
2474 ast: &PolydatFile,
2475 options: &CompileOptions,
2476 log: Option<&mut super::events::CompileEventLog>,
2477 ) -> Self {
2478 // Relative data-file paths (csv/jsonl nodes) resolve against the
2479 // program's own directory for the duration of this synchronous
2480 // compile; see `library::datafile::set_data_base_dir`.
2481 let _data_base = options.source_dir.as_deref().map(DataBaseDirGuard::set);
2482 let pragmas = super::pragmas::collect_from_ast(ast);
2483 if let Some(log) = log {
2484 record_pragma_events(&pragmas, log);
2485 }
2486 // The required-outputs list is extended with the const bindings
2487 // only when the caller passed one: an empty list keeps every
2488 // binding, and extending it would flip its meaning.
2489 let required = if options.required_outputs.is_empty() {
2490 Vec::new()
2491 } else {
2492 extend_required_with_const_bindings(&options.required_outputs, ast)
2493 };
2494 let mut compiler = Compiler::with_lib_paths(
2495 options.source_dir.clone(),
2496 options.lib_paths.clone(),
2497 options.strict,
2498 );
2499 compiler.source_text = source.to_string();
2500 // An empty context keeps the compiler's default label, so a
2501 // failure reads the same whichever entry point built the kernel.
2502 if !options.context.is_empty() {
2503 compiler.context_label = options.context.clone();
2504 }
2505 compiler.cursor_limit = options.cursor_limit;
2506 compiler.pragmas = pragmas;
2507 if let Some(ledger) = &options.ledger {
2508 compiler.ledger = ledger.clone();
2509 }
2510 Prepared {
2511 compiler,
2512 required,
2513 _data_base,
2514 }
2515 }
2516
2517 /// The compiler and the output filter, `None` for every output.
2518 fn parts(&mut self) -> (&mut Compiler, Option<&[String]>) {
2519 let filter = if self.required.is_empty() {
2520 None
2521 } else {
2522 Some(self.required.as_slice())
2523 };
2524 (&mut self.compiler, filter)
2525 }
2526}
2527
2528/// The one path from a parsed file to a kernel, on every engine: the
2529/// `for` statements and producer bindings are lifted out, the parent
2530/// assembles and `build` makes its kernel, each body compiles once
2531/// against the parent's types and is attached, the tile events reach
2532/// the log, and every cursor extent the program computes from constants
2533/// is resolved on the kernel. Returns the kernel with the parent file
2534/// the traversals were lifted from.
2535fn compile_file_with<K: Built>(
2536 compiler: &mut Compiler,
2537 file: &PolydatFile,
2538 filter: Option<&[String]>,
2539 mut log: Option<&mut super::events::CompileEventLog>,
2540 build: impl FnOnce(
2541 PolydatAssembler,
2542 Option<&mut super::events::CompileEventLog>,
2543 ) -> Result<K, crate::KernelError>,
2544) -> Result<(K, PolydatFile), crate::KernelError> {
2545 use crate::KernelError;
2546 let (parent_file, for_stmts, producers) = super::traversal::strip_for_forms(
2547 file,
2548 compiler.validation_mode(),
2549 &compiler.source_scope(),
2550 &mut compiler.pending_events,
2551 )
2552 .map_err(KernelError::Source)?;
2553 compiler.producers_seen = producers.clone();
2554 let asm = compiler
2555 .assemble_parent(&parent_file, filter)
2556 .map_err(KernelError::Source)?;
2557 // The tiles typed while assembling belong to this program's log.
2558 if let Some(log) = log.as_deref_mut() {
2559 for e in compiler.pending_events.drain(..) {
2560 log.push(e);
2561 }
2562 }
2563 let mut built = build(asm, log.as_deref_mut())?;
2564 let kernel: &mut dyn crate::Kernel = built.kernel();
2565 if !for_stmts.is_empty() || !producers.is_empty() {
2566 let externs = kernel.externs();
2567 let inputs = kernel.input_names();
2568 let type_of = |name: &str| {
2569 kernel.output_type(name).or_else(|| {
2570 externs
2571 .iter()
2572 .find(|(n, _)| n == name)
2573 .map(|(_, t)| *t)
2574 .or_else(|| {
2575 // A coordinate: the one input kind that is not an extern.
2576 inputs
2577 .iter()
2578 .any(|n| n == name)
2579 .then_some(crate::ast::PortType::U64)
2580 })
2581 })
2582 };
2583 let traversals = compiler
2584 .compile_traversals(&for_stmts, &producers, &type_of)
2585 .map_err(KernelError::Source)?;
2586 crate::kernel::KernelInternals::set_traversals(kernel, traversals, producers);
2587 // Tiles inside the bodies, typed in the child compilers.
2588 if let Some(log) = log {
2589 for e in compiler.pending_events.drain(..) {
2590 log.push(e);
2591 }
2592 }
2593 }
2594 // A cursor whose range is computed from constants gets its extent
2595 // from the values the build folded, on every engine.
2596 for deferred in &compiler.deferred_extents {
2597 let start = kernel
2598 .folded_value(&deferred.start_output)
2599 .map(|v| v.as_u64());
2600 let end = kernel
2601 .folded_value(&deferred.end_output)
2602 .map(|v| v.as_u64());
2603 if let (Some(s), Some(e)) = (start, end) {
2604 let resolved = e.saturating_sub(s);
2605 let extent = compiler
2606 .cursor_limit
2607 .map(|limit| resolved.min(limit))
2608 .unwrap_or(resolved);
2609 if let Some(schema) = compiler.cursor_schemas.get_mut(deferred.schema_idx) {
2610 schema.extent = Some(extent);
2611 }
2612 kernel.set_cursor_extent(deferred.schema_idx, extent);
2613 }
2614 }
2615 Ok((built, parent_file))
2616}
2617
2618/// What a build hands back to the compile path: the interpreter's
2619/// concrete kernel or any engine's boxed one, each reachable as the one
2620/// trait the lowering drives.
2621trait Built {
2622 fn kernel(&mut self) -> &mut dyn crate::Kernel;
2623}
2624
2625impl Built for PolydatKernel {
2626 fn kernel(&mut self) -> &mut dyn crate::Kernel {
2627 self
2628 }
2629}
2630
2631impl Built for Box<dyn crate::Kernel> {
2632 fn kernel(&mut self) -> &mut dyn crate::Kernel {
2633 self.as_mut()
2634 }
2635}
2636
2637/// The kernel of a parsed file on `engine`: [`compile_file_with`] with
2638/// the engine's build, and the interpreter's concrete kernel boxed when
2639/// the engine is the interpreter.
2640pub(super) fn compile_file_on_engine(
2641 compiler: &mut Compiler,
2642 file: &PolydatFile,
2643 filter: Option<&[String]>,
2644 engine: crate::Engine,
2645 log: Option<&mut super::events::CompileEventLog>,
2646) -> Result<Box<dyn crate::Kernel>, crate::KernelError> {
2647 if let crate::Engine::Interpreter(cones) = engine {
2648 return compiler
2649 .compile_interpreter(file, filter, log, cones)
2650 .map(|k| Box::new(k) as Box<dyn crate::Kernel>);
2651 }
2652 let (kernel, _) = compile_file_with(compiler, file, filter, log, |asm, log| {
2653 asm.compile_engine_with_log(engine, log)
2654 })?;
2655 Ok(kernel)
2656}
2657// ── Former names of the interpreter-typed entry points ──────────────
2658//
2659// These returned the interpreter's concrete kernel under names that did
2660// not say so, which read as though they were the general way to compile
2661// under options. They are the exception, not the rule: a kernel is used
2662// through the `Kernel` trait, and the concrete type is for observing the
2663// interpreter's own internals in testing and diagnostics (engines.md
2664// §3.6). The names now say that; these keep the old ones working.
2665
2666#[cfg(test)]
2667mod tests {
2668 use super::*;
2669
2670 /// The interpreter kernel under `strict` alone.
2671 fn strict(src: &str, strict: bool) -> Result<PolydatKernel, crate::KernelError> {
2672 let options = CompileOptions {
2673 strict,
2674 ..CompileOptions::default()
2675 };
2676 compile_polydat_interpreter_with_options(src, &options, None)
2677 }
2678
2679 #[test]
2680 fn array_literal_binding_compiles_as_string() {
2681 // A list-valued binding (`const xs := [1, 2, 3]`) is a sweep
2682 // axis / interpolation value, not a scalar wire. polydat has no
2683 // const-vector node, so it binds to a `ConstStr` holding the
2684 // list's literal text rather than failing the compile — which
2685 // is what lets list-valued workload params (`limit_values:
2686 // [25]`) load.
2687 let result = compile_polydat_interpreter(
2688 "input cycle: u64\nconst eh_values := [1, 2, 3]\nout := cycle",
2689 );
2690 assert!(
2691 result.is_ok(),
2692 "array-literal binding should compile (binds as a string const), got: {:?}",
2693 result.err(),
2694 );
2695 // The resolved value is the comma-joined, bracket-free form a
2696 // sweep-axis param carries (so a `WorkloadParamList` source
2697 // splits it on `, ` exactly like a string-valued sweep param).
2698 let kernel = result.unwrap();
2699 match kernel.get_constant("eh_values") {
2700 Some(crate::ast::Value::Str(s)) => assert_eq!(s.as_ref(), "1, 2, 3"),
2701 other => panic!("expected eh_values = Str(\"1, 2, 3\"), got {other:?}"),
2702 }
2703 }
2704
2705 #[test]
2706 fn array_literal_in_argument_position_is_refused() {
2707 // The other half of the same rule: a list literal is a
2708 // binding-position form and has no meaning as a call argument
2709 // (polydat_grammar.md §18.1 T-ArrayLit). It used to lower to a
2710 // const argument nothing read, so the call reached the runtime
2711 // with one wire input missing and panicked there instead.
2712 let err = compile_polydat_interpreter("input cycle: u64\nout := printf(\"{}\", [1, 2])")
2713 .expect_err("a list literal in argument position is a compile error");
2714 let text = err.to_string();
2715 assert!(
2716 text.contains("binding-position form") && text.contains("printf"),
2717 "the error should name the form and the call: {text}",
2718 );
2719 // Bound first, the same list works, which is what the message
2720 // tells the author to do.
2721 let ok =
2722 compile_polydat_interpreter("input cycle: u64\nw := [1, 2]\nout := printf(\"{}\", w)");
2723 assert!(ok.is_ok(), "{:?}", ok.err());
2724 }
2725
2726 #[test]
2727 fn embedding_error_display_includes_source_text() {
2728 let e = EmbeddingError::LifecycleMismatch {
2729 source: "hash(cycle)".to_string(),
2730 dynamic_inputs: vec!["cycle".to_string()],
2731 };
2732 let s = format!("{e}");
2733 assert!(
2734 s.contains("hash(cycle)"),
2735 "display should include source: {s}"
2736 );
2737 assert!(
2738 s.contains("cycle"),
2739 "display should mention dynamic input: {s}"
2740 );
2741 }
2742
2743 #[test]
2744 fn embedding_error_from_string_shim() {
2745 let e = EmbeddingError::UnresolvedPlaceholder {
2746 name: "k".to_string(),
2747 source: "{k} > 5".to_string(),
2748 };
2749 let s: String = e.clone().into();
2750 assert_eq!(s, format!("{e}"));
2751 }
2752
2753 #[test]
2754 fn embedding_error_all_variants_display() {
2755 // Smoke test: every variant constructs and displays without panicking.
2756 let variants: Vec<EmbeddingError> = vec![
2757 EmbeddingError::Parse {
2758 source: "x +".into(),
2759 message: "unexpected EOF".into(),
2760 position: Some(3),
2761 },
2762 EmbeddingError::UnresolvedPlaceholder {
2763 name: "k".into(),
2764 source: "{k}".into(),
2765 },
2766 EmbeddingError::LifecycleMismatch {
2767 source: "hash(cycle)".into(),
2768 dynamic_inputs: vec!["cycle".into()],
2769 },
2770 EmbeddingError::UnknownNode {
2771 name: "frobnicate".into(),
2772 source: "frobnicate(x)".into(),
2773 suggestion: Some("fabricate".into()),
2774 },
2775 EmbeddingError::TypeMismatch {
2776 from_node: "n1".into(),
2777 from_type: crate::ast::PortType::U64,
2778 to_node: "n2".into(),
2779 to_type: crate::ast::PortType::Str,
2780 source: "n1 -> n2".into(),
2781 },
2782 EmbeddingError::NodeEvalPanic {
2783 node_name: "div".into(),
2784 message: "div by zero".into(),
2785 source: "div(a, b)".into(),
2786 },
2787 EmbeddingError::NonePropagated {
2788 accessor: "as_bool",
2789 source: "{missing}".into(),
2790 },
2791 ];
2792 for v in variants {
2793 let _ = format!("{v}");
2794 }
2795 }
2796
2797 #[test]
2798 fn typed_surface_string() {
2799 let v: String = eval_const_expr_typed("\"hello\"").unwrap();
2800 assert_eq!(v, "hello");
2801 }
2802
2803 #[test]
2804 fn typed_surface_type_mismatch() {
2805 // expression yields U64; host requests f64 — widening allowed
2806 let v: f64 = eval_const_expr_typed("42").unwrap();
2807 assert_eq!(v, 42.0);
2808 // expression yields U64; host requests bool — interpreted as bool (nonzero)
2809 let v: bool = eval_const_expr_typed("1").unwrap();
2810 assert!(v);
2811 let v: bool = eval_const_expr_typed("0").unwrap();
2812 assert!(!v);
2813 }
2814
2815 #[test]
2816 fn typed_surface_return_path_adapter() {
2817 // γ-6: expression produces U64; host requests String.
2818 // The catalog's U64ToString adapter heals the return-path.
2819 let v: String = eval_const_expr_typed("42").unwrap();
2820 assert_eq!(v, "42");
2821
2822 // Expression produces F64; host requests String via catalog
2823 // F64ToString. (Note: f64's Display is locale-independent
2824 // but format may add trailing zeros.)
2825 let v: String = eval_const_expr_typed("3.14").unwrap();
2826 assert!(v.starts_with("3.14"), "got {v}");
2827 }
2828
2829 #[test]
2830 fn typed_surface_return_path_no_adapter_errors() {
2831 // Bytes → Bool isn't in the catalog. Confirm the typed
2832 // error fires when the catalog can't heal.
2833 // (Need an expression producing Bytes; use a string-
2834 // literal-to-bytes conversion via bytes_of or similar
2835 // if available; otherwise use a roundtrip that fails.)
2836 //
2837 // Skipping concrete bytes producer for this test —
2838 // the contract is exercised by the negative path in
2839 // typed_surface_type_mismatch already.
2840 }
2841
2842 #[test]
2843 fn typed_strict_rejects_lossy_conversion() {
2844 // U64 → Bool is in the catalog (γ-6 added it) but
2845 // lossy. Strict mode must reject.
2846 let result: Result<bool, _> = eval_const_expr_typed_strict("42");
2847 match result {
2848 Err(EmbeddingError::TypeMismatch {
2849 from_type, to_type, ..
2850 }) => {
2851 assert!(matches!(from_type, crate::ast::PortType::U64));
2852 assert!(matches!(to_type, crate::ast::PortType::Bool));
2853 }
2854 other => panic!("expected TypeMismatch, got {other:?}"),
2855 }
2856 }
2857
2858 #[test]
2859 fn typed_strict_accepts_lossless_conversion() {
2860 // U64 → String via display — lossless.
2861 let v: String = eval_const_expr_typed_strict("42").unwrap();
2862 assert_eq!(v, "42");
2863
2864 // Same type, no adapter.
2865 let v: f64 = eval_const_expr_typed_strict("42.0").unwrap();
2866 assert_eq!(v, 42.0);
2867 }
2868
2869 /// Strict mode answers about the types, not about the one value
2870 /// in hand: `U64 → F64` is refused because `u64` has 64 magnitude
2871 /// bits and `f64`'s significand holds 53, so values above `2^53`
2872 /// round. A host that wants the number as an `f64` writes it as
2873 /// one. The type-level answer is the same for every input, which
2874 /// a value-level one would not be.
2875 #[test]
2876 fn typed_strict_refuses_a_widening_that_rounds() {
2877 let r: Result<f64, _> = eval_const_expr_typed_strict("42");
2878 assert!(
2879 matches!(r, Err(EmbeddingError::TypeMismatch { .. })),
2880 "{r:?}"
2881 );
2882 assert!(!is_lossless_adapter(
2883 crate::ast::PortType::U64,
2884 crate::ast::PortType::F64
2885 ));
2886 assert!(!is_lossless_adapter(
2887 crate::ast::PortType::I64,
2888 crate::ast::PortType::F64
2889 ));
2890 // The narrow integers do fit, which the old eleven-pair
2891 // table did not say.
2892 for from in [
2893 crate::ast::PortType::U8,
2894 crate::ast::PortType::U16,
2895 crate::ast::PortType::U32,
2896 ] {
2897 assert!(
2898 is_lossless_adapter(from, crate::ast::PortType::U64),
2899 "{from:?} → U64"
2900 );
2901 assert!(
2902 is_lossless_adapter(from, crate::ast::PortType::F64),
2903 "{from:?} → F64"
2904 );
2905 }
2906 // Signed never fits unsigned, however wide.
2907 assert!(!is_lossless_adapter(
2908 crate::ast::PortType::I8,
2909 crate::ast::PortType::U128
2910 ));
2911 }
2912
2913 #[test]
2914 fn shared_non_literal_init_rejected() {
2915 // Non-literal `shared` initializers no longer fall
2916 // through to the cycle-binding shape. Compile error
2917 // surfaces with a clear message naming the binding and
2918 // pointing at the SRD-16 §"Non-literal `shared`
2919 // initializers" section.
2920 let src = r#"
2921 input cycle: u64
2922 shared rolling := hash(cycle)
2923 "#;
2924 let err =
2925 compile_polydat_interpreter(src).expect_err("non-literal shared const must error");
2926 assert!(
2927 err.to_string().contains("shared binding 'rolling'"),
2928 "error: {err}"
2929 );
2930 assert!(
2931 err.to_string().contains("literal initial value"),
2932 "error: {err}"
2933 );
2934 }
2935
2936 #[test]
2937 fn final_modifier_tracked() {
2938 let src = r#"
2939 input cycle: u64
2940 const dim := 128
2941 "#;
2942 let kernel = compile_polydat_interpreter(src).unwrap();
2943 assert_eq!(
2944 kernel.program().output_modifier("dim"),
2945 crate::dsl::ast::BindingModifier::CONST
2946 );
2947 }
2948
2949 #[test]
2950 fn shared_literal_modifier_tracked() {
2951 let src = r#"
2952 input cycle: u64
2953 shared budget := 100
2954 "#;
2955 let kernel = compile_polydat_interpreter(src).unwrap();
2956 assert_eq!(
2957 kernel.program().output_modifier("budget"),
2958 crate::dsl::ast::BindingModifier::SHARED
2959 );
2960 // Shared cells back the output via a port-passthrough node
2961 // reading the input slot; `lookup` is the cell-aware read.
2962 assert_eq!(kernel.lookup("budget").unwrap().as_u64(), 100);
2963 }
2964
2965 #[test]
2966 fn const_literal_modifier_tracked() {
2967 let src = r#"
2968 input cycle: u64
2969 const max_dim := 256
2970 "#;
2971 let kernel = compile_polydat_interpreter(src).unwrap();
2972 assert_eq!(
2973 kernel.program().output_modifier("max_dim"),
2974 crate::dsl::ast::BindingModifier::CONST
2975 );
2976 assert_eq!(kernel.get_constant("max_dim").unwrap().as_u64(), 256);
2977 }
2978
2979 #[test]
2980 fn compile_string_constant() {
2981 let src = r#"
2982 input cycle: u64
2983 label := "hello world"
2984 "#;
2985 let mut kernel = compile_polydat_interpreter(src).unwrap();
2986 kernel.set_inputs(&[0]);
2987 assert_eq!(kernel.pull_ref("label").as_str(), "hello world");
2988 }
2989
2990 #[test]
2991 fn compile_int_constant() {
2992 let src = r#"
2993 input cycle: u64
2994 base := 1710000000000
2995 "#;
2996 let mut kernel = compile_polydat_interpreter(src).unwrap();
2997 kernel.set_inputs(&[0]);
2998 assert_eq!(kernel.pull_ref("base").as_u64(), 1_710_000_000_000);
2999 }
3000
3001 // --- Diagnostic tests ---
3002
3003 #[test]
3004 fn error_unknown_function() {
3005 let src = "input cycle: u64\nresult := foobar(cycle)";
3006 let (_result, report) = compile_polydat_checked(src);
3007 assert!(report.has_errors());
3008 let errors = report.errors();
3009 assert!(
3010 errors
3011 .iter()
3012 .any(|e| e.message.contains("unknown function"))
3013 );
3014 assert!(errors.iter().any(|e| e.message.contains("foobar")));
3015 }
3016
3017 #[test]
3018 fn explicit_coordinates_rejects_unbound() {
3019 // With explicit coordinates, unbound references are errors
3020 let src = "input cycle: u64\nh := hash(unknown)";
3021 let (_, report) = compile_polydat_checked(src);
3022 assert!(report.has_errors());
3023 assert!(
3024 report
3025 .errors()
3026 .iter()
3027 .any(|e| e.message.contains("undefined") && e.message.contains("unknown"))
3028 );
3029 }
3030
3031 #[test]
3032 fn warning_forward_reference() {
3033 let src = r#"
3034 input cycle: u64
3035 result := mod(h, 100)
3036 h := hash(cycle)
3037 "#;
3038 let (_, report) = compile_polydat_checked(src);
3039 let warnings = report.warnings();
3040 assert!(
3041 warnings
3042 .iter()
3043 .any(|w| w.message.contains("forward reference")),
3044 "should warn about forward ref, got: {:?}",
3045 warnings
3046 );
3047 }
3048
3049 #[test]
3050 fn error_undefined_wire() {
3051 let src = r#"
3052 input cycle: u64
3053 result := hash(nonexistent)
3054 "#;
3055 let (_, report) = compile_polydat_checked(src);
3056 assert!(report.has_errors());
3057 assert!(
3058 report
3059 .errors()
3060 .iter()
3061 .any(|e| e.message.contains("undefined") && e.message.contains("nonexistent"))
3062 );
3063 }
3064
3065 #[test]
3066 fn error_report_includes_source_line() {
3067 let src = "input cycle: u64\nresult := unknown_func(cycle)";
3068 let (_, report) = compile_polydat_checked(src);
3069 let s = report.to_string();
3070 assert!(
3071 s.contains("unknown_func"),
3072 "report should include source context"
3073 );
3074 }
3075
3076 // --- Strict mode tests ---
3077
3078 #[test]
3079 fn strict_requires_explicit_inputs() {
3080 // Without inputs declaration, strict mode should error
3081 let src = "h := hash(cycle)";
3082 let result = strict(src, true);
3083 assert!(result.is_err());
3084 let err = result.unwrap_err();
3085 assert!(
3086 err.to_string().contains("strict mode"),
3087 "expected strict error, got: {err}"
3088 );
3089 assert!(
3090 err.to_string().contains("inputs"),
3091 "expected inputs mention, got: {err}"
3092 );
3093 }
3094
3095 // --- Dead code elimination tests ---
3096
3097 // --- Strict mode comprehensive tests ---
3098
3099 // --- eval_const_expr tests ---
3100
3101 #[test]
3102 fn eval_const_expr_fails_on_inputs() {
3103 // 'cycle' is a runtime input — should fail as const expr
3104 let r = eval_const_expr("hash(cycle)");
3105 assert!(r.is_err(), "hash(cycle) should fail as a const expression");
3106 }
3107
3108 // ─────────────────────────────────────────────────────────────
3109 // Init-Binding Contract (SRD 11 §"Init Binding Contract")
3110 //
3111 // Plan A — compile-time check: every binding declared `init`
3112 // must classify as compile-const or scope-init. A wire chain
3113 // reaching a coordinate input, a external-write port, or a
3114 // non-deterministic source disqualifies the binding.
3115 // ─────────────────────────────────────────────────────────────
3116
3117 #[test]
3118 fn init_binding_compile_const_folded() {
3119 // Pure init: literal arg, no externs. Folds at compile
3120 // time; the compiled program's output_map points at a
3121 // ConstU64 leaf.
3122 let src = "const dim := 128\n";
3123 let kernel = compile_polydat_interpreter(src).expect("init compile-const");
3124 let prog = kernel.program();
3125 assert!(prog.const_outputs().contains(&"dim"));
3126 let &(node_idx, _) = prog.output_map_lookup("dim").expect("dim in output map");
3127 // After fold, the node has empty wiring (leaf const).
3128 assert!(
3129 prog.wiring[node_idx].is_empty(),
3130 "compile-const init binding 'dim' must fold to a leaf const node"
3131 );
3132 }
3133
3134 #[test]
3135 fn init_binding_with_iteration_extern_passes_plan_a() {
3136 // Init binding wired through an iteration extern: this is
3137 // legal under Plan A — the wire chain reaches an
3138 // IterationExtern input slot, which is effectively-const at
3139 // scope-init time. Plan B (executor-side) is what actually
3140 // evaluates it; the compile step just must not reject.
3141 let src = "extern profile: String\n\
3142 const label := format_str(\"label_%s\", profile)\n";
3143 let result = compile_polydat_interpreter(src);
3144 // We don't care if format_str exists in the stdlib — what
3145 // we're testing is that the contract check itself doesn't
3146 // fail (any error must be about an unknown function, not
3147 // about the init contract).
3148 match result {
3149 Ok(_) => {} // ideal: kernel built
3150 Err(e) => assert!(
3151 !e.to_string().contains("violates the init contract"),
3152 "Plan A must accept iteration-extern wires in init bindings; got: {e}"
3153 ),
3154 }
3155 }
3156
3157 #[test]
3158 fn init_binding_wired_to_nondeterministic_rejected() {
3159 // `counter()` is non-deterministic; init bindings must not
3160 // depend on it.
3161 let src = "const bad := counter()\n";
3162 let err = compile_polydat_interpreter(src)
3163 .expect_err("Plan A must reject init binding wired to a non-deterministic source");
3164 assert!(
3165 err.to_string().contains("init binding 'bad'")
3166 && err.to_string().contains("init contract"),
3167 "diagnostic must name the binding and the contract; got: {err}"
3168 );
3169 }
3170
3171 #[test]
3172 fn init_outputs_threaded_into_program() {
3173 // Sanity: the compiler records every `init`-declared name
3174 // on GkProgram.const_outputs so Plan B (executor side) can
3175 // walk them at scope activation.
3176 let src = "const a := 1\n\
3177 const b := 2\n\
3178 c := 3\n";
3179 let kernel = compile_polydat_interpreter(src).unwrap();
3180 let init_set = kernel.program().const_outputs();
3181 assert!(init_set.contains(&"a"), "const 'a' should be tracked");
3182 assert!(init_set.contains(&"b"), "const 'b' should be tracked");
3183 assert!(
3184 !init_set.contains(&"c"),
3185 "non-const 'c' must not be tracked"
3186 );
3187 }
3188
3189 /// Auto-extern slots inferred from RHS shape land at the
3190 /// boundary with their actual type (Str / U64 / F64 / Bool)
3191 /// rather than the legacy `PortType::Ext` catchall. This
3192 /// removes the `U64 → Ext` boundary-adapter miss the audit
3193 /// log used to warn about for workloads that use `set:`
3194 /// blocks with iter-var interpolation.
3195 ///
3196 /// Test path: declare an iteration extern explicitly with
3197 /// `extern N: str` (no default → `IterationExtern` kind,
3198 /// effectively-const at scope-init); reference it from a
3199 /// const RHS. The const target then needs an auto-extern
3200 /// slot (RHS has a ref), and the inferrer picks the
3201 /// referenced input's type.
3202 #[test]
3203 fn auto_extern_slot_inherits_string_template_type() {
3204 let src = r#"
3205 extern some_outer_var: str
3206 const x := "{some_outer_var}"
3207 "#;
3208 let kernel = compile_polydat_interpreter(src).expect("compile");
3209 assert_eq!(
3210 kernel.program().input_port_type("x"),
3211 Some(crate::ast::PortType::Str),
3212 "string-template auto-extern MUST be Str, not Ext",
3213 );
3214 }
3215
3216 /// Identifier reference auto-extern inherits the referenced
3217 /// input's type. `const y := other_str_input` → y is Str.
3218 #[test]
3219 fn auto_extern_slot_inherits_ident_reference_type() {
3220 let src = r#"
3221 extern other: str
3222 const y := other
3223 "#;
3224 let kernel = compile_polydat_interpreter(src).expect("compile");
3225 assert_eq!(
3226 kernel.program().input_port_type("y"),
3227 Some(crate::ast::PortType::Str),
3228 "ident-RHS auto-extern MUST inherit referenced input's type",
3229 );
3230 }
3231
3232 /// `dataset_prebuffer(...)` returns `Value::Handle` — the
3233 /// auto-extern slot for `const prebuffered := dataset_prebuffer(...)`
3234 /// MUST be `PortType::Handle`, not the legacy `Ext` catchall.
3235 /// This is the second specific call site we patched in the
3236 /// inferrer after the `printf` string-template case.
3237 /// (`dataset_prebuffer` is a vectordata node, so the test only
3238 /// exists when that feature registers it.)
3239 #[cfg(feature = "vectordata")]
3240 #[test]
3241 fn auto_extern_slot_for_dataset_prebuffer_is_handle() {
3242 let src = r#"
3243 extern source_uri: str
3244 const prebuffered := dataset_prebuffer(source_uri)
3245 "#;
3246 let kernel = compile_polydat_interpreter(src).expect("compile");
3247 assert_eq!(
3248 kernel.program().input_port_type("prebuffered"),
3249 Some(crate::ast::PortType::Handle),
3250 "dataset_prebuffer auto-extern MUST be Handle, not Ext",
3251 );
3252 }
3253}