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