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