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