Skip to main content

polydat_core/dsl/
compile.rs

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