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