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