Skip to main content

polydat_core/dsl/
compile.rs

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