Skip to main content

graphcal_compiler/tir/
typed.rs

1//! Typed Intermediate Representation (TIR) — type annotations resolved to semantic types.
2//!
3//! The TIR layer resolves ambiguous syntax-level type paths (`NamePath` in
4//! `DimTerm::name`, type applications, and `TypeExprKind::Indexed::indexes`) into
5//! concrete dimensions, struct types, generic dimension parameters, or generic
6//! index parameters.
7
8use std::collections::{BTreeMap, BTreeSet, HashMap};
9use std::sync::Arc;
10
11use miette::NamedSource;
12
13use crate::desugar::desugared_ast::{MulDivOp, TypeExpr, TypeExprKind};
14use crate::hir;
15use crate::hir::diagnostics::{
16    expr_lower_error_to_graphcal, hir_lower_error_to_graphcal, resolved_decl_key,
17};
18pub use crate::ir::lower::{LoweredPlotBody, LoweredPlotField};
19use crate::syntax::dimension::{Dimension, Rational};
20use crate::syntax::names::{
21    ConstructorName, DeclName, DimName, FieldName, GenericParamName, IndexName, ModuleAliasName,
22    NameAtom, NamePath, StructTypeName,
23};
24use crate::syntax::nat::Monomial;
25pub use crate::syntax::nat::{NatLinearForm, NatPolyForm};
26use crate::syntax::span::{Span, Spanned};
27
28use crate::ir::lower::IR;
29use crate::ir::resolve::{DeclCategory, ExpectedFail};
30use crate::registry::declared_type::IndexTypeRef;
31use crate::registry::error::GraphcalError;
32use crate::registry::time_scale::TimeScale;
33use crate::registry::types::{
34    IndexDef, Registry, RegistryBuilder, TypeDef, TypeGenericConstraint, UnionMemberDef,
35};
36use crate::syntax::module_resolve::{ModuleResolveError, ModuleResolver};
37use crate::syntax::names::{ResolvedName, ScopedName, namespace};
38
39// ---------------------------------------------------------------------------
40// Resolved type types
41// ---------------------------------------------------------------------------
42
43/// A fully-resolved type expression.
44///
45/// Unlike the raw AST `TypeExpr`, every name here has been classified as a
46/// concrete dimension, struct, generic dim param, or index generic argument.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum ResolvedTypeExpr {
49    /// `Dimensionless`
50    Dimensionless,
51    /// `Bool`
52    Bool,
53    /// `Int`
54    Int,
55    /// A datetime instant in a specific time scale (e.g., `Datetime` = UTC, `Datetime<TT>`).
56    Datetime(TimeScale),
57    /// An index argument to a generic type parameter constrained as `Index`.
58    ///
59    /// This is not a standalone value type and must not appear as a resolved
60    /// declaration annotation.
61    IndexArg(ResolvedIndex),
62    /// A concrete scalar dimension, e.g. `Length * Time^-2`
63    Scalar(Dimension),
64    /// A non-generic struct type name, e.g. `TransferResult`.
65    Struct(ResolvedName<namespace::StructType>, Span),
66    /// A generic struct with concrete type arguments, e.g. `Vec3<Length, ECI>`.
67    GenericStruct {
68        name: ResolvedName<namespace::StructType>,
69        type_args: Vec<Self>,
70        span: Span,
71    },
72    /// A single generic dimension parameter, e.g. `D`
73    GenericDimParam(GenericParamName, Span),
74    /// A generic type parameter, e.g. `F: Type`.
75    GenericTypeParam(GenericParamName, Span),
76    /// A compound dimension expression containing at least one generic param, e.g. `D^2`
77    GenericDimExpr {
78        terms: Vec<ResolvedDimTerm>,
79        span: Span,
80    },
81    /// An indexed type, e.g. `Velocity[Maneuver]` or `D[I]`
82    Indexed {
83        base: Box<Self>,
84        indexes: Vec<ResolvedIndex>,
85    },
86}
87
88impl ResolvedTypeExpr {
89    /// Format as a human-readable string, e.g. `"Length / Time^2"`, `"Bool"`, `"Vec3<Length, ECI>"`.
90    #[must_use]
91    pub fn format(&self, registry: &Registry) -> String {
92        match self {
93            Self::Dimensionless => "Dimensionless".to_string(),
94            Self::Bool => "Bool".to_string(),
95            Self::Int => "Int".to_string(),
96            Self::Datetime(scale) => {
97                if scale.is_utc() {
98                    "Datetime".to_string()
99                } else {
100                    format!("Datetime<{scale}>")
101                }
102            }
103            Self::IndexArg(index) => format!("index {}", format_resolved_index(index)),
104            Self::Scalar(dim) => {
105                let formatted = registry.dimensions.format_dimension(dim);
106                if formatted.is_empty() {
107                    "Dimensionless".to_string()
108                } else {
109                    formatted
110                }
111            }
112            Self::Struct(name, _) => name.as_str().to_string(),
113            Self::GenericStruct {
114                name, type_args, ..
115            } => {
116                let args: Vec<String> = type_args.iter().map(|a| a.format(registry)).collect();
117                format!("{}<{}>", name.as_str(), args.join(", "))
118            }
119            Self::GenericDimParam(name, _) | Self::GenericTypeParam(name, _) => name.to_string(),
120            Self::GenericDimExpr { terms, .. } => {
121                let parts: Vec<String> = terms.iter().map(|t| t.format(registry)).collect();
122                parts.join(" ")
123            }
124            Self::Indexed { base, indexes } => {
125                let base_str = base.format(registry);
126                let idx_strs: Vec<String> = indexes.iter().map(format_resolved_index).collect();
127                format!("{base_str}[{}]", idx_strs.join(", "))
128            }
129        }
130    }
131}
132
133fn format_resolved_index(index: &ResolvedIndex) -> String {
134    match index {
135        ResolvedIndex::Concrete(name, _) => name.as_str().to_string(),
136        ResolvedIndex::GenericParam(name, _) => name.to_string(),
137        ResolvedIndex::NatExpr(form, _) => format!("range({})", form.format()),
138    }
139}
140
141/// A single term in a resolved dimension expression.
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub enum ResolvedDimTerm {
144    /// A concrete dimension with power and combining operator.
145    Concrete {
146        dim: Dimension,
147        power: Rational,
148        op: MulDivOp,
149    },
150    /// A generic dimension parameter with power and combining operator.
151    GenericParam {
152        name: GenericParamName,
153        power: Rational,
154        op: MulDivOp,
155        span: Span,
156    },
157}
158
159impl ResolvedDimTerm {
160    /// Get the combining operator for this term.
161    #[must_use]
162    pub const fn op(&self) -> MulDivOp {
163        match self {
164            Self::Concrete { op, .. } | Self::GenericParam { op, .. } => *op,
165        }
166    }
167
168    /// Format this term as a human-readable string, e.g. `"Length"`, `"/ Time^2"`, `"D^2"`.
169    #[must_use]
170    pub fn format(&self, registry: &Registry) -> String {
171        let (name, power, op) = match self {
172            Self::Concrete { dim, power, op } => {
173                (registry.dimensions.format_dimension(dim), *power, *op)
174            }
175            Self::GenericParam {
176                name, power, op, ..
177            } => (name.to_string(), *power, *op),
178        };
179        let prefix = match op {
180            MulDivOp::Mul => "",
181            MulDivOp::Div => "/ ",
182        };
183        if power == Rational::ONE {
184            format!("{prefix}{name}")
185        } else {
186            format!(
187                "{prefix}{name}{}",
188                crate::registry::format::format_exponent(power)
189            )
190        }
191    }
192}
193
194/// Typed identity for a Nat-range index used by type inference.
195///
196/// Generic forms such as `range(N + 1)` are carried as normalized
197/// [`NatPolyForm`] values. They are rendered to `range(...)` only for
198/// diagnostics or display adapters; semantic comparisons use the typed form.
199#[derive(Debug, Clone, PartialEq, Eq)]
200pub struct NatRangeIndexIdentity {
201    form: NatPolyForm,
202}
203
204impl NatRangeIndexIdentity {
205    /// Create a Nat-range identity from a normalized Nat polynomial form.
206    ///
207    /// # Errors
208    ///
209    /// Returns an error when the form is a concrete `0` or cannot be
210    /// represented as a non-empty in-memory Nat range on this target.
211    pub fn try_from_form(
212        form: NatPolyForm,
213    ) -> Result<Self, crate::registry::types::NatRangeIndexError> {
214        if form.is_constant() {
215            crate::registry::types::NatRangeIndex::try_from_u64(form.constant())?;
216        }
217        Ok(Self { form })
218    }
219
220    /// Borrow the normalized Nat form (`N`, `N + 1`, `3`, ...).
221    #[must_use]
222    pub const fn form(&self) -> &NatPolyForm {
223        &self.form
224    }
225
226    /// Consume and return the normalized Nat form.
227    #[must_use]
228    pub fn into_form(self) -> NatPolyForm {
229        self.form
230    }
231
232    /// Convert to an index type reference without serializing the Nat form
233    /// into a recoverable string.
234    ///
235    /// # Errors
236    ///
237    /// Returns an error if the identity invariant was violated before this
238    /// conversion (for example, a concrete zero-sized range).
239    pub fn to_index_type_ref(
240        &self,
241    ) -> Result<IndexTypeRef, crate::registry::types::NatRangeIndexError> {
242        IndexTypeRef::from_nat_range_form(self.form.clone())
243    }
244}
245
246impl NatPolyForm {
247    /// Wrap this normalized Nat form as a typed Nat-range index identity.
248    ///
249    /// # Errors
250    ///
251    /// Returns an error when the form is a concrete invalid Nat range size.
252    pub fn to_nat_range_identity(
253        &self,
254    ) -> Result<NatRangeIndexIdentity, crate::registry::types::NatRangeIndexError> {
255        NatRangeIndexIdentity::try_from_form(self.clone())
256    }
257}
258/// Normalize an AST `NatExpr` into a `NatPolyForm`.
259///
260/// All variables referenced must be Nat generic parameters in scope.
261/// Returns an error if a variable is not a known Nat param.
262pub fn normalize_nat_expr(
263    expr: &crate::desugar::desugared_ast::NatExpr,
264    nat_params: &[GenericParamName],
265    src: &NamedSource<Arc<String>>,
266) -> Result<NatPolyForm, GraphcalError> {
267    use crate::desugar::desugared_ast::NatExpr;
268    match expr {
269        NatExpr::Literal(n, _) => Ok(NatPolyForm::from_constant(*n)),
270        NatExpr::Var(ident) => {
271            let gp = nat_params
272                .iter()
273                .find(|p| p.as_str() == ident.name.as_str())
274                .ok_or_else(|| GraphcalError::UnknownIndex {
275                    name: IndexName::new(&ident.name),
276                    src: src.clone(),
277                    span: ident.span.into(),
278                })?;
279            Ok(NatPolyForm::from_var(gp.clone()))
280        }
281        NatExpr::Add(lhs, rhs, span) => {
282            let l = normalize_nat_expr(lhs, nat_params, src)?;
283            let r = normalize_nat_expr(rhs, nat_params, src)?;
284            l.add(&r).map_err(|err| nat_overflow_error(err, src, *span))
285        }
286        NatExpr::Mul(lhs, rhs, span) => {
287            let l = normalize_nat_expr(lhs, nat_params, src)?;
288            let r = normalize_nat_expr(rhs, nat_params, src)?;
289            l.mul(&r).map_err(|err| nat_overflow_error(err, src, *span))
290        }
291    }
292}
293
294/// Convert a [`NatOverflowError`](crate::syntax::nat::NatOverflowError)
295/// into a spanned [`GraphcalError`].
296#[must_use]
297pub fn nat_overflow_error(
298    err: crate::syntax::nat::NatOverflowError,
299    src: &NamedSource<Arc<String>>,
300    span: Span,
301) -> GraphcalError {
302    GraphcalError::EvalError {
303        message: err.to_string(),
304        src: src.clone(),
305        span: span.into(),
306    }
307}
308
309/// A resolved index in an indexed type.
310#[derive(Debug, Clone, PartialEq, Eq)]
311pub enum ResolvedIndex {
312    /// A concrete index name, e.g. `Maneuver`.
313    Concrete(ResolvedName<namespace::Index>, Span),
314    /// A generic index parameter, e.g. `I`
315    GenericParam(GenericParamName, Span),
316    /// A Nat expression in index position (covers literals, variables, addition, and multiplication).
317    ///
318    /// Examples: `3` → constant form, `N` → single-variable form, `N + 1` → linear,
319    /// `M * N` → polynomial.
320    NatExpr(NatPolyForm, Span),
321}
322
323impl ResolvedIndex {
324    #[must_use]
325    pub fn format_for_diagnostic(&self) -> String {
326        match self {
327            Self::Concrete(name, _) => name.as_str().to_string(),
328            Self::GenericParam(name, _) => name.to_string(),
329            Self::NatExpr(form, _) => format!("range({})", form.format()),
330        }
331    }
332}
333
334/// Canonical type-system definitions keyed by [`ResolvedName`] identities.
335///
336/// The standalone [`Registry`] remains leaf-keyed for now because runtime values and
337/// declaration types still use local names. This registry is the module-aware
338/// lookup side table used by TIR resolution: qualified source paths are first
339/// resolved through [`ModuleResolver`] to canonical owners, then looked up here
340/// instead of by source alias text or a dotted string.
341#[derive(Debug, Clone)]
342pub struct ModuleConstructorDef {
343    pub owning_type: ResolvedName<namespace::StructType>,
344    pub type_def: TypeDef,
345    pub variant: UnionMemberDef,
346}
347
348#[derive(Debug, Default, Clone)]
349pub struct ModuleTypeRegistry {
350    dimensions: HashMap<ResolvedName<namespace::Dim>, Dimension>,
351    indexes: HashMap<ResolvedName<namespace::Index>, IndexDef>,
352    struct_types: HashMap<ResolvedName<namespace::StructType>, TypeDef>,
353    constructors: HashMap<ResolvedName<namespace::Constructor>, ModuleConstructorDef>,
354}
355
356impl ModuleTypeRegistry {
357    /// Insert canonical Graphcal prelude dimensions under the synthetic prelude owner.
358    ///
359    /// # Errors
360    ///
361    /// Returns a rational arithmetic error only if the built-in prelude itself
362    /// fails to construct, which would be a compiler bug.
363    pub fn insert_graphcal_prelude(
364        &mut self,
365    ) -> Result<(), crate::syntax::dimension::RationalError> {
366        let mut builder = RegistryBuilder::new();
367        crate::registry::prelude::load_prelude(&mut builder)?;
368        let registry = builder.build();
369        let owner = crate::registry::prelude::prelude_dag_id();
370        for name in crate::registry::prelude::PRELUDE_DIMENSION_NAMES {
371            if let Some(dim) = registry.dimensions.get_dimension(name) {
372                self.dimensions.insert(
373                    ResolvedName::from_def(owner.clone(), DimName::new(*name)),
374                    dim.clone(),
375                );
376            }
377        }
378        Ok(())
379    }
380
381    /// Insert every type-system definition from `registry` under `owner`.
382    ///
383    /// This is intentionally an owner-qualified view over existing registries,
384    /// not a new source of truth. It lets module-aware resolution validate that
385    /// `alias.Name` denotes the definition owned by the dependency selected by
386    /// the loader.
387    pub fn insert_registry(&mut self, owner: &crate::dag_id::DagId, registry: &Registry) {
388        for (name, dim) in registry.dimensions.all_dimensions() {
389            self.dimensions.insert(
390                ResolvedName::from_def(owner.clone(), name.clone()),
391                dim.clone(),
392            );
393        }
394        for index in registry.indexes.all_indexes() {
395            self.indexes.insert(
396                ResolvedName::from_def(owner.clone(), index.name.clone()),
397                index.clone(),
398            );
399        }
400        for type_def in registry.types.all_types() {
401            let type_name = ResolvedName::from_def(owner.clone(), type_def.name.clone());
402            self.struct_types
403                .insert(type_name.clone(), type_def.clone());
404            if let Some(members) = type_def.union_members() {
405                for member in members {
406                    self.constructors.insert(
407                        ResolvedName::from_def(owner.clone(), member.name.clone()),
408                        ModuleConstructorDef {
409                            owning_type: type_name.clone(),
410                            type_def: type_def.clone(),
411                            variant: member.clone(),
412                        },
413                    );
414                }
415            }
416        }
417    }
418
419    #[must_use]
420    pub fn get_dimension(&self, name: &ResolvedName<namespace::Dim>) -> Option<&Dimension> {
421        self.dimensions.get(name)
422    }
423
424    #[must_use]
425    pub fn get_index(&self, name: &ResolvedName<namespace::Index>) -> Option<&IndexDef> {
426        self.indexes.get(name)
427    }
428
429    #[must_use]
430    pub fn get_struct_type(&self, name: &ResolvedName<namespace::StructType>) -> Option<&TypeDef> {
431        self.struct_types.get(name)
432    }
433
434    /// Look up the owner type and union member for a canonical constructor identity.
435    #[must_use]
436    pub fn lookup_constructor(
437        &self,
438        constructor: &ResolvedName<namespace::Constructor>,
439    ) -> Option<&ModuleConstructorDef> {
440        self.constructors.get(constructor)
441    }
442}
443
444/// Module-aware type-resolution context for one DAG body.
445#[derive(Debug, Clone, Copy)]
446pub struct ModuleTypeContext<'a> {
447    owner: &'a crate::dag_id::DagId,
448    resolver: &'a ModuleResolver,
449    types: &'a ModuleTypeRegistry,
450}
451
452impl<'a> ModuleTypeContext<'a> {
453    #[must_use]
454    pub const fn new(
455        owner: &'a crate::dag_id::DagId,
456        resolver: &'a ModuleResolver,
457        types: &'a ModuleTypeRegistry,
458    ) -> Self {
459        Self {
460            owner,
461            resolver,
462            types,
463        }
464    }
465
466    #[must_use]
467    pub const fn owner(self) -> &'a crate::dag_id::DagId {
468        self.owner
469    }
470}
471
472// ---------------------------------------------------------------------------
473// Resolved domain constraints
474// ---------------------------------------------------------------------------
475
476/// A resolved domain constraint with evaluated SI-unit bounds.
477///
478/// Produced during module-aware TIR construction by evaluating the bound expressions
479/// in `DomainBound` to concrete f64 values (in SI units).
480#[derive(Debug, Clone)]
481pub struct ResolvedDomainConstraint {
482    /// Minimum bound in SI units, or `None` if no `min:` was specified.
483    pub min: Option<f64>,
484    /// Maximum bound in SI units, or `None` if no `max:` was specified.
485    pub max: Option<f64>,
486    /// Original min expression text for diagnostics (e.g., `"100 kg"`).
487    pub min_display: Option<String>,
488    /// Original max expression text for diagnostics (e.g., `"2000 kg"`).
489    pub max_display: Option<String>,
490    /// Span covering the entire constraint clause for error reporting.
491    pub span: Span,
492}
493
494/// Owner-qualified key for a domain constraint declared on a struct/union field.
495///
496/// The owning type carries a canonical owner when module-aware type resolution
497/// supplied one. The constructor remains a separate typed leaf because union
498/// members can share the same field names with different constraints.
499#[derive(Debug, Clone, PartialEq, Eq, Hash)]
500pub struct StructFieldConstraintKey {
501    pub owning_type: crate::registry::declared_type::StructTypeRef,
502    pub constructor: ConstructorName,
503    pub field: FieldName,
504}
505
506impl StructFieldConstraintKey {
507    #[must_use]
508    pub const fn new(
509        owning_type: crate::registry::declared_type::StructTypeRef,
510        constructor: ConstructorName,
511        field: FieldName,
512    ) -> Self {
513        Self {
514            owning_type,
515            constructor,
516            field,
517        }
518    }
519}
520
521// ---------------------------------------------------------------------------
522// DAG registry
523// ---------------------------------------------------------------------------
524
525/// Map from canonical [`DagId`](crate::dag_id::DagId) to its
526/// compiled per-DAG TIR.
527///
528/// Holds every DAG in scope at this file: the file's own top-level body
529/// (keyed by [`TIR::root_dag_id`]), every inline `dag X { ... }` child
530/// (keyed by `parent_dag_id.child(name)`), and every dep DAG merged in
531/// by `merge_dep_dag_tirs` (keyed by the dep's canonical id).
532pub type DagRegistry = HashMap<crate::dag_id::DagId, DagTIR>;
533
534/// Canonical dependency maps for one DAG body, collected from HIR expressions.
535#[derive(Debug, Clone, Default, PartialEq, Eq)]
536pub struct ResolvedDagDependencies {
537    /// For each param/node declaration, the canonical declarations it reads via `@`.
538    pub runtime_deps:
539        HashMap<ResolvedName<namespace::Decl>, BTreeSet<ResolvedName<namespace::Decl>>>,
540    /// For each const declaration, the canonical const declarations it reads.
541    pub const_deps: HashMap<ResolvedName<namespace::Decl>, BTreeSet<ResolvedName<namespace::Decl>>>,
542}
543
544/// HIR expressions for value declarations.
545#[derive(Debug, Clone, Default)]
546pub struct ResolvedExpressions {
547    /// Const declaration expression keyed by its canonical declaration identity.
548    pub consts: HashMap<ResolvedName<namespace::Decl>, hir::Expr>,
549    /// Param default expression keyed by its canonical declaration identity.
550    pub param_defaults: HashMap<ResolvedName<namespace::Decl>, hir::Expr>,
551    /// Node expression keyed by its canonical declaration identity.
552    pub nodes: HashMap<ResolvedName<namespace::Decl>, hir::Expr>,
553    /// Assert body keyed by its canonical declaration identity.
554    pub asserts: HashMap<ResolvedName<namespace::Decl>, hir::AssertBody>,
555}
556
557impl ResolvedExpressions {
558    /// Look up the HIR expression for a runtime declaration (param default or node).
559    #[must_use]
560    pub fn runtime_expr(&self, key: &ResolvedName<namespace::Decl>) -> Option<&hir::Expr> {
561        self.param_defaults.get(key).or_else(|| self.nodes.get(key))
562    }
563}
564
565/// Canonical HIR-derived index references used by collection/index inference.
566#[derive(Debug, Clone, Default)]
567pub struct ResolvedCollectionRefs {
568    /// Canonical index definitions observed while collecting the refs
569    /// or owner-qualified declaration types that runtime collection semantics
570    /// may need (for example `unfold` over a declared indexed node).
571    pub index_defs: HashMap<ResolvedName<namespace::Index>, IndexDef>,
572}
573
574/// Canonical HIR-derived constructor references used by constructor and match inference.
575#[derive(Debug, Clone, Default)]
576pub struct ResolvedConstructorRefs {
577    /// Canonical constructor definitions observed while collecting constructor
578    /// calls, const-like constructor refs, and match patterns. HIR carries the
579    /// resolved constructor name inline; this map supplies the rich target.
580    pub constructor_defs: HashMap<ResolvedName<namespace::Constructor>, ResolvedConstructorTarget>,
581}
582
583/// Canonical HIR-derived inline-DAG calls used by dim-check/eval routing.
584#[derive(Debug, Clone, Default)]
585pub struct ResolvedInlineDagRefs {
586    /// Full inline-DAG call expression span -> resolved call routing metadata.
587    pub calls: HashMap<Span, ResolvedInlineDagCall>,
588}
589
590/// Canonical field type identity inside a resolved struct/tagged-union type.
591#[derive(Debug, Clone, PartialEq, Eq, Hash)]
592pub struct ResolvedStructFieldTypeKey {
593    /// Canonical owner/name of the type that owns the constructor.
594    pub owning_type: ResolvedName<namespace::StructType>,
595    /// Constructor/union-member leaf inside the owning type.
596    pub constructor: ConstructorName,
597    /// Field leaf inside the constructor payload.
598    pub field: FieldName,
599}
600
601/// Canonical type definitions referenced by module-aware TIR.
602#[derive(Debug, Clone, Default)]
603pub struct ResolvedTypeDefs {
604    /// Struct/tagged-union definitions keyed by canonical owner/name.
605    pub struct_types: HashMap<ResolvedName<namespace::StructType>, TypeDef>,
606    /// Field type annotations resolved in the owning type's generic scope.
607    pub field_types: HashMap<ResolvedStructFieldTypeKey, ResolvedTypeExpr>,
608    /// Field domain bounds lowered to HIR in the owning type's generic scope.
609    pub field_bounds: HashMap<ResolvedStructFieldTypeKey, Vec<ResolvedDomainBound>>,
610    /// Generic parameter defaults resolved in the owning type's generic scope.
611    pub generic_defaults:
612        HashMap<(ResolvedName<namespace::StructType>, GenericParamName), ResolvedTypeExpr>,
613}
614
615/// A `min:`/`max:` domain bound with its expression lowered to HIR.
616///
617/// Domain bounds are full expressions, but the source-shaped declaration
618/// entries keep them as resolved syntax AST. Lowering them here at
619/// type-resolution time (the only stage that holds a `ModuleResolver`) lets
620/// dimension checking and evaluation run on HIR like every other expression.
621#[derive(Debug, Clone)]
622pub struct ResolvedDomainBound {
623    /// Whether this is a `min:` or `max:` bound.
624    pub kind: crate::syntax::ast::DomainBoundKind,
625    /// Span of the `min`/`max` keyword.
626    pub kind_span: Span,
627    /// The bound expression, lowered.
628    pub value: hir::Expr,
629    /// Span of the whole bound.
630    pub span: Span,
631}
632
633/// Authoritative semantic body facts for a checked DAG.
634///
635/// The source-shaped declaration entries on [`DagTIR`] retain spans,
636/// formatting, and declaration metadata. This structure carries the semantic
637/// program model used by checking and evaluation.
638#[derive(Debug, Clone, Default)]
639pub struct DagSemanticBody {
640    /// HIR expressions for const/default/node expressions.
641    pub expressions: ResolvedExpressions,
642    /// Domain bounds per declaration, lowered to HIR, in source order.
643    pub domain_bounds: HashMap<ResolvedName<namespace::Decl>, Vec<ResolvedDomainBound>>,
644    /// Plot/figure/layer expressions lowered to HIR, keyed by declaration name.
645    pub plot_exprs: ResolvedPlotExprs,
646    /// Dynamic unit scale expressions lowered to HIR, keyed by unit reference.
647    ///
648    /// Units are file-level declarations, so only the root DAG's semantic
649    /// body carries entries; evaluation looks them up through the TIR root.
650    /// Module-alias-qualified references (`u.mile`) key the entry the import
651    /// merged under that alias.
652    pub dynamic_unit_scales: HashMap<crate::syntax::names::UnitRef, hir::Expr>,
653    /// Canonical dependency maps for this DAG.
654    pub dependencies: ResolvedDagDependencies,
655    /// Canonical HIR-derived collection/index references.
656    pub collection_refs: ResolvedCollectionRefs,
657    /// Canonical HIR-derived constructor calls and match patterns.
658    pub constructor_refs: ResolvedConstructorRefs,
659    /// Canonical HIR-derived inline-DAG routing identities for calls from this DAG.
660    pub inline_dag_refs: ResolvedInlineDagRefs,
661    /// Canonical type definitions referenced by this DAG.
662    pub type_defs: ResolvedTypeDefs,
663    /// Canonical declaration identity for every value name visible in this DAG.
664    pub decl_bindings: HashMap<ScopedName, ResolvedName<namespace::Decl>>,
665}
666
667/// Plot/figure/layer expressions lowered to HIR.
668///
669/// Evaluation walks these lowered bodies instead of the source-shaped
670/// declarations; the source entries on [`DagTIR`] keep spans and mark/plot
671/// metadata for diagnostics and output shaping.
672#[derive(Debug, Clone, Default)]
673pub struct ResolvedPlotExprs {
674    /// Lowered plot bodies keyed by the plot's declaration name.
675    pub plots: HashMap<ScopedName, LoweredPlotBody>,
676    /// Lowered figure field expressions keyed by the figure's declaration name.
677    pub figures: HashMap<ScopedName, Vec<LoweredPlotField>>,
678    /// Lowered layer field expressions keyed by the layer's declaration name.
679    pub layers: HashMap<ScopedName, Vec<LoweredPlotField>>,
680}
681
682/// A resolved inline-DAG invocation target, bindings, and projected output.
683#[derive(Debug, Clone)]
684pub struct ResolvedInlineDagCall {
685    pub target: crate::dag_id::DagId,
686    /// Param binding name span -> canonical declaration in the target DAG.
687    pub arg_targets: HashMap<Span, ResolvedName<namespace::Decl>>,
688    /// Canonical projected declaration in the target DAG.
689    pub output: Spanned<ResolvedName<namespace::Decl>>,
690}
691
692/// A resolved constructor and the tagged-union member it constructs.
693#[derive(Debug, Clone)]
694pub struct ResolvedConstructorTarget {
695    pub constructor: ResolvedName<namespace::Constructor>,
696    pub owning_type: ResolvedName<namespace::StructType>,
697    pub type_def: TypeDef,
698    pub variant: UnionMemberDef,
699}
700
701// ---------------------------------------------------------------------------
702// TIR struct
703// ---------------------------------------------------------------------------
704
705/// Typed Intermediate Representation of a single Graphcal file.
706///
707/// Wraps a file-scoped [`Registry`] plus a flat [`DagRegistry`] of every
708/// DAG in scope. The file's own top-level body lives at
709/// `dags[&root_dag_id]`; inline `dag X { ... }` children live at
710/// `dags[&root_dag_id.child(name)]`; cross-file dep DAGs merged in by
711/// `merge_dep_dag_tirs` live at their own canonical
712/// [`DagId`](crate::dag_id::DagId).
713#[derive(Debug, Clone)]
714pub struct TIR {
715    /// The type/unit/dimension/index/struct registry, shared by every DAG
716    /// in this file.
717    pub registry: Registry,
718    /// Canonical id of the file itself; the key under which the file's
719    /// own top-level body lives in `dags`.
720    pub root_dag_id: crate::dag_id::DagId,
721    /// Every DAG reachable from this file. Always contains an entry for
722    /// `root_dag_id`. Inline children and merged dep DAGs are inserted by
723    /// the project pipeline.
724    pub dags: DagRegistry,
725    /// Maps each `import path as alias` (or `import path`) module alias to
726    /// the dep file's canonical `DagId`. Used by [`TIR::lookup_call_target`]
727    /// to translate user-typed `@alias.dag(args)` references into the
728    /// canonical key under which the dep's DAGs were inserted by
729    /// `merge_dep_dag_tirs`.
730    pub module_aliases: HashMap<ModuleAliasName, crate::dag_id::DagId>,
731}
732
733impl TIR {
734    /// Borrow the file's own top-level [`DagTIR`].
735    ///
736    /// # Panics
737    ///
738    /// Panics if `root_dag_id` is not in `dags`. Construction sites
739    /// (`type_resolve_with_modules`) populate this entry; the invariant must
740    /// not be broken by callers.
741    #[must_use]
742    #[expect(
743        clippy::expect_used,
744        reason = "TIR invariant: root entry always present"
745    )]
746    pub fn root(&self) -> &DagTIR {
747        self.dags
748            .get(&self.root_dag_id)
749            .expect("TIR.dags must contain root_dag_id")
750    }
751
752    /// Mutably borrow the file's own top-level [`DagTIR`].
753    ///
754    /// # Panics
755    ///
756    /// Panics if `root_dag_id` is not in `dags`.
757    #[expect(
758        clippy::expect_used,
759        reason = "TIR invariant: root entry always present"
760    )]
761    pub fn root_mut(&mut self) -> &mut DagTIR {
762        self.dags
763            .get_mut(&self.root_dag_id)
764            .expect("TIR.dags must contain root_dag_id")
765    }
766
767    /// Returns true if this file declares any required param or required index.
768    ///
769    /// Such files cannot be evaluated standalone; they must be bound via a
770    /// parameterized include from another file.
771    #[must_use]
772    pub fn is_library(&self) -> bool {
773        self.root().params.iter().any(|p| p.default_expr.is_none())
774            || self
775                .registry
776                .indexes
777                .all_indexes()
778                .any(crate::registry::types::IndexDef::is_required)
779    }
780
781    /// Build a concrete `DeclaredType` map from the file root's resolved
782    /// types plus its imported-value metadata. Adds builtin constants as
783    /// `Dimensionless`.
784    ///
785    /// # Errors
786    ///
787    /// Returns a [`GraphcalError`] if any resolved type contains unresolved generic
788    /// parameters.
789    pub fn build_declared_types(
790        &self,
791        src: &NamedSource<Arc<String>>,
792    ) -> Result<HashMap<ScopedName, crate::registry::declared_type::DeclaredType>, GraphcalError>
793    {
794        self.root().build_declared_types(src)
795    }
796
797    /// Resolve a user-typed inline-DAG call path to the corresponding
798    /// [`DagTIR`] in [`Self::dags`].
799    ///
800    /// - Single-segment `[name]` (a same-file call `@name(args)`) → looks
801    ///   up `root_dag_id.child(name)`.
802    /// - Multi-segment `[alias, name, ...]` (a cross-file qualified call
803    ///   `@alias.name(args)`) → translates `alias` via [`Self::module_aliases`]
804    ///   to the dep file's `DagId`, then appends the remaining segments.
805    ///
806    /// Returns `None` when the path doesn't resolve (unknown alias, no
807    /// matching DAG, etc.); call sites surface a structured error.
808    #[must_use]
809    pub fn lookup_call_target(&self, path: &crate::syntax::ast::ModulePath) -> Option<&DagTIR> {
810        let id = self.resolve_call_path(path)?;
811        self.dags.get(&id)
812    }
813
814    /// Build the canonical [`DagId`](crate::dag_id::DagId) that
815    /// `path` refers to under this file's scope (alias-translated for
816    /// multi-segment paths, file-root-scoped for single-segment paths).
817    ///
818    /// Returns `None` when the leading alias of a multi-segment path is
819    /// unknown.
820    #[must_use]
821    pub fn resolve_call_path(
822        &self,
823        path: &crate::syntax::ast::ModulePath,
824    ) -> Option<crate::dag_id::DagId> {
825        if path.segments.len() == 1 {
826            return Some(self.root_dag_id.child(path.segments[0].name.as_str()));
827        }
828        let alias = path.segments[0].name.as_str();
829        let dep_id = self.module_aliases.get(alias)?;
830        let mut id = dep_id.clone();
831        for seg in &path.segments.as_slice()[1..] {
832            id = id.child(seg.name.as_str());
833        }
834        Some(id)
835    }
836
837    /// Construct a minimal `TIR` for callers that need a context to satisfy
838    /// the eval pipeline's invariants but never look up an inline DAG.
839    ///
840    /// Currently used by display-only unit-scale resolution. The returned
841    /// TIR has a synthetic root id and empty per-DAG content; calling
842    /// [`Self::lookup_call_target`] on it always returns `None`.
843    #[must_use]
844    pub fn empty_for_eval_helpers(registry: Registry) -> Self {
845        let root_dag_id = crate::dag_id::DagId::root("<eval-helper>");
846        let mut dags = DagRegistry::new();
847        dags.insert(
848            root_dag_id.clone(),
849            DagTIR {
850                dag_id: root_dag_id.clone(),
851                consts: Vec::new(),
852                params: Vec::new(),
853                nodes: Vec::new(),
854                asserts: Vec::new(),
855                plots: Vec::new(),
856                figures: Vec::new(),
857                layers: Vec::new(),
858                included_plots: Vec::new(),
859                semantic: DagSemanticBody::default(),
860                source_order: Vec::new(),
861                assert_names: std::collections::HashSet::new(),
862                assumes_map: HashMap::new(),
863                expected_fail: HashMap::new(),
864                resolved_decl_types: HashMap::new(),
865                domain_constraints: HashMap::new(),
866                imported_values: HashMap::new(),
867                imported_decl_types: HashMap::new(),
868                imported_value_sources: HashMap::new(),
869                pub_nodes: std::collections::HashSet::new(),
870            },
871        );
872        Self {
873            registry,
874            root_dag_id,
875            dags,
876            module_aliases: HashMap::new(),
877        }
878    }
879}
880
881/// The per-DAG compiled body — every field that's specific to one DAG (the
882/// file's own top-level body or an inline `dag X { ... }` child).
883///
884/// Inserted into [`TIR::dags`] by `type_resolve_with_modules` (one entry per
885/// file root) and by the project pipeline's
886/// `compile_inline_dag_bodies` / `merge_dep_dag_tirs`.
887#[derive(Debug, Clone)]
888pub struct DagTIR {
889    /// Canonical identity of this DAG. Equal to the key under which this
890    /// `DagTIR` is stored in [`TIR::dags`]; carried inline so the struct
891    /// is self-describing when passed by reference.
892    pub dag_id: crate::dag_id::DagId,
893    /// Const declarations in source order.
894    pub consts: Vec<crate::ir::lower::ConstEntry>,
895    /// Param declarations in source order.
896    pub params: Vec<crate::ir::lower::ParamEntry>,
897    /// Node declarations in source order.
898    pub nodes: Vec<crate::ir::lower::NodeEntry>,
899    /// Assert declarations in source order.
900    pub asserts: Vec<crate::ir::lower::AssertEntry>,
901    /// Plot declarations in source order.
902    pub plots: Vec<crate::ir::lower::PlotEntry>,
903    /// Figure declarations in source order.
904    pub figures: Vec<crate::ir::lower::FigureEntry>,
905    /// Layer declarations in source order.
906    pub layers: Vec<crate::ir::lower::LayerEntry>,
907    /// Plot aliases from include brace lists (#847).
908    pub included_plots: Vec<crate::ir::lower::IncludedPlotEntry>,
909    /// Authoritative semantic facts for this checked DAG body.
910    pub semantic: DagSemanticBody,
911    /// All declaration names in source order with their category.
912    pub source_order: Vec<(ScopedName, DeclCategory)>,
913    /// Set of all assert names. Membership-only, never iterated.
914    pub assert_names: std::collections::HashSet<ScopedName>,
915    /// Mapping from assert name to the list of declarations that assume it.
916    pub assumes_map: HashMap<ScopedName, Vec<ScopedName>>,
917    /// Mapping from assert name to its expected-fail configuration.
918    pub expected_fail: HashMap<ScopedName, ExpectedFail>,
919    /// Resolved type for each const/param/node declaration.
920    pub resolved_decl_types: HashMap<ScopedName, ResolvedTypeExpr>,
921    /// Resolved domain constraints for declarations that have them.
922    pub domain_constraints: HashMap<ScopedName, ResolvedDomainConstraint>,
923    /// Pre-evaluated values imported from dependency files (passed through from IR).
924    pub imported_values: HashMap<
925        ScopedName,
926        (
927            crate::registry::runtime_value::RuntimeValue,
928            crate::registry::declared_type::DeclaredType,
929        ),
930    >,
931    /// Declared types for imported names whose values are supplied by a caller
932    /// or dependency at evaluation time.
933    pub imported_decl_types: HashMap<ScopedName, crate::registry::declared_type::DeclaredType>,
934    /// Runtime source bindings for imported DAG-body values.
935    pub imported_value_sources: HashMap<ScopedName, crate::ir::lower::ImportedValueSource>,
936    /// Names of `pub` nodes declared in this dag body.
937    ///
938    /// Used by `dim_check` to reject cross-file projection of private
939    /// nodes (`@mod.dag(args).private_node` → `ImportPrivateItem`). The
940    /// same-file case reads visibility from the AST; cross-file merges
941    /// drop the AST, so this set is the compiled proxy.
942    pub pub_nodes: std::collections::HashSet<DeclName>,
943}
944
945impl DagTIR {
946    /// Build a concrete `DeclaredType` map from this DAG's resolved types
947    /// plus its imported-value metadata. Adds builtin constants as
948    /// `Dimensionless`.
949    ///
950    /// # Errors
951    ///
952    /// Returns a [`GraphcalError`] if any resolved type contains unresolved
953    /// generic parameters.
954    pub fn build_declared_types(
955        &self,
956        src: &NamedSource<Arc<String>>,
957    ) -> Result<HashMap<ScopedName, crate::registry::declared_type::DeclaredType>, GraphcalError>
958    {
959        // Layer the sources so the most authoritative wins on key collisions:
960        //   builtins  <  imported_decl_types  <  imported_values  <  resolved_decl_types
961        // A DAG's own resolved decls always shadow imports of the same name —
962        // necessary because `merge_dependency` may propagate placeholder
963        // imported decl types from an inline DAG's self-import back onto the
964        // importer for names the importer already declares itself.
965        let mut declared_types = HashMap::new();
966        for name in crate::registry::builtins::builtin_constants().keys() {
967            declared_types.insert(
968                ScopedName::local(*name),
969                crate::registry::declared_type::DeclaredType::Scalar(Dimension::dimensionless()),
970            );
971        }
972        for (name, dt) in &self.imported_decl_types {
973            declared_types.insert(name.clone(), dt.clone());
974        }
975        for (name, (_rv, dt)) in &self.imported_values {
976            declared_types.insert(name.clone(), dt.clone());
977        }
978        for (name, resolved) in &self.resolved_decl_types {
979            let dt = resolved_to_declared_type(resolved, src)?;
980            declared_types.insert(name.clone(), dt);
981        }
982        Ok(declared_types)
983    }
984
985    /// Populate this DAG's `pub_nodes` set from its source body.
986    pub fn populate_pub_nodes(&mut self, body: &[crate::desugar::desugared_ast::Declaration]) {
987        use crate::desugar::desugared_ast::DeclKind;
988
989        for decl in body {
990            if let DeclKind::Node(n) = &decl.kind
991                && n.visibility.is_public()
992            {
993                self.pub_nodes.insert(n.name.value.clone());
994            }
995        }
996    }
997
998    /// Return the resolved declaration key for a declaration visible from this DAG.
999    ///
1000    /// Qualified source keys synthesize a child owner under this DAG so
1001    /// source-facing entries still use resolved identities instead of
1002    /// source-keyed runtime maps.
1003    #[must_use]
1004    pub fn resolved_decl_key_for_local(
1005        &self,
1006        name: &ScopedName,
1007    ) -> Option<ResolvedName<namespace::Decl>> {
1008        if let Some(resolved) = self.semantic.decl_bindings.get(name) {
1009            return Some(resolved.clone());
1010        }
1011        if self.resolved_decl_types.contains_key(name)
1012            || self
1013                .source_order
1014                .iter()
1015                .any(|(source_name, _)| source_name == name)
1016        {
1017            return resolved_decl_key(&self.dag_id, name);
1018        }
1019        if !name.is_qualified() {
1020            let mut candidates = self
1021                .resolved_decl_types
1022                .keys()
1023                .filter(|candidate| candidate.member() == name.member())
1024                .filter_map(|candidate| resolved_decl_key(&self.dag_id, candidate));
1025            if let Some(candidate) = candidates.next()
1026                && candidates.next().is_none()
1027            {
1028                return Some(candidate);
1029            }
1030        }
1031        resolved_decl_key(&self.dag_id, name)
1032    }
1033}
1034
1035/// Resolve all type annotations in an `IR` using module-aware type-system
1036/// resolution for syntactic paths.
1037///
1038/// Qualified source paths such as `lib.Length`, `lib.Vec3<...>`, and
1039/// `lib.Phase` are first lowered into HIR canonical references using
1040/// `module_resolver`; TIR then consumes those HIR references and reads the
1041/// corresponding definition from `module_types`. Runtime-facing values still
1042/// keep display leaves for diagnostics, but semantic lookup no longer depends on
1043/// source alias strings.
1044pub fn type_resolve_with_modules(
1045    ir: IR,
1046    root_dag_id: crate::dag_id::DagId,
1047    src: &NamedSource<Arc<String>>,
1048    module_resolver: &ModuleResolver,
1049    module_types: &ModuleTypeRegistry,
1050) -> Result<TIR, GraphcalError> {
1051    let owner_for_ctx = root_dag_id.clone();
1052    let ctx = ModuleTypeContext::new(&owner_for_ctx, module_resolver, module_types);
1053    type_resolve_impl(ir, root_dag_id, src, ctx)
1054}
1055
1056fn type_resolve_impl(
1057    ir: IR,
1058    root_dag_id: crate::dag_id::DagId,
1059    src: &NamedSource<Arc<String>>,
1060    module_ctx: ModuleTypeContext<'_>,
1061) -> Result<TIR, GraphcalError> {
1062    let imported_value_sources_for_hir = ir.imported_value_sources.clone();
1063    let asserts_for_hir = ir.asserts.clone();
1064    let mut root_dag = type_resolve_dag(
1065        ir.consts,
1066        ir.params,
1067        ir.nodes,
1068        &asserts_for_hir,
1069        &ir.registry,
1070        src,
1071        &root_dag_id,
1072        module_ctx,
1073        &imported_value_sources_for_hir,
1074    )?
1075    .with_body(
1076        ir.asserts,
1077        ir.plots,
1078        ir.figures,
1079        ir.layers,
1080        ir.included_plots,
1081        ir.source_order,
1082        ir.assert_names,
1083        ir.assumes_map,
1084        ir.expected_fail,
1085        ir.imported_values,
1086        ir.imported_decl_types,
1087        ir.imported_value_sources,
1088        module_ctx,
1089        src,
1090    )?;
1091    lower_dynamic_unit_scales(&ir.registry, module_ctx, &mut root_dag.semantic);
1092    augment_runtime_deps_for_dynamic_units(&mut root_dag.semantic);
1093    check_hir_body_policies(
1094        &root_dag.semantic,
1095        &ir.registry,
1096        &ir.pub_names,
1097        module_ctx,
1098        src,
1099    )?;
1100    let mut dags = DagRegistry::new();
1101    dags.insert(root_dag_id.clone(), root_dag);
1102    Ok(TIR {
1103        registry: ir.registry,
1104        root_dag_id,
1105        dags,
1106        module_aliases: HashMap::new(),
1107    })
1108}
1109
1110/// Resolve type annotations for one DAG body with module-aware type-system
1111/// path lookup.
1112pub fn type_resolve_single_with_modules(
1113    ir: IR,
1114    dag_id: &crate::dag_id::DagId,
1115    src: &NamedSource<Arc<String>>,
1116    module_resolver: &ModuleResolver,
1117    module_types: &ModuleTypeRegistry,
1118) -> Result<DagTIR, GraphcalError> {
1119    let ctx = ModuleTypeContext::new(dag_id, module_resolver, module_types);
1120    type_resolve_single_impl(ir, dag_id, src, ctx)
1121}
1122
1123fn type_resolve_single_impl(
1124    ir: IR,
1125    dag_id: &crate::dag_id::DagId,
1126    src: &NamedSource<Arc<String>>,
1127    module_ctx: ModuleTypeContext<'_>,
1128) -> Result<DagTIR, GraphcalError> {
1129    let imported_value_sources_for_hir = ir.imported_value_sources.clone();
1130    let asserts_for_hir = ir.asserts.clone();
1131    let mut dag = type_resolve_dag(
1132        ir.consts,
1133        ir.params,
1134        ir.nodes,
1135        &asserts_for_hir,
1136        &ir.registry,
1137        src,
1138        dag_id,
1139        module_ctx,
1140        &imported_value_sources_for_hir,
1141    )?
1142    .with_body(
1143        ir.asserts,
1144        ir.plots,
1145        ir.figures,
1146        ir.layers,
1147        ir.included_plots,
1148        ir.source_order,
1149        ir.assert_names,
1150        ir.assumes_map,
1151        ir.expected_fail,
1152        ir.imported_values,
1153        ir.imported_decl_types,
1154        ir.imported_value_sources,
1155        module_ctx,
1156        src,
1157    )?;
1158    lower_dynamic_unit_scales(&ir.registry, module_ctx, &mut dag.semantic);
1159    augment_runtime_deps_for_dynamic_units(&mut dag.semantic);
1160    check_hir_body_policies(&dag.semantic, &ir.registry, &ir.pub_names, module_ctx, src)?;
1161    Ok(dag)
1162}
1163
1164/// Lower the registry's dynamic unit scale expressions to HIR into the DAG's
1165/// semantic body.
1166///
1167/// A scale expression that fails to lower is omitted; evaluation reports a
1168/// dynamic-scale resolution error if such a unit is actually used. This keeps
1169/// the laziness of the previous evaluation-time path, where an unused broken
1170/// dynamic unit never surfaced an error.
1171fn lower_dynamic_unit_scales(
1172    registry: &Registry,
1173    ctx: ModuleTypeContext<'_>,
1174    semantic: &mut DagSemanticBody,
1175) {
1176    let generic_scope = hir::GenericScope::new();
1177    let prelude = hir::PreludeTypeScope::graphcal();
1178    let expr_ctx = hir::ExprLoweringContext::new(ctx.owner, ctx.resolver, &generic_scope)
1179        .with_prelude(&prelude)
1180        .with_decl_bindings(&semantic.decl_bindings);
1181    for (name, _dim, scale) in registry.units.all_units() {
1182        if let crate::registry::types::UnitScale::Dynamic { scale_expr, .. } = scale
1183            && let Ok(lowered) = hir::lower_expr(scale_expr, expr_ctx)
1184        {
1185            semantic.dynamic_unit_scales.insert(name.clone(), lowered);
1186        }
1187    }
1188}
1189
1190/// Internal helper: resolve type annotations for the const/param/node
1191/// declarations of a single DAG, returning a partially-built [`DagTIR`].
1192#[expect(
1193    clippy::too_many_arguments,
1194    reason = "orchestrates per-DAG type resolution across IR declarations and semantic body data"
1195)]
1196fn type_resolve_dag(
1197    consts: Vec<crate::ir::lower::ConstEntry>,
1198    params: Vec<crate::ir::lower::ParamEntry>,
1199    nodes: Vec<crate::ir::lower::NodeEntry>,
1200    asserts: &[crate::ir::lower::AssertEntry],
1201    registry: &Registry,
1202    src: &NamedSource<Arc<String>>,
1203    dag_id: &crate::dag_id::DagId,
1204    module_ctx: ModuleTypeContext<'_>,
1205    imported_value_sources: &HashMap<ScopedName, crate::ir::lower::ImportedValueSource>,
1206) -> Result<DagTIRSeed, GraphcalError> {
1207    let mut resolved_decl_types = HashMap::new();
1208    let no_generic_params: &[GenericParamName] = &[];
1209
1210    // A merged dependency declaration's type annotation keeps the dependency
1211    // file's offsets, so resolution errors must render against its own source
1212    // rather than the importer's `src` (#868).
1213    for entry in &consts {
1214        let resolved = resolve_type_expr_inner(
1215            &entry.type_ann,
1216            registry,
1217            dag_id,
1218            no_generic_params,
1219            no_generic_params,
1220            no_generic_params,
1221            entry.src.resolve(src),
1222            Some(module_ctx),
1223        )?;
1224        resolved_decl_types.insert(entry.name.clone(), resolved);
1225    }
1226    for entry in &params {
1227        let resolved = resolve_type_expr_inner(
1228            &entry.type_ann,
1229            registry,
1230            dag_id,
1231            no_generic_params,
1232            no_generic_params,
1233            no_generic_params,
1234            entry.src.resolve(src),
1235            Some(module_ctx),
1236        )?;
1237        resolved_decl_types.insert(entry.name.clone(), resolved);
1238    }
1239    for entry in &nodes {
1240        let resolved = resolve_type_expr_inner(
1241            &entry.type_ann,
1242            registry,
1243            dag_id,
1244            no_generic_params,
1245            no_generic_params,
1246            no_generic_params,
1247            entry.src.resolve(src),
1248            Some(module_ctx),
1249        )?;
1250        resolved_decl_types.insert(entry.name.clone(), resolved);
1251    }
1252
1253    let LoweredDagExpressions {
1254        exprs: expressions,
1255        domain_bounds,
1256    } = lower_resolved_expressions(
1257        &consts,
1258        &params,
1259        &nodes,
1260        asserts,
1261        module_ctx,
1262        imported_value_sources,
1263        src,
1264    )?;
1265    let dependencies =
1266        collect_resolved_dag_dependencies(&consts, &params, &nodes, &expressions, module_ctx, src)?;
1267    let collection_refs = collect_resolved_collection_refs(
1268        &expressions,
1269        &domain_bounds,
1270        &resolved_decl_types,
1271        module_ctx,
1272        src,
1273    )?;
1274    let constructor_refs =
1275        collect_resolved_constructor_refs(&expressions, &domain_bounds, module_ctx, src)?;
1276    let inline_dag_refs = collect_resolved_inline_dag_refs(&expressions);
1277    let type_defs = collect_resolved_type_defs(
1278        &resolved_decl_types,
1279        &constructor_refs,
1280        module_ctx,
1281        registry,
1282        src,
1283    )?;
1284
1285    let semantic = DagSemanticBody {
1286        expressions,
1287        domain_bounds,
1288        plot_exprs: ResolvedPlotExprs::default(),
1289        dynamic_unit_scales: HashMap::new(),
1290        dependencies,
1291        collection_refs,
1292        constructor_refs,
1293        inline_dag_refs,
1294        type_defs,
1295        decl_bindings: HashMap::new(),
1296    };
1297
1298    Ok(DagTIRSeed {
1299        dag_id: dag_id.clone(),
1300        consts,
1301        params,
1302        nodes,
1303        resolved_decl_types,
1304        semantic,
1305    })
1306}
1307
1308fn collect_resolved_type_defs(
1309    resolved_decl_types: &HashMap<ScopedName, ResolvedTypeExpr>,
1310    constructor_refs: &ResolvedConstructorRefs,
1311    ctx: ModuleTypeContext<'_>,
1312    registry: &Registry,
1313    src: &NamedSource<Arc<String>>,
1314) -> Result<ResolvedTypeDefs, GraphcalError> {
1315    let mut defs = ResolvedTypeDefs::default();
1316    if let Some(symbols) = ctx.resolver.modules().get(ctx.owner) {
1317        for symbol in symbols.struct_types().values() {
1318            record_resolved_struct_type_def(symbol.resolved(), ctx, registry, src, &mut defs)?;
1319        }
1320    }
1321    for resolved in resolved_decl_types.values() {
1322        collect_struct_type_defs_from_resolved_type(resolved, ctx, registry, src, &mut defs)?;
1323    }
1324    for target in constructor_refs.constructor_defs.values() {
1325        record_resolved_struct_type_def(&target.owning_type, ctx, registry, src, &mut defs)?;
1326    }
1327    Ok(defs)
1328}
1329
1330fn collect_struct_type_defs_from_resolved_type(
1331    resolved: &ResolvedTypeExpr,
1332    ctx: ModuleTypeContext<'_>,
1333    registry: &Registry,
1334    src: &NamedSource<Arc<String>>,
1335    defs: &mut ResolvedTypeDefs,
1336) -> Result<(), GraphcalError> {
1337    match resolved {
1338        ResolvedTypeExpr::Struct(name, _) => {
1339            record_resolved_struct_type_def(name, ctx, registry, src, defs)?;
1340        }
1341        ResolvedTypeExpr::GenericStruct {
1342            name, type_args, ..
1343        } => {
1344            record_resolved_struct_type_def(name, ctx, registry, src, defs)?;
1345            for arg in type_args {
1346                collect_struct_type_defs_from_resolved_type(arg, ctx, registry, src, defs)?;
1347            }
1348        }
1349        ResolvedTypeExpr::Indexed { base, indexes: _ } => {
1350            collect_struct_type_defs_from_resolved_type(base, ctx, registry, src, defs)?;
1351        }
1352        ResolvedTypeExpr::Dimensionless
1353        | ResolvedTypeExpr::Bool
1354        | ResolvedTypeExpr::Int
1355        | ResolvedTypeExpr::Datetime(_)
1356        | ResolvedTypeExpr::IndexArg(_)
1357        | ResolvedTypeExpr::Scalar(_)
1358        | ResolvedTypeExpr::GenericDimParam(_, _)
1359        | ResolvedTypeExpr::GenericTypeParam(_, _)
1360        | ResolvedTypeExpr::GenericDimExpr { .. } => {}
1361    }
1362    Ok(())
1363}
1364
1365fn record_resolved_struct_type_def(
1366    name: &ResolvedName<namespace::StructType>,
1367    ctx: ModuleTypeContext<'_>,
1368    registry: &Registry,
1369    src: &NamedSource<Arc<String>>,
1370    defs: &mut ResolvedTypeDefs,
1371) -> Result<(), GraphcalError> {
1372    if defs.struct_types.contains_key(name) {
1373        return Ok(());
1374    }
1375    let Some(type_def) = ctx.types.get_struct_type(name) else {
1376        return Ok(());
1377    };
1378
1379    for param in &type_def.generic_params {
1380        if let Some(default) = &param.default {
1381            let resolved =
1382                resolve_type_expr_in_struct_scope(default, name, type_def, ctx, registry, src)?;
1383            defs.generic_defaults
1384                .insert((name.clone(), param.name.clone()), resolved);
1385        }
1386    }
1387
1388    if let Some(members) = type_def.union_members() {
1389        let generic_scope = generic_scope_for_type_def(name, type_def, src)?;
1390        let prelude = hir::PreludeTypeScope::graphcal();
1391        let bound_expr_ctx =
1392            hir::ExprLoweringContext::new(name.owner(), ctx.resolver, &generic_scope)
1393                .with_prelude(&prelude);
1394        for member in members {
1395            for field in &member.fields {
1396                let key = ResolvedStructFieldTypeKey {
1397                    owning_type: name.clone(),
1398                    constructor: member.name.clone(),
1399                    field: field.name.clone(),
1400                };
1401                let resolved = resolve_type_expr_in_struct_scope(
1402                    &field.type_ann,
1403                    name,
1404                    type_def,
1405                    ctx,
1406                    registry,
1407                    src,
1408                )?;
1409                let bounds = lower_domain_bounds(&field.type_ann, bound_expr_ctx, src)?;
1410                if !bounds.is_empty() {
1411                    defs.field_bounds.insert(key.clone(), bounds);
1412                }
1413                defs.field_types.insert(key, resolved);
1414            }
1415        }
1416    }
1417
1418    defs.struct_types.insert(name.clone(), type_def.clone());
1419    Ok(())
1420}
1421
1422/// Build the lexical generic scope of a type definition so field-bound
1423/// expressions can lower references to the type's generic parameters.
1424fn generic_scope_for_type_def(
1425    name: &ResolvedName<namespace::StructType>,
1426    type_def: &TypeDef,
1427    src: &NamedSource<Arc<String>>,
1428) -> Result<hir::GenericScope, GraphcalError> {
1429    let owner = hir::GenericParamOwner::Type(name.clone());
1430    let mut scope = hir::GenericScope::new();
1431    for param in &type_def.generic_params {
1432        let constraint = match param.constraint {
1433            TypeGenericConstraint::Dim => crate::syntax::ast::GenericConstraint::Dim,
1434            TypeGenericConstraint::Index => crate::syntax::ast::GenericConstraint::Index,
1435            TypeGenericConstraint::Nat => crate::syntax::ast::GenericConstraint::Nat,
1436            TypeGenericConstraint::Unconstrained => crate::syntax::ast::GenericConstraint::Type,
1437        };
1438        scope
1439            .insert_binding(hir::GenericParamBinding::new(
1440                hir::GenericParamId::new(owner.clone(), param.name.clone()),
1441                constraint,
1442                Span::new(0, 0),
1443            ))
1444            .map_err(|err| GraphcalError::InternalError {
1445                message: format!("duplicate generic param while scoping `{name}`: {err}"),
1446                src: src.clone(),
1447                span: Span::new(0, 0).into(),
1448            })?;
1449    }
1450    Ok(scope)
1451}
1452
1453fn resolve_type_expr_in_struct_scope(
1454    type_expr: &TypeExpr,
1455    type_owner: &ResolvedName<namespace::StructType>,
1456    type_def: &TypeDef,
1457    ctx: ModuleTypeContext<'_>,
1458    registry: &Registry,
1459    src: &NamedSource<Arc<String>>,
1460) -> Result<ResolvedTypeExpr, GraphcalError> {
1461    let prelude = hir::PreludeTypeScope::graphcal();
1462    let resolve_ctx = HirTypeResolutionContext {
1463        src,
1464        resolver: ctx.resolver,
1465        module_types: ctx.types,
1466        registry: Some(registry),
1467        prelude: &prelude,
1468    };
1469    let hir_type = lower_type_generic_default(type_expr, type_owner, type_def, resolve_ctx)?;
1470    resolve_hir_type_expr_inner(&hir_type, resolve_ctx)
1471}
1472
1473/// Assemble the semantic expression maps from the IR's lowered bodies and
1474/// lower the declaration domain bounds.
1475///
1476/// Bodies were lowered to HIR at [`crate::ir::lower::UnfrozenIR::freeze`];
1477/// this step keys them by canonical declaration identity and lowers the
1478/// type-annotation domain-bound expressions, which live in declaration
1479/// signatures rather than bodies.
1480fn lower_resolved_expressions(
1481    consts: &[crate::ir::lower::ConstEntry],
1482    params: &[crate::ir::lower::ParamEntry],
1483    nodes: &[crate::ir::lower::NodeEntry],
1484    asserts: &[crate::ir::lower::AssertEntry],
1485    ctx: ModuleTypeContext<'_>,
1486    imported_value_sources: &HashMap<ScopedName, crate::ir::lower::ImportedValueSource>,
1487    src: &NamedSource<Arc<String>>,
1488) -> Result<LoweredDagExpressions, GraphcalError> {
1489    let generic_scope = hir::GenericScope::new();
1490    let prelude = hir::PreludeTypeScope::graphcal();
1491    let decl_bindings = collect_hir_decl_bindings(
1492        ctx.owner,
1493        consts,
1494        params,
1495        nodes,
1496        imported_value_sources,
1497        src,
1498    )?;
1499    let expr_ctx = hir::ExprLoweringContext::new(ctx.owner, ctx.resolver, &generic_scope)
1500        .with_prelude(&prelude)
1501        .with_decl_bindings(&decl_bindings);
1502    let mut exprs = ResolvedExpressions::default();
1503    let mut domain_bounds = HashMap::new();
1504
1505    // Domain-bound and key errors for a merged dependency body must render
1506    // against the dependency's own source, not the importer's `src` (#868).
1507    for entry in consts {
1508        let body_src = entry.src.resolve(src);
1509        let key = decl_key_or_internal_error(ctx.owner, &entry.name, entry.span, body_src)?;
1510        let bounds = lower_domain_bounds(&entry.type_ann, expr_ctx, body_src)?;
1511        if !bounds.is_empty() {
1512            domain_bounds.insert(key.clone(), bounds);
1513        }
1514        exprs.consts.insert(key, entry.expr.clone());
1515    }
1516    for entry in params {
1517        let body_src = entry.src.resolve(src);
1518        let key = decl_key_or_internal_error(ctx.owner, &entry.name, entry.span, body_src)?;
1519        let bounds = lower_domain_bounds(&entry.type_ann, expr_ctx, body_src)?;
1520        if !bounds.is_empty() {
1521            domain_bounds.insert(key.clone(), bounds);
1522        }
1523        let Some(expr) = &entry.default_expr else {
1524            continue;
1525        };
1526        exprs.param_defaults.insert(key, expr.clone());
1527    }
1528    for entry in nodes {
1529        let body_src = entry.src.resolve(src);
1530        let key = decl_key_or_internal_error(ctx.owner, &entry.name, entry.span, body_src)?;
1531        let bounds = lower_domain_bounds(&entry.type_ann, expr_ctx, body_src)?;
1532        if !bounds.is_empty() {
1533            domain_bounds.insert(key.clone(), bounds);
1534        }
1535        exprs.nodes.insert(key, entry.expr.clone());
1536    }
1537    for entry in asserts {
1538        let key =
1539            decl_key_or_internal_error(ctx.owner, &entry.name, entry.span, entry.src.resolve(src))?;
1540        exprs.asserts.insert(key, entry.body.clone());
1541    }
1542
1543    Ok(LoweredDagExpressions {
1544        exprs,
1545        domain_bounds,
1546    })
1547}
1548
1549/// Populate the semantic body's plot expression maps from the IR's lowered
1550/// plot/figure/layer entries and run the reference-collection walks over
1551/// them.
1552///
1553/// Plot bodies were lowered (best-effort) at
1554/// [`crate::ir::lower::UnfrozenIR::freeze`]; an entry without a complete
1555/// body is omitted here, and the runtime then skips that plot.
1556fn collect_plot_exprs(
1557    plots: &[crate::ir::lower::PlotEntry],
1558    figures: &[crate::ir::lower::FigureEntry],
1559    layers: &[crate::ir::lower::LayerEntry],
1560    ctx: ModuleTypeContext<'_>,
1561    src: &NamedSource<Arc<String>>,
1562    semantic: &mut DagSemanticBody,
1563) -> Result<(), GraphcalError> {
1564    let mut plot_exprs = ResolvedPlotExprs::default();
1565
1566    let collect = |expr: &hir::Expr,
1567                   collection_refs: &mut ResolvedCollectionRefs,
1568                   constructor_refs: &mut ResolvedConstructorRefs|
1569     -> Result<(), GraphcalError> {
1570        collect_resolved_collection_refs_from_expr(expr, ctx, src, collection_refs)?;
1571        collect_resolved_constructor_refs_from_expr(expr, ctx, src, constructor_refs)
1572    };
1573
1574    for entry in plots {
1575        let Some(body) = &entry.body else {
1576            continue;
1577        };
1578        for (_, expr) in &body.encodings {
1579            collect(
1580                expr,
1581                &mut semantic.collection_refs,
1582                &mut semantic.constructor_refs,
1583            )?;
1584        }
1585        for field in body.mark_properties.iter().chain(&body.properties) {
1586            collect(
1587                &field.value,
1588                &mut semantic.collection_refs,
1589                &mut semantic.constructor_refs,
1590            )?;
1591        }
1592        plot_exprs.plots.insert(entry.name.clone(), body.clone());
1593    }
1594
1595    for (name, fields, is_figure) in figures
1596        .iter()
1597        .map(|entry| (&entry.name, &entry.fields, true))
1598        .chain(
1599            layers
1600                .iter()
1601                .map(|entry| (&entry.name, &entry.fields, false)),
1602        )
1603    {
1604        for field in fields {
1605            collect(
1606                &field.value,
1607                &mut semantic.collection_refs,
1608                &mut semantic.constructor_refs,
1609            )?;
1610        }
1611        if is_figure {
1612            plot_exprs.figures.insert(name.clone(), fields.clone());
1613        } else {
1614            plot_exprs.layers.insert(name.clone(), fields.clone());
1615        }
1616    }
1617
1618    semantic.plot_exprs = plot_exprs;
1619    Ok(())
1620}
1621
1622/// Build the canonical declaration key for `name`, reporting an internal
1623/// error when the name cannot form one.
1624fn decl_key_or_internal_error(
1625    owner: &crate::dag_id::DagId,
1626    name: &ScopedName,
1627    span: Span,
1628    src: &NamedSource<Arc<String>>,
1629) -> Result<ResolvedName<namespace::Decl>, GraphcalError> {
1630    resolved_decl_key(owner, name).ok_or_else(|| {
1631        internal_error(
1632            format!("could not build canonical declaration key for `{name}`"),
1633            src,
1634            span,
1635        )
1636    })
1637}
1638
1639/// Lower a declaration type annotation's domain bounds to HIR.
1640fn lower_domain_bounds(
1641    type_ann: &crate::desugar::desugared_ast::TypeExpr,
1642    expr_ctx: hir::ExprLoweringContext<'_>,
1643    src: &NamedSource<Arc<String>>,
1644) -> Result<Vec<ResolvedDomainBound>, GraphcalError> {
1645    type_ann
1646        .domain_bounds()
1647        .iter()
1648        .map(|bound| {
1649            let value = hir::lower_expr(&bound.value, expr_ctx)
1650                .map_err(|err| expr_lower_error_to_graphcal(&err, src))?;
1651            Ok(ResolvedDomainBound {
1652                kind: bound.kind,
1653                kind_span: bound.kind_span,
1654                value,
1655                span: bound.span,
1656            })
1657        })
1658        .collect()
1659}
1660
1661/// Output of [`lower_resolved_expressions`]: the lowered declaration bodies
1662/// plus the HIR domain bounds collected while lowering them.
1663struct LoweredDagExpressions {
1664    exprs: ResolvedExpressions,
1665    domain_bounds: HashMap<ResolvedName<namespace::Decl>, Vec<ResolvedDomainBound>>,
1666}
1667
1668/// HIR-level body policies that replaced the retired syntax-AST scope checks.
1669///
1670/// Walks every lowered body of one DAG and enforces:
1671/// - const bodies must not `@`-reference runtime declarations (E020-style
1672///   [`GraphcalError::GraphRefInConst`]) or use runtime units in literals / conversion targets;
1673/// - no body may `@`-reference an assert declaration
1674///   ([`GraphcalError::GraphRefToAssert`]);
1675/// - A10(c) / V004: bodies of non-bindable kinds owned by this module must
1676///   not mention variant literals of the module's own `pub(bind)` indexes
1677///   ([`GraphcalError::PubIndexVariantLiteral`]). Params are exempt (A10(a));
1678///   sink kinds (assert/plot/figure/layer) are checked only when `pub`.
1679fn check_hir_body_policies(
1680    semantic: &DagSemanticBody,
1681    registry: &Registry,
1682    pub_names: &std::collections::HashSet<DeclName>,
1683    ctx: ModuleTypeContext<'_>,
1684    src: &NamedSource<Arc<String>>,
1685) -> Result<(), GraphcalError> {
1686    let checker = HirPolicyChecker { registry, ctx, src };
1687    let local = |key: &ResolvedName<namespace::Decl>| key.owner() == ctx.owner;
1688    let is_pub = |leaf: &str| pub_names.contains(&DeclName::new(leaf));
1689
1690    for (key, expr) in &semantic.expressions.consts {
1691        checker.check_expr(expr, true, local(key))?;
1692    }
1693    for (key, bounds) in &semantic.domain_bounds {
1694        if semantic.expressions.consts.contains_key(key) {
1695            for bound in bounds {
1696                checker.check_expr(&bound.value, true, local(key))?;
1697            }
1698        }
1699    }
1700    for (key, expr) in &semantic.expressions.nodes {
1701        checker.check_expr(expr, false, local(key))?;
1702    }
1703    for expr in semantic.expressions.param_defaults.values() {
1704        // Params are exempt from A10 (a rebinding importer is forced to
1705        // rebind the param too — V005 at the include site).
1706        checker.check_expr(expr, false, false)?;
1707    }
1708    for (key, body) in &semantic.expressions.asserts {
1709        let check_literals = local(key) && is_pub(key.as_str());
1710        match body {
1711            hir::AssertBody::Expr(expr) => checker.check_expr(expr, false, check_literals)?,
1712            hir::AssertBody::Tolerance {
1713                actual,
1714                expected,
1715                tolerance,
1716                ..
1717            } => {
1718                checker.check_expr(actual, false, check_literals)?;
1719                checker.check_expr(expected, false, check_literals)?;
1720                checker.check_expr(tolerance, false, check_literals)?;
1721            }
1722        }
1723    }
1724    for (name, body) in &semantic.plot_exprs.plots {
1725        let check_literals = !name.is_qualified() && is_pub(name.member());
1726        for (_, expr) in &body.encodings {
1727            checker.check_expr(expr, false, check_literals)?;
1728        }
1729        for field in body.mark_properties.iter().chain(&body.properties) {
1730            checker.check_expr(&field.value, false, check_literals)?;
1731        }
1732    }
1733    for (name, fields) in semantic
1734        .plot_exprs
1735        .figures
1736        .iter()
1737        .chain(&semantic.plot_exprs.layers)
1738    {
1739        let check_literals = !name.is_qualified() && is_pub(name.member());
1740        for field in fields {
1741            checker.check_expr(&field.value, false, check_literals)?;
1742        }
1743    }
1744    Ok(())
1745}
1746
1747struct HirPolicyChecker<'a> {
1748    registry: &'a Registry,
1749    ctx: ModuleTypeContext<'a>,
1750    src: &'a NamedSource<Arc<String>>,
1751}
1752
1753impl HirPolicyChecker<'_> {
1754    fn check_expr(
1755        &self,
1756        expr: &hir::Expr,
1757        const_body: bool,
1758        check_pub_bind_literals: bool,
1759    ) -> Result<(), GraphcalError> {
1760        // Recursion choke point: recurses once per tree level.
1761        crate::stack::with_stack_growth(|| {
1762            self.check_expr_inner(expr, const_body, check_pub_bind_literals)
1763        })
1764    }
1765
1766    fn check_expr_inner(
1767        &self,
1768        expr: &hir::Expr,
1769        const_body: bool,
1770        check_pub_bind_literals: bool,
1771    ) -> Result<(), GraphcalError> {
1772        let recurse =
1773            |inner: &hir::Expr| self.check_expr(inner, const_body, check_pub_bind_literals);
1774        match &expr.kind {
1775            hir::ExprKind::Error
1776            | hir::ExprKind::Number(_)
1777            | hir::ExprKind::Integer(_)
1778            | hir::ExprKind::Bool(_)
1779            | hir::ExprKind::StringLiteral(_)
1780            | hir::ExprKind::TypeSystemRef(_)
1781            | hir::ExprKind::ConstRef(_)
1782            | hir::ExprKind::LocalRef(_) => Ok(()),
1783            hir::ExprKind::UnitLiteral { unit, .. } => self.check_const_unit_expr(unit, const_body),
1784            hir::ExprKind::GraphRef(target) => {
1785                // Use the whole `@name` span (the reference Spanned covers
1786                // only the name) so the label includes the sigil.
1787                self.check_graph_ref(target, expr.span, const_body)
1788            }
1789            hir::ExprKind::VariantLiteral(variant) => {
1790                self.check_variant_literal(variant, check_pub_bind_literals)
1791            }
1792            hir::ExprKind::BinOp { lhs, rhs, .. } => {
1793                recurse(lhs)?;
1794                recurse(rhs)
1795            }
1796            hir::ExprKind::UnaryOp { operand, .. }
1797            | hir::ExprKind::DisplayTimezone { expr: operand, .. }
1798            | hir::ExprKind::FieldAccess { expr: operand, .. } => recurse(operand),
1799            hir::ExprKind::Convert {
1800                expr: operand,
1801                target,
1802            } => {
1803                self.check_const_unit_expr(target, const_body)?;
1804                recurse(operand)
1805            }
1806            hir::ExprKind::FnCall { args, .. } => args.iter().try_for_each(recurse),
1807            hir::ExprKind::If {
1808                condition,
1809                then_branch,
1810                else_branch,
1811            } => {
1812                recurse(condition)?;
1813                recurse(then_branch)?;
1814                recurse(else_branch)
1815            }
1816            hir::ExprKind::ConstructorCall { fields, .. } => {
1817                fields.iter().try_for_each(|field| recurse(&field.value))
1818            }
1819            hir::ExprKind::MapLiteral { entries } => {
1820                for entry in entries {
1821                    for key in &entry.keys {
1822                        if let hir::expr::MapEntryKey::IndexVariant(variant) = key {
1823                            self.check_variant_literal(variant, check_pub_bind_literals)?;
1824                        }
1825                    }
1826                    recurse(&entry.value)?;
1827                }
1828                Ok(())
1829            }
1830            hir::ExprKind::ForComp { body, .. } => recurse(body),
1831            hir::ExprKind::IndexAccess { expr: inner, args } => {
1832                recurse(inner)?;
1833                for arg in args {
1834                    match arg {
1835                        hir::expr::IndexArg::Variant(variant) => {
1836                            self.check_variant_literal(variant, check_pub_bind_literals)?;
1837                        }
1838                        hir::expr::IndexArg::Expr(arg_expr) => recurse(arg_expr)?,
1839                        hir::expr::IndexArg::Var(_) => {}
1840                    }
1841                }
1842                Ok(())
1843            }
1844            hir::ExprKind::Scan {
1845                source, init, body, ..
1846            } => {
1847                recurse(source)?;
1848                recurse(init)?;
1849                recurse(body)
1850            }
1851            hir::ExprKind::Unfold { init, body, .. } => {
1852                recurse(init)?;
1853                recurse(body)
1854            }
1855            hir::ExprKind::Match { scrutinee, arms } => {
1856                recurse(scrutinee)?;
1857                for arm in arms {
1858                    if let hir::expr::MatchPattern::IndexLabel { variant, .. } = &arm.pattern {
1859                        self.check_variant_literal(variant, check_pub_bind_literals)?;
1860                    }
1861                    recurse(&arm.body)?;
1862                }
1863                Ok(())
1864            }
1865            hir::ExprKind::InlineDagRef { args, .. } => {
1866                args.iter().try_for_each(|arg| recurse(&arg.value))
1867            }
1868        }
1869    }
1870
1871    fn check_const_unit_expr(
1872        &self,
1873        unit: &crate::desugar::desugared_ast::UnitExpr,
1874        const_body: bool,
1875    ) -> Result<(), GraphcalError> {
1876        if !const_body {
1877            return Ok(());
1878        }
1879        for term in &unit.terms {
1880            let Some(info) = self.registry.units.get_unit(&term.name.value) else {
1881                // Unknown units get their own diagnostics from dimension checking.
1882                continue;
1883            };
1884            if !info.constness.is_const() {
1885                return Err(GraphcalError::NonConstUnitInConst {
1886                    name: term.name.value.clone(),
1887                    src: self.src.clone(),
1888                    span: term.name.span.into(),
1889                });
1890            }
1891        }
1892        Ok(())
1893    }
1894
1895    fn check_graph_ref(
1896        &self,
1897        target: &Spanned<ResolvedName<namespace::Decl>>,
1898        ref_span: Span,
1899        const_body: bool,
1900    ) -> Result<(), GraphcalError> {
1901        let Ok(kind) = self.ctx.resolver.decl_symbol_kind(&target.value) else {
1902            // Unknown targets get their own diagnostic from dependency
1903            // collection; the policy walk only classifies known ones.
1904            return Ok(());
1905        };
1906        if matches!(kind, crate::syntax::module_resolve::DeclSymbolKind::Assert) {
1907            return Err(GraphcalError::GraphRefToAssert {
1908                name: DeclName::new(target.value.as_str()),
1909                src: self.src.clone(),
1910                span: ref_span.into(),
1911            });
1912        }
1913        if const_body && !kind.is_const() {
1914            return Err(GraphcalError::GraphRefInConst {
1915                name: ScopedName::local(target.value.as_str()),
1916                src: self.src.clone(),
1917                span: ref_span.into(),
1918            });
1919        }
1920        Ok(())
1921    }
1922
1923    fn check_variant_literal(
1924        &self,
1925        variant: &hir::expr::IndexVariantRef,
1926        check_pub_bind_literals: bool,
1927    ) -> Result<(), GraphcalError> {
1928        if !check_pub_bind_literals {
1929            return Ok(());
1930        }
1931        let index = variant.variant.index();
1932        if index.owner() != self.ctx.owner {
1933            return Ok(());
1934        }
1935        let is_pub_bind = self
1936            .ctx
1937            .resolver
1938            .modules()
1939            .get(self.ctx.owner)
1940            .and_then(|symbols| symbols.indexes().get(&IndexName::new(index.as_str())))
1941            .is_some_and(|symbol| {
1942                symbol.visibility().is_bindable() && !symbol.variants().is_empty()
1943            });
1944        if is_pub_bind {
1945            return Err(GraphcalError::PubIndexVariantLiteral {
1946                index: index.as_str().to_string(),
1947                variant: variant.variant.variant().as_str().to_string(),
1948                src: self.src.clone(),
1949                span: variant.path_span().into(),
1950            });
1951        }
1952        Ok(())
1953    }
1954}
1955
1956/// Augment runtime deps with transitive dependencies through dynamic units.
1957///
1958/// When a param/node expression references a dynamic unit (via a unit
1959/// literal or conversion), the `@`-references in that unit's scale
1960/// expression become implicit dependencies of the param/node, so the scale's
1961/// inputs are evaluated first.
1962fn augment_runtime_deps_for_dynamic_units(semantic: &mut DagSemanticBody) {
1963    if semantic.dynamic_unit_scales.is_empty() {
1964        return;
1965    }
1966    let scale_deps: HashMap<
1967        crate::syntax::names::UnitRef,
1968        BTreeSet<ResolvedName<namespace::Decl>>,
1969    > = semantic
1970        .dynamic_unit_scales
1971        .iter()
1972        .map(|(name, expr)| {
1973            (
1974                name.clone(),
1975                hir::collect_expr_dependencies(expr).graph_refs,
1976            )
1977        })
1978        .collect();
1979
1980    let DagSemanticBody {
1981        expressions,
1982        dependencies,
1983        ..
1984    } = semantic;
1985    for (key, expr) in expressions.param_defaults.iter().chain(&expressions.nodes) {
1986        let mut unit_names = std::collections::HashSet::new();
1987        collect_unit_names_from_hir(expr, &mut unit_names);
1988        let extra: BTreeSet<ResolvedName<namespace::Decl>> = unit_names
1989            .iter()
1990            .filter_map(|unit| scale_deps.get(unit))
1991            .flatten()
1992            .cloned()
1993            .collect();
1994        if !extra.is_empty() {
1995            dependencies
1996                .runtime_deps
1997                .entry(key.clone())
1998                .or_default()
1999                .extend(extra);
2000        }
2001    }
2002}
2003
2004/// Collect every unit name mentioned by `UnitLiteral` / `Convert` nodes.
2005fn collect_unit_names_from_hir(
2006    expr: &hir::Expr,
2007    names: &mut std::collections::HashSet<crate::syntax::names::UnitRef>,
2008) {
2009    // Recursion choke point: recurses once per tree level.
2010    crate::stack::with_stack_growth(|| match &expr.kind {
2011        hir::ExprKind::UnitLiteral { unit, .. } => {
2012            for term in &unit.terms {
2013                names.insert(term.name.value.clone());
2014            }
2015        }
2016        hir::ExprKind::Convert {
2017            expr: inner,
2018            target,
2019        } => {
2020            for term in &target.terms {
2021                names.insert(term.name.value.clone());
2022            }
2023            collect_unit_names_from_hir(inner, names);
2024        }
2025        hir::ExprKind::Error
2026        | hir::ExprKind::Number(_)
2027        | hir::ExprKind::Integer(_)
2028        | hir::ExprKind::Bool(_)
2029        | hir::ExprKind::StringLiteral(_)
2030        | hir::ExprKind::TypeSystemRef(_)
2031        | hir::ExprKind::GraphRef(_)
2032        | hir::ExprKind::ConstRef(_)
2033        | hir::ExprKind::LocalRef(_)
2034        | hir::ExprKind::VariantLiteral(_) => {}
2035        hir::ExprKind::BinOp { lhs, rhs, .. } => {
2036            collect_unit_names_from_hir(lhs, names);
2037            collect_unit_names_from_hir(rhs, names);
2038        }
2039        hir::ExprKind::UnaryOp { operand, .. }
2040        | hir::ExprKind::DisplayTimezone { expr: operand, .. }
2041        | hir::ExprKind::FieldAccess { expr: operand, .. } => {
2042            collect_unit_names_from_hir(operand, names);
2043        }
2044        hir::ExprKind::FnCall { args, .. } => {
2045            for arg in args {
2046                collect_unit_names_from_hir(arg, names);
2047            }
2048        }
2049        hir::ExprKind::If {
2050            condition,
2051            then_branch,
2052            else_branch,
2053        } => {
2054            collect_unit_names_from_hir(condition, names);
2055            collect_unit_names_from_hir(then_branch, names);
2056            collect_unit_names_from_hir(else_branch, names);
2057        }
2058        hir::ExprKind::ConstructorCall { fields, .. } => {
2059            for field in fields {
2060                collect_unit_names_from_hir(&field.value, names);
2061            }
2062        }
2063        hir::ExprKind::MapLiteral { entries } => {
2064            for entry in entries {
2065                collect_unit_names_from_hir(&entry.value, names);
2066            }
2067        }
2068        hir::ExprKind::ForComp { body, .. } => collect_unit_names_from_hir(body, names),
2069        hir::ExprKind::IndexAccess { expr: inner, args } => {
2070            collect_unit_names_from_hir(inner, names);
2071            for arg in args {
2072                if let hir::expr::IndexArg::Expr(arg_expr) = arg {
2073                    collect_unit_names_from_hir(arg_expr, names);
2074                }
2075            }
2076        }
2077        hir::ExprKind::Scan {
2078            source, init, body, ..
2079        } => {
2080            collect_unit_names_from_hir(source, names);
2081            collect_unit_names_from_hir(init, names);
2082            collect_unit_names_from_hir(body, names);
2083        }
2084        hir::ExprKind::Unfold { init, body, .. } => {
2085            collect_unit_names_from_hir(init, names);
2086            collect_unit_names_from_hir(body, names);
2087        }
2088        hir::ExprKind::Match { scrutinee, arms } => {
2089            collect_unit_names_from_hir(scrutinee, names);
2090            for arm in arms {
2091                collect_unit_names_from_hir(&arm.body, names);
2092            }
2093        }
2094        hir::ExprKind::InlineDagRef { args, .. } => {
2095            for arg in args {
2096                collect_unit_names_from_hir(&arg.value, names);
2097            }
2098        }
2099    });
2100}
2101
2102fn collect_resolved_dag_dependencies(
2103    consts: &[crate::ir::lower::ConstEntry],
2104    params: &[crate::ir::lower::ParamEntry],
2105    nodes: &[crate::ir::lower::NodeEntry],
2106    exprs: &ResolvedExpressions,
2107    ctx: ModuleTypeContext<'_>,
2108    src: &NamedSource<Arc<String>>,
2109) -> Result<ResolvedDagDependencies, GraphcalError> {
2110    let mut resolved = ResolvedDagDependencies::default();
2111
2112    for entry in consts {
2113        let body_src = entry.src.resolve(src);
2114        let key = resolved_decl_key(ctx.owner, &entry.name).ok_or_else(|| {
2115            internal_error(
2116                format!(
2117                    "could not build canonical declaration key for `{}`",
2118                    entry.name
2119                ),
2120                body_src,
2121                entry.span,
2122            )
2123        })?;
2124        let hir_expr = exprs.consts.get(&key).ok_or_else(|| {
2125            internal_error(
2126                format!(
2127                    "missing HIR expression for const declaration `{}`",
2128                    entry.name
2129                ),
2130                body_src,
2131                entry.span,
2132            )
2133        })?;
2134        let mut deps = hir::collect_expr_dependencies(hir_expr);
2135        for graph_ref in &deps.graph_refs {
2136            // `@const_name` in a const body is a const dependency. Non-const
2137            // `@` targets are rejected with a spanned diagnostic by
2138            // `check_hir_body_policies`.
2139            let kind = ctx
2140                .resolver
2141                .decl_symbol_kind(graph_ref)
2142                .map_err(|err| module_resolve_error(&err, body_src, entry.span))?;
2143            if kind.is_const() {
2144                deps.const_refs.insert(graph_ref.clone());
2145            }
2146        }
2147        resolved.const_deps.insert(key, deps.const_refs);
2148    }
2149
2150    for entry in params {
2151        let key = resolved_decl_key(ctx.owner, &entry.name).ok_or_else(|| {
2152            internal_error(
2153                format!(
2154                    "could not build canonical declaration key for `{}`",
2155                    entry.name
2156                ),
2157                entry.src.resolve(src),
2158                entry.span,
2159            )
2160        })?;
2161        let deps = exprs.param_defaults.get(&key).map_or_else(
2162            hir::ExprDependencies::default,
2163            hir::collect_expr_dependencies,
2164        );
2165        resolved.runtime_deps.insert(key, deps.graph_refs);
2166    }
2167
2168    for entry in nodes {
2169        let body_src = entry.src.resolve(src);
2170        let key = resolved_decl_key(ctx.owner, &entry.name).ok_or_else(|| {
2171            internal_error(
2172                format!(
2173                    "could not build canonical declaration key for `{}`",
2174                    entry.name
2175                ),
2176                body_src,
2177                entry.span,
2178            )
2179        })?;
2180        let hir_expr = exprs.nodes.get(&key).ok_or_else(|| {
2181            internal_error(
2182                format!(
2183                    "missing HIR expression for node declaration `{}`",
2184                    entry.name
2185                ),
2186                body_src,
2187                entry.span,
2188            )
2189        })?;
2190        let mut deps = hir::collect_expr_dependencies(hir_expr);
2191        // Unfold self-references access the previous step, not the node's
2192        // own value: drop the self-edge whenever every self-reference lies
2193        // inside an unfold subtree (covers nested forms like
2194        // `if c { unfold(…) } else { unfold(…) }`, not just a top-level
2195        // unfold). A self-reference outside any unfold stays and is
2196        // reported as a genuine cycle.
2197        if !hir::has_ref_outside_unfold(hir_expr, &key) {
2198            deps.graph_refs.remove(&key);
2199        }
2200        resolved.runtime_deps.insert(key, deps.graph_refs);
2201    }
2202
2203    Ok(resolved)
2204}
2205
2206fn collect_resolved_collection_refs(
2207    exprs: &ResolvedExpressions,
2208    domain_bounds: &HashMap<ResolvedName<namespace::Decl>, Vec<ResolvedDomainBound>>,
2209    resolved_decl_types: &HashMap<ScopedName, ResolvedTypeExpr>,
2210    ctx: ModuleTypeContext<'_>,
2211    src: &NamedSource<Arc<String>>,
2212) -> Result<ResolvedCollectionRefs, GraphcalError> {
2213    let mut refs = ResolvedCollectionRefs::default();
2214
2215    for resolved_type in resolved_decl_types.values() {
2216        collect_resolved_collection_indexes_from_type(resolved_type, ctx, src, &mut refs)?;
2217    }
2218
2219    for hir_expr in exprs
2220        .consts
2221        .values()
2222        .chain(exprs.param_defaults.values())
2223        .chain(exprs.nodes.values())
2224        .chain(domain_bounds.values().flatten().map(|bound| &bound.value))
2225    {
2226        collect_resolved_collection_refs_from_expr(hir_expr, ctx, src, &mut refs)?;
2227    }
2228    for body in exprs.asserts.values() {
2229        collect_resolved_collection_refs_from_assert_body(body, ctx, src, &mut refs)?;
2230    }
2231
2232    Ok(refs)
2233}
2234
2235fn record_resolved_collection_index(
2236    index: &ResolvedName<namespace::Index>,
2237    ctx: ModuleTypeContext<'_>,
2238    src: &NamedSource<Arc<String>>,
2239    span: Span,
2240    refs: &mut ResolvedCollectionRefs,
2241) -> Result<(), GraphcalError> {
2242    if refs.index_defs.contains_key(index) {
2243        return Ok(());
2244    }
2245    let def = ctx.types.get_index(index).cloned().ok_or_else(|| {
2246        internal_error(
2247            format!("semantic collection metadata references unknown index `{index}`"),
2248            src,
2249            span,
2250        )
2251    })?;
2252    refs.index_defs.insert(index.clone(), def);
2253    Ok(())
2254}
2255
2256fn collect_resolved_collection_indexes_from_type(
2257    resolved_type: &ResolvedTypeExpr,
2258    ctx: ModuleTypeContext<'_>,
2259    src: &NamedSource<Arc<String>>,
2260    refs: &mut ResolvedCollectionRefs,
2261) -> Result<(), GraphcalError> {
2262    match resolved_type {
2263        ResolvedTypeExpr::IndexArg(ResolvedIndex::Concrete(index, span)) => {
2264            record_resolved_collection_index(index, ctx, src, *span, refs)
2265        }
2266        ResolvedTypeExpr::Indexed { base, indexes } => {
2267            collect_resolved_collection_indexes_from_type(base, ctx, src, refs)?;
2268            for index in indexes {
2269                if let ResolvedIndex::Concrete(resolved, span) = index {
2270                    record_resolved_collection_index(resolved, ctx, src, *span, refs)?;
2271                }
2272            }
2273            Ok(())
2274        }
2275        ResolvedTypeExpr::GenericStruct { type_args, .. } => {
2276            for arg in type_args {
2277                collect_resolved_collection_indexes_from_type(arg, ctx, src, refs)?;
2278            }
2279            Ok(())
2280        }
2281        ResolvedTypeExpr::Dimensionless
2282        | ResolvedTypeExpr::Bool
2283        | ResolvedTypeExpr::Int
2284        | ResolvedTypeExpr::Datetime(_)
2285        | ResolvedTypeExpr::IndexArg(_)
2286        | ResolvedTypeExpr::Scalar(_)
2287        | ResolvedTypeExpr::Struct(_, _)
2288        | ResolvedTypeExpr::GenericDimParam(_, _)
2289        | ResolvedTypeExpr::GenericTypeParam(_, _)
2290        | ResolvedTypeExpr::GenericDimExpr { .. } => Ok(()),
2291    }
2292}
2293
2294fn collect_resolved_collection_refs_from_expr(
2295    expr: &hir::Expr,
2296    ctx: ModuleTypeContext<'_>,
2297    src: &NamedSource<Arc<String>>,
2298    refs: &mut ResolvedCollectionRefs,
2299) -> Result<(), GraphcalError> {
2300    // Recursion choke point: recurses once per tree level (unbounded for
2301    // left-nested operator chains).
2302    crate::stack::with_stack_growth(|| {
2303        collect_resolved_collection_refs_from_expr_inner(expr, ctx, src, refs)
2304    })
2305}
2306
2307#[expect(
2308    clippy::too_many_lines,
2309    reason = "expression traversal mirrors HIR variants"
2310)]
2311fn collect_resolved_collection_refs_from_expr_inner(
2312    expr: &hir::Expr,
2313    ctx: ModuleTypeContext<'_>,
2314    src: &NamedSource<Arc<String>>,
2315    refs: &mut ResolvedCollectionRefs,
2316) -> Result<(), GraphcalError> {
2317    match &expr.kind {
2318        hir::ExprKind::Error
2319        | hir::ExprKind::Number(_)
2320        | hir::ExprKind::Integer(_)
2321        | hir::ExprKind::Bool(_)
2322        | hir::ExprKind::StringLiteral(_)
2323        | hir::ExprKind::TypeSystemRef(_)
2324        | hir::ExprKind::GraphRef(_)
2325        | hir::ExprKind::LocalRef(_)
2326        | hir::ExprKind::ConstRef(_)
2327        | hir::ExprKind::UnitLiteral { .. } => Ok(()),
2328        hir::ExprKind::VariantLiteral(variant) => record_resolved_collection_index(
2329            variant.variant.index(),
2330            ctx,
2331            src,
2332            variant.path_span(),
2333            refs,
2334        ),
2335        hir::ExprKind::BinOp { lhs, rhs, .. } => {
2336            collect_resolved_collection_refs_from_expr(lhs, ctx, src, refs)?;
2337            collect_resolved_collection_refs_from_expr(rhs, ctx, src, refs)
2338        }
2339        hir::ExprKind::UnaryOp { operand, .. } => {
2340            collect_resolved_collection_refs_from_expr(operand, ctx, src, refs)
2341        }
2342        hir::ExprKind::FnCall { args, .. } => {
2343            for arg in args {
2344                collect_resolved_collection_refs_from_expr(arg, ctx, src, refs)?;
2345            }
2346            Ok(())
2347        }
2348        hir::ExprKind::If {
2349            condition,
2350            then_branch,
2351            else_branch,
2352        } => {
2353            collect_resolved_collection_refs_from_expr(condition, ctx, src, refs)?;
2354            collect_resolved_collection_refs_from_expr(then_branch, ctx, src, refs)?;
2355            collect_resolved_collection_refs_from_expr(else_branch, ctx, src, refs)
2356        }
2357        hir::ExprKind::Convert { expr, .. }
2358        | hir::ExprKind::DisplayTimezone { expr, .. }
2359        | hir::ExprKind::FieldAccess { expr, .. } => {
2360            collect_resolved_collection_refs_from_expr(expr, ctx, src, refs)
2361        }
2362        hir::ExprKind::ConstructorCall { fields, .. } => {
2363            for field in fields {
2364                collect_resolved_collection_refs_from_expr(&field.value, ctx, src, refs)?;
2365            }
2366            Ok(())
2367        }
2368        hir::ExprKind::MapLiteral { entries } => {
2369            for entry in entries {
2370                for key in &entry.keys {
2371                    match key {
2372                        hir::expr::MapEntryKey::IndexVariant(variant) => {
2373                            record_resolved_collection_index(
2374                                variant.variant.index(),
2375                                ctx,
2376                                src,
2377                                variant.variant_span,
2378                                refs,
2379                            )?;
2380                        }
2381                        hir::expr::MapEntryKey::NatRangeVariant { .. } => {}
2382                    }
2383                }
2384                collect_resolved_collection_refs_from_expr(&entry.value, ctx, src, refs)?;
2385            }
2386            Ok(())
2387        }
2388        hir::ExprKind::ForComp { bindings, body } => {
2389            for binding in bindings {
2390                match &binding.index {
2391                    hir::expr::ForBindingIndex::Named(index) => {
2392                        record_resolved_collection_index(&index.value, ctx, src, index.span, refs)?;
2393                    }
2394                    hir::expr::ForBindingIndex::Range { .. } => {}
2395                }
2396            }
2397            collect_resolved_collection_refs_from_expr(body, ctx, src, refs)
2398        }
2399        hir::ExprKind::IndexAccess { expr, args } => {
2400            collect_resolved_collection_refs_from_expr(expr, ctx, src, refs)?;
2401            for arg in args {
2402                match arg {
2403                    hir::expr::IndexArg::Variant(variant) => {
2404                        record_resolved_collection_index(
2405                            variant.variant.index(),
2406                            ctx,
2407                            src,
2408                            variant.path_span(),
2409                            refs,
2410                        )?;
2411                    }
2412                    hir::expr::IndexArg::Expr(expr) => {
2413                        collect_resolved_collection_refs_from_expr(expr, ctx, src, refs)?;
2414                    }
2415                    hir::expr::IndexArg::Var(_) => {}
2416                }
2417            }
2418            Ok(())
2419        }
2420        hir::ExprKind::Scan {
2421            source, init, body, ..
2422        } => {
2423            collect_resolved_collection_refs_from_expr(source, ctx, src, refs)?;
2424            collect_resolved_collection_refs_from_expr(init, ctx, src, refs)?;
2425            collect_resolved_collection_refs_from_expr(body, ctx, src, refs)
2426        }
2427        hir::ExprKind::Unfold { init, body, .. } => {
2428            collect_resolved_collection_refs_from_expr(init, ctx, src, refs)?;
2429            collect_resolved_collection_refs_from_expr(body, ctx, src, refs)
2430        }
2431        hir::ExprKind::Match { scrutinee, arms } => {
2432            collect_resolved_collection_refs_from_expr(scrutinee, ctx, src, refs)?;
2433            for arm in arms {
2434                if let hir::expr::MatchPattern::IndexLabel { variant, span: _ } = &arm.pattern {
2435                    record_resolved_collection_index(
2436                        variant.variant.index(),
2437                        ctx,
2438                        src,
2439                        variant.path_span(),
2440                        refs,
2441                    )?;
2442                }
2443                collect_resolved_collection_refs_from_expr(&arm.body, ctx, src, refs)?;
2444            }
2445            Ok(())
2446        }
2447        hir::ExprKind::InlineDagRef { args, .. } => {
2448            for arg in args {
2449                collect_resolved_collection_refs_from_expr(&arg.value, ctx, src, refs)?;
2450            }
2451            Ok(())
2452        }
2453    }
2454}
2455
2456fn collect_resolved_collection_refs_from_assert_body(
2457    body: &hir::AssertBody,
2458    ctx: ModuleTypeContext<'_>,
2459    src: &NamedSource<Arc<String>>,
2460    refs: &mut ResolvedCollectionRefs,
2461) -> Result<(), GraphcalError> {
2462    match body {
2463        hir::AssertBody::Expr(expr) => {
2464            collect_resolved_collection_refs_from_expr(expr, ctx, src, refs)
2465        }
2466        hir::AssertBody::Tolerance {
2467            actual,
2468            expected,
2469            tolerance,
2470            is_relative: _,
2471        } => {
2472            collect_resolved_collection_refs_from_expr(actual, ctx, src, refs)?;
2473            collect_resolved_collection_refs_from_expr(expected, ctx, src, refs)?;
2474            collect_resolved_collection_refs_from_expr(tolerance, ctx, src, refs)
2475        }
2476    }
2477}
2478
2479fn collect_resolved_constructor_refs(
2480    exprs: &ResolvedExpressions,
2481    domain_bounds: &HashMap<ResolvedName<namespace::Decl>, Vec<ResolvedDomainBound>>,
2482    ctx: ModuleTypeContext<'_>,
2483    src: &NamedSource<Arc<String>>,
2484) -> Result<ResolvedConstructorRefs, GraphcalError> {
2485    let mut refs = ResolvedConstructorRefs::default();
2486
2487    for hir_expr in exprs
2488        .consts
2489        .values()
2490        .chain(exprs.param_defaults.values())
2491        .chain(exprs.nodes.values())
2492        .chain(domain_bounds.values().flatten().map(|bound| &bound.value))
2493    {
2494        collect_resolved_constructor_refs_from_expr(hir_expr, ctx, src, &mut refs)?;
2495    }
2496    for body in exprs.asserts.values() {
2497        collect_resolved_constructor_refs_from_assert_body(body, ctx, src, &mut refs)?;
2498    }
2499
2500    Ok(refs)
2501}
2502
2503fn record_resolved_constructor_target(
2504    constructor: &ResolvedName<namespace::Constructor>,
2505    ctx: ModuleTypeContext<'_>,
2506    src: &NamedSource<Arc<String>>,
2507    span: Span,
2508    refs: &mut ResolvedConstructorRefs,
2509) -> Result<ResolvedConstructorTarget, GraphcalError> {
2510    if let Some(target) = refs.constructor_defs.get(constructor) {
2511        return Ok(target.clone());
2512    }
2513
2514    let def = ctx.types.lookup_constructor(constructor).ok_or_else(|| {
2515        internal_error(
2516            format!("semantic constructor metadata references unknown constructor `{constructor}`"),
2517            src,
2518            span,
2519        )
2520    })?;
2521    let target = ResolvedConstructorTarget {
2522        constructor: constructor.clone(),
2523        owning_type: def.owning_type.clone(),
2524        type_def: def.type_def.clone(),
2525        variant: def.variant.clone(),
2526    };
2527    refs.constructor_defs
2528        .insert(constructor.clone(), target.clone());
2529    Ok(target)
2530}
2531
2532fn collect_resolved_constructor_refs_from_expr(
2533    expr: &hir::Expr,
2534    ctx: ModuleTypeContext<'_>,
2535    src: &NamedSource<Arc<String>>,
2536    refs: &mut ResolvedConstructorRefs,
2537) -> Result<(), GraphcalError> {
2538    // Recursion choke point: recurses once per tree level (unbounded for
2539    // left-nested operator chains).
2540    crate::stack::with_stack_growth(|| {
2541        collect_resolved_constructor_refs_from_expr_inner(expr, ctx, src, refs)
2542    })
2543}
2544
2545#[expect(
2546    clippy::too_many_lines,
2547    reason = "expression traversal mirrors HIR variants"
2548)]
2549fn collect_resolved_constructor_refs_from_expr_inner(
2550    expr: &hir::Expr,
2551    ctx: ModuleTypeContext<'_>,
2552    src: &NamedSource<Arc<String>>,
2553    refs: &mut ResolvedConstructorRefs,
2554) -> Result<(), GraphcalError> {
2555    match &expr.kind {
2556        hir::ExprKind::Error
2557        | hir::ExprKind::Number(_)
2558        | hir::ExprKind::Integer(_)
2559        | hir::ExprKind::Bool(_)
2560        | hir::ExprKind::StringLiteral(_)
2561        | hir::ExprKind::TypeSystemRef(_)
2562        | hir::ExprKind::GraphRef(_)
2563        | hir::ExprKind::LocalRef(_)
2564        | hir::ExprKind::UnitLiteral { .. }
2565        | hir::ExprKind::VariantLiteral(_) => Ok(()),
2566        hir::ExprKind::ConstRef(target) => {
2567            if let hir::ConstRef::Constructor(constructor) = &target.value {
2568                record_resolved_constructor_target(constructor, ctx, src, target.span, refs)?;
2569            }
2570            Ok(())
2571        }
2572        hir::ExprKind::BinOp { lhs, rhs, .. } => {
2573            collect_resolved_constructor_refs_from_expr(lhs, ctx, src, refs)?;
2574            collect_resolved_constructor_refs_from_expr(rhs, ctx, src, refs)
2575        }
2576        hir::ExprKind::UnaryOp { operand, .. } => {
2577            collect_resolved_constructor_refs_from_expr(operand, ctx, src, refs)
2578        }
2579        hir::ExprKind::FnCall { args, .. } => {
2580            for arg in args {
2581                collect_resolved_constructor_refs_from_expr(arg, ctx, src, refs)?;
2582            }
2583            Ok(())
2584        }
2585        hir::ExprKind::If {
2586            condition,
2587            then_branch,
2588            else_branch,
2589        } => {
2590            collect_resolved_constructor_refs_from_expr(condition, ctx, src, refs)?;
2591            collect_resolved_constructor_refs_from_expr(then_branch, ctx, src, refs)?;
2592            collect_resolved_constructor_refs_from_expr(else_branch, ctx, src, refs)
2593        }
2594        hir::ExprKind::Convert { expr, .. }
2595        | hir::ExprKind::DisplayTimezone { expr, .. }
2596        | hir::ExprKind::FieldAccess { expr, .. } => {
2597            collect_resolved_constructor_refs_from_expr(expr, ctx, src, refs)
2598        }
2599        hir::ExprKind::ConstructorCall { callee, fields, .. } => {
2600            record_resolved_constructor_target(&callee.value, ctx, src, callee.span, refs)?;
2601            for field in fields {
2602                collect_resolved_constructor_refs_from_expr(&field.value, ctx, src, refs)?;
2603            }
2604            Ok(())
2605        }
2606        hir::ExprKind::MapLiteral { entries } => {
2607            for entry in entries {
2608                collect_resolved_constructor_refs_from_expr(&entry.value, ctx, src, refs)?;
2609            }
2610            Ok(())
2611        }
2612        hir::ExprKind::ForComp { body, .. } => {
2613            collect_resolved_constructor_refs_from_expr(body, ctx, src, refs)
2614        }
2615        hir::ExprKind::IndexAccess { expr, args } => {
2616            collect_resolved_constructor_refs_from_expr(expr, ctx, src, refs)?;
2617            for arg in args {
2618                if let hir::expr::IndexArg::Expr(expr) = arg {
2619                    collect_resolved_constructor_refs_from_expr(expr, ctx, src, refs)?;
2620                }
2621            }
2622            Ok(())
2623        }
2624        hir::ExprKind::Scan {
2625            source, init, body, ..
2626        } => {
2627            collect_resolved_constructor_refs_from_expr(source, ctx, src, refs)?;
2628            collect_resolved_constructor_refs_from_expr(init, ctx, src, refs)?;
2629            collect_resolved_constructor_refs_from_expr(body, ctx, src, refs)
2630        }
2631        hir::ExprKind::Unfold { init, body, .. } => {
2632            collect_resolved_constructor_refs_from_expr(init, ctx, src, refs)?;
2633            collect_resolved_constructor_refs_from_expr(body, ctx, src, refs)
2634        }
2635        hir::ExprKind::Match { scrutinee, arms } => {
2636            collect_resolved_constructor_refs_from_expr(scrutinee, ctx, src, refs)?;
2637            for arm in arms {
2638                if let hir::expr::MatchPattern::Constructor { constructor, .. } = &arm.pattern {
2639                    record_resolved_constructor_target(
2640                        &constructor.value,
2641                        ctx,
2642                        src,
2643                        constructor.span,
2644                        refs,
2645                    )?;
2646                }
2647                collect_resolved_constructor_refs_from_expr(&arm.body, ctx, src, refs)?;
2648            }
2649            Ok(())
2650        }
2651        hir::ExprKind::InlineDagRef { args, .. } => {
2652            for arg in args {
2653                collect_resolved_constructor_refs_from_expr(&arg.value, ctx, src, refs)?;
2654            }
2655            Ok(())
2656        }
2657    }
2658}
2659
2660fn collect_resolved_constructor_refs_from_assert_body(
2661    body: &hir::AssertBody,
2662    ctx: ModuleTypeContext<'_>,
2663    src: &NamedSource<Arc<String>>,
2664    refs: &mut ResolvedConstructorRefs,
2665) -> Result<(), GraphcalError> {
2666    match body {
2667        hir::AssertBody::Expr(expr) => {
2668            collect_resolved_constructor_refs_from_expr(expr, ctx, src, refs)
2669        }
2670        hir::AssertBody::Tolerance {
2671            actual,
2672            expected,
2673            tolerance,
2674            is_relative: _,
2675        } => {
2676            collect_resolved_constructor_refs_from_expr(actual, ctx, src, refs)?;
2677            collect_resolved_constructor_refs_from_expr(expected, ctx, src, refs)?;
2678            collect_resolved_constructor_refs_from_expr(tolerance, ctx, src, refs)
2679        }
2680    }
2681}
2682
2683fn collect_resolved_inline_dag_refs(exprs: &ResolvedExpressions) -> ResolvedInlineDagRefs {
2684    let mut refs = ResolvedInlineDagRefs::default();
2685
2686    for hir_expr in exprs
2687        .consts
2688        .values()
2689        .chain(exprs.param_defaults.values())
2690        .chain(exprs.nodes.values())
2691    {
2692        collect_resolved_inline_dag_refs_from_expr(hir_expr, &mut refs);
2693    }
2694    for body in exprs.asserts.values() {
2695        collect_resolved_inline_dag_refs_from_assert_body(body, &mut refs);
2696    }
2697
2698    refs
2699}
2700
2701fn collect_resolved_inline_dag_refs_from_expr(expr: &hir::Expr, refs: &mut ResolvedInlineDagRefs) {
2702    // Recursion choke point: recurses once per tree level (unbounded for
2703    // left-nested operator chains).
2704    crate::stack::with_stack_growth(|| {
2705        collect_resolved_inline_dag_refs_from_expr_inner(expr, refs);
2706    });
2707}
2708
2709fn collect_resolved_inline_dag_refs_from_expr_inner(
2710    expr: &hir::Expr,
2711    refs: &mut ResolvedInlineDagRefs,
2712) {
2713    match &expr.kind {
2714        hir::ExprKind::Error
2715        | hir::ExprKind::Number(_)
2716        | hir::ExprKind::Integer(_)
2717        | hir::ExprKind::Bool(_)
2718        | hir::ExprKind::StringLiteral(_)
2719        | hir::ExprKind::TypeSystemRef(_)
2720        | hir::ExprKind::GraphRef(_)
2721        | hir::ExprKind::ConstRef(_)
2722        | hir::ExprKind::LocalRef(_)
2723        | hir::ExprKind::UnitLiteral { .. }
2724        | hir::ExprKind::VariantLiteral(_) => {}
2725        hir::ExprKind::BinOp { lhs, rhs, .. } => {
2726            collect_resolved_inline_dag_refs_from_expr(lhs, refs);
2727            collect_resolved_inline_dag_refs_from_expr(rhs, refs);
2728        }
2729        hir::ExprKind::UnaryOp { operand, .. }
2730        | hir::ExprKind::Convert { expr: operand, .. }
2731        | hir::ExprKind::DisplayTimezone { expr: operand, .. }
2732        | hir::ExprKind::FieldAccess { expr: operand, .. } => {
2733            collect_resolved_inline_dag_refs_from_expr(operand, refs);
2734        }
2735        hir::ExprKind::FnCall { args, .. } => {
2736            for arg in args {
2737                collect_resolved_inline_dag_refs_from_expr(arg, refs);
2738            }
2739        }
2740        hir::ExprKind::If {
2741            condition,
2742            then_branch,
2743            else_branch,
2744        } => {
2745            collect_resolved_inline_dag_refs_from_expr(condition, refs);
2746            collect_resolved_inline_dag_refs_from_expr(then_branch, refs);
2747            collect_resolved_inline_dag_refs_from_expr(else_branch, refs);
2748        }
2749        hir::ExprKind::ConstructorCall { fields, .. } => {
2750            for field in fields {
2751                collect_resolved_inline_dag_refs_from_expr(&field.value, refs);
2752            }
2753        }
2754        hir::ExprKind::MapLiteral { entries } => {
2755            for entry in entries {
2756                collect_resolved_inline_dag_refs_from_expr(&entry.value, refs);
2757            }
2758        }
2759        hir::ExprKind::ForComp { body, .. } => {
2760            collect_resolved_inline_dag_refs_from_expr(body, refs);
2761        }
2762        hir::ExprKind::IndexAccess { expr, args } => {
2763            collect_resolved_inline_dag_refs_from_expr(expr, refs);
2764            for arg in args {
2765                if let hir::expr::IndexArg::Expr(expr) = arg {
2766                    collect_resolved_inline_dag_refs_from_expr(expr, refs);
2767                }
2768            }
2769        }
2770        hir::ExprKind::Scan {
2771            source, init, body, ..
2772        } => {
2773            collect_resolved_inline_dag_refs_from_expr(source, refs);
2774            collect_resolved_inline_dag_refs_from_expr(init, refs);
2775            collect_resolved_inline_dag_refs_from_expr(body, refs);
2776        }
2777        hir::ExprKind::Unfold { init, body, .. } => {
2778            collect_resolved_inline_dag_refs_from_expr(init, refs);
2779            collect_resolved_inline_dag_refs_from_expr(body, refs);
2780        }
2781        hir::ExprKind::Match { scrutinee, arms } => {
2782            collect_resolved_inline_dag_refs_from_expr(scrutinee, refs);
2783            for arm in arms {
2784                collect_resolved_inline_dag_refs_from_expr(&arm.body, refs);
2785            }
2786        }
2787        hir::ExprKind::InlineDagRef {
2788            target,
2789            args,
2790            output,
2791        } => {
2792            let arg_targets = args
2793                .iter()
2794                .map(|arg| (arg.target.span, arg.target.value.clone()))
2795                .collect();
2796            refs.calls.insert(
2797                expr.span,
2798                ResolvedInlineDagCall {
2799                    target: target.value.clone(),
2800                    arg_targets,
2801                    output: output.clone(),
2802                },
2803            );
2804            for arg in args {
2805                collect_resolved_inline_dag_refs_from_expr(&arg.value, refs);
2806            }
2807        }
2808    }
2809}
2810
2811fn collect_resolved_inline_dag_refs_from_assert_body(
2812    body: &hir::AssertBody,
2813    refs: &mut ResolvedInlineDagRefs,
2814) {
2815    match body {
2816        hir::AssertBody::Expr(expr) => collect_resolved_inline_dag_refs_from_expr(expr, refs),
2817        hir::AssertBody::Tolerance {
2818            actual,
2819            expected,
2820            tolerance,
2821            is_relative: _,
2822        } => {
2823            collect_resolved_inline_dag_refs_from_expr(actual, refs);
2824            collect_resolved_inline_dag_refs_from_expr(expected, refs);
2825            collect_resolved_inline_dag_refs_from_expr(tolerance, refs);
2826        }
2827    }
2828}
2829
2830fn collect_hir_decl_bindings(
2831    owner: &crate::dag_id::DagId,
2832    consts: &[crate::ir::lower::ConstEntry],
2833    params: &[crate::ir::lower::ParamEntry],
2834    nodes: &[crate::ir::lower::NodeEntry],
2835    imported_value_sources: &HashMap<ScopedName, crate::ir::lower::ImportedValueSource>,
2836    src: &NamedSource<Arc<String>>,
2837) -> Result<HashMap<ScopedName, ResolvedName<namespace::Decl>>, GraphcalError> {
2838    let mut bindings = HashMap::new();
2839
2840    for name in consts
2841        .iter()
2842        .map(|entry| &entry.name)
2843        .chain(params.iter().map(|entry| &entry.name))
2844        .chain(nodes.iter().map(|entry| &entry.name))
2845    {
2846        let resolved = resolved_decl_key(owner, name).ok_or_else(|| {
2847            internal_error(
2848                format!("could not build canonical declaration key for `{name}`"),
2849                src,
2850                Span::new(0, 0),
2851            )
2852        })?;
2853        bindings.insert(name.clone(), resolved);
2854    }
2855
2856    for (name, source) in imported_value_sources {
2857        bindings.insert(
2858            name.clone(),
2859            ResolvedName::from_def(source.dag_id.clone(), source.source_name.clone()),
2860        );
2861    }
2862
2863    Ok(bindings)
2864}
2865
2866#[expect(
2867    clippy::too_many_arguments,
2868    reason = "collects local and imported declaration binding sources for a completed DAG"
2869)]
2870fn collect_resolved_decl_bindings(
2871    ctx: ModuleTypeContext<'_>,
2872    consts: &[crate::ir::lower::ConstEntry],
2873    params: &[crate::ir::lower::ParamEntry],
2874    nodes: &[crate::ir::lower::NodeEntry],
2875    imported_values: &HashMap<
2876        ScopedName,
2877        (
2878            crate::registry::runtime_value::RuntimeValue,
2879            crate::registry::declared_type::DeclaredType,
2880        ),
2881    >,
2882    imported_decl_types: &HashMap<ScopedName, crate::registry::declared_type::DeclaredType>,
2883    imported_value_sources: &HashMap<ScopedName, crate::ir::lower::ImportedValueSource>,
2884    src: &NamedSource<Arc<String>>,
2885) -> Result<HashMap<ScopedName, ResolvedName<namespace::Decl>>, GraphcalError> {
2886    let mut bindings = collect_hir_decl_bindings(
2887        ctx.owner,
2888        consts,
2889        params,
2890        nodes,
2891        imported_value_sources,
2892        src,
2893    )?;
2894
2895    for name in imported_values
2896        .keys()
2897        .chain(imported_decl_types.keys())
2898        .chain(imported_value_sources.keys())
2899    {
2900        if bindings.contains_key(name) {
2901            continue;
2902        }
2903        let path = scoped_name_to_name_path(name).ok_or_else(|| {
2904            internal_error(
2905                format!("could not convert visible declaration `{name}` to a name path"),
2906                src,
2907                Span::new(0, 0),
2908            )
2909        })?;
2910        let resolved = match ctx.resolver.resolve_decl_path(ctx.owner, &path) {
2911            Ok(resolved) => resolved,
2912            Err(_err)
2913                if imported_values.contains_key(name) || imported_decl_types.contains_key(name) =>
2914            {
2915                // Instantiated inline-DAG includes can carry hidden imported
2916                // values from the included DAG body into the importer. Those
2917                // aliases are not import declarations in the importer's module
2918                // scope, but they are explicit IR inputs, so bind them as
2919                // synthetic declarations owned by the current DAG.
2920                let synthetic_owner = name
2921                    .qualifier()
2922                    .iter()
2923                    .fold(ctx.owner.clone(), |owner, segment| {
2924                        owner.child(segment.as_ref())
2925                    });
2926                ResolvedName::from_def(synthetic_owner, DeclName::new(name.member()))
2927            }
2928            Err(err) => return Err(module_resolve_error(&err, src, Span::new(0, 0))),
2929        };
2930        bindings.insert(name.clone(), resolved);
2931    }
2932
2933    Ok(bindings)
2934}
2935
2936fn scoped_name_to_name_path(name: &ScopedName) -> Option<NamePath> {
2937    let qualifier = name
2938        .qualifier()
2939        .iter()
2940        .map(|segment| NameAtom::parse(segment.as_ref()).ok())
2941        .collect::<Option<Vec<_>>>()?;
2942    let leaf = NameAtom::parse(name.member()).ok()?;
2943    Some(if qualifier.is_empty() {
2944        NamePath::local(leaf)
2945    } else {
2946        NamePath::qualified_path(qualifier, leaf)
2947    })
2948}
2949
2950fn resolve_expected_fail_keys(
2951    expected_fail: HashMap<ScopedName, ExpectedFail>,
2952    ctx: ModuleTypeContext<'_>,
2953    src: &NamedSource<Arc<String>>,
2954) -> Result<HashMap<ScopedName, ExpectedFail>, GraphcalError> {
2955    expected_fail
2956        .into_iter()
2957        .map(|(assert_name, expected)| {
2958            let resolved = match expected {
2959                ExpectedFail::All => ExpectedFail::All,
2960                ExpectedFail::Variants(keys) => {
2961                    let resolved_keys = keys
2962                        .into_iter()
2963                        .map(|key| {
2964                            key.into_iter()
2965                                .map(|part| {
2966                                    let Some(index_path) = part.source_index_path().cloned() else {
2967                                        return Ok(part);
2968                                    };
2969                                    let resolved = ctx
2970                                        .resolver
2971                                        .resolve_index_variant_parts(
2972                                            ctx.owner,
2973                                            &index_path,
2974                                            &part.variant(),
2975                                        )
2976                                        .map_err(|err| {
2977                                            module_resolve_error(&err, src, part.span())
2978                                        })?;
2979                                    Ok(part.with_resolved_variant(resolved))
2980                                })
2981                                .collect::<Result<_, GraphcalError>>()
2982                        })
2983                        .collect::<Result<_, GraphcalError>>()?;
2984                    ExpectedFail::Variants(resolved_keys)
2985                }
2986            };
2987            Ok((assert_name, resolved))
2988        })
2989        .collect()
2990}
2991
2992/// Partially-built [`DagTIR`] returned by [`type_resolve_dag`]; finalized
2993/// by [`DagTIRSeed::with_body`] which fills in the rest of the per-DAG
2994/// fields.
2995struct DagTIRSeed {
2996    dag_id: crate::dag_id::DagId,
2997    consts: Vec<crate::ir::lower::ConstEntry>,
2998    params: Vec<crate::ir::lower::ParamEntry>,
2999    nodes: Vec<crate::ir::lower::NodeEntry>,
3000    resolved_decl_types: HashMap<ScopedName, ResolvedTypeExpr>,
3001    semantic: DagSemanticBody,
3002}
3003
3004impl DagTIRSeed {
3005    #[expect(
3006        clippy::too_many_arguments,
3007        reason = "single conversion that absorbs every IR field beyond the resolved decls"
3008    )]
3009    fn with_body(
3010        self,
3011        asserts: Vec<crate::ir::lower::AssertEntry>,
3012        plots: Vec<crate::ir::lower::PlotEntry>,
3013        figures: Vec<crate::ir::lower::FigureEntry>,
3014        layers: Vec<crate::ir::lower::LayerEntry>,
3015        included_plots: Vec<crate::ir::lower::IncludedPlotEntry>,
3016        source_order: Vec<(ScopedName, DeclCategory)>,
3017        assert_names: std::collections::HashSet<ScopedName>,
3018        assumes_map: HashMap<ScopedName, Vec<ScopedName>>,
3019        expected_fail: HashMap<ScopedName, ExpectedFail>,
3020        imported_values: HashMap<
3021            ScopedName,
3022            (
3023                crate::registry::runtime_value::RuntimeValue,
3024                crate::registry::declared_type::DeclaredType,
3025            ),
3026        >,
3027        imported_decl_types: HashMap<ScopedName, crate::registry::declared_type::DeclaredType>,
3028        imported_value_sources: HashMap<ScopedName, crate::ir::lower::ImportedValueSource>,
3029        module_ctx: ModuleTypeContext<'_>,
3030        src: &NamedSource<Arc<String>>,
3031    ) -> Result<DagTIR, GraphcalError> {
3032        let decl_bindings = collect_resolved_decl_bindings(
3033            module_ctx,
3034            &self.consts,
3035            &self.params,
3036            &self.nodes,
3037            &imported_values,
3038            &imported_decl_types,
3039            &imported_value_sources,
3040            src,
3041        )?;
3042        let expected_fail = resolve_expected_fail_keys(expected_fail, module_ctx, src)?;
3043
3044        let mut semantic = self.semantic;
3045        semantic.decl_bindings = decl_bindings;
3046        collect_plot_exprs(&plots, &figures, &layers, module_ctx, src, &mut semantic)?;
3047
3048        Ok(DagTIR {
3049            dag_id: self.dag_id,
3050            consts: self.consts,
3051            params: self.params,
3052            nodes: self.nodes,
3053            asserts,
3054            plots,
3055            figures,
3056            layers,
3057            included_plots,
3058            semantic,
3059            source_order,
3060            assert_names,
3061            assumes_map,
3062            expected_fail,
3063            resolved_decl_types: self.resolved_decl_types,
3064            domain_constraints: HashMap::new(), // Resolved later in compile()
3065            imported_values,
3066            imported_decl_types,
3067            imported_value_sources,
3068            pub_nodes: std::collections::HashSet::new(),
3069        })
3070    }
3071}
3072
3073// ---------------------------------------------------------------------------
3074// Conversion to DeclaredType
3075// ---------------------------------------------------------------------------
3076
3077/// Convert a non-generic [`ResolvedTypeExpr`] to a `DeclaredType`.
3078///
3079/// This is used by downstream stages (`dim_check`, `eval`) that work with concrete
3080/// types. Generic variants (`GenericDimParam`, `GenericDimExpr`, generic indexes)
3081/// cannot be converted and will return an error.
3082///
3083/// # Errors
3084///
3085/// Returns a [`GraphcalError`] if the resolved type contains unresolved generic
3086/// parameters.
3087pub fn resolved_to_declared_type(
3088    resolved: &ResolvedTypeExpr,
3089    src: &NamedSource<Arc<String>>,
3090) -> Result<crate::registry::declared_type::DeclaredType, GraphcalError> {
3091    use crate::registry::declared_type::{DeclaredType, StructTypeRef};
3092
3093    match resolved {
3094        ResolvedTypeExpr::Dimensionless => Ok(DeclaredType::Scalar(Dimension::dimensionless())),
3095        ResolvedTypeExpr::Bool => Ok(DeclaredType::Bool),
3096        ResolvedTypeExpr::Int => Ok(DeclaredType::Int),
3097        ResolvedTypeExpr::Datetime(scale) => Ok(DeclaredType::Datetime(*scale)),
3098        ResolvedTypeExpr::IndexArg(index) => Err(GraphcalError::EvalError {
3099            message: format!(
3100                "index `{}` cannot be used as a value type",
3101                format_resolved_index(index)
3102            ),
3103            src: src.clone(),
3104            span: resolved_type_expr_span(resolved).into(),
3105        }),
3106        ResolvedTypeExpr::Scalar(dim) => Ok(DeclaredType::Scalar(dim.clone())),
3107        ResolvedTypeExpr::Struct(name, _) => Ok(DeclaredType::Struct(
3108            StructTypeRef::from_resolved(name.clone()),
3109            vec![],
3110        )),
3111        ResolvedTypeExpr::GenericStruct {
3112            name, type_args, ..
3113        } => {
3114            let mut declared_args = Vec::with_capacity(type_args.len());
3115            for arg in type_args {
3116                declared_args.push(resolved_type_arg_to_declared_type(arg, src)?);
3117            }
3118            Ok(DeclaredType::Struct(
3119                StructTypeRef::from_resolved(name.clone()),
3120                declared_args,
3121            ))
3122        }
3123        ResolvedTypeExpr::GenericDimParam(name, span) => Err(GraphcalError::EvalError {
3124            message: format!("cannot use generic dimension parameter `{name}` as a concrete type"),
3125            src: src.clone(),
3126            span: (*span).into(),
3127        }),
3128        ResolvedTypeExpr::GenericTypeParam(name, span) => Err(GraphcalError::EvalError {
3129            message: format!("cannot use generic type parameter `{name}` as a concrete type"),
3130            src: src.clone(),
3131            span: (*span).into(),
3132        }),
3133        ResolvedTypeExpr::GenericDimExpr { span, .. } => Err(GraphcalError::EvalError {
3134            message: "cannot use generic dimension expression as a concrete type".to_string(),
3135            src: src.clone(),
3136            span: (*span).into(),
3137        }),
3138        ResolvedTypeExpr::Indexed { base, indexes } => {
3139            let mut result = resolved_to_declared_type(base, src)?;
3140            for idx in indexes.iter().rev() {
3141                match idx {
3142                    ResolvedIndex::Concrete(name, _) => {
3143                        result = DeclaredType::Indexed {
3144                            element: Box::new(result),
3145                            index: IndexTypeRef::from_resolved(name.clone()),
3146                        };
3147                    }
3148                    ResolvedIndex::NatExpr(form, span) => {
3149                        if !form.is_constant() {
3150                            return Err(GraphcalError::EvalError {
3151                                message: format!(
3152                                    "cannot use generic nat expression `{}` as a concrete type",
3153                                    form.format()
3154                                ),
3155                                src: src.clone(),
3156                                span: (*span).into(),
3157                            });
3158                        }
3159                        let nat_range =
3160                            crate::registry::types::NatRangeIndex::try_from_u64(form.constant())
3161                                .map_err(|err| GraphcalError::EvalError {
3162                                    message: err.to_string(),
3163                                    src: src.clone(),
3164                                    span: (*span).into(),
3165                                })?;
3166                        result = DeclaredType::Indexed {
3167                            element: Box::new(result),
3168                            index: IndexTypeRef::from_nat_range(nat_range),
3169                        };
3170                    }
3171                    ResolvedIndex::GenericParam(name, span) => {
3172                        return Err(GraphcalError::EvalError {
3173                            message: format!(
3174                                "cannot use generic index parameter `{name}` as a concrete type"
3175                            ),
3176                            src: src.clone(),
3177                            span: (*span).into(),
3178                        });
3179                    }
3180                }
3181            }
3182            Ok(result)
3183        }
3184    }
3185}
3186
3187fn resolved_type_arg_to_declared_type(
3188    resolved: &ResolvedTypeExpr,
3189    src: &NamedSource<Arc<String>>,
3190) -> Result<crate::registry::declared_type::DeclaredType, GraphcalError> {
3191    match resolved {
3192        ResolvedTypeExpr::IndexArg(index) => resolved_index_to_declared_arg(index, src),
3193        _ => resolved_to_declared_type(resolved, src),
3194    }
3195}
3196
3197fn resolved_type_expr_span(resolved: &ResolvedTypeExpr) -> Span {
3198    match resolved {
3199        ResolvedTypeExpr::Dimensionless
3200        | ResolvedTypeExpr::Bool
3201        | ResolvedTypeExpr::Int
3202        | ResolvedTypeExpr::Datetime(_)
3203        | ResolvedTypeExpr::Scalar(_) => Span::new(0, 0),
3204        ResolvedTypeExpr::IndexArg(index) => resolved_index_span(index),
3205        ResolvedTypeExpr::Struct(_, span)
3206        | ResolvedTypeExpr::GenericDimParam(_, span)
3207        | ResolvedTypeExpr::GenericTypeParam(_, span)
3208        | ResolvedTypeExpr::GenericDimExpr { span, .. }
3209        | ResolvedTypeExpr::GenericStruct { span, .. } => *span,
3210        ResolvedTypeExpr::Indexed { base, .. } => resolved_type_expr_span(base),
3211    }
3212}
3213
3214const fn resolved_index_span(index: &ResolvedIndex) -> Span {
3215    match index {
3216        ResolvedIndex::Concrete(_, span)
3217        | ResolvedIndex::GenericParam(_, span)
3218        | ResolvedIndex::NatExpr(_, span) => *span,
3219    }
3220}
3221
3222fn resolved_index_to_declared_arg(
3223    index: &ResolvedIndex,
3224    src: &NamedSource<Arc<String>>,
3225) -> Result<crate::registry::declared_type::DeclaredType, GraphcalError> {
3226    let reference = match index {
3227        ResolvedIndex::Concrete(name, _) => IndexTypeRef::from_resolved(name.clone()),
3228        ResolvedIndex::NatExpr(form, span) => IndexTypeRef::from_nat_range_form(form.clone())
3229            .map_err(|err| GraphcalError::EvalError {
3230                message: err.to_string(),
3231                src: src.clone(),
3232                span: (*span).into(),
3233            })?,
3234        ResolvedIndex::GenericParam(name, span) => {
3235            return Err(GraphcalError::EvalError {
3236                message: format!("generic index parameter `{name}` is not bound"),
3237                src: src.clone(),
3238                span: (*span).into(),
3239            });
3240        }
3241    };
3242    Ok(crate::registry::declared_type::DeclaredType::IndexArg(
3243        reference,
3244    ))
3245}
3246
3247fn resolved_index_to_inferred(
3248    index: &ResolvedIndex,
3249    src: &NamedSource<Arc<String>>,
3250) -> Result<crate::tir::dim_check::InferredIndex, GraphcalError> {
3251    let reference = match index {
3252        ResolvedIndex::Concrete(name, _) => IndexTypeRef::from_resolved(name.clone()),
3253        ResolvedIndex::NatExpr(form, span) => IndexTypeRef::from_nat_range_form(form.clone())
3254            .map_err(|err| GraphcalError::EvalError {
3255                message: err.to_string(),
3256                src: src.clone(),
3257                span: (*span).into(),
3258            })?,
3259        ResolvedIndex::GenericParam(name, span) => {
3260            return Err(GraphcalError::EvalError {
3261                message: format!("generic index parameter `{name}` is not bound"),
3262                src: src.clone(),
3263                span: (*span).into(),
3264            });
3265        }
3266    };
3267    Ok(crate::tir::dim_check::InferredIndex::from_ref(reference))
3268}
3269
3270fn resolved_index_matches_inferred(
3271    expected: &ResolvedIndex,
3272    actual: &crate::tir::dim_check::InferredIndex,
3273) -> bool {
3274    match expected {
3275        ResolvedIndex::Concrete(name, _) => actual.matches_resolved(name),
3276        ResolvedIndex::GenericParam(_, _) => false,
3277        ResolvedIndex::NatExpr(form, _) => actual.nat_range_form().as_ref() == Some(form),
3278    }
3279}
3280
3281fn resolved_index_display_name(index: &ResolvedIndex) -> IndexName {
3282    match index {
3283        ResolvedIndex::Concrete(name, _) => name.to_unowned_def_name(),
3284        ResolvedIndex::GenericParam(name, _) => IndexName::from_atom(name.atom().clone()),
3285        ResolvedIndex::NatExpr(form, _) => IndexName::new(format!("range({})", form.format())),
3286    }
3287}
3288
3289// ---------------------------------------------------------------------------
3290// Nat polynomial form unification
3291// ---------------------------------------------------------------------------
3292
3293/// Solve a polynomial equation `form = target` for Nat generic params.
3294///
3295/// Substitutes already-bound variables, then:
3296/// - If no unbound vars remain: checks evaluated form == target.
3297/// - If exactly one unbound var appears only linearly (degree 1): solves the linear equation.
3298/// - Otherwise: returns an error (ambiguous or non-linear in unbound vars).
3299fn unify_nat_poly_form(
3300    form: &NatPolyForm,
3301    target: u64,
3302    nat_sub: &mut HashMap<GenericParamName, u64>,
3303    actual_idx: &IndexName,
3304    src: &NamedSource<Arc<String>>,
3305    span: Span,
3306) -> Result<(), GraphcalError> {
3307    // Substitute already-bound variables in each monomial, collecting
3308    // a reduced polynomial in only unbound variables + a constant part.
3309    let mut reduced_constant: u64 = 0;
3310    // (reduced_monomial, coefficient) pairs for terms with unbound variables
3311    let mut reduced_terms: BTreeMap<Monomial, u64> = BTreeMap::new();
3312
3313    // Shared "this form cannot match the actual index" error, used both for
3314    // genuine mismatches and for arithmetic overflow during reduction (an
3315    // overflowing form cannot match any concrete index size).
3316    let form_mismatch = || GraphcalError::IndexMismatch {
3317        expected: IndexName::new(format!("range({})", form.format())),
3318        found: actual_idx.clone(),
3319        src: src.clone(),
3320        span: span.into(),
3321    };
3322
3323    for (mono, coeff) in &form.terms {
3324        let (remaining_mono, factor) = mono.substitute(nat_sub).ok_or_else(form_mismatch)?;
3325        let term_value = coeff.checked_mul(factor).ok_or_else(form_mismatch)?;
3326        if remaining_mono.is_constant() {
3327            reduced_constant = reduced_constant
3328                .checked_add(term_value)
3329                .ok_or_else(form_mismatch)?;
3330        } else {
3331            let entry = reduced_terms.entry(remaining_mono).or_insert(0);
3332            *entry = entry.checked_add(term_value).ok_or_else(form_mismatch)?;
3333        }
3334    }
3335    // Remove zero terms
3336    reduced_terms.retain(|_, c| *c != 0);
3337
3338    if reduced_terms.is_empty() {
3339        // All variables bound — check equality
3340        if reduced_constant != target {
3341            let expected = match form.evaluate(nat_sub) {
3342                Some(n) => crate::registry::types::NatRangeIndex::try_from_u64(n)
3343                    .map_err(|err| GraphcalError::EvalError {
3344                        message: err.to_string(),
3345                        src: src.clone(),
3346                        span: span.into(),
3347                    })?
3348                    .display_name(),
3349                None => IndexName::new(format!("range({})", form.format())),
3350            };
3351            return Err(GraphcalError::IndexMismatch {
3352                expected,
3353                found: actual_idx.clone(),
3354                src: src.clone(),
3355                span: span.into(),
3356            });
3357        }
3358        return Ok(());
3359    }
3360
3361    // Check if exactly one unbound variable appears, only at degree 1
3362    let mut unbound_vars = std::collections::BTreeSet::new();
3363    for mono in reduced_terms.keys() {
3364        for var in mono.0.keys() {
3365            unbound_vars.insert(var.clone());
3366        }
3367    }
3368
3369    if let [var] = unbound_vars.iter().collect::<Vec<_>>().as_slice() {
3370        let var = (*var).clone();
3371        // Check all remaining monomials are linear in this variable
3372        let all_linear = reduced_terms
3373            .keys()
3374            .all(|m| m.0.len() == 1 && m.0.get(&var) == Some(&1));
3375
3376        if all_linear {
3377            // Solve: coeff * var + reduced_constant = target
3378            let total_coeff = reduced_terms
3379                .values()
3380                .try_fold(0u64, |acc, c| acc.checked_add(*c))
3381                .ok_or_else(form_mismatch)?;
3382            if target < reduced_constant {
3383                return Err(form_mismatch());
3384            }
3385            let remainder = target - reduced_constant;
3386            if total_coeff == 0 || !remainder.is_multiple_of(total_coeff) {
3387                return Err(form_mismatch());
3388            }
3389            let value = remainder / total_coeff;
3390            bind_or_check(nat_sub, var, value, |prev, _| {
3391                match crate::registry::types::NatRangeIndex::try_from_u64(*prev) {
3392                    Ok(index) => GraphcalError::IndexMismatch {
3393                        expected: index.display_name(),
3394                        found: actual_idx.clone(),
3395                        src: src.clone(),
3396                        span: span.into(),
3397                    },
3398                    Err(err) => GraphcalError::EvalError {
3399                        message: err.to_string(),
3400                        src: src.clone(),
3401                        span: span.into(),
3402                    },
3403                }
3404            })?;
3405            return Ok(());
3406        }
3407    }
3408
3409    // Multiple unbound variables or non-linear — ambiguous
3410    let var_names: Vec<&str> = unbound_vars.iter().map(GenericParamName::as_str).collect();
3411    Err(GraphcalError::EvalError {
3412        message: format!(
3413            "cannot infer Nat parameters [{}] from a single index — \
3414             provide more arguments or use explicit type annotations",
3415            var_names.join(", ")
3416        ),
3417        src: src.clone(),
3418        span: span.into(),
3419    })
3420}
3421
3422// ---------------------------------------------------------------------------
3423// Unification
3424// ---------------------------------------------------------------------------
3425
3426/// Bind a generic parameter in a substitution map, or check consistency if already bound.
3427///
3428/// If `key` is not yet in `sub`, inserts `(key, value)`. If `key` is already bound
3429/// to a value equal to `value`, succeeds. Otherwise, calls `on_conflict` with the
3430/// previously bound value and the new value to produce an error.
3431fn bind_or_check<K, V, E>(
3432    sub: &mut HashMap<K, V>,
3433    key: K,
3434    value: V,
3435    on_conflict: impl FnOnce(&V, &V) -> E,
3436) -> Result<(), E>
3437where
3438    K: Eq + std::hash::Hash,
3439    V: PartialEq,
3440{
3441    if let Some(prev) = sub.get(&key) {
3442        if *prev != value {
3443            return Err(on_conflict(prev, &value));
3444        }
3445    } else {
3446        sub.insert(key, value);
3447    }
3448    Ok(())
3449}
3450
3451/// Unify a resolved type expression against an actual inferred type,
3452/// binding generic dimension and index parameters.
3453///
3454/// For example, if `resolved` is `GenericDimParam("D")` and `actual` is
3455/// `Scalar(Length)`, binds `D = Length` in `dim_sub`.
3456///
3457/// # Errors
3458///
3459/// Returns a [`GraphcalError`] on type mismatch or conflicting bindings.
3460#[expect(
3461    clippy::too_many_lines,
3462    reason = "complex generic unification requires many match arms"
3463)]
3464#[expect(
3465    clippy::implicit_hasher,
3466    reason = "always called with standard HashMap"
3467)]
3468#[expect(
3469    clippy::too_many_arguments,
3470    reason = "unification needs all substitution maps, registry, and source context"
3471)]
3472pub fn unify_resolved_type(
3473    resolved: &ResolvedTypeExpr,
3474    actual: &crate::tir::dim_check::InferredType,
3475    dim_sub: &mut HashMap<GenericParamName, Dimension>,
3476    index_sub: &mut HashMap<GenericParamName, IndexTypeRef>,
3477    nat_sub: &mut HashMap<GenericParamName, u64>,
3478    registry: &Registry,
3479    src: &NamedSource<Arc<String>>,
3480    span: Span,
3481) -> Result<(), GraphcalError> {
3482    use crate::tir::dim_check::InferredType;
3483
3484    match resolved {
3485        ResolvedTypeExpr::Indexed { base, indexes } => {
3486            // Peel off index layers from actual type, binding index generics.
3487            // Iterate forward: first index in the list is the outermost Indexed layer.
3488            let mut current = actual;
3489            for idx in indexes {
3490                let InferredType::Indexed {
3491                    element,
3492                    index: actual_idx,
3493                } = current
3494                else {
3495                    return Err(GraphcalError::DimensionMismatch {
3496                        expected: "indexed type".to_string(),
3497                        found: crate::tir::dim_check::format_inferred_type(current, registry),
3498                        help: "expected an indexed value".to_string(),
3499                        src: src.clone(),
3500                        span: span.into(),
3501                    });
3502                };
3503                match idx {
3504                    ResolvedIndex::GenericParam(gp, _) => {
3505                        bind_or_check(
3506                            index_sub,
3507                            gp.clone(),
3508                            actual_idx.type_ref().clone(),
3509                            |prev, _| GraphcalError::IndexMismatch {
3510                                expected: prev.display_name(),
3511                                found: actual_idx.name(),
3512                                src: src.clone(),
3513                                span: span.into(),
3514                            },
3515                        )?;
3516                    }
3517                    ResolvedIndex::Concrete(name, _) => {
3518                        if !actual_idx.matches_resolved(name) {
3519                            return Err(GraphcalError::IndexMismatch {
3520                                expected: name.to_unowned_def_name(),
3521                                found: actual_idx.name(),
3522                                src: src.clone(),
3523                                span: span.into(),
3524                            });
3525                        }
3526                    }
3527                    ResolvedIndex::NatExpr(form, _) => {
3528                        // Extract the concrete nat value from the typed actual Nat-range identity.
3529                        let actual_nat = actual_idx
3530                            .nat_range_form()
3531                            .filter(NatPolyForm::is_constant)
3532                            .map(|actual_form| actual_form.constant())
3533                            .ok_or_else(|| GraphcalError::IndexMismatch {
3534                                expected: IndexName::new(format!("range({})", form.format())),
3535                                found: actual_idx.name(),
3536                                src: src.clone(),
3537                                span: span.into(),
3538                            })?;
3539                        // Solve the polynomial equation: form = actual_nat
3540                        let actual_idx_name = actual_idx.name();
3541                        unify_nat_poly_form(
3542                            form,
3543                            actual_nat,
3544                            nat_sub,
3545                            &actual_idx_name,
3546                            src,
3547                            span,
3548                        )?;
3549                    }
3550                }
3551                current = element;
3552            }
3553            unify_resolved_type(
3554                base, current, dim_sub, index_sub, nat_sub, registry, src, span,
3555            )
3556        }
3557
3558        ResolvedTypeExpr::Bool => {
3559            if *actual != InferredType::Bool {
3560                return Err(GraphcalError::DimensionMismatch {
3561                    expected: "Bool".to_string(),
3562                    found: crate::tir::dim_check::format_inferred_type(actual, registry),
3563                    help: "expected Bool argument".to_string(),
3564                    src: src.clone(),
3565                    span: span.into(),
3566                });
3567            }
3568            Ok(())
3569        }
3570
3571        ResolvedTypeExpr::Int => {
3572            if !actual.is_int_like() {
3573                return Err(GraphcalError::DimensionMismatch {
3574                    expected: "Int".to_string(),
3575                    found: crate::tir::dim_check::format_inferred_type(actual, registry),
3576                    help: "expected Int argument".to_string(),
3577                    src: src.clone(),
3578                    span: span.into(),
3579                });
3580            }
3581            Ok(())
3582        }
3583
3584        ResolvedTypeExpr::Datetime(expected_scale) => {
3585            if *actual != InferredType::Datetime(*expected_scale) {
3586                let expected_str = if expected_scale.is_utc() {
3587                    "Datetime".to_string()
3588                } else {
3589                    format!("Datetime<{expected_scale}>")
3590                };
3591                return Err(GraphcalError::DimensionMismatch {
3592                    expected: expected_str,
3593                    found: crate::tir::dim_check::format_inferred_type(actual, registry),
3594                    help: "expected Datetime argument".to_string(),
3595                    src: src.clone(),
3596                    span: span.into(),
3597                });
3598            }
3599            Ok(())
3600        }
3601
3602        ResolvedTypeExpr::IndexArg(expected_index) => {
3603            let InferredType::NamedIndex(actual_index) = actual else {
3604                return Err(GraphcalError::DimensionMismatch {
3605                    expected: format!("index {}", format_resolved_index(expected_index)),
3606                    found: crate::tir::dim_check::format_inferred_type(actual, registry),
3607                    help: "expected an index generic argument".to_string(),
3608                    src: src.clone(),
3609                    span: span.into(),
3610                });
3611            };
3612            if !resolved_index_matches_inferred(expected_index, actual_index) {
3613                return Err(GraphcalError::IndexMismatch {
3614                    expected: resolved_index_display_name(expected_index),
3615                    found: actual_index.name(),
3616                    src: src.clone(),
3617                    span: span.into(),
3618                });
3619            }
3620            Ok(())
3621        }
3622
3623        ResolvedTypeExpr::Dimensionless => {
3624            let actual_dim = crate::tir::dim_check::expect_scalar(actual, registry, src, span)?;
3625            if !actual_dim.is_dimensionless() {
3626                return Err(GraphcalError::DimensionMismatch {
3627                    expected: "Dimensionless".to_string(),
3628                    found: registry.dimensions.format_dimension(&actual_dim),
3629                    help: "expected Dimensionless argument".to_string(),
3630                    src: src.clone(),
3631                    span: span.into(),
3632                });
3633            }
3634            Ok(())
3635        }
3636
3637        ResolvedTypeExpr::Scalar(expected_dim) => {
3638            let actual_dim = crate::tir::dim_check::expect_scalar(actual, registry, src, span)?;
3639            if *expected_dim != actual_dim {
3640                return Err(GraphcalError::DimensionMismatch {
3641                    expected: registry.dimensions.format_dimension(expected_dim),
3642                    found: registry.dimensions.format_dimension(&actual_dim),
3643                    help: "dimension mismatch in function argument".to_string(),
3644                    src: src.clone(),
3645                    span: span.into(),
3646                });
3647            }
3648            Ok(())
3649        }
3650
3651        ResolvedTypeExpr::GenericStruct {
3652            name, type_args, ..
3653        } => {
3654            // Unify the struct identity AND its type arguments: skipping the
3655            // args would let `Vec3<Length>` silently unify with `Vec3<Mass>`.
3656            let InferredType::Struct(actual_name, actual_args) = actual else {
3657                return Err(GraphcalError::DimensionMismatch {
3658                    expected: name.as_str().to_string(),
3659                    found: crate::tir::dim_check::format_inferred_type(actual, registry),
3660                    help: format!("expected struct type `{}`", name.as_str()),
3661                    src: src.clone(),
3662                    span: span.into(),
3663                });
3664            };
3665            if actual_name.resolved() != name {
3666                return Err(GraphcalError::DimensionMismatch {
3667                    expected: name.as_str().to_string(),
3668                    found: crate::tir::dim_check::format_inferred_type(actual, registry),
3669                    help: format!("expected struct type `{}`", name.as_str()),
3670                    src: src.clone(),
3671                    span: span.into(),
3672                });
3673            }
3674            // Recursively unify each declared type argument against the
3675            // actual one when both sides carry them. (Inferred values may
3676            // omit args for non-generic uses — only mismatched *pairs* are
3677            // an error.)
3678            for (declared_arg, actual_arg) in type_args.iter().zip(actual_args) {
3679                unify_resolved_type(
3680                    declared_arg,
3681                    actual_arg,
3682                    dim_sub,
3683                    index_sub,
3684                    nat_sub,
3685                    registry,
3686                    src,
3687                    span,
3688                )?;
3689            }
3690            Ok(())
3691        }
3692
3693        ResolvedTypeExpr::Struct(name, _) => {
3694            // When both sides carry canonical struct identities, compare
3695            // owners as well.
3696            let InferredType::Struct(actual_name, _) = actual else {
3697                return Err(GraphcalError::DimensionMismatch {
3698                    expected: name.as_str().to_string(),
3699                    found: crate::tir::dim_check::format_inferred_type(actual, registry),
3700                    help: format!("expected struct type `{}`", name.as_str()),
3701                    src: src.clone(),
3702                    span: span.into(),
3703                });
3704            };
3705            if actual_name.resolved() != name {
3706                return Err(GraphcalError::DimensionMismatch {
3707                    expected: name.as_str().to_string(),
3708                    found: crate::tir::dim_check::format_inferred_type(actual, registry),
3709                    help: format!("expected struct type `{}`", name.as_str()),
3710                    src: src.clone(),
3711                    span: span.into(),
3712                });
3713            }
3714            Ok(())
3715        }
3716
3717        ResolvedTypeExpr::GenericDimParam(gp, _) => {
3718            let actual_dim = crate::tir::dim_check::expect_scalar(actual, registry, src, span)?;
3719            bind_or_check(dim_sub, gp.clone(), actual_dim, |prev, new| {
3720                GraphcalError::DimensionMismatch {
3721                    expected: registry.dimensions.format_dimension(prev),
3722                    found: registry.dimensions.format_dimension(new),
3723                    help: format!(
3724                        "generic `{gp}` was bound to {} but this argument requires {}",
3725                        registry.dimensions.format_dimension(prev),
3726                        registry.dimensions.format_dimension(new),
3727                    ),
3728                    src: src.clone(),
3729                    span: span.into(),
3730                }
3731            })
3732        }
3733
3734        ResolvedTypeExpr::GenericTypeParam(gp, gp_span) => Err(GraphcalError::EvalError {
3735            message: format!(
3736                "cannot infer unconstrained generic type parameter `{gp}` in this position yet"
3737            ),
3738            src: src.clone(),
3739            span: (*gp_span).into(),
3740        }),
3741
3742        ResolvedTypeExpr::GenericDimExpr { terms, .. } => {
3743            let actual_dim = crate::tir::dim_check::expect_scalar(actual, registry, src, span)?;
3744
3745            // Single generic term with power: D^n means D = actual^(1/n)
3746            if terms.len() == 1
3747                && let ResolvedDimTerm::GenericParam {
3748                    name: gp,
3749                    power,
3750                    op: MulDivOp::Mul,
3751                    ..
3752                } = &terms[0]
3753            {
3754                let bound_dim = if *power == Rational::ONE {
3755                    actual_dim
3756                } else {
3757                    // D^(p/q) bound against `actual` means D = actual^(q/p).
3758                    let exponent = Rational::try_new(power.den(), power.num()).map_err(|_| {
3759                        GraphcalError::InternalError {
3760                            message: format!("generic dimension parameter `{gp}` has zero power"),
3761                            src: src.clone(),
3762                            span: span.into(),
3763                        }
3764                    })?;
3765                    actual_dim
3766                        .pow(exponent)
3767                        .map_err(|_| GraphcalError::DimensionOverflow {
3768                            src: src.clone(),
3769                            span: span.into(),
3770                        })?
3771                };
3772                bind_or_check(dim_sub, gp.clone(), bound_dim, |prev, new| {
3773                    GraphcalError::DimensionMismatch {
3774                        expected: registry.dimensions.format_dimension(prev),
3775                        found: registry.dimensions.format_dimension(new),
3776                        help: format!(
3777                            "generic `{gp}` was bound to {} but this argument requires {}",
3778                            registry.dimensions.format_dimension(prev),
3779                            registry.dimensions.format_dimension(new),
3780                        ),
3781                        src: src.clone(),
3782                        span: span.into(),
3783                    }
3784                })?;
3785                return Ok(());
3786            }
3787
3788            // General case: compute expected dimension from already-bound generics + concrete terms
3789            let mut expected_dim = Dimension::dimensionless();
3790            for term in terms {
3791                let overflow_err = || GraphcalError::DimensionOverflow {
3792                    src: src.clone(),
3793                    span: span.into(),
3794                };
3795                let term_dim = match term {
3796                    ResolvedDimTerm::Concrete { dim, power, .. } => {
3797                        dim.pow(*power).map_err(|_| overflow_err())?
3798                    }
3799                    ResolvedDimTerm::GenericParam {
3800                        name: gp, power, ..
3801                    } => {
3802                        if let Some(prev) = dim_sub.get(gp) {
3803                            prev.pow(*power).map_err(|_| overflow_err())?
3804                        } else {
3805                            return Err(GraphcalError::DimensionMismatch {
3806                                expected: format!("generic `{gp}` (unresolved)"),
3807                                found: registry.dimensions.format_dimension(&actual_dim),
3808                                help: format!(
3809                                    "generic `{gp}` could not be inferred from this argument"
3810                                ),
3811                                src: src.clone(),
3812                                span: span.into(),
3813                            });
3814                        }
3815                    }
3816                };
3817                expected_dim = match term.op() {
3818                    MulDivOp::Mul => (expected_dim * term_dim).map_err(|_| overflow_err())?,
3819                    MulDivOp::Div => (expected_dim / term_dim).map_err(|_| overflow_err())?,
3820                };
3821            }
3822
3823            if expected_dim != actual_dim {
3824                return Err(GraphcalError::DimensionMismatch {
3825                    expected: registry.dimensions.format_dimension(&expected_dim),
3826                    found: registry.dimensions.format_dimension(&actual_dim),
3827                    help: "dimension mismatch in function argument".to_string(),
3828                    src: src.clone(),
3829                    span: span.into(),
3830                });
3831            }
3832            Ok(())
3833        }
3834    }
3835}
3836
3837// ---------------------------------------------------------------------------
3838// Substitution
3839// ---------------------------------------------------------------------------
3840
3841/// Substitute generic parameters in a resolved type, producing an `InferredType`.
3842///
3843/// This replaces `resolve_type_with_substitution()` from `dim_check.rs`.
3844#[expect(
3845    clippy::implicit_hasher,
3846    reason = "always called with standard HashMap"
3847)]
3848pub fn substitute_resolved_type(
3849    resolved: &ResolvedTypeExpr,
3850    dim_sub: &HashMap<GenericParamName, Dimension>,
3851    index_sub: &HashMap<GenericParamName, IndexTypeRef>,
3852    nat_sub: &HashMap<GenericParamName, u64>,
3853    src: &NamedSource<Arc<String>>,
3854) -> Result<crate::tir::dim_check::InferredType, GraphcalError> {
3855    let no_type_sub = HashMap::new();
3856    substitute_resolved_type_with_types(resolved, dim_sub, index_sub, nat_sub, &no_type_sub, src)
3857}
3858
3859/// Like [`substitute_resolved_type`], but with generic *type* parameters.
3860///
3861/// Unconstrained generic type parameters are substituted from `type_sub`
3862/// (used by HIR constructor-call inference, which binds them from
3863/// call-site arguments).
3864#[expect(
3865    clippy::implicit_hasher,
3866    reason = "always called with standard HashMap"
3867)]
3868#[expect(
3869    clippy::too_many_lines,
3870    reason = "single dispatch over ResolvedTypeExpr variants with per-variant generic-substitution + dimension-arithmetic overflow handling"
3871)]
3872pub fn substitute_resolved_type_with_types(
3873    resolved: &ResolvedTypeExpr,
3874    dim_sub: &HashMap<GenericParamName, Dimension>,
3875    index_sub: &HashMap<GenericParamName, IndexTypeRef>,
3876    nat_sub: &HashMap<GenericParamName, u64>,
3877    type_sub: &HashMap<GenericParamName, crate::tir::dim_check::InferredType>,
3878    src: &NamedSource<Arc<String>>,
3879) -> Result<crate::tir::dim_check::InferredType, GraphcalError> {
3880    use crate::tir::dim_check::InferredType;
3881
3882    match resolved {
3883        ResolvedTypeExpr::Dimensionless => Ok(InferredType::Scalar(Dimension::dimensionless())),
3884        ResolvedTypeExpr::Bool => Ok(InferredType::Bool),
3885        ResolvedTypeExpr::Int => Ok(InferredType::Int),
3886        ResolvedTypeExpr::Datetime(scale) => Ok(InferredType::Datetime(*scale)),
3887        ResolvedTypeExpr::IndexArg(index) => {
3888            resolved_index_to_inferred(index, src).map(InferredType::NamedIndex)
3889        }
3890        ResolvedTypeExpr::Scalar(dim) => Ok(InferredType::Scalar(dim.clone())),
3891        ResolvedTypeExpr::Struct(name, _) => Ok(InferredType::Struct(
3892            crate::tir::dim_check::InferredStructType::from_resolved(name.clone()),
3893            vec![],
3894        )),
3895        ResolvedTypeExpr::GenericStruct {
3896            name, type_args, ..
3897        } => {
3898            let mut inferred_args = Vec::with_capacity(type_args.len());
3899            for arg in type_args {
3900                inferred_args.push(substitute_resolved_type_with_types(
3901                    arg, dim_sub, index_sub, nat_sub, type_sub, src,
3902                )?);
3903            }
3904            Ok(InferredType::Struct(
3905                crate::tir::dim_check::InferredStructType::from_resolved(name.clone()),
3906                inferred_args,
3907            ))
3908        }
3909
3910        ResolvedTypeExpr::GenericDimParam(gp, span) => dim_sub.get(gp).map_or_else(
3911            || {
3912                Err(GraphcalError::EvalError {
3913                    message: format!("generic `{gp}` not bound during substitution"),
3914                    src: src.clone(),
3915                    span: (*span).into(),
3916                })
3917            },
3918            |dim| Ok(InferredType::Scalar(dim.clone())),
3919        ),
3920
3921        ResolvedTypeExpr::GenericTypeParam(gp, span) => type_sub.get(gp).map_or_else(
3922            || {
3923                Err(GraphcalError::EvalError {
3924                    message: format!("generic type parameter `{gp}` not bound during substitution"),
3925                    src: src.clone(),
3926                    span: (*span).into(),
3927                })
3928            },
3929            |ty| Ok(ty.clone()),
3930        ),
3931
3932        ResolvedTypeExpr::GenericDimExpr { terms, span } => {
3933            let overflow_err = || GraphcalError::DimensionOverflow {
3934                src: src.clone(),
3935                span: (*span).into(),
3936            };
3937            let mut result = Dimension::dimensionless();
3938            for term in terms {
3939                let term_dim = match term {
3940                    ResolvedDimTerm::Concrete { dim, power, .. } => {
3941                        dim.pow(*power).map_err(|_| overflow_err())?
3942                    }
3943                    ResolvedDimTerm::GenericParam {
3944                        name: gp,
3945                        power,
3946                        span: term_span,
3947                        ..
3948                    } => {
3949                        let base = dim_sub.get(gp).ok_or_else(|| GraphcalError::EvalError {
3950                            message: format!("generic `{gp}` not bound during substitution"),
3951                            src: src.clone(),
3952                            span: (*term_span).into(),
3953                        })?;
3954                        base.pow(*power).map_err(|_| overflow_err())?
3955                    }
3956                };
3957                result = match term.op() {
3958                    MulDivOp::Mul => (result * term_dim).map_err(|_| overflow_err())?,
3959                    MulDivOp::Div => (result / term_dim).map_err(|_| overflow_err())?,
3960                };
3961            }
3962            Ok(InferredType::Scalar(result))
3963        }
3964
3965        ResolvedTypeExpr::Indexed { base, indexes } => {
3966            let mut result = substitute_resolved_type_with_types(
3967                base, dim_sub, index_sub, nat_sub, type_sub, src,
3968            )?;
3969            for idx in indexes.iter().rev() {
3970                let resolved_idx = match idx {
3971                    ResolvedIndex::Concrete(name, _) => {
3972                        result = InferredType::Indexed {
3973                            element: Box::new(result),
3974                            index: crate::tir::dim_check::InferredIndex::from_resolved(
3975                                name.clone(),
3976                            ),
3977                        };
3978                        continue;
3979                    }
3980                    ResolvedIndex::GenericParam(gp, span) => {
3981                        crate::tir::dim_check::InferredIndex::from_ref(
3982                            index_sub
3983                                .get(gp)
3984                                .cloned()
3985                                .ok_or_else(|| GraphcalError::EvalError {
3986                                    message: format!(
3987                                        "generic index `{gp}` not bound during substitution"
3988                                    ),
3989                                    src: src.clone(),
3990                                    span: (*span).into(),
3991                                })?,
3992                        )
3993                    }
3994                    ResolvedIndex::NatExpr(form, span) => {
3995                        let n = form.evaluate(nat_sub).ok_or_else(|| {
3996                            let vars = form.variables();
3997                            let unbound: Vec<&str> = vars
3998                                .iter()
3999                                .filter(|k| !nat_sub.contains_key(*k))
4000                                .map(GenericParamName::as_str)
4001                                .collect();
4002                            GraphcalError::EvalError {
4003                                message: format!(
4004                                    "generic nat parameter(s) [{}] not bound during substitution",
4005                                    unbound.join(", ")
4006                                ),
4007                                src: src.clone(),
4008                                span: (*span).into(),
4009                            }
4010                        })?;
4011                        crate::tir::dim_check::InferredIndex::from_nat_range_form(
4012                            NatPolyForm::from_constant(n),
4013                        )
4014                        .map_err(|err| GraphcalError::EvalError {
4015                            message: err.to_string(),
4016                            src: src.clone(),
4017                            span: (*span).into(),
4018                        })?
4019                    }
4020                };
4021                result = InferredType::Indexed {
4022                    element: Box::new(result),
4023                    index: resolved_idx,
4024                };
4025            }
4026            Ok(result)
4027        }
4028    }
4029}
4030
4031// ---------------------------------------------------------------------------
4032// Helpers
4033// ---------------------------------------------------------------------------
4034
4035// ---------------------------------------------------------------------------
4036// Type resolution (single TypeExpr)
4037// ---------------------------------------------------------------------------
4038
4039fn require_local_type_level_path<'a>(
4040    path: &'a NamePath,
4041    span: Span,
4042    src: &NamedSource<Arc<String>>,
4043) -> Result<&'a str, GraphcalError> {
4044    path.as_bare()
4045        .map(super::super::syntax::names::NameAtom::as_str)
4046        .ok_or_else(|| GraphcalError::EvalError {
4047            message: format!(
4048                "qualified type-level reference `{path}` needs module-aware resolution"
4049            ),
4050            src: src.clone(),
4051            span: span.into(),
4052        })
4053}
4054
4055fn module_resolve_error(
4056    err: &ModuleResolveError,
4057    src: &NamedSource<Arc<String>>,
4058    span: Span,
4059) -> GraphcalError {
4060    GraphcalError::EvalError {
4061        message: err.to_string(),
4062        src: src.clone(),
4063        span: span.into(),
4064    }
4065}
4066
4067fn internal_error(message: String, src: &NamedSource<Arc<String>>, span: Span) -> GraphcalError {
4068    GraphcalError::InternalError {
4069        message,
4070        src: src.clone(),
4071        span: span.into(),
4072    }
4073}
4074
4075const fn module_lookup_is_absent(err: &ModuleResolveError) -> bool {
4076    matches!(err, ModuleResolveError::UnknownName { .. })
4077}
4078
4079fn type_lower_error_to_graphcal(
4080    err: &hir::HirLowerError,
4081    type_ann: &TypeExpr,
4082    src: &NamedSource<Arc<String>>,
4083) -> GraphcalError {
4084    if let hir::HirLowerError::UnknownTypePath { path, span } = err {
4085        if type_expr_has_index_name_at_span(type_ann, *span)
4086            && let Ok(name) = IndexName::try_new(path.clone())
4087        {
4088            return GraphcalError::UnknownIndex {
4089                name,
4090                src: src.clone(),
4091                span: (*span).into(),
4092            };
4093        }
4094        if type_expr_has_dim_term_at_span(type_ann, *span)
4095            && let Ok(name) = DimName::try_new(path.clone())
4096        {
4097            return GraphcalError::UnknownDimension {
4098                name,
4099                src: src.clone(),
4100                span: (*span).into(),
4101            };
4102        }
4103    }
4104    hir_lower_error_to_graphcal(err, src)
4105}
4106
4107fn type_expr_has_index_name_at_span(type_ann: &TypeExpr, span: Span) -> bool {
4108    match &type_ann.kind {
4109        TypeExprKind::Indexed { base, indexes } => {
4110            type_expr_has_index_name_at_span(base, span)
4111                || indexes.iter().any(|index| match index {
4112                    crate::desugar::desugared_ast::IndexExpr::Name(name) => name.span == span,
4113                    crate::desugar::desugared_ast::IndexExpr::NatExpr(_) => false,
4114                })
4115        }
4116        TypeExprKind::TypeApplication { type_args, .. }
4117        | TypeExprKind::DatetimeApplication { type_args } => type_args
4118            .iter()
4119            .any(|arg| type_expr_has_index_name_at_span(arg, span)),
4120        TypeExprKind::Dimensionless
4121        | TypeExprKind::Bool
4122        | TypeExprKind::Int
4123        | TypeExprKind::Datetime
4124        | TypeExprKind::DimExpr(_) => false,
4125    }
4126}
4127
4128fn type_expr_has_dim_term_at_span(type_ann: &TypeExpr, span: Span) -> bool {
4129    match &type_ann.kind {
4130        TypeExprKind::DimExpr(dim_expr) => dim_expr
4131            .terms
4132            .iter()
4133            .any(|item| item.term.name.span == span),
4134        TypeExprKind::Indexed { base, .. } => type_expr_has_dim_term_at_span(base, span),
4135        TypeExprKind::TypeApplication { type_args, .. }
4136        | TypeExprKind::DatetimeApplication { type_args } => type_args
4137            .iter()
4138            .any(|arg| type_expr_has_dim_term_at_span(arg, span)),
4139        TypeExprKind::Dimensionless
4140        | TypeExprKind::Bool
4141        | TypeExprKind::Int
4142        | TypeExprKind::Datetime => false,
4143    }
4144}
4145
4146#[derive(Clone, Copy)]
4147struct HirTypeResolutionContext<'a> {
4148    src: &'a NamedSource<Arc<String>>,
4149    resolver: &'a ModuleResolver,
4150    module_types: &'a ModuleTypeRegistry,
4151    registry: Option<&'a Registry>,
4152    prelude: &'a hir::PreludeTypeScope,
4153}
4154
4155/// Resolve an already-lowered HIR type expression into the TIR type
4156/// representation.
4157///
4158/// This is the new semantic entry point for module-aware TIR type resolution:
4159/// source paths should be lowered to HIR first, then TIR consumes canonical
4160/// `ResolvedName<Ns>` and lexical generic IDs from HIR instead of performing
4161/// source-path lookup itself.
4162pub fn resolve_hir_type_expr(
4163    type_ann: &hir::TypeExpr,
4164    _registry: &Registry,
4165    src: &NamedSource<Arc<String>>,
4166    module_ctx: ModuleTypeContext<'_>,
4167) -> Result<ResolvedTypeExpr, GraphcalError> {
4168    let prelude = hir::PreludeTypeScope::graphcal();
4169    let ctx = HirTypeResolutionContext {
4170        src,
4171        resolver: module_ctx.resolver,
4172        module_types: module_ctx.types,
4173        registry: None,
4174        prelude: &prelude,
4175    };
4176    resolve_hir_type_expr_inner(type_ann, ctx)
4177}
4178
4179fn resolve_ast_type_expr_via_hir(
4180    type_ann: &TypeExpr,
4181    registry: &Registry,
4182    src: &NamedSource<Arc<String>>,
4183    module_ctx: ModuleTypeContext<'_>,
4184) -> Result<ResolvedTypeExpr, GraphcalError> {
4185    let generic_scope = hir::GenericScope::new();
4186    let prelude = hir::PreludeTypeScope::graphcal();
4187    let lower_ctx =
4188        hir::TypeLoweringContext::new(module_ctx.owner, module_ctx.resolver, &generic_scope)
4189            .with_prelude(&prelude);
4190    let hir_type = hir::lower_type_expr(type_ann, lower_ctx)
4191        .map_err(|err| type_lower_error_to_graphcal(&err, type_ann, src))?;
4192    let resolve_ctx = HirTypeResolutionContext {
4193        src,
4194        resolver: module_ctx.resolver,
4195        module_types: module_ctx.types,
4196        registry: Some(registry),
4197        prelude: &prelude,
4198    };
4199    resolve_hir_type_expr_inner(&hir_type, resolve_ctx)
4200}
4201
4202fn resolve_hir_type_expr_inner(
4203    type_ann: &hir::TypeExpr,
4204    ctx: HirTypeResolutionContext<'_>,
4205) -> Result<ResolvedTypeExpr, GraphcalError> {
4206    match &type_ann.kind {
4207        hir::TypeExprKind::Builtin(builtin) => Ok(resolve_hir_builtin_type(*builtin)),
4208        hir::TypeExprKind::DimExpr(dim_expr) => resolve_hir_dim_expr(dim_expr, ctx),
4209        hir::TypeExprKind::Index(index) => Err(GraphcalError::EvalError {
4210            message: format!(
4211                "index `{}` cannot be used as a type",
4212                format_hir_index_ref(index)
4213            ),
4214            src: ctx.src.clone(),
4215            span: hir_index_ref_span(index).into(),
4216        }),
4217        hir::TypeExprKind::Struct(name) => {
4218            hir_struct_type_def(&name.value, name.span, ctx)?;
4219            Ok(ResolvedTypeExpr::Struct(name.value.clone(), name.span))
4220        }
4221        hir::TypeExprKind::GenericTypeParam(param) => Ok(ResolvedTypeExpr::GenericTypeParam(
4222            param.value.name.clone(),
4223            param.span,
4224        )),
4225        hir::TypeExprKind::TypeApplication { name, type_args } => {
4226            resolve_hir_type_application(type_ann, name, type_args, ctx)
4227        }
4228        hir::TypeExprKind::Indexed { base, indexes } => {
4229            let resolved_base = resolve_hir_type_expr_inner(base, ctx)?;
4230            let resolved_indexes = indexes
4231                .iter()
4232                .map(|index| resolve_hir_index_ref(index, ctx))
4233                .collect::<Result<Vec<_>, _>>()?;
4234            Ok(ResolvedTypeExpr::Indexed {
4235                base: Box::new(resolved_base),
4236                indexes: resolved_indexes,
4237            })
4238        }
4239    }
4240}
4241
4242const fn hir_index_ref_span(index: &hir::IndexRef) -> Span {
4243    match index {
4244        hir::IndexRef::Concrete(name) => name.span,
4245        hir::IndexRef::GenericParam(param) => param.span,
4246        hir::IndexRef::NatExpr(nat_expr) => nat_expr.span(),
4247    }
4248}
4249
4250fn format_hir_index_ref(index: &hir::IndexRef) -> String {
4251    match index {
4252        hir::IndexRef::Concrete(name) => name.value.as_str().to_string(),
4253        hir::IndexRef::GenericParam(param) => param.value.name.to_string(),
4254        hir::IndexRef::NatExpr(nat_expr) => format!("range({})", format_hir_nat_expr(nat_expr)),
4255    }
4256}
4257
4258fn format_hir_nat_expr(nat_expr: &hir::NatExpr) -> String {
4259    match nat_expr {
4260        hir::NatExpr::Literal(n, _) => n.to_string(),
4261        hir::NatExpr::Param(param) => param.value.name.to_string(),
4262        hir::NatExpr::Add(lhs, rhs, _) => {
4263            format!(
4264                "{} + {}",
4265                format_hir_nat_expr(lhs),
4266                format_hir_nat_expr(rhs)
4267            )
4268        }
4269        hir::NatExpr::Mul(lhs, rhs, _) => {
4270            format!(
4271                "{} * {}",
4272                format_hir_nat_expr(lhs),
4273                format_hir_nat_expr(rhs)
4274            )
4275        }
4276    }
4277}
4278
4279const fn resolve_hir_builtin_type(builtin: hir::BuiltinType) -> ResolvedTypeExpr {
4280    match builtin {
4281        hir::BuiltinType::Dimensionless => ResolvedTypeExpr::Dimensionless,
4282        hir::BuiltinType::Bool => ResolvedTypeExpr::Bool,
4283        hir::BuiltinType::Int => ResolvedTypeExpr::Int,
4284        hir::BuiltinType::Datetime(scale) => ResolvedTypeExpr::Datetime(scale.scale()),
4285    }
4286}
4287
4288fn hir_dimension(
4289    name: &ResolvedName<namespace::Dim>,
4290    span: Span,
4291    ctx: HirTypeResolutionContext<'_>,
4292) -> Result<Dimension, GraphcalError> {
4293    ctx.module_types
4294        .get_dimension(name)
4295        .cloned()
4296        .or_else(|| {
4297            ctx.registry.and_then(|registry| {
4298                registry
4299                    .dimensions
4300                    .get_dimension(name.to_unowned_def_name().as_str())
4301                    .cloned()
4302            })
4303        })
4304        .ok_or_else(|| GraphcalError::UnknownDimension {
4305            name: name.to_unowned_def_name(),
4306            src: ctx.src.clone(),
4307            span: span.into(),
4308        })
4309}
4310
4311fn hir_index_name(
4312    name: &ResolvedName<namespace::Index>,
4313    span: Span,
4314    ctx: HirTypeResolutionContext<'_>,
4315) -> Result<IndexName, GraphcalError> {
4316    if ctx.module_types.get_index(name).is_some() {
4317        Ok(name.to_unowned_def_name())
4318    } else {
4319        Err(GraphcalError::UnknownIndex {
4320            name: name.to_unowned_def_name(),
4321            src: ctx.src.clone(),
4322            span: span.into(),
4323        })
4324    }
4325}
4326
4327fn hir_struct_type_def<'a>(
4328    name: &ResolvedName<namespace::StructType>,
4329    span: Span,
4330    ctx: HirTypeResolutionContext<'a>,
4331) -> Result<&'a TypeDef, GraphcalError> {
4332    ctx.module_types
4333        .get_struct_type(name)
4334        .ok_or_else(|| GraphcalError::UnknownStructType {
4335            name: name.to_string(),
4336            src: ctx.src.clone(),
4337            span: span.into(),
4338        })
4339}
4340
4341fn resolve_hir_dim_expr(
4342    dim_expr: &hir::DimExpr,
4343    ctx: HirTypeResolutionContext<'_>,
4344) -> Result<ResolvedTypeExpr, GraphcalError> {
4345    let terms = dim_expr
4346        .terms
4347        .iter()
4348        .map(|item| resolve_hir_dim_expr_item(item, ctx))
4349        .collect::<Result<Vec<_>, _>>()?;
4350
4351    if let [
4352        ResolvedDimTerm::GenericParam {
4353            name,
4354            power,
4355            op: MulDivOp::Mul,
4356            span,
4357        },
4358    ] = terms.as_slice()
4359        && *power == Rational::ONE
4360    {
4361        return Ok(ResolvedTypeExpr::GenericDimParam(name.clone(), *span));
4362    }
4363
4364    let has_generic = terms
4365        .iter()
4366        .any(|term| matches!(term, ResolvedDimTerm::GenericParam { .. }));
4367    if has_generic {
4368        return Ok(ResolvedTypeExpr::GenericDimExpr {
4369            terms,
4370            span: dim_expr.span,
4371        });
4372    }
4373
4374    let result = terms.iter().try_fold(
4375        Dimension::dimensionless(),
4376        |acc, term| -> Result<Dimension, GraphcalError> {
4377            let ResolvedDimTerm::Concrete { dim, power, op } = term else {
4378                return Err(GraphcalError::InternalError {
4379                    message: "generic dimension term reached concrete dimension folding"
4380                        .to_string(),
4381                    src: ctx.src.clone(),
4382                    span: dim_expr.span.into(),
4383                });
4384            };
4385            let overflow_err = || GraphcalError::DimensionOverflow {
4386                src: ctx.src.clone(),
4387                span: dim_expr.span.into(),
4388            };
4389            let powered = dim.pow(*power).map_err(|_| overflow_err())?;
4390            match op {
4391                MulDivOp::Mul => (acc * powered).map_err(|_| overflow_err()),
4392                MulDivOp::Div => (acc / powered).map_err(|_| overflow_err()),
4393            }
4394        },
4395    )?;
4396    Ok(ResolvedTypeExpr::Scalar(result))
4397}
4398
4399fn resolve_hir_dim_expr_item(
4400    item: &hir::DimExprItem,
4401    ctx: HirTypeResolutionContext<'_>,
4402) -> Result<ResolvedDimTerm, GraphcalError> {
4403    let power = item.term.power.unwrap_or(Rational::ONE);
4404    match &item.term.target {
4405        hir::DimTermTarget::Dimension(name) => Ok(ResolvedDimTerm::Concrete {
4406            dim: hir_dimension(&name.value, name.span, ctx)?,
4407            power,
4408            op: item.op,
4409        }),
4410        hir::DimTermTarget::GenericParam(param) => Ok(ResolvedDimTerm::GenericParam {
4411            name: param.value.name.clone(),
4412            power,
4413            op: item.op,
4414            span: item.term.span,
4415        }),
4416    }
4417}
4418
4419fn resolve_hir_index_ref(
4420    index: &hir::IndexRef,
4421    ctx: HirTypeResolutionContext<'_>,
4422) -> Result<ResolvedIndex, GraphcalError> {
4423    match index {
4424        hir::IndexRef::Concrete(name) => {
4425            hir_index_name(&name.value, name.span, ctx)?;
4426            Ok(ResolvedIndex::Concrete(name.value.clone(), name.span))
4427        }
4428        hir::IndexRef::GenericParam(param) => Ok(ResolvedIndex::GenericParam(
4429            param.value.name.clone(),
4430            param.span,
4431        )),
4432        hir::IndexRef::NatExpr(nat_expr) => Ok(ResolvedIndex::NatExpr(
4433            normalize_hir_nat_expr(nat_expr)
4434                .map_err(|err| nat_overflow_error(err, ctx.src, nat_expr.span()))?,
4435            nat_expr.span(),
4436        )),
4437    }
4438}
4439
4440fn normalize_hir_nat_expr(
4441    expr: &hir::NatExpr,
4442) -> Result<NatPolyForm, crate::syntax::nat::NatOverflowError> {
4443    match expr {
4444        hir::NatExpr::Literal(value, _) => Ok(NatPolyForm::from_constant(*value)),
4445        hir::NatExpr::Param(param) => Ok(NatPolyForm::from_var(param.value.name.clone())),
4446        hir::NatExpr::Add(lhs, rhs, _) => {
4447            normalize_hir_nat_expr(lhs)?.add(&normalize_hir_nat_expr(rhs)?)
4448        }
4449        hir::NatExpr::Mul(lhs, rhs, _) => {
4450            normalize_hir_nat_expr(lhs)?.mul(&normalize_hir_nat_expr(rhs)?)
4451        }
4452    }
4453}
4454
4455/// Validate the generic-argument count for a type application: at least
4456/// the number of non-defaulted parameters, at most the total count.
4457/// Shared by the HIR and syntax type-application resolvers.
4458fn check_type_application_arity(
4459    type_name: &str,
4460    type_def: &TypeDef,
4461    arg_count: usize,
4462    span: Span,
4463    src: &NamedSource<Arc<String>>,
4464) -> Result<(), GraphcalError> {
4465    let total_params = type_def.generic_params.len();
4466    let required_count = type_def
4467        .generic_params
4468        .iter()
4469        .take_while(|p| p.default.is_none())
4470        .count();
4471    if arg_count < required_count || arg_count > total_params {
4472        let hint = if required_count == total_params {
4473            format!("{total_params}")
4474        } else {
4475            format!("{required_count}..{total_params}")
4476        };
4477        return Err(GraphcalError::EvalError {
4478            message: format!("type `{type_name}` expects {hint} type argument(s), got {arg_count}"),
4479            src: src.clone(),
4480            span: span.into(),
4481        });
4482    }
4483    Ok(())
4484}
4485
4486fn resolve_hir_type_application(
4487    type_ann: &hir::TypeExpr,
4488    name: &crate::syntax::span::Spanned<ResolvedName<namespace::StructType>>,
4489    type_args: &[hir::TypeExpr],
4490    ctx: HirTypeResolutionContext<'_>,
4491) -> Result<ResolvedTypeExpr, GraphcalError> {
4492    let type_def = hir_struct_type_def(&name.value, name.span, ctx)?;
4493    check_type_application_arity(
4494        name.value.as_str(),
4495        type_def,
4496        type_args.len(),
4497        type_ann.span,
4498        ctx.src,
4499    )?;
4500
4501    let mut resolved_args = Vec::with_capacity(type_def.generic_params.len());
4502    for (param, arg) in type_def.generic_params.iter().zip(type_args) {
4503        resolved_args.push(resolve_hir_type_arg_for_param(param, arg, ctx)?);
4504    }
4505
4506    for param in type_def.generic_params.iter().skip(type_args.len()) {
4507        let default_expr = param
4508            .default
4509            .as_ref()
4510            .ok_or_else(|| GraphcalError::EvalError {
4511                message: format!(
4512                    "internal: generic parameter `{}` has no default",
4513                    param.name
4514                ),
4515                src: ctx.src.clone(),
4516                span: type_ann.span.into(),
4517            })?;
4518        let default_hir = lower_type_generic_default(default_expr, &name.value, type_def, ctx)?;
4519        resolved_args.push(resolve_hir_type_arg_for_param(param, &default_hir, ctx)?);
4520    }
4521
4522    Ok(ResolvedTypeExpr::GenericStruct {
4523        name: name.value.clone(),
4524        type_args: resolved_args,
4525        span: type_ann.span,
4526    })
4527}
4528
4529fn resolve_hir_type_arg_for_param(
4530    param: &crate::registry::types::TypeGenericParam,
4531    arg: &hir::TypeExpr,
4532    ctx: HirTypeResolutionContext<'_>,
4533) -> Result<ResolvedTypeExpr, GraphcalError> {
4534    match param.constraint {
4535        TypeGenericConstraint::Index => match &arg.kind {
4536            hir::TypeExprKind::Index(index) => {
4537                resolve_hir_index_ref(index, ctx).map(ResolvedTypeExpr::IndexArg)
4538            }
4539            _ => Err(GraphcalError::EvalError {
4540                message: format!(
4541                    "generic parameter `{}` expects an Index argument",
4542                    param.name
4543                ),
4544                src: ctx.src.clone(),
4545                span: arg.span.into(),
4546            }),
4547        },
4548        TypeGenericConstraint::Nat => Err(GraphcalError::EvalError {
4549            message: format!(
4550                "generic parameter `{}` expects a Nat argument, got a type argument",
4551                param.name
4552            ),
4553            src: ctx.src.clone(),
4554            span: arg.span.into(),
4555        }),
4556        TypeGenericConstraint::Dim | TypeGenericConstraint::Unconstrained => {
4557            resolve_hir_type_expr_inner(arg, ctx)
4558        }
4559    }
4560}
4561
4562fn lower_type_generic_default(
4563    default_expr: &TypeExpr,
4564    type_owner: &ResolvedName<namespace::StructType>,
4565    type_def: &TypeDef,
4566    ctx: HirTypeResolutionContext<'_>,
4567) -> Result<hir::TypeExpr, GraphcalError> {
4568    let mut scope = hir::GenericScope::new();
4569    for param in &type_def.generic_params {
4570        let constraint = match param.constraint {
4571            TypeGenericConstraint::Dim => crate::syntax::ast::GenericConstraint::Dim,
4572            TypeGenericConstraint::Index => crate::syntax::ast::GenericConstraint::Index,
4573            TypeGenericConstraint::Nat => crate::syntax::ast::GenericConstraint::Nat,
4574            TypeGenericConstraint::Unconstrained => crate::syntax::ast::GenericConstraint::Type,
4575        };
4576        let id = hir::GenericParamId::new(
4577            hir::GenericParamOwner::Type(type_owner.clone()),
4578            param.name.clone(),
4579        );
4580        scope
4581            .insert_binding(hir::GenericParamBinding::new(
4582                id,
4583                constraint,
4584                default_expr.span,
4585            ))
4586            .map_err(|err| hir_lower_error_to_graphcal(&err, ctx.src))?;
4587    }
4588
4589    let lower_ctx = hir::TypeLoweringContext::new(type_owner.owner(), ctx.resolver, &scope)
4590        .with_prelude(ctx.prelude);
4591    hir::lower_type_expr(default_expr, lower_ctx)
4592        .map_err(|err| hir_lower_error_to_graphcal(&err, ctx.src))
4593}
4594
4595#[expect(
4596    clippy::too_many_arguments,
4597    reason = "resolves one AST index path against generic params, local registry, and module context"
4598)]
4599fn resolve_index_expr_name(
4600    path: &NamePath,
4601    span: Span,
4602    registry: &Registry,
4603    owner: &crate::dag_id::DagId,
4604    index_params: &[GenericParamName],
4605    nat_params: &[GenericParamName],
4606    src: &NamedSource<Arc<String>>,
4607    module_ctx: Option<ModuleTypeContext<'_>>,
4608) -> Result<ResolvedIndex, GraphcalError> {
4609    if let Some(atom) = path.as_bare() {
4610        let text = atom.as_str();
4611        if let Some(gp) = nat_params.iter().find(|p| p.as_str() == text) {
4612            return Ok(ResolvedIndex::NatExpr(
4613                NatPolyForm::from_var(gp.clone()),
4614                span,
4615            ));
4616        }
4617        if let Some(gp) = index_params.iter().find(|p| p.as_str() == text) {
4618            return Ok(ResolvedIndex::GenericParam(gp.clone(), span));
4619        }
4620    }
4621
4622    if let Some(ctx) = module_ctx {
4623        match ctx.resolver.resolve_index_path(ctx.owner, path) {
4624            Ok(resolved) => {
4625                if ctx.types.get_index(&resolved).is_some() {
4626                    return Ok(ResolvedIndex::Concrete(resolved, span));
4627                }
4628                return Err(GraphcalError::UnknownIndex {
4629                    name: resolved.to_unowned_def_name(),
4630                    src: src.clone(),
4631                    span: span.into(),
4632                });
4633            }
4634            Err(err) if path.is_bare() && module_lookup_is_absent(&err) => {}
4635            Err(err) => return Err(module_resolve_error(&err, src, span)),
4636        }
4637    }
4638
4639    let text = require_local_type_level_path(path, span, src)?;
4640    if registry.indexes.get_index(text).is_some() {
4641        Ok(ResolvedIndex::Concrete(
4642            ResolvedName::from_def(owner.clone(), IndexName::new(text)),
4643            span,
4644        ))
4645    } else {
4646        Err(GraphcalError::UnknownIndex {
4647            name: IndexName::new(text),
4648            src: src.clone(),
4649            span: span.into(),
4650        })
4651    }
4652}
4653
4654fn resolve_concrete_index_path(
4655    path: &NamePath,
4656    span: Span,
4657    registry: &Registry,
4658    owner: &crate::dag_id::DagId,
4659    src: &NamedSource<Arc<String>>,
4660    module_ctx: Option<ModuleTypeContext<'_>>,
4661) -> Result<Option<ResolvedName<namespace::Index>>, GraphcalError> {
4662    if let Some(ctx) = module_ctx {
4663        match ctx.resolver.resolve_index_path(ctx.owner, path) {
4664            Ok(resolved) => {
4665                let Some(index) = ctx.types.get_index(&resolved) else {
4666                    return Err(GraphcalError::UnknownIndex {
4667                        name: resolved.to_unowned_def_name(),
4668                        src: src.clone(),
4669                        span: span.into(),
4670                    });
4671                };
4672                if matches!(
4673                    index.kind,
4674                    crate::registry::types::IndexKind::Named { .. }
4675                        | crate::registry::types::IndexKind::RequiredNamed
4676                ) {
4677                    return Ok(Some(resolved));
4678                }
4679                return Ok(None);
4680            }
4681            Err(err) if module_lookup_is_absent(&err) => {}
4682            Err(_) if path.is_bare() => {
4683                // A bare non-local name may still be a prelude or registry-only
4684                // compatibility entry. Fall through to the leaf-keyed registry.
4685            }
4686            Err(err) => return Err(module_resolve_error(&err, src, span)),
4687        }
4688    }
4689
4690    let Some(atom) = path.as_bare() else {
4691        return Ok(None);
4692    };
4693    let Some(index) = registry.indexes.get_index(atom.as_str()) else {
4694        return Ok(None);
4695    };
4696    Ok(matches!(
4697        index.kind,
4698        crate::registry::types::IndexKind::Named { .. }
4699            | crate::registry::types::IndexKind::RequiredNamed
4700    )
4701    .then(|| ResolvedName::from_def(owner.clone(), IndexName::from_atom(atom.clone()))))
4702}
4703
4704type ResolvedStructTypeLookup<'a> = Option<(ResolvedName<namespace::StructType>, &'a TypeDef)>;
4705
4706fn resolve_struct_type_path<'a>(
4707    path: &NamePath,
4708    span: Span,
4709    registry: &'a Registry,
4710    owner: &crate::dag_id::DagId,
4711    src: &NamedSource<Arc<String>>,
4712    module_ctx: Option<ModuleTypeContext<'a>>,
4713) -> Result<ResolvedStructTypeLookup<'a>, GraphcalError> {
4714    if let Some(ctx) = module_ctx {
4715        match ctx.resolver.resolve_struct_type_path(ctx.owner, path) {
4716            Ok(resolved) => {
4717                if let Some(type_def) = ctx.types.get_struct_type(&resolved) {
4718                    return Ok(Some((resolved, type_def)));
4719                }
4720                return Err(GraphcalError::UnknownStructType {
4721                    name: resolved.to_string(),
4722                    src: src.clone(),
4723                    span: span.into(),
4724                });
4725            }
4726            Err(err) if module_lookup_is_absent(&err) => {}
4727            Err(_err) if path.is_bare() => {}
4728            Err(err) => return Err(module_resolve_error(&err, src, span)),
4729        }
4730    }
4731
4732    let Some(atom) = path.as_bare() else {
4733        return Ok(None);
4734    };
4735    Ok(registry.types.get_type(atom.as_str()).map(|type_def| {
4736        (
4737            ResolvedName::from_def(owner.clone(), StructTypeName::from_atom(atom.clone())),
4738            type_def,
4739        )
4740    }))
4741}
4742
4743fn resolve_dimension_path(
4744    path: &NamePath,
4745    span: Span,
4746    registry: &Registry,
4747    src: &NamedSource<Arc<String>>,
4748    module_ctx: Option<ModuleTypeContext<'_>>,
4749) -> Result<Option<Dimension>, GraphcalError> {
4750    if let Some(ctx) = module_ctx {
4751        match ctx.resolver.resolve_dimension_path(ctx.owner, path) {
4752            Ok(resolved) => {
4753                return ctx
4754                    .types
4755                    .get_dimension(&resolved)
4756                    .cloned()
4757                    .map(Some)
4758                    .ok_or_else(|| GraphcalError::UnknownDimension {
4759                        name: resolved.to_unowned_def_name(),
4760                        src: src.clone(),
4761                        span: span.into(),
4762                    });
4763            }
4764            Err(err) if path.is_bare() && module_lookup_is_absent(&err) => {}
4765            Err(err) => return Err(module_resolve_error(&err, src, span)),
4766        }
4767    }
4768
4769    let text = require_local_type_level_path(path, span, src)?;
4770    Ok(registry.dimensions.get_dimension(text).cloned())
4771}
4772
4773/// Resolve a `TypeExpr` into a `ResolvedTypeExpr`.
4774///
4775/// `dim_params` and `index_params` are the generic parameters in scope (empty
4776/// for top-level declarations, non-empty inside function signatures).
4777///
4778/// # Errors
4779///
4780/// Returns a [`GraphcalError`] if a name cannot be resolved (not a known
4781/// dimension, struct, index, or in-scope generic parameter).
4782pub fn resolve_type_expr(
4783    type_ann: &TypeExpr,
4784    registry: &Registry,
4785    dim_params: &[GenericParamName],
4786    index_params: &[GenericParamName],
4787    nat_params: &[GenericParamName],
4788    src: &NamedSource<Arc<String>>,
4789) -> Result<ResolvedTypeExpr, GraphcalError> {
4790    let owner = crate::dag_id::DagId::root("<type-resolution>");
4791    resolve_type_expr_inner(
4792        type_ann,
4793        registry,
4794        &owner,
4795        dim_params,
4796        index_params,
4797        nat_params,
4798        src,
4799        None,
4800    )
4801}
4802
4803/// Resolve a `TypeExpr` with an optional module-aware path context.
4804pub fn resolve_type_expr_with_modules(
4805    type_ann: &TypeExpr,
4806    registry: &Registry,
4807    dim_params: &[GenericParamName],
4808    index_params: &[GenericParamName],
4809    nat_params: &[GenericParamName],
4810    src: &NamedSource<Arc<String>>,
4811    module_ctx: ModuleTypeContext<'_>,
4812) -> Result<ResolvedTypeExpr, GraphcalError> {
4813    resolve_type_expr_inner(
4814        type_ann,
4815        registry,
4816        module_ctx.owner,
4817        dim_params,
4818        index_params,
4819        nat_params,
4820        src,
4821        Some(module_ctx),
4822    )
4823}
4824
4825#[expect(
4826    clippy::too_many_arguments,
4827    reason = "recursive resolver threads generic parameter scopes and optional module context"
4828)]
4829fn resolve_type_expr_inner(
4830    type_ann: &TypeExpr,
4831    registry: &Registry,
4832    owner: &crate::dag_id::DagId,
4833    dim_params: &[GenericParamName],
4834    index_params: &[GenericParamName],
4835    nat_params: &[GenericParamName],
4836    src: &NamedSource<Arc<String>>,
4837    module_ctx: Option<ModuleTypeContext<'_>>,
4838) -> Result<ResolvedTypeExpr, GraphcalError> {
4839    if let Some(ctx) = module_ctx
4840        && dim_params.is_empty()
4841        && index_params.is_empty()
4842        && nat_params.is_empty()
4843    {
4844        return resolve_ast_type_expr_via_hir(type_ann, registry, src, ctx);
4845    }
4846
4847    match &type_ann.kind {
4848        TypeExprKind::Dimensionless => Ok(ResolvedTypeExpr::Dimensionless),
4849        TypeExprKind::Bool => Ok(ResolvedTypeExpr::Bool),
4850        TypeExprKind::Int => Ok(ResolvedTypeExpr::Int),
4851        TypeExprKind::Datetime => Ok(ResolvedTypeExpr::Datetime(TimeScale::UTC)),
4852        TypeExprKind::DatetimeApplication { type_args } => {
4853            resolve_datetime_application(type_ann, type_args, src)
4854        }
4855
4856        TypeExprKind::Indexed { base, indexes } => {
4857            let resolved_base = resolve_type_expr_inner(
4858                base,
4859                registry,
4860                owner,
4861                dim_params,
4862                index_params,
4863                nat_params,
4864                src,
4865                module_ctx,
4866            )?;
4867            let mut resolved_indexes = Vec::with_capacity(indexes.len());
4868            for idx in indexes {
4869                match idx {
4870                    crate::desugar::desugared_ast::IndexExpr::NatExpr(nat_expr) => {
4871                        let form = normalize_nat_expr(nat_expr, nat_params, src)?;
4872                        resolved_indexes.push(ResolvedIndex::NatExpr(form, nat_expr.span()));
4873                    }
4874                    crate::desugar::desugared_ast::IndexExpr::Name(path) => {
4875                        resolved_indexes.push(resolve_index_expr_name(
4876                            &path.value,
4877                            path.span,
4878                            registry,
4879                            owner,
4880                            index_params,
4881                            nat_params,
4882                            src,
4883                            module_ctx,
4884                        )?);
4885                    }
4886                }
4887            }
4888            Ok(ResolvedTypeExpr::Indexed {
4889                base: Box::new(resolved_base),
4890                indexes: resolved_indexes,
4891            })
4892        }
4893
4894        TypeExprKind::DimExpr(dim_expr) => resolve_dim_expr(
4895            dim_expr,
4896            registry,
4897            owner,
4898            dim_params,
4899            index_params,
4900            src,
4901            module_ctx,
4902        ),
4903
4904        TypeExprKind::TypeApplication { name, type_args } => resolve_type_application(
4905            type_ann,
4906            name,
4907            type_args,
4908            registry,
4909            owner,
4910            dim_params,
4911            index_params,
4912            nat_params,
4913            src,
4914            module_ctx,
4915        ),
4916    }
4917}
4918
4919/// Resolve a dimension expression to either a [`ResolvedTypeExpr::Scalar`],
4920/// [`ResolvedTypeExpr::GenericDimExpr`], [`ResolvedTypeExpr::IndexArg`],
4921/// [`ResolvedTypeExpr::Struct`], or [`ResolvedTypeExpr::GenericDimParam`].
4922///
4923/// A single-term, no-power expression is first checked against named indexes,
4924/// struct types, and generic dimension parameters. Multi-term expressions with
4925/// generic params become `GenericDimExpr`; fully concrete expressions become `Scalar`.
4926fn resolve_dim_expr(
4927    dim_expr: &crate::desugar::desugared_ast::DimExpr,
4928    registry: &Registry,
4929    owner: &crate::dag_id::DagId,
4930    dim_params: &[GenericParamName],
4931    index_params: &[GenericParamName],
4932    src: &NamedSource<Arc<String>>,
4933    module_ctx: Option<ModuleTypeContext<'_>>,
4934) -> Result<ResolvedTypeExpr, GraphcalError> {
4935    // Single-term, no power: may be a nominal type-level reference rather than
4936    // a scalar dimension expression.
4937    if dim_expr.terms.len() == 1 && dim_expr.terms[0].term.power.is_none() {
4938        let term = &dim_expr.terms[0].term;
4939        if let Some(index) = resolve_concrete_index_path(
4940            &term.name.value,
4941            term.name.span,
4942            registry,
4943            owner,
4944            src,
4945            module_ctx,
4946        )? {
4947            return Ok(ResolvedTypeExpr::IndexArg(ResolvedIndex::Concrete(
4948                index, term.span,
4949            )));
4950        }
4951        if let Some(atom) = term.name.value.as_bare()
4952            && let Some(gp) = index_params.iter().find(|p| p.as_str() == atom.as_str())
4953        {
4954            return Ok(ResolvedTypeExpr::IndexArg(ResolvedIndex::GenericParam(
4955                gp.clone(),
4956                term.span,
4957            )));
4958        }
4959        if let Some((type_name, _)) = resolve_struct_type_path(
4960            &term.name.value,
4961            term.name.span,
4962            registry,
4963            owner,
4964            src,
4965            module_ctx,
4966        )? {
4967            return Ok(ResolvedTypeExpr::Struct(type_name, term.span));
4968        }
4969        if let Some(atom) = term.name.value.as_bare()
4970            && let Some(gp) = dim_params.iter().find(|p| p.as_str() == atom.as_str())
4971        {
4972            return Ok(ResolvedTypeExpr::GenericDimParam(gp.clone(), term.span));
4973        }
4974    }
4975
4976    let has_generic = dim_expr.terms.iter().any(|item| {
4977        item.term
4978            .name
4979            .value
4980            .as_bare()
4981            .is_some_and(|atom| dim_params.iter().any(|p| p.as_str() == atom.as_str()))
4982    });
4983
4984    if has_generic {
4985        let terms = dim_expr
4986            .terms
4987            .iter()
4988            .map(|item| {
4989                resolve_dim_term_in_generic_expr(item, registry, dim_params, src, module_ctx)
4990            })
4991            .collect::<Result<Vec<_>, _>>()?;
4992        Ok(ResolvedTypeExpr::GenericDimExpr {
4993            terms,
4994            span: dim_expr.span,
4995        })
4996    } else {
4997        let result = dim_expr.terms.iter().try_fold(
4998            Dimension::dimensionless(),
4999            |acc, item| -> Result<Dimension, GraphcalError> {
5000                let base = concrete_dimension_for_term(item, registry, src, module_ctx)?;
5001                let exp = item.term.power.unwrap_or(Rational::ONE);
5002                let overflow_err = || GraphcalError::DimensionOverflow {
5003                    src: src.clone(),
5004                    span: item.term.span.into(),
5005                };
5006                let powered = base.pow(exp).map_err(|_| overflow_err())?;
5007                match item.op {
5008                    MulDivOp::Mul => (acc * powered).map_err(|_| overflow_err()),
5009                    MulDivOp::Div => (acc / powered).map_err(|_| overflow_err()),
5010                }
5011            },
5012        )?;
5013        Ok(ResolvedTypeExpr::Scalar(result))
5014    }
5015}
5016
5017fn resolve_dim_term_in_generic_expr(
5018    item: &crate::desugar::desugared_ast::DimExprItem,
5019    registry: &Registry,
5020    dim_params: &[GenericParamName],
5021    src: &NamedSource<Arc<String>>,
5022    module_ctx: Option<ModuleTypeContext<'_>>,
5023) -> Result<ResolvedDimTerm, GraphcalError> {
5024    let power = item.term.power.unwrap_or(Rational::ONE);
5025    let op = item.op;
5026    if let Some(atom) = item.term.name.value.as_bare()
5027        && let Some(gp) = dim_params.iter().find(|p| p.as_str() == atom.as_str())
5028    {
5029        return Ok(ResolvedDimTerm::GenericParam {
5030            name: gp.clone(),
5031            power,
5032            op,
5033            span: item.term.span,
5034        });
5035    }
5036    concrete_dimension_for_term(item, registry, src, module_ctx)
5037        .map(|dim| ResolvedDimTerm::Concrete { dim, power, op })
5038}
5039
5040fn concrete_dimension_for_term(
5041    item: &crate::desugar::desugared_ast::DimExprItem,
5042    registry: &Registry,
5043    src: &NamedSource<Arc<String>>,
5044    module_ctx: Option<ModuleTypeContext<'_>>,
5045) -> Result<Dimension, GraphcalError> {
5046    resolve_dimension_path(
5047        &item.term.name.value,
5048        item.term.name.span,
5049        registry,
5050        src,
5051        module_ctx,
5052    )?
5053    .ok_or_else(|| {
5054        let name = item
5055            .term
5056            .name
5057            .value
5058            .as_bare()
5059            .map_or_else(|| item.term.name.value.display_path(), ToString::to_string);
5060        GraphcalError::UnknownDimension {
5061            name: DimName::new(name),
5062            src: src.clone(),
5063            span: item.term.span.into(),
5064        }
5065    })
5066}
5067
5068/// Resolve a `Datetime<TimeScale>` application to a [`ResolvedTypeExpr::Datetime`].
5069///
5070/// The argument list is expected to hold exactly one type argument that
5071/// parses as a [`TimeScale`] identifier (`UTC`, `TAI`, `TT`, …). Surfaced as
5072/// a dedicated helper rather than living inside [`resolve_type_application`]
5073/// so the dispatch in [`resolve_type_expr`] is on the AST variant rather than
5074/// a string compare of the built-in name.
5075fn resolve_datetime_application(
5076    type_ann: &TypeExpr,
5077    type_args: &[TypeExpr],
5078    src: &NamedSource<Arc<String>>,
5079) -> Result<ResolvedTypeExpr, GraphcalError> {
5080    if type_args.len() != 1 {
5081        return Err(GraphcalError::EvalError {
5082            message: format!(
5083                "type `Datetime` expects 0 or 1 type argument(s), got {}",
5084                type_args.len()
5085            ),
5086            src: src.clone(),
5087            span: type_ann.span.into(),
5088        });
5089    }
5090    let arg = &type_args[0];
5091    match &arg.kind {
5092        TypeExprKind::DimExpr(dim_expr)
5093            if dim_expr.terms.len() == 1 && dim_expr.terms[0].term.power.is_none() =>
5094        {
5095            let term = &dim_expr.terms[0].term;
5096            let name = require_local_type_level_path(&term.name.value, term.name.span, src)?;
5097            name.parse::<TimeScale>().map_or_else(
5098                |_| {
5099                    Err(GraphcalError::EvalError {
5100                        message: format!(
5101                            "unknown time scale `{name}`; \
5102                         expected one of: UTC, TAI, TT, TDB, ET, GPST, GST, BDT"
5103                        ),
5104                        src: src.clone(),
5105                        span: arg.span.into(),
5106                    })
5107                },
5108                |scale| Ok(ResolvedTypeExpr::Datetime(scale)),
5109            )
5110        }
5111        _ => Err(GraphcalError::EvalError {
5112            message: "expected a time scale name (e.g., UTC, TAI, TT, TDB, GPST)".to_string(),
5113            src: src.clone(),
5114            span: arg.span.into(),
5115        }),
5116    }
5117}
5118
5119/// Resolve a user-defined type application like `Vec3<Length, ECI>` to a
5120/// [`ResolvedTypeExpr`] by looking the name up in the type registry and
5121/// substituting defaults for any trailing optional parameters.
5122///
5123/// Built-in parameterized types (`Datetime<...>`) reach [`resolve_type_expr`]
5124/// through their own AST variant and never enter this function.
5125#[expect(
5126    clippy::too_many_arguments,
5127    reason = "passes full type resolution context from resolve_type_expr"
5128)]
5129fn resolve_type_application(
5130    type_ann: &TypeExpr,
5131    name: &crate::syntax::span::Spanned<NamePath>,
5132    type_args: &[TypeExpr],
5133    registry: &Registry,
5134    owner: &crate::dag_id::DagId,
5135    dim_params: &[GenericParamName],
5136    index_params: &[GenericParamName],
5137    nat_params: &[GenericParamName],
5138    src: &NamedSource<Arc<String>>,
5139    module_ctx: Option<ModuleTypeContext<'_>>,
5140) -> Result<ResolvedTypeExpr, GraphcalError> {
5141    let (type_name, type_def) =
5142        resolve_struct_type_path(&name.value, name.span, registry, owner, src, module_ctx)?
5143            .ok_or_else(|| GraphcalError::UnknownStructType {
5144                name: name.value.display_path(),
5145                src: src.clone(),
5146                span: name.span.into(),
5147            })?;
5148    check_type_application_arity(
5149        type_name.as_str(),
5150        type_def,
5151        type_args.len(),
5152        type_ann.span,
5153        src,
5154    )?;
5155    let mut resolved_args = Vec::with_capacity(type_def.generic_params.len());
5156    for (param, arg) in type_def.generic_params.iter().zip(type_args) {
5157        let resolved = resolve_type_arg_for_param(
5158            param,
5159            arg,
5160            registry,
5161            owner,
5162            dim_params,
5163            index_params,
5164            nat_params,
5165            src,
5166            module_ctx,
5167        )?;
5168        resolved_args.push(resolved);
5169    }
5170    // Fill in defaults for any remaining params
5171    for param in type_def.generic_params.iter().skip(type_args.len()) {
5172        let default_expr = param
5173            .default
5174            .as_ref()
5175            .ok_or_else(|| GraphcalError::EvalError {
5176                message: format!(
5177                    "internal: generic parameter `{}` has no default",
5178                    param.name
5179                ),
5180                src: src.clone(),
5181                span: type_ann.span.into(),
5182            })?;
5183        let default_ctx = module_ctx
5184            .map(|ctx| ModuleTypeContext::new(type_name.owner(), ctx.resolver, ctx.types));
5185        let resolved = resolve_type_arg_for_param(
5186            param,
5187            default_expr,
5188            registry,
5189            type_name.owner(),
5190            dim_params,
5191            index_params,
5192            nat_params,
5193            src,
5194            default_ctx,
5195        )?;
5196        resolved_args.push(resolved);
5197    }
5198    Ok(ResolvedTypeExpr::GenericStruct {
5199        name: type_name,
5200        type_args: resolved_args,
5201        span: type_ann.span,
5202    })
5203}
5204
5205#[expect(
5206    clippy::too_many_arguments,
5207    reason = "passes full type resolution context from resolve_type_application"
5208)]
5209fn resolve_type_arg_for_param(
5210    param: &crate::registry::types::TypeGenericParam,
5211    arg: &TypeExpr,
5212    registry: &Registry,
5213    owner: &crate::dag_id::DagId,
5214    dim_params: &[GenericParamName],
5215    index_params: &[GenericParamName],
5216    nat_params: &[GenericParamName],
5217    src: &NamedSource<Arc<String>>,
5218    module_ctx: Option<ModuleTypeContext<'_>>,
5219) -> Result<ResolvedTypeExpr, GraphcalError> {
5220    let resolved = resolve_type_expr_inner(
5221        arg,
5222        registry,
5223        owner,
5224        dim_params,
5225        index_params,
5226        nat_params,
5227        src,
5228        module_ctx,
5229    )?;
5230    match (param.constraint, &resolved) {
5231        (TypeGenericConstraint::Index, ResolvedTypeExpr::IndexArg(_)) => Ok(resolved),
5232        (TypeGenericConstraint::Index, _) => Err(GraphcalError::EvalError {
5233            message: format!(
5234                "generic parameter `{}` expects an Index argument",
5235                param.name
5236            ),
5237            src: src.clone(),
5238            span: arg.span.into(),
5239        }),
5240        (TypeGenericConstraint::Nat, _) => Err(GraphcalError::EvalError {
5241            message: format!(
5242                "generic parameter `{}` expects a Nat argument, got a type argument",
5243                param.name
5244            ),
5245            src: src.clone(),
5246            span: arg.span.into(),
5247        }),
5248        (TypeGenericConstraint::Dim, ResolvedTypeExpr::IndexArg(index)) => {
5249            Err(GraphcalError::EvalError {
5250                message: format!(
5251                    "index `{}` cannot be used as a Dim argument",
5252                    format_resolved_index(index)
5253                ),
5254                src: src.clone(),
5255                span: arg.span.into(),
5256            })
5257        }
5258        (TypeGenericConstraint::Unconstrained, ResolvedTypeExpr::IndexArg(index)) => {
5259            Err(GraphcalError::EvalError {
5260                message: format!(
5261                    "index `{}` cannot be used as a Type argument",
5262                    format_resolved_index(index)
5263                ),
5264                src: src.clone(),
5265                span: arg.span.into(),
5266            })
5267        }
5268        (TypeGenericConstraint::Dim | TypeGenericConstraint::Unconstrained, _) => Ok(resolved),
5269    }
5270}
5271
5272#[cfg(test)]
5273mod tests {
5274    use super::*;
5275    use crate::registry::prelude::load_prelude;
5276    use crate::registry::types::RegistryBuilder;
5277    use crate::syntax::dimension::BaseDimId;
5278    use crate::syntax::parser::Parser;
5279
5280    fn make_registry() -> Registry {
5281        let mut b = RegistryBuilder::new();
5282        load_prelude(&mut b).unwrap();
5283        b.build()
5284    }
5285
5286    fn make_dim_term_name(
5287        name: &str,
5288    ) -> crate::syntax::span::Spanned<crate::syntax::names::NamePath> {
5289        crate::syntax::span::Spanned::new(
5290            crate::syntax::names::NamePath::from(name),
5291            Span::new(0, 0),
5292        )
5293    }
5294
5295    /// Create a simple dimension `TypeExpr` from a name string like `"Velocity"`.
5296    fn make_dim_type_expr(name: &str) -> crate::desugar::desugared_ast::TypeExpr {
5297        crate::desugar::desugared_ast::TypeExpr {
5298            kind: crate::desugar::desugared_ast::TypeExprKind::DimExpr(
5299                crate::desugar::desugared_ast::DimExpr {
5300                    terms: vec![crate::desugar::desugared_ast::DimExprItem {
5301                        op: crate::desugar::desugared_ast::MulDivOp::Mul,
5302                        term: crate::desugar::desugared_ast::DimTerm {
5303                            name: make_dim_term_name(name),
5304                            power: None,
5305                            span: Span::new(0, 0),
5306                        },
5307                    }],
5308                    span: Span::new(0, 0),
5309                },
5310            ),
5311            constraints: vec![],
5312            span: Span::new(0, 0),
5313        }
5314    }
5315
5316    fn make_registry_with_struct() -> Registry {
5317        let mut b = RegistryBuilder::new();
5318        load_prelude(&mut b).unwrap();
5319        b.register_type(crate::registry::types::TypeDef {
5320            name: StructTypeName::new("TransferResult"),
5321            generic_params: vec![],
5322            kind: crate::registry::types::TypeDefKind::Union {
5323                members: vec![crate::registry::types::UnionMemberDef {
5324                    name: crate::syntax::names::ConstructorName::new("TransferResult"),
5325                    fields: vec![
5326                        crate::registry::types::StructField {
5327                            name: crate::syntax::names::FieldName::new("dv1"),
5328                            type_ann: make_dim_type_expr("Velocity"),
5329                        },
5330                        crate::registry::types::StructField {
5331                            name: crate::syntax::names::FieldName::new("dv2"),
5332                            type_ann: make_dim_type_expr("Velocity"),
5333                        },
5334                    ],
5335                }],
5336            },
5337        });
5338        b.build()
5339    }
5340
5341    fn make_registry_with_index() -> Registry {
5342        let mut b = RegistryBuilder::new();
5343        load_prelude(&mut b).unwrap();
5344        b.register_index(crate::registry::types::IndexDef {
5345            name: IndexName::new("Maneuver"),
5346            kind: crate::registry::types::IndexKind::Named {
5347                variants: vec![
5348                    crate::syntax::names::IndexVariantName::new("Departure"),
5349                    crate::syntax::names::IndexVariantName::new("Insertion"),
5350                ],
5351            },
5352        });
5353        b.build()
5354    }
5355
5356    fn make_src() -> NamedSource<Arc<String>> {
5357        NamedSource::new("test", Arc::new(String::new()))
5358    }
5359
5360    /// Parse a type annotation from a param declaration and return the `TypeExpr`.
5361    fn parse_type(source: &str) -> TypeExpr {
5362        // Wrap in a param declaration so the parser can handle it
5363        let full = format!("param x: {source} = 0.0;");
5364        let raw_file = Parser::new(&full).parse_file().unwrap();
5365        let desugared = crate::syntax::desugar::desugar_multi_decls_in_file(raw_file);
5366        let file = desugared;
5367        match &file.declarations[0].kind {
5368            crate::desugar::desugared_ast::DeclKind::Param(p) => p.type_ann.clone(),
5369            _ => panic!("expected param"),
5370        }
5371    }
5372
5373    #[test]
5374    fn resolve_dimensionless() {
5375        let r = make_registry();
5376        let te = parse_type("Dimensionless");
5377        let resolved = resolve_type_expr(&te, &r, &[], &[], &[], &make_src()).unwrap();
5378        assert_eq!(resolved, ResolvedTypeExpr::Dimensionless);
5379    }
5380
5381    #[test]
5382    fn resolve_bool() {
5383        let r = make_registry();
5384        let te = parse_type("Bool");
5385        let resolved = resolve_type_expr(&te, &r, &[], &[], &[], &make_src()).unwrap();
5386        assert_eq!(resolved, ResolvedTypeExpr::Bool);
5387    }
5388
5389    #[test]
5390    fn resolve_int() {
5391        let r = make_registry();
5392        let te = parse_type("Int");
5393        let resolved = resolve_type_expr(&te, &r, &[], &[], &[], &make_src()).unwrap();
5394        assert_eq!(resolved, ResolvedTypeExpr::Int);
5395    }
5396
5397    #[test]
5398    fn resolve_concrete_dimension() {
5399        let r = make_registry();
5400        let te = parse_type("Length");
5401        let resolved = resolve_type_expr(&te, &r, &[], &[], &[], &make_src()).unwrap();
5402        assert_eq!(
5403            resolved,
5404            ResolvedTypeExpr::Scalar(Dimension::base(BaseDimId::Prelude("Length".to_string())))
5405        );
5406    }
5407
5408    #[test]
5409    fn resolve_compound_dimension() {
5410        let r = make_registry();
5411        let te = parse_type("Length / Time^2");
5412        let resolved = resolve_type_expr(&te, &r, &[], &[], &[], &make_src()).unwrap();
5413        let expected = (Dimension::base(BaseDimId::Prelude("Length".to_string()))
5414            / Dimension::base(BaseDimId::Prelude("Time".to_string()))
5415                .pow_int(2)
5416                .unwrap())
5417        .unwrap();
5418        assert_eq!(resolved, ResolvedTypeExpr::Scalar(expected));
5419    }
5420
5421    #[test]
5422    fn resolve_struct_type() {
5423        let r = make_registry_with_struct();
5424        let te = parse_type("TransferResult");
5425        let resolved = resolve_type_expr(&te, &r, &[], &[], &[], &make_src()).unwrap();
5426        assert!(
5427            matches!(resolved, ResolvedTypeExpr::Struct(name, _) if name.as_str() == "TransferResult")
5428        );
5429    }
5430
5431    #[test]
5432    fn resolve_generic_dim_param() {
5433        let r = make_registry();
5434        let dim_params = vec![GenericParamName::new("D")];
5435        let te = parse_type("D");
5436        let resolved = resolve_type_expr(&te, &r, &dim_params, &[], &[], &make_src()).unwrap();
5437        assert!(
5438            matches!(resolved, ResolvedTypeExpr::GenericDimParam(name, _) if name.as_str() == "D")
5439        );
5440    }
5441
5442    #[test]
5443    fn resolve_generic_dim_expr_with_power() {
5444        let r = make_registry();
5445        let dim_params = vec![GenericParamName::new("D")];
5446        let te = parse_type("D^2");
5447        let resolved = resolve_type_expr(&te, &r, &dim_params, &[], &[], &make_src()).unwrap();
5448        match resolved {
5449            ResolvedTypeExpr::GenericDimExpr { terms, .. } => {
5450                assert_eq!(terms.len(), 1);
5451                match &terms[0] {
5452                    ResolvedDimTerm::GenericParam { name, power, .. } => {
5453                        assert_eq!(name.as_str(), "D");
5454                        assert_eq!(*power, Rational::from_int(2));
5455                    }
5456                    ResolvedDimTerm::Concrete { .. } => panic!("expected GenericParam term"),
5457                }
5458            }
5459            _ => panic!("expected GenericDimExpr"),
5460        }
5461    }
5462
5463    #[test]
5464    fn resolve_mixed_generic_concrete() {
5465        let r = make_registry();
5466        let dim_params = vec![GenericParamName::new("D")];
5467        // D * Length  — this is a DimExpr with a generic and a concrete term
5468        let te = parse_type("D * Length");
5469        let resolved = resolve_type_expr(&te, &r, &dim_params, &[], &[], &make_src()).unwrap();
5470        match resolved {
5471            ResolvedTypeExpr::GenericDimExpr { terms, .. } => {
5472                assert_eq!(terms.len(), 2);
5473                assert!(
5474                    matches!(&terms[0], ResolvedDimTerm::GenericParam { name, .. } if name.as_str() == "D")
5475                );
5476                assert!(matches!(&terms[1], ResolvedDimTerm::Concrete { .. }));
5477            }
5478            _ => panic!("expected GenericDimExpr, got {resolved:?}"),
5479        }
5480    }
5481
5482    #[test]
5483    fn resolve_concrete_indexed() {
5484        let r = make_registry_with_index();
5485        let te = parse_type("Length[Maneuver]");
5486        let resolved = resolve_type_expr(&te, &r, &[], &[], &[], &make_src()).unwrap();
5487        match resolved {
5488            ResolvedTypeExpr::Indexed { base, indexes } => {
5489                assert_eq!(
5490                    *base,
5491                    ResolvedTypeExpr::Scalar(Dimension::base(BaseDimId::Prelude(
5492                        "Length".to_string()
5493                    )))
5494                );
5495                assert_eq!(indexes.len(), 1);
5496                assert!(
5497                    matches!(&indexes[0], ResolvedIndex::Concrete(name, _) if name.as_str() == "Maneuver")
5498                );
5499            }
5500            _ => panic!("expected Indexed"),
5501        }
5502    }
5503
5504    #[test]
5505    fn resolve_generic_indexed() {
5506        let r = make_registry();
5507        let dim_params = vec![GenericParamName::new("D")];
5508        let index_params = vec![GenericParamName::new("I")];
5509        let te = parse_type("D[I]");
5510        let resolved =
5511            resolve_type_expr(&te, &r, &dim_params, &index_params, &[], &make_src()).unwrap();
5512        match resolved {
5513            ResolvedTypeExpr::Indexed { base, indexes } => {
5514                assert!(
5515                    matches!(*base, ResolvedTypeExpr::GenericDimParam(ref name, _) if name.as_str() == "D")
5516                );
5517                assert_eq!(indexes.len(), 1);
5518                assert!(
5519                    matches!(&indexes[0], ResolvedIndex::GenericParam(name, _) if name.as_str() == "I")
5520                );
5521            }
5522            _ => panic!("expected Indexed"),
5523        }
5524    }
5525
5526    #[test]
5527    fn resolve_unknown_dimension_error() {
5528        let r = make_registry();
5529        let te = parse_type("UnknownDim");
5530        let err = resolve_type_expr(&te, &r, &[], &[], &[], &make_src()).unwrap_err();
5531        assert!(matches!(err, GraphcalError::UnknownDimension { .. }));
5532    }
5533
5534    #[test]
5535    fn resolve_unknown_index_error() {
5536        let r = make_registry();
5537        let te = parse_type("Length[UnknownIdx]");
5538        let err = resolve_type_expr(&te, &r, &[], &[], &[], &make_src()).unwrap_err();
5539        assert!(matches!(err, GraphcalError::UnknownIndex { .. }));
5540    }
5541
5542    #[test]
5543    fn resolve_struct_takes_priority_over_dim_param() {
5544        // When a name matches both a struct and a generic param,
5545        // struct should win (structs are concrete, params are only
5546        // in scope inside a function that has that param).
5547        // In practice this shouldn't happen because struct names are
5548        // PascalCase and generic params are single letters, but let's
5549        // make sure the priority is correct.
5550        let r = make_registry_with_struct();
5551        let dim_params = vec![GenericParamName::new("TransferResult")];
5552        let te = parse_type("TransferResult");
5553        let resolved = resolve_type_expr(&te, &r, &dim_params, &[], &[], &make_src()).unwrap();
5554        assert!(matches!(resolved, ResolvedTypeExpr::Struct(..)));
5555    }
5556
5557    #[test]
5558    fn resolve_velocity_derived_dimension() {
5559        let r = make_registry();
5560        let te = parse_type("Velocity");
5561        let resolved = resolve_type_expr(&te, &r, &[], &[], &[], &make_src()).unwrap();
5562        let expected = (Dimension::base(BaseDimId::Prelude("Length".to_string()))
5563            / Dimension::base(BaseDimId::Prelude("Time".to_string())))
5564        .unwrap();
5565        assert_eq!(resolved, ResolvedTypeExpr::Scalar(expected));
5566    }
5567
5568    // --- module-aware type resolution integration tests ---
5569
5570    /// Single-file integration helper: lower + type-resolve + compile each
5571    /// inline dag body using the dumb `lower_dag_body_to_ir` primitive
5572    /// directly (no self-import preprocessing — fixtures exercised here
5573    /// either don't use self-imports or are expected to surface errors that
5574    /// fall out of the unprocessed body).
5575    fn parse_and_type_resolve(source: &str) -> Result<TIR, GraphcalError> {
5576        let raw_file = Parser::new(source).parse_file().unwrap();
5577        let desugared = crate::syntax::desugar::desugar_multi_decls_in_file(raw_file);
5578        let file = desugared;
5579        let src = NamedSource::new("test.gcl", Arc::new(source.to_string()));
5580        let ir = crate::ir::lower::lower(&file, &src)?;
5581        let parent_dag_id =
5582            crate::dag_id::DagId::from_relative_path(std::path::Path::new("test.gcl")).unwrap();
5583        let mut resolver = ModuleResolver::default();
5584        resolver
5585            .add_module(parent_dag_id.clone(), &file.declarations)
5586            .map_err(|err| {
5587                internal_error(
5588                    format!("test module resolver failed for root module: {err}"),
5589                    &src,
5590                    Span::new(0, 0),
5591                )
5592            })?;
5593        for decl in &file.declarations {
5594            if let crate::desugar::desugared_ast::DeclKind::Dag(dag) = &decl.kind {
5595                resolver
5596                    .add_module(parent_dag_id.child(dag.name.value.as_str()), &dag.body)
5597                    .map_err(|err| {
5598                        internal_error(
5599                            format!(
5600                                "test module resolver failed for inline dag `{}`: {err}",
5601                                dag.name.value
5602                            ),
5603                            &src,
5604                            Span::new(0, 0),
5605                        )
5606                    })?;
5607            }
5608        }
5609        let mut module_types = ModuleTypeRegistry::default();
5610        module_types.insert_graphcal_prelude().map_err(|err| {
5611            internal_error(
5612                format!("test module type prelude failed: {err}"),
5613                &src,
5614                Span::new(0, 0),
5615            )
5616        })?;
5617        module_types.insert_registry(&parent_dag_id, &ir.registry);
5618        let mut tir =
5619            type_resolve_with_modules(ir, parent_dag_id.clone(), &src, &resolver, &module_types)?;
5620        compile_inline_dag_bodies_test(&mut tir, &src, &parent_dag_id, &file.declarations)?;
5621        Ok(tir)
5622    }
5623
5624    /// Compile each inline dag body in `tir` with no self-import
5625    /// preprocessing. Used by compiler-side integration tests that don't
5626    /// have access to the eval crate's project pipeline.
5627    fn compile_inline_dag_bodies_test(
5628        tir: &mut TIR,
5629        src: &NamedSource<Arc<String>>,
5630        parent_dag_id: &crate::dag_id::DagId,
5631        parent_declarations: &[crate::desugar::desugared_ast::Declaration],
5632    ) -> Result<(), GraphcalError> {
5633        let dag_bodies = tir
5634            .registry
5635            .dags
5636            .all_dags()
5637            .map(|(name, dag)| (name.clone(), dag.body.clone()))
5638            .collect::<Vec<_>>();
5639        let mut resolver = ModuleResolver::default();
5640        resolver
5641            .add_module(parent_dag_id.clone(), parent_declarations)
5642            .map_err(|err| {
5643                internal_error(
5644                    format!("test module resolver failed for parent module: {err}"),
5645                    src,
5646                    Span::new(0, 0),
5647                )
5648            })?;
5649        for (name, body) in &dag_bodies {
5650            resolver
5651                .add_module(parent_dag_id.child(name.as_str()), body)
5652                .map_err(|err| {
5653                    internal_error(
5654                        format!("test module resolver failed for inline dag `{name}`: {err}"),
5655                        src,
5656                        Span::new(0, 0),
5657                    )
5658                })?;
5659        }
5660        let mut module_types = ModuleTypeRegistry::default();
5661        module_types.insert_graphcal_prelude().map_err(|err| {
5662            internal_error(
5663                format!("test module type prelude failed: {err}"),
5664                src,
5665                Span::new(0, 0),
5666            )
5667        })?;
5668        module_types.insert_registry(parent_dag_id, &tir.registry);
5669
5670        for (name, body) in dag_bodies {
5671            let dag_body_ir = crate::ir::lower::lower_dag_body_to_ir(
5672                name.as_str(),
5673                &body,
5674                &tir.registry,
5675                &resolver,
5676                &crate::ir::resolve::ImportedValueNames::default(),
5677                HashMap::new(),
5678                HashMap::new(),
5679                src,
5680                parent_dag_id,
5681            )?;
5682            let dag_id = parent_dag_id.child(name.as_str());
5683            let mut compiled_dag = type_resolve_single_with_modules(
5684                dag_body_ir,
5685                &dag_id,
5686                src,
5687                &resolver,
5688                &module_types,
5689            )?;
5690            compiled_dag.populate_pub_nodes(&body);
5691            tir.dags.insert(dag_id, compiled_dag);
5692        }
5693        Ok(())
5694    }
5695
5696    #[test]
5697    fn module_aware_type_resolve_records_semantic_deps() {
5698        let source = "const node C: Dimensionless = 1.0;\n\
5699                      const node D: Dimensionless = C;\n\
5700                      param p: Dimensionless;\n\
5701                      node x: Dimensionless = @p + D;";
5702        let raw_file = Parser::new(source).parse_file().unwrap();
5703        let desugared = crate::syntax::desugar::desugar_multi_decls_in_file(raw_file);
5704        let file = desugared;
5705        let src = NamedSource::new("test.gcl", Arc::new(source.to_string()));
5706        let dag_id =
5707            crate::dag_id::DagId::from_relative_path(std::path::Path::new("test.gcl")).unwrap();
5708        let ir = crate::ir::lower::lower(&file, &src).unwrap();
5709        let mut resolver = ModuleResolver::default();
5710        resolver
5711            .add_module(dag_id.clone(), &file.declarations)
5712            .unwrap();
5713        let mut module_types = ModuleTypeRegistry::default();
5714        module_types.insert_graphcal_prelude().unwrap();
5715        module_types.insert_registry(&dag_id, &ir.registry);
5716
5717        let tir =
5718            type_resolve_with_modules(ir, dag_id.clone(), &src, &resolver, &module_types).unwrap();
5719        let deps = &tir.root().semantic.dependencies;
5720        let c = ResolvedName::from_def(dag_id.clone(), DeclName::new("C"));
5721        let d = ResolvedName::from_def(dag_id.clone(), DeclName::new("D"));
5722        let p = ResolvedName::from_def(dag_id.clone(), DeclName::new("p"));
5723        let x = ResolvedName::from_def(dag_id, DeclName::new("x"));
5724
5725        assert!(deps.const_deps[&d].contains(&c));
5726        assert!(deps.const_deps[&c].is_empty());
5727        assert!(deps.runtime_deps[&x].contains(&p));
5728        assert!(deps.runtime_deps[&p].is_empty());
5729    }
5730
5731    #[test]
5732    fn type_resolve_rocket() {
5733        let source = include_str!("../../../../tests/fixtures/valid/rocket.gcl");
5734        let tir = parse_and_type_resolve(source).unwrap();
5735        // All declarations should have resolved types
5736        assert!(
5737            tir.root()
5738                .resolved_decl_types
5739                .contains_key(&ScopedName::local("dry_mass"))
5740        );
5741        assert!(
5742            tir.root()
5743                .resolved_decl_types
5744                .contains_key(&ScopedName::local("delta_v"))
5745        );
5746        assert!(
5747            tir.root()
5748                .resolved_decl_types
5749                .contains_key(&ScopedName::local("g0"))
5750        );
5751    }
5752
5753    #[test]
5754    fn type_resolve_indexed() {
5755        let source = include_str!("../../../../tests/fixtures/valid/indexed.gcl");
5756        let tir = parse_and_type_resolve(source).unwrap();
5757        // delta_v should be Velocity[Maneuver]
5758        let dv_type = &tir.root().resolved_decl_types[&ScopedName::local("delta_v")];
5759        assert!(matches!(dv_type, ResolvedTypeExpr::Indexed { .. }));
5760    }
5761
5762    #[test]
5763    fn type_resolve_hohmann() {
5764        // hohmann.gcl uses DAG+include. Project-level `graphcal check`
5765        // accepts it (see the CLI tests), but single-file TIR resolution
5766        // rejects it: there's no project loader to resolve cross-DAG
5767        // references like `import hohmann.{...}`, and `@transfer` from the
5768        // unexpanded include surfaces as an unresolved reference during HIR
5769        // lowering. Resolution fails on the first unresolved name it
5770        // encounters.
5771        let source = include_str!("../../../../tests/fixtures/valid/hohmann.gcl");
5772        let err = parse_and_type_resolve(source).unwrap_err();
5773        assert!(
5774            err.to_string().contains("transfer"),
5775            "unexpected error: {err}"
5776        );
5777    }
5778
5779    #[test]
5780    fn type_resolve_generics() {
5781        let source = include_str!("../../../../tests/fixtures/valid/generics.gcl");
5782        let tir = parse_and_type_resolve(source).unwrap();
5783        // pos_eci should be a GenericStruct with type args
5784        let pos_type = &tir.root().resolved_decl_types[&ScopedName::local("pos_eci")];
5785        match pos_type {
5786            ResolvedTypeExpr::GenericStruct {
5787                name, type_args, ..
5788            } => {
5789                assert_eq!(name.as_str(), "Vec3");
5790                assert_eq!(type_args.len(), 2);
5791                assert_eq!(
5792                    type_args[0],
5793                    ResolvedTypeExpr::Scalar(Dimension::base(BaseDimId::Prelude(
5794                        "Length".to_string()
5795                    )))
5796                );
5797                assert!(
5798                    matches!(&type_args[1], ResolvedTypeExpr::Struct(n, _) if n.as_str() == "Eci")
5799                );
5800            }
5801            other => panic!("expected GenericStruct, got {other:?}"),
5802        }
5803        // x_pos should be scalar Length
5804        assert_eq!(
5805            tir.root().resolved_decl_types[&ScopedName::local("x_pos")],
5806            ResolvedTypeExpr::Scalar(Dimension::base(BaseDimId::Prelude("Length".to_string())))
5807        );
5808    }
5809
5810    #[test]
5811    fn type_resolve_default_type_params() {
5812        let source = include_str!("../../../../tests/fixtures/valid/generics.gcl");
5813        let tir = parse_and_type_resolve(source).unwrap();
5814
5815        // pos3_eci: Pos3<Length, Eci> — explicit, 2 type args
5816        let pos3_eci = &tir.root().resolved_decl_types[&ScopedName::local("pos3_eci")];
5817        match pos3_eci {
5818            ResolvedTypeExpr::GenericStruct {
5819                name, type_args, ..
5820            } => {
5821                assert_eq!(name.as_str(), "Pos3");
5822                assert_eq!(type_args.len(), 2);
5823                assert_eq!(
5824                    type_args[0],
5825                    ResolvedTypeExpr::Scalar(Dimension::base(BaseDimId::Prelude(
5826                        "Length".to_string()
5827                    )))
5828                );
5829                assert!(
5830                    matches!(&type_args[1], ResolvedTypeExpr::Struct(n, _) if n.as_str() == "Eci")
5831                );
5832            }
5833            other => panic!("expected GenericStruct, got {other:?}"),
5834        }
5835
5836        // pos3_default: Pos3<Length> — default fills in Unframed
5837        let pos3_default = &tir.root().resolved_decl_types[&ScopedName::local("pos3_default")];
5838        match pos3_default {
5839            ResolvedTypeExpr::GenericStruct {
5840                name, type_args, ..
5841            } => {
5842                assert_eq!(name.as_str(), "Pos3");
5843                assert_eq!(type_args.len(), 2);
5844                assert_eq!(
5845                    type_args[0],
5846                    ResolvedTypeExpr::Scalar(Dimension::base(BaseDimId::Prelude(
5847                        "Length".to_string()
5848                    )))
5849                );
5850                assert!(
5851                    matches!(&type_args[1], ResolvedTypeExpr::Struct(n, _) if n.as_str() == "Unframed"),
5852                    "expected Struct(Unframed), got {:?}",
5853                    type_args[1]
5854                );
5855            }
5856            other => panic!("expected GenericStruct, got {other:?}"),
5857        }
5858    }
5859
5860    // --- resolved_to_declared_type() tests ---
5861
5862    use crate::registry::declared_type::{DeclaredType, IndexTypeRef, StructTypeRef};
5863
5864    #[test]
5865    fn generic_index_substitution_preserves_resolved_owner() {
5866        use crate::tir::dim_check::{InferredIndex, InferredType};
5867
5868        let src = make_src();
5869        let registry = make_registry();
5870        let owner = crate::dag_id::DagId::root("a");
5871        let resolved_index = ResolvedName::from_def(owner, IndexName::new("Phase"));
5872        let generic = GenericParamName::new("I");
5873        let resolved_type = ResolvedTypeExpr::Indexed {
5874            base: Box::new(ResolvedTypeExpr::Dimensionless),
5875            indexes: vec![ResolvedIndex::GenericParam(
5876                generic.clone(),
5877                Span::new(0, 0),
5878            )],
5879        };
5880        let actual = InferredType::Indexed {
5881            element: Box::new(InferredType::Scalar(Dimension::dimensionless())),
5882            index: InferredIndex::from_resolved(resolved_index.clone()),
5883        };
5884        let mut dim_sub = HashMap::new();
5885        let mut index_sub = HashMap::new();
5886        let mut nat_sub = HashMap::new();
5887
5888        unify_resolved_type(
5889            &resolved_type,
5890            &actual,
5891            &mut dim_sub,
5892            &mut index_sub,
5893            &mut nat_sub,
5894            &registry,
5895            &src,
5896            Span::new(0, 0),
5897        )
5898        .unwrap();
5899        assert_eq!(
5900            index_sub[&generic].declared_resolved(),
5901            Some(&resolved_index)
5902        );
5903
5904        let substituted =
5905            substitute_resolved_type(&resolved_type, &dim_sub, &index_sub, &nat_sub, &src).unwrap();
5906        let InferredType::Indexed { index, .. } = substituted else {
5907            panic!("expected indexed type after substitution");
5908        };
5909        assert_eq!(index.declared_resolved(), Some(&resolved_index));
5910    }
5911
5912    #[test]
5913    fn convert_dimensionless() {
5914        let dt = resolved_to_declared_type(&ResolvedTypeExpr::Dimensionless, &make_src()).unwrap();
5915        assert_eq!(dt, DeclaredType::Scalar(Dimension::dimensionless()));
5916    }
5917
5918    #[test]
5919    fn convert_bool() {
5920        let dt = resolved_to_declared_type(&ResolvedTypeExpr::Bool, &make_src()).unwrap();
5921        assert_eq!(dt, DeclaredType::Bool);
5922    }
5923
5924    #[test]
5925    fn convert_int() {
5926        let dt = resolved_to_declared_type(&ResolvedTypeExpr::Int, &make_src()).unwrap();
5927        assert_eq!(dt, DeclaredType::Int);
5928    }
5929
5930    #[test]
5931    fn convert_scalar() {
5932        let dim = Dimension::base(BaseDimId::Prelude("Length".to_string()));
5933        let dt =
5934            resolved_to_declared_type(&ResolvedTypeExpr::Scalar(dim.clone()), &make_src()).unwrap();
5935        assert_eq!(dt, DeclaredType::Scalar(dim));
5936    }
5937
5938    #[test]
5939    fn convert_struct() {
5940        let owner = crate::dag_id::DagId::root("test");
5941        let resolved = ResolvedName::from_def(owner, StructTypeName::new("Foo"));
5942        let dt = resolved_to_declared_type(
5943            &ResolvedTypeExpr::Struct(resolved.clone(), Span::new(0, 0)),
5944            &make_src(),
5945        )
5946        .unwrap();
5947        assert_eq!(
5948            dt,
5949            DeclaredType::Struct(StructTypeRef::from_resolved(resolved), vec![])
5950        );
5951    }
5952
5953    #[test]
5954    fn convert_indexed() {
5955        let owner = crate::dag_id::DagId::root("test");
5956        let resolved_index = ResolvedName::from_def(owner, IndexName::new("M"));
5957        let dt = resolved_to_declared_type(
5958            &ResolvedTypeExpr::Indexed {
5959                base: Box::new(ResolvedTypeExpr::Scalar(Dimension::base(
5960                    BaseDimId::Prelude("Length".to_string()),
5961                ))),
5962                indexes: vec![ResolvedIndex::Concrete(
5963                    resolved_index.clone(),
5964                    Span::new(0, 0),
5965                )],
5966            },
5967            &make_src(),
5968        )
5969        .unwrap();
5970        assert_eq!(
5971            dt,
5972            DeclaredType::Indexed {
5973                element: Box::new(DeclaredType::Scalar(Dimension::base(BaseDimId::Prelude(
5974                    "Length".to_string()
5975                )))),
5976                index: IndexTypeRef::from_resolved(resolved_index),
5977            }
5978        );
5979    }
5980
5981    #[test]
5982    fn convert_generic_dim_param_fails() {
5983        let err = resolved_to_declared_type(
5984            &ResolvedTypeExpr::GenericDimParam(GenericParamName::new("D"), Span::new(0, 0)),
5985            &make_src(),
5986        )
5987        .unwrap_err();
5988        assert!(matches!(err, GraphcalError::EvalError { .. }));
5989    }
5990
5991    #[test]
5992    fn convert_generic_index_fails() {
5993        let err = resolved_to_declared_type(
5994            &ResolvedTypeExpr::Indexed {
5995                base: Box::new(ResolvedTypeExpr::Dimensionless),
5996                indexes: vec![ResolvedIndex::GenericParam(
5997                    GenericParamName::new("I"),
5998                    Span::new(0, 0),
5999                )],
6000            },
6001            &make_src(),
6002        )
6003        .unwrap_err();
6004        assert!(matches!(err, GraphcalError::EvalError { .. }));
6005    }
6006
6007    // --- Datetime type resolution tests ---
6008
6009    #[test]
6010    fn resolve_bare_datetime() {
6011        let r = make_registry();
6012        let te = parse_type("Datetime");
6013        let resolved = resolve_type_expr(&te, &r, &[], &[], &[], &make_src()).unwrap();
6014        assert_eq!(resolved, ResolvedTypeExpr::Datetime(TimeScale::UTC));
6015    }
6016
6017    #[test]
6018    fn resolve_datetime_utc() {
6019        let r = make_registry();
6020        let te = parse_type("Datetime<UTC>");
6021        let resolved = resolve_type_expr(&te, &r, &[], &[], &[], &make_src()).unwrap();
6022        assert_eq!(resolved, ResolvedTypeExpr::Datetime(TimeScale::UTC));
6023    }
6024
6025    #[test]
6026    fn resolve_datetime_tt() {
6027        let r = make_registry();
6028        let te = parse_type("Datetime<TT>");
6029        let resolved = resolve_type_expr(&te, &r, &[], &[], &[], &make_src()).unwrap();
6030        assert_eq!(resolved, ResolvedTypeExpr::Datetime(TimeScale::TT));
6031    }
6032
6033    #[test]
6034    fn resolve_datetime_tai() {
6035        let r = make_registry();
6036        let te = parse_type("Datetime<TAI>");
6037        let resolved = resolve_type_expr(&te, &r, &[], &[], &[], &make_src()).unwrap();
6038        assert_eq!(resolved, ResolvedTypeExpr::Datetime(TimeScale::TAI));
6039    }
6040
6041    #[test]
6042    fn resolve_datetime_gpst() {
6043        let r = make_registry();
6044        let te = parse_type("Datetime<GPST>");
6045        let resolved = resolve_type_expr(&te, &r, &[], &[], &[], &make_src()).unwrap();
6046        assert_eq!(resolved, ResolvedTypeExpr::Datetime(TimeScale::GPST));
6047    }
6048
6049    #[test]
6050    fn resolve_datetime_unknown_scale_error() {
6051        let r = make_registry();
6052        let te = parse_type("Datetime<XYZ>");
6053        let err = resolve_type_expr(&te, &r, &[], &[], &[], &make_src()).unwrap_err();
6054        assert!(matches!(err, GraphcalError::EvalError { .. }));
6055    }
6056
6057    #[test]
6058    fn convert_datetime_utc() {
6059        let dt =
6060            resolved_to_declared_type(&ResolvedTypeExpr::Datetime(TimeScale::UTC), &make_src())
6061                .unwrap();
6062        assert_eq!(dt, DeclaredType::Datetime(TimeScale::UTC));
6063    }
6064
6065    #[test]
6066    fn convert_datetime_tt() {
6067        let dt = resolved_to_declared_type(&ResolvedTypeExpr::Datetime(TimeScale::TT), &make_src())
6068            .unwrap();
6069        assert_eq!(dt, DeclaredType::Datetime(TimeScale::TT));
6070    }
6071
6072    // -----------------------------------------------------------------------
6073    // NatLinearForm::is_leq tests
6074    // -----------------------------------------------------------------------
6075
6076    #[test]
6077    fn nat_leq_constant_equal() {
6078        let a = NatPolyForm::from_constant(3);
6079        let b = NatPolyForm::from_constant(3);
6080        assert!(a.is_leq(&b));
6081    }
6082
6083    #[test]
6084    fn nat_leq_constant_less() {
6085        let a = NatPolyForm::from_constant(2);
6086        let b = NatPolyForm::from_constant(5);
6087        assert!(a.is_leq(&b));
6088    }
6089
6090    #[test]
6091    fn nat_leq_constant_greater() {
6092        let a = NatPolyForm::from_constant(5);
6093        let b = NatPolyForm::from_constant(3);
6094        assert!(!a.is_leq(&b));
6095    }
6096
6097    #[test]
6098    fn nat_leq_same_var() {
6099        // N <= N
6100        let a = NatPolyForm::from_var(GenericParamName::new("N"));
6101        let b = NatPolyForm::from_var(GenericParamName::new("N"));
6102        assert!(a.is_leq(&b));
6103    }
6104
6105    #[test]
6106    fn nat_leq_var_plus_constant() {
6107        // N <= N + 1
6108        let a = NatPolyForm::from_var(GenericParamName::new("N"));
6109        let b = NatPolyForm::from_var(GenericParamName::new("N"))
6110            .add(&NatPolyForm::from_constant(1))
6111            .unwrap();
6112        assert!(a.is_leq(&b));
6113    }
6114
6115    #[test]
6116    fn nat_leq_var_plus_constant_reverse() {
6117        // N + 1 <= N → false
6118        let a = NatPolyForm::from_var(GenericParamName::new("N"))
6119            .add(&NatPolyForm::from_constant(1))
6120            .unwrap();
6121        let b = NatPolyForm::from_var(GenericParamName::new("N"));
6122        assert!(!a.is_leq(&b));
6123    }
6124
6125    #[test]
6126    fn nat_leq_different_vars() {
6127        // N <= M → false (N could be larger)
6128        let a = NatPolyForm::from_var(GenericParamName::new("N"));
6129        let b = NatPolyForm::from_var(GenericParamName::new("M"));
6130        assert!(!a.is_leq(&b));
6131    }
6132
6133    #[test]
6134    fn nat_leq_zero_leq_anything() {
6135        // 0 <= N
6136        let a = NatPolyForm::from_constant(0);
6137        let b = NatPolyForm::from_var(GenericParamName::new("N"));
6138        assert!(a.is_leq(&b));
6139    }
6140
6141    // -----------------------------------------------------------------------
6142    // NatRangeIndexIdentity typed-reference tests
6143    // -----------------------------------------------------------------------
6144
6145    #[test]
6146    fn nat_range_identity_concrete_to_index_type_ref() -> Result<(), Box<dyn std::error::Error>> {
6147        let reference = NatPolyForm::from_constant(3)
6148            .to_nat_range_identity()?
6149            .to_index_type_ref()?;
6150        assert_eq!(
6151            reference
6152                .nat_range()
6153                .map(crate::registry::types::NatRangeIndex::size_u64),
6154            Some(3)
6155        );
6156        assert_eq!(reference.display_name().as_str(), "range(3)");
6157        Ok(())
6158    }
6159
6160    #[test]
6161    fn nat_range_identity_symbolic_to_display_only_index_type_ref()
6162    -> Result<(), Box<dyn std::error::Error>> {
6163        let reference = NatPolyForm::from_var(GenericParamName::new("N"))
6164            .add(&NatPolyForm::from_constant(1))
6165            .unwrap()
6166            .to_nat_range_identity()?
6167            .to_index_type_ref()?;
6168        assert_eq!(reference.nat_range(), None);
6169        assert_eq!(reference.display_name().as_str(), "range(N + 1)");
6170        Ok(())
6171    }
6172
6173    // -----------------------------------------------------------------------
6174    // NatPolyForm multiplication tests (Level 2)
6175    // -----------------------------------------------------------------------
6176
6177    #[test]
6178    fn nat_mul_constants() {
6179        let a = NatPolyForm::from_constant(3);
6180        let b = NatPolyForm::from_constant(4);
6181        assert_eq!(a.mul(&b).unwrap(), NatPolyForm::from_constant(12));
6182    }
6183
6184    #[test]
6185    fn nat_mul_var_by_constant() {
6186        // N * 3
6187        let n = NatPolyForm::from_var(GenericParamName::new("N"));
6188        let three = NatPolyForm::from_constant(3);
6189        let result = n.mul(&three).unwrap();
6190        // Should format as "3 * N"
6191        assert_eq!(result.format(), "3 * N");
6192        // Evaluate with N=5 → 15
6193        let mut bindings = HashMap::new();
6194        bindings.insert(GenericParamName::new("N"), 5);
6195        assert_eq!(result.evaluate(&bindings), Some(15));
6196    }
6197
6198    #[test]
6199    fn nat_mul_two_vars() {
6200        // M * N
6201        let m = NatPolyForm::from_var(GenericParamName::new("M"));
6202        let n = NatPolyForm::from_var(GenericParamName::new("N"));
6203        let result = m.mul(&n).unwrap();
6204        assert_eq!(result.format(), "M * N");
6205        let mut bindings = HashMap::new();
6206        bindings.insert(GenericParamName::new("M"), 3);
6207        bindings.insert(GenericParamName::new("N"), 4);
6208        assert_eq!(result.evaluate(&bindings), Some(12));
6209    }
6210
6211    #[test]
6212    fn nat_mul_distributive() {
6213        // (M + 1) * N = M * N + N
6214        let m = NatPolyForm::from_var(GenericParamName::new("M"));
6215        let n = NatPolyForm::from_var(GenericParamName::new("N"));
6216        let m_plus_1 = m.add(&NatPolyForm::from_constant(1)).unwrap();
6217        let result = m_plus_1.mul(&n).unwrap();
6218        // Evaluate with M=2, N=3 → (2+1)*3 = 9
6219        let mut bindings = HashMap::new();
6220        bindings.insert(GenericParamName::new("M"), 2);
6221        bindings.insert(GenericParamName::new("N"), 3);
6222        assert_eq!(result.evaluate(&bindings), Some(9));
6223    }
6224
6225    #[test]
6226    fn nat_mul_mixed_add() {
6227        // M * N + 1
6228        let m = NatPolyForm::from_var(GenericParamName::new("M"));
6229        let n = NatPolyForm::from_var(GenericParamName::new("N"));
6230        let result = m
6231            .mul(&n)
6232            .unwrap()
6233            .add(&NatPolyForm::from_constant(1))
6234            .unwrap();
6235        assert_eq!(result.format(), "M * N + 1");
6236        let mut bindings = HashMap::new();
6237        bindings.insert(GenericParamName::new("M"), 2);
6238        bindings.insert(GenericParamName::new("N"), 3);
6239        assert_eq!(result.evaluate(&bindings), Some(7));
6240    }
6241
6242    #[test]
6243    fn nat_poly_is_constant() {
6244        let c = NatPolyForm::from_constant(5);
6245        assert!(c.is_constant());
6246
6247        let n = NatPolyForm::from_var(GenericParamName::new("N"));
6248        assert!(!n.is_constant());
6249
6250        let mn = NatPolyForm::from_var(GenericParamName::new("M"))
6251            .mul(&NatPolyForm::from_var(GenericParamName::new("N")))
6252            .unwrap();
6253        assert!(!mn.is_constant());
6254    }
6255
6256    #[test]
6257    fn nat_poly_leq_with_mul() {
6258        // M * N <= M * N + 1
6259        let mn = NatPolyForm::from_var(GenericParamName::new("M"))
6260            .mul(&NatPolyForm::from_var(GenericParamName::new("N")))
6261            .unwrap();
6262        let mn_plus_1 = mn.add(&NatPolyForm::from_constant(1)).unwrap();
6263        assert!(mn.is_leq(&mn_plus_1));
6264        assert!(!mn_plus_1.is_leq(&mn));
6265    }
6266
6267    #[test]
6268    fn nat_add_overflow_errors() {
6269        // Regression: coefficient addition used to wrap silently, letting a
6270        // wrapped form unify with an unrelated type.
6271        let a = NatPolyForm::from_constant(u64::MAX);
6272        let b = NatPolyForm::from_constant(1);
6273        assert!(a.add(&b).is_err());
6274    }
6275
6276    #[test]
6277    fn nat_mul_overflow_errors() {
6278        // Regression: coefficient multiplication used to wrap silently.
6279        let a = NatPolyForm::from_constant(u64::MAX);
6280        let b = NatPolyForm::from_constant(2);
6281        assert!(a.mul(&b).is_err());
6282    }
6283
6284    #[test]
6285    fn nat_unify_substituted_term_overflow_errors() {
6286        // Regression: `unify_nat_poly_form` multiplied a term coefficient by
6287        // a substituted binding without overflow checking (debug panic,
6288        // release wraparound). `2 * N` with N bound near u64::MAX must report
6289        // a mismatch instead.
6290        let form = NatPolyForm::from_constant(2)
6291            .mul(&NatPolyForm::from_var(GenericParamName::new("N")))
6292            .unwrap();
6293        let mut nat_sub = HashMap::new();
6294        nat_sub.insert(GenericParamName::new("N"), u64::MAX / 2 + 1);
6295        let src = NamedSource::new("<test>", Arc::new(String::new()));
6296        let result = unify_nat_poly_form(
6297            &form,
6298            4,
6299            &mut nat_sub,
6300            &IndexName::new("range(4)"),
6301            &src,
6302            Span::new(0, 0),
6303        );
6304        assert!(result.is_err());
6305    }
6306
6307    #[test]
6308    fn nat_poly_format_zero() {
6309        let z = NatPolyForm::from_constant(0);
6310        assert_eq!(z.format(), "0");
6311    }
6312}