Skip to main content

graphcal_compiler/tir/dim_check/
mod.rs

1use std::collections::{HashMap, HashSet};
2use std::sync::Arc;
3
4use miette::NamedSource;
5
6use crate::registry::declared_type::{IndexTypeRef, StructTypeRef};
7use crate::registry::resolve_types::{ExpectedFail, ExpectedFailKey, ExpectedFailKeyPart};
8use crate::syntax::dimension::Dimension;
9use crate::syntax::names::{
10    IndexName, IndexVariantName, ResolvedName, ScopedName, StructTypeName, namespace,
11};
12
13use crate::registry::builtins::builtin_functions;
14use crate::registry::error::GraphcalError;
15use crate::registry::time_scale::TimeScale;
16use crate::registry::types::Registry;
17use crate::tir::typed::{NatLinearForm, NatRangeIndexIdentity};
18
19pub(crate) use helpers::{expect_scalar, format_inferred_type};
20
21use helpers::{format_declared_type, is_bool_type, resolved_type_matches_inferred, types_match};
22
23mod builtins;
24mod helpers;
25#[expect(
26    clippy::too_many_arguments,
27    clippy::too_many_lines,
28    reason = "inference functions pass compilation context through many parameters; \
29              large match on ExprKind variants is inherently long"
30)]
31mod infer;
32mod plot;
33#[cfg(test)]
34mod tests;
35
36pub use crate::registry::declared_type::DeclaredType;
37
38/// Index identity carried by inferred collection/label types.
39///
40/// Declared indexes compare by owner-qualified [`IndexTypeRef`]. Nat-range
41/// indexes additionally carry their normalized Nat form so generic ranges such
42/// as `range(N + 1)` are not encoded in or compared through synthetic strings.
43#[derive(Debug, Clone, Eq)]
44pub struct InferredIndex {
45    reference: IndexTypeRef,
46}
47
48impl InferredIndex {
49    #[must_use]
50    pub fn with_owner(owner: crate::dag_id::DagId, name: IndexName) -> Self {
51        Self::from_ref(IndexTypeRef::with_owner(owner, name))
52    }
53
54    #[must_use]
55    pub fn from_resolved(resolved: ResolvedName<namespace::Index>) -> Self {
56        Self {
57            reference: IndexTypeRef::from_resolved(resolved),
58        }
59    }
60
61    #[must_use]
62    pub const fn from_ref(reference: IndexTypeRef) -> Self {
63        Self { reference }
64    }
65
66    /// Create an inferred Nat range index from a validated Nat-range identity.
67    ///
68    /// # Errors
69    ///
70    /// Returns an error if the identity cannot be converted to an index type reference.
71    pub fn from_nat_range_identity(
72        identity: &NatRangeIndexIdentity,
73    ) -> Result<Self, crate::registry::types::NatRangeIndexError> {
74        Ok(Self {
75            reference: identity.to_index_type_ref()?,
76        })
77    }
78
79    /// Create an inferred Nat range index from a normalized Nat form.
80    ///
81    /// # Errors
82    ///
83    /// Returns an error when the form is a concrete invalid Nat range size.
84    pub fn from_nat_range_form(
85        form: NatLinearForm,
86    ) -> Result<Self, crate::registry::types::NatRangeIndexError> {
87        Self::from_nat_range_identity(&NatRangeIndexIdentity::try_from_form(form)?)
88    }
89
90    #[must_use]
91    pub const fn type_ref(&self) -> &IndexTypeRef {
92        &self.reference
93    }
94
95    #[must_use]
96    pub fn name(&self) -> IndexName {
97        self.reference.display_name()
98    }
99
100    #[must_use]
101    pub const fn declared_resolved(&self) -> Option<&ResolvedName<namespace::Index>> {
102        self.reference.declared_resolved()
103    }
104
105    #[must_use]
106    pub const fn concrete_nat_range(&self) -> Option<crate::registry::types::NatRangeIndex> {
107        self.reference.nat_range()
108    }
109
110    #[must_use]
111    pub fn nat_range_form(&self) -> Option<NatLinearForm> {
112        self.reference.nat_range_form()
113    }
114
115    #[must_use]
116    pub fn matches_resolved(&self, expected: &ResolvedName<namespace::Index>) -> bool {
117        self.declared_resolved() == Some(expected)
118    }
119
120    #[must_use]
121    pub fn matches_ref(&self, expected: &IndexTypeRef) -> bool {
122        self.reference.matches_ref(expected)
123    }
124}
125
126impl PartialEq for InferredIndex {
127    fn eq(&self, other: &Self) -> bool {
128        self.reference.matches_ref(&other.reference)
129    }
130}
131
132impl std::fmt::Display for InferredIndex {
133    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134        self.reference.fmt(f)
135    }
136}
137
138/// Struct/type identity carried by inferred constructor, match, and field types.
139///
140/// Equality is owner-sensitive; leaf-only names must be resolved before they
141/// become inferred semantic types.
142#[derive(Debug, Clone, Eq)]
143pub struct InferredStructType {
144    reference: StructTypeRef,
145}
146
147impl InferredStructType {
148    #[must_use]
149    pub fn with_owner(owner: crate::dag_id::DagId, name: StructTypeName) -> Self {
150        Self {
151            reference: StructTypeRef::with_owner(owner, name),
152        }
153    }
154
155    #[must_use]
156    pub fn from_resolved(resolved: ResolvedName<namespace::StructType>) -> Self {
157        Self {
158            reference: StructTypeRef::from_resolved(resolved),
159        }
160    }
161
162    #[must_use]
163    pub const fn from_ref(reference: StructTypeRef) -> Self {
164        Self { reference }
165    }
166
167    #[must_use]
168    pub const fn type_ref(&self) -> &StructTypeRef {
169        &self.reference
170    }
171
172    #[must_use]
173    pub const fn name(&self) -> &StructTypeName {
174        self.reference.name()
175    }
176
177    #[must_use]
178    pub const fn resolved(&self) -> &ResolvedName<namespace::StructType> {
179        self.reference.resolved()
180    }
181
182    #[must_use]
183    pub fn matches_resolved(&self, expected: &ResolvedName<namespace::StructType>) -> bool {
184        self.resolved() == expected
185    }
186
187    #[must_use]
188    pub fn matches_ref(&self, expected: &StructTypeRef) -> bool {
189        self.reference.matches_ref(expected)
190    }
191}
192
193impl PartialEq for InferredStructType {
194    fn eq(&self, other: &Self) -> bool {
195        self.reference.matches_ref(&other.reference)
196    }
197}
198
199impl std::fmt::Display for InferredStructType {
200    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
201        self.reference.fmt(f)
202    }
203}
204
205impl std::ops::Deref for InferredStructType {
206    type Target = StructTypeName;
207
208    fn deref(&self) -> &Self::Target {
209        self.name()
210    }
211}
212
213/// The inferred type of an expression.
214#[derive(Debug, Clone, PartialEq, Eq)]
215pub enum InferredType {
216    Scalar(Dimension),
217    Bool,
218    Int,
219    /// A bounded natural number `Fin(N)`: the type of loop variables over `range(N)`.
220    ///
221    /// A value of type `Fin(N)` satisfies `0 <= value < N`. This enables compile-time
222    /// bounds checking: `v[i]` is valid when `i : Fin(N)` and `v : T[M]` with `N <= M`.
223    ///
224    /// `Fin(N)` is not a user-declarable type — it only arises as the type of loop
225    /// variables in `for i: range(N) { ... }`.
226    Fin(NatLinearForm),
227    /// A datetime instant in a specific time scale.
228    Datetime(TimeScale),
229    /// A named index identity in an index-only position.
230    ///
231    /// This is used for named-index loop variables and `Index` generic
232    /// arguments. It is intentionally not a Graphcal value type.
233    NamedIndex(InferredIndex),
234    /// A struct type, optionally with concrete type arguments for generic structs.
235    Struct(InferredStructType, Vec<Self>),
236    Indexed {
237        element: Box<Self>,
238        index: InferredIndex,
239    },
240}
241
242impl InferredType {
243    /// Returns `true` if this type is `Int` or `Fin(N)` (integer-like).
244    #[must_use]
245    pub const fn is_int_like(&self) -> bool {
246        matches!(self, Self::Int | Self::Fin(_))
247    }
248}
249
250/// Per-DAG context bundle threaded through the dimension-check passes.
251///
252/// Bundles the read-only inputs that every per-declaration check needs
253/// (declared types, the locals scope, TIR, registry, builtins, source)
254/// so individual helpers take a single `&DimCheckContext` instead of
255/// six positional arguments.
256#[derive(Clone, Copy)]
257struct DimCheckContext<'a> {
258    declared_types: &'a HashMap<ScopedName, DeclaredType>,
259    dag: Option<&'a crate::tir::typed::DagTIR>,
260    tir: &'a crate::tir::typed::TIR,
261    registry: &'a Registry,
262    builtin_fns: &'a HashMap<&'a str, crate::registry::builtins::BuiltinFunction>,
263    src: &'a NamedSource<Arc<String>>,
264}
265
266impl<'a> DimCheckContext<'a> {
267    /// Re-anchor diagnostics on `body_src`, the source a particular
268    /// declaration's spans index into (#868). A declaration merged in from an
269    /// instantiated dependency keeps that dependency file's offsets, so its
270    /// checks must render against the dependency source rather than the
271    /// importer's ambient `src`.
272    const fn for_body(self, body_src: &'a NamedSource<Arc<String>>) -> Self {
273        Self {
274            src: body_src,
275            ..self
276        }
277    }
278}
279
280impl DimCheckContext<'_> {
281    /// Look up the module-aware HIR expression for a local declaration.
282    fn hir_expr_for_decl(
283        &self,
284        name: &crate::syntax::names::ScopedName,
285    ) -> Option<&crate::hir::Expr> {
286        let dag = self.dag?;
287        let key = dag.resolved_decl_key_for_local(name)?;
288        dag.semantic
289            .expressions
290            .consts
291            .get(&key)
292            .or_else(|| dag.semantic.expressions.runtime_expr(&key))
293    }
294
295    /// Look up the module-aware HIR assertion body for a local assertion.
296    fn hir_assert_body(
297        &self,
298        name: &crate::syntax::names::ScopedName,
299        span: crate::syntax::span::Span,
300    ) -> Result<&crate::hir::AssertBody, GraphcalError> {
301        let dag = self.dag.ok_or_else(|| GraphcalError::InternalError {
302            message: "HIR assertion lookup requires semantic DAG context".to_string(),
303            src: self.src.clone(),
304            span: span.into(),
305        })?;
306        let key =
307            dag.resolved_decl_key_for_local(name)
308                .ok_or_else(|| GraphcalError::InternalError {
309                    message: format!("semantic declaration key missing for assertion `{name}`"),
310                    src: self.src.clone(),
311                    span: span.into(),
312                })?;
313        dag.semantic
314            .expressions
315            .asserts
316            .get(&key)
317            .ok_or_else(|| GraphcalError::InternalError {
318                message: format!("semantic HIR body missing for assertion `{name}`"),
319                src: self.src.clone(),
320                span: span.into(),
321            })
322    }
323
324    /// Infer the type of a module-aware HIR expression using this context's bindings.
325    fn infer_hir(&self, expr: &crate::hir::Expr) -> Result<InferredType, GraphcalError> {
326        let dag = self.dag.ok_or_else(|| GraphcalError::InternalError {
327            message: "HIR assertion inference requires semantic DAG context".to_string(),
328            src: self.src.clone(),
329            span: expr.span.into(),
330        })?;
331        infer::hir::infer_hir_type_with_owner(
332            expr,
333            None,
334            self.declared_types,
335            dag,
336            self.tir,
337            self.registry,
338            self.builtin_fns,
339            self.src,
340        )
341    }
342}
343
344/// Check that a declaration's expression type matches its declared type annotation.
345fn check_decl_expr_type(
346    ctx: &DimCheckContext<'_>,
347    name: &crate::syntax::names::ScopedName,
348    type_ann_span: &crate::syntax::span::Span,
349) -> Result<(), GraphcalError> {
350    let declared = ctx
351        .declared_types
352        .get(name)
353        .ok_or_else(|| GraphcalError::InternalError {
354            message: format!("no declared type recorded for `{name}`"),
355            src: ctx.src.clone(),
356            span: (*type_ann_span).into(),
357        })?;
358    let dag = ctx.dag.ok_or_else(|| GraphcalError::InternalError {
359        message: format!("semantic DAG missing while checking `{name}`"),
360        src: ctx.src.clone(),
361        span: (*type_ann_span).into(),
362    })?;
363    let hir_expr = ctx
364        .hir_expr_for_decl(name)
365        .ok_or_else(|| GraphcalError::InternalError {
366            message: format!("semantic HIR expression missing for declaration `{name}`"),
367            src: ctx.src.clone(),
368            span: (*type_ann_span).into(),
369        })?;
370    let inferred = infer::hir::infer_hir_type_with_owner(
371        hir_expr,
372        Some(name.member()),
373        ctx.declared_types,
374        dag,
375        ctx.tir,
376        ctx.registry,
377        ctx.builtin_fns,
378        ctx.src,
379    )?;
380    let matches = ctx
381        .dag
382        .and_then(|dag| dag.resolved_decl_types.get(name))
383        .map_or_else(
384            || types_match(declared, &inferred),
385            |resolved| resolved_type_matches_inferred(resolved, &inferred),
386        );
387    if !matches {
388        return Err(GraphcalError::DimensionMismatchInAnnotation {
389            declared: format_declared_type(declared, ctx.registry),
390            inferred: format_inferred_type(&inferred, ctx.registry),
391            src: ctx.src.clone(),
392            span: (*type_ann_span).into(),
393        });
394    }
395    check_ineffective_conversions(hir_expr, true, ctx.src)?;
396    Ok(())
397}
398
399/// Reject `->` conversions whose display effect is discarded (#648 B3).
400///
401/// A conversion only matters in a *display position*: the top level of a
402/// declaration body, a selected `if`/`match` branch, a constructor field
403/// initializer, a map-literal entry, a for-comprehension body, or a
404/// `scan`/`unfold` init. Anywhere else — arithmetic operands, function
405/// arguments, comparison operands, conditions, scrutinees, assertion bodies —
406/// the conversion evaluates to the unchanged SI value and its display target
407/// is silently dropped, so it is either a typo or dead code.
408fn check_ineffective_conversions(
409    expr: &crate::hir::Expr,
410    display_position: bool,
411    src: &NamedSource<Arc<String>>,
412) -> Result<(), GraphcalError> {
413    // Recursion choke point: recurses once per tree level.
414    crate::stack::with_stack_growth(|| {
415        check_ineffective_conversions_inner(expr, display_position, src)
416    })
417}
418
419fn check_ineffective_conversions_inner(
420    expr: &crate::hir::Expr,
421    display_position: bool,
422    src: &NamedSource<Arc<String>>,
423) -> Result<(), GraphcalError> {
424    use crate::hir::ExprKind;
425    match &expr.kind {
426        ExprKind::Convert { expr: inner, .. } | ExprKind::DisplayTimezone { expr: inner, .. } => {
427            if !display_position {
428                return Err(GraphcalError::IneffectiveConversion {
429                    src: src.clone(),
430                    span: expr.span.into(),
431                });
432            }
433            // The operand of a conversion is not itself a display position
434            // (direct nesting is already rejected as D012).
435            check_ineffective_conversions(inner, false, src)
436        }
437        ExprKind::If {
438            condition,
439            then_branch,
440            else_branch,
441        } => {
442            check_ineffective_conversions(condition, false, src)?;
443            check_ineffective_conversions(then_branch, display_position, src)?;
444            check_ineffective_conversions(else_branch, display_position, src)
445        }
446        ExprKind::Match { scrutinee, arms } => {
447            check_ineffective_conversions(scrutinee, false, src)?;
448            for arm in arms {
449                check_ineffective_conversions(&arm.body, display_position, src)?;
450            }
451            Ok(())
452        }
453        ExprKind::ConstructorCall { fields, .. } => {
454            for init in fields {
455                check_ineffective_conversions(&init.value, display_position, src)?;
456            }
457            Ok(())
458        }
459        ExprKind::MapLiteral { entries } => {
460            for entry in entries {
461                check_ineffective_conversions(&entry.value, display_position, src)?;
462            }
463            Ok(())
464        }
465        ExprKind::ForComp { body, .. } => {
466            check_ineffective_conversions(body, display_position, src)
467        }
468        ExprKind::Scan {
469            source, init, body, ..
470        } => {
471            check_ineffective_conversions(source, false, src)?;
472            check_ineffective_conversions(init, display_position, src)?;
473            check_ineffective_conversions(body, false, src)
474        }
475        ExprKind::Unfold { init, body, .. } => {
476            check_ineffective_conversions(init, display_position, src)?;
477            check_ineffective_conversions(body, false, src)
478        }
479        ExprKind::BinOp { lhs, rhs, .. } => {
480            check_ineffective_conversions(lhs, false, src)?;
481            check_ineffective_conversions(rhs, false, src)
482        }
483        ExprKind::UnaryOp { operand, .. } => check_ineffective_conversions(operand, false, src),
484        ExprKind::FnCall { args, .. } => {
485            for arg in args {
486                check_ineffective_conversions(arg, false, src)?;
487            }
488            Ok(())
489        }
490        ExprKind::FieldAccess { expr: inner, .. } => {
491            check_ineffective_conversions(inner, false, src)
492        }
493        ExprKind::IndexAccess { expr: inner, args } => {
494            check_ineffective_conversions(inner, false, src)?;
495            for arg in args {
496                if let crate::hir::expr::IndexArg::Expr(e) = arg {
497                    check_ineffective_conversions(e, false, src)?;
498                }
499            }
500            Ok(())
501        }
502        // Inline-dag param bindings flow into the callee's params, whose
503        // reads propagate display metadata; treat them as display positions.
504        ExprKind::InlineDagRef { args, .. } => {
505            for binding in args {
506                check_ineffective_conversions(&binding.value, display_position, src)?;
507            }
508            Ok(())
509        }
510        ExprKind::Error
511        | ExprKind::Number(_)
512        | ExprKind::Integer(_)
513        | ExprKind::Bool(_)
514        | ExprKind::StringLiteral(_)
515        | ExprKind::TypeSystemRef(_)
516        | ExprKind::GraphRef(_)
517        | ExprKind::ConstRef(_)
518        | ExprKind::LocalRef(_)
519        | ExprKind::UnitLiteral { .. }
520        | ExprKind::VariantLiteral(_) => Ok(()),
521    }
522}
523
524#[derive(Debug)]
525struct AssertionIndexShape {
526    axes: Vec<InferredIndex>,
527}
528
529impl AssertionIndexShape {
530    fn from_bool_type(ty: &InferredType) -> Self {
531        Self {
532            axes: peel_index_axes(ty).0,
533        }
534    }
535
536    const fn is_indexed(&self) -> bool {
537        !self.axes.is_empty()
538    }
539
540    const fn rank(&self) -> usize {
541        self.axes.len()
542    }
543}
544
545/// Check dimensions for a lowered HIR assertion body.
546fn check_hir_assert_body(
547    ctx: &DimCheckContext<'_>,
548    body: &crate::hir::AssertBody,
549    span: crate::syntax::span::Span,
550) -> Result<AssertionIndexShape, GraphcalError> {
551    let registry = ctx.registry;
552    let src = ctx.src;
553    match body {
554        crate::hir::AssertBody::Expr(body_expr) => {
555            let inferred = ctx.infer_hir(body_expr)?;
556            if !is_bool_type(&inferred) {
557                return Err(GraphcalError::AssertBodyNotBool {
558                    found: format_inferred_type(&inferred, registry),
559                    src: src.clone(),
560                    span: span.into(),
561                });
562            }
563            Ok(AssertionIndexShape::from_bool_type(&inferred))
564        }
565        crate::hir::AssertBody::Tolerance {
566            actual,
567            expected,
568            tolerance,
569            is_relative,
570        } => {
571            let actual_type = ctx.infer_hir(actual)?;
572            let expected_type = ctx.infer_hir(expected)?;
573            let tolerance_type = ctx.infer_hir(tolerance)?;
574
575            // Element-wise broadcasting (#809): the assertion's index shape
576            // comes from `actual`; `expected` and `tolerance` are each scalar
577            // (broadcast to every key) or indexed by exactly the same axes.
578            let (actual_axes, actual_elem) = peel_index_axes(&actual_type);
579            let expected_elem = broadcast_operand_element(
580                &actual_axes,
581                &actual_type,
582                &expected_type,
583                expected.span,
584                registry,
585                src,
586            )?;
587            let tolerance_elem = broadcast_operand_element(
588                &actual_axes,
589                &actual_type,
590                &tolerance_type,
591                tolerance.span,
592                registry,
593                src,
594            )?;
595
596            let actual_dim = expect_scalar(actual_elem, registry, src, actual.span)?;
597            let expected_dim = expect_scalar(expected_elem, registry, src, expected.span)?;
598            if actual_dim != expected_dim {
599                return Err(GraphcalError::DimensionMismatch {
600                    expected: registry.dimensions.format_dimension(&actual_dim),
601                    found: registry.dimensions.format_dimension(&expected_dim),
602                    help: "actual and expected in tolerance assertion must have the same dimension"
603                        .to_string(),
604                    src: src.clone(),
605                    span: expected.span.into(),
606                });
607            }
608
609            let tolerance_ok = if *is_relative {
610                tolerance_elem.is_int_like()
611                    || matches!(tolerance_elem, InferredType::Scalar(d) if d.is_dimensionless())
612            } else {
613                let tolerance_dim = expect_scalar(tolerance_elem, registry, src, tolerance.span)?;
614                tolerance_dim == actual_dim
615            };
616            if !tolerance_ok {
617                let (expected_str, help_str) = if *is_relative {
618                    (
619                        "Dimensionless".to_string(),
620                        "relative tolerance (%) must be dimensionless".to_string(),
621                    )
622                } else {
623                    (
624                        registry.dimensions.format_dimension(&actual_dim),
625                        "absolute tolerance must have the same dimension as actual/expected"
626                            .to_string(),
627                    )
628                };
629                return Err(GraphcalError::DimensionMismatch {
630                    expected: expected_str,
631                    found: format_inferred_type(&tolerance_type, registry),
632                    help: help_str,
633                    src: src.clone(),
634                    span: tolerance.span.into(),
635                });
636            }
637
638            // A negative tolerance makes the assertion unsatisfiable (even an
639            // exact match fails `abs(delta) <= tol`), so a statically-known
640            // negative value is a compile error (#815). Tolerances computed
641            // at runtime are validated by the evaluator instead.
642            if let Some(value) = statically_known_tolerance(tolerance)
643                && value < 0.0
644            {
645                return Err(GraphcalError::NegativeTolerance {
646                    found: crate::registry::format::format_number(value),
647                    src: src.clone(),
648                    span: tolerance.span.into(),
649                });
650            }
651            Ok(AssertionIndexShape { axes: actual_axes })
652        }
653    }
654}
655
656/// Peel the index axes off an inferred type, outermost first.
657fn peel_index_axes(ty: &InferredType) -> (Vec<InferredIndex>, &InferredType) {
658    let mut axes = Vec::new();
659    let mut current = ty;
660    while let InferredType::Indexed { element, index } = current {
661        axes.push(index.clone());
662        current = element;
663    }
664    (axes, current)
665}
666
667/// Validate that a tolerance-assertion operand broadcasts against `actual`'s
668/// axes (#809): it is either unindexed (applied to every key) or indexed by
669/// exactly the same axes in the same order. Returns the operand's element
670/// type.
671fn broadcast_operand_element<'a>(
672    actual_axes: &[InferredIndex],
673    actual_type: &InferredType,
674    operand_type: &'a InferredType,
675    operand_span: crate::syntax::span::Span,
676    registry: &Registry,
677    src: &NamedSource<Arc<String>>,
678) -> Result<&'a InferredType, GraphcalError> {
679    let (operand_axes, operand_elem) = peel_index_axes(operand_type);
680    if !operand_axes.is_empty() && operand_axes != *actual_axes {
681        return Err(GraphcalError::IndexedShapeMismatch {
682            context: "tolerance assertion".to_string(),
683            lhs: format_inferred_type(actual_type, registry),
684            rhs: format_inferred_type(operand_type, registry),
685            src: src.clone(),
686            span: operand_span.into(),
687        });
688    }
689    Ok(operand_elem)
690}
691
692/// Structurally fold a tolerance expression to its written literal value
693/// when the sign is statically known: a numeric literal (`0.1`, `5`,
694/// `0.1 m` — unit scales are always positive, so the written value carries
695/// the sign), optionally under unary negation. Returns `None` for anything
696/// computed at runtime; those are sign-checked by the evaluator instead.
697fn statically_known_tolerance(expr: &crate::hir::Expr) -> Option<f64> {
698    match &expr.kind {
699        crate::hir::ExprKind::Number(n) => Some(*n),
700        #[expect(
701            clippy::cast_precision_loss,
702            reason = "tolerance literals are small integers"
703        )]
704        crate::hir::ExprKind::Integer(i) => Some(*i as f64),
705        crate::hir::ExprKind::UnitLiteral { value, .. } => Some(*value),
706        crate::hir::ExprKind::UnaryOp {
707            op: crate::syntax::ast::UnaryOp::Neg,
708            operand,
709        } => statically_known_tolerance(operand).map(|v| -v),
710        _ => None,
711    }
712}
713
714fn expected_fail_key_span(key: &ExpectedFailKey) -> crate::syntax::span::Span {
715    key.iter()
716        .map(ExpectedFailKeyPart::span)
717        .reduce(crate::syntax::span::Span::merge)
718        .unwrap_or_else(|| crate::syntax::span::Span::new(0, 0))
719}
720
721fn expected_fail_key_signature(
722    key: &ExpectedFailKey,
723) -> Vec<(Option<IndexTypeRef>, IndexVariantName)> {
724    key.iter()
725        .map(|part| (part.named_index().cloned(), part.variant()))
726        .collect()
727}
728
729fn validate_expected_fail_key(
730    key: &ExpectedFailKey,
731    shape: &AssertionIndexShape,
732    src: &NamedSource<Arc<String>>,
733) -> Result<(), GraphcalError> {
734    if key.len() != shape.rank() {
735        return Err(GraphcalError::ExpectedFailKeyShapeMismatch {
736            expected: shape.rank(),
737            found: key.len(),
738            src: src.clone(),
739            span: expected_fail_key_span(key).into(),
740        });
741    }
742
743    for (part, expected_axis) in key.iter().zip(&shape.axes) {
744        match part {
745            ExpectedFailKeyPart::Named { index, .. } => {
746                if !index.matches_ref(expected_axis.type_ref()) {
747                    return Err(GraphcalError::ExpectedFailKeyIndexMismatch {
748                        expected: expected_axis.name().to_string(),
749                        found: part.display(),
750                        src: src.clone(),
751                        span: part.span().into(),
752                    });
753                }
754            }
755            ExpectedFailKeyPart::RangeStep { step, span } => {
756                let Some(range) = expected_axis.type_ref().nat_range_ref() else {
757                    return Err(GraphcalError::ExpectedFailKeyIndexMismatch {
758                        expected: expected_axis.name().to_string(),
759                        found: part.display(),
760                        src: src.clone(),
761                        span: (*span).into(),
762                    });
763                };
764                // Bound-check `#N` against a statically known range size.
765                // Symbolic sizes are checked nowhere earlier; an out-of-range
766                // step there can never match at runtime, which surfaces as an
767                // "unexpected pass" — acceptable for the symbolic case.
768                if let Some(concrete) = range.concrete_index()
769                    && *step >= concrete.size_u64()
770                {
771                    return Err(GraphcalError::ExpectedFailRangeStepOutOfBounds {
772                        step: *step,
773                        size: concrete.size_u64(),
774                        src: src.clone(),
775                        span: (*span).into(),
776                    });
777                }
778            }
779        }
780    }
781
782    Ok(())
783}
784
785fn validate_expected_fail(
786    expected_fail: &ExpectedFail,
787    shape: &AssertionIndexShape,
788    src: &NamedSource<Arc<String>>,
789    assert_span: crate::syntax::span::Span,
790) -> Result<(), GraphcalError> {
791    match expected_fail {
792        ExpectedFail::All if shape.is_indexed() => Err(GraphcalError::ExpectedFailAllOnIndexed {
793            src: src.clone(),
794            span: assert_span.into(),
795        }),
796        ExpectedFail::All => Ok(()),
797        ExpectedFail::Variants(keys) if !shape.is_indexed() => {
798            Err(GraphcalError::ExpectedFailNotIndexed {
799                src: src.clone(),
800                span: keys
801                    .first()
802                    .map_or(assert_span, expected_fail_key_span)
803                    .into(),
804            })
805        }
806        ExpectedFail::Variants(keys) => {
807            let mut seen = HashSet::new();
808            for key in keys {
809                validate_expected_fail_key(key, shape, src)?;
810                if !seen.insert(expected_fail_key_signature(key)) {
811                    return Err(GraphcalError::ExpectedFailDuplicateKey {
812                        src: src.clone(),
813                        span: expected_fail_key_span(key).into(),
814                    });
815                }
816            }
817            Ok(())
818        }
819    }
820}
821
822/// Check dimensions for all declarations in a file.
823///
824/// For each const/param/node, infers the dimension of the RHS expression
825/// and verifies it matches the declared type annotation. Uses
826/// `tir.build_declared_types()` (derived from `resolved_decl_types`) to validate
827/// that every RHS expression matches its declared type annotation.
828///
829/// This is a pure validation step — returns `()` on success.
830///
831/// # Errors
832///
833/// Returns a [`GraphcalError`] if dimensions are inconsistent.
834pub fn check_dimensions_tir(
835    tir: &crate::tir::typed::TIR,
836    src: &NamedSource<Arc<String>>,
837) -> Result<(), GraphcalError> {
838    detect_decl_cycles(tir, src)?;
839    detect_cross_dag_cycles(tir, src)?;
840    let builtin_fns = builtin_functions();
841
842    // Dim-check the file's own DAGs (root + inline children) against the
843    // file's shared registry. Dep DAGs merged in by `merge_dep_dag_tirs`
844    // were already dim-checked in their own file's pipeline, against
845    // their own registry — re-checking them here against the importer's
846    // registry would fail on types renamed by include bindings.
847    for (id, dag) in &tir.dags {
848        if id == &tir.root_dag_id || id.parent().as_ref() == Some(&tir.root_dag_id) {
849            check_dimensions_dag(dag, tir, &tir.registry, builtin_fns, src)?;
850        }
851    }
852
853    // Validate domain constraints on struct/union member fields. The check
854    // walks the registry's `TypeDef`s once per file. Types reachable through
855    // dep imports were already validated in their defining file's pipeline,
856    // so the redundant pass is idempotent. (#450 Position 1+2.)
857    let declared_types = tir.build_declared_types(src)?;
858    check_no_constraints_on_generic_type_args(tir, src)?;
859    check_field_domain_constraint_targets(tir, src)?;
860    check_field_domain_constraint_dimensions(
861        tir,
862        &declared_types,
863        &tir.registry,
864        builtin_fns,
865        src,
866    )?;
867
868    Ok(())
869}
870
871/// Dim-check a single [`DagTIR`] against the file's shared registry and
872/// the full flat dag map.
873fn check_dimensions_dag(
874    dag: &crate::tir::typed::DagTIR,
875    tir: &crate::tir::typed::TIR,
876    registry: &crate::registry::types::Registry,
877    builtin_fns: &HashMap<&str, crate::registry::builtins::BuiltinFunction>,
878    src: &NamedSource<Arc<String>>,
879) -> Result<(), GraphcalError> {
880    let declared_types = dag.build_declared_types(src)?;
881    let ctx = DimCheckContext {
882        declared_types: &declared_types,
883        dag: Some(dag),
884        tir,
885        registry,
886        builtin_fns,
887        src,
888    };
889
890    // Declarations merged in from instantiated dependencies keep the
891    // dependency file's spans, so each is checked against its own source (#868).
892    for entry in &dag.consts {
893        let entry_ctx = ctx.for_body(entry.src.resolve(src));
894        check_decl_expr_type(&entry_ctx, &entry.name, &entry.type_ann.span)?;
895    }
896    for entry in &dag.nodes {
897        let entry_ctx = ctx.for_body(entry.src.resolve(src));
898        check_decl_expr_type(&entry_ctx, &entry.name, &entry.type_ann.span)?;
899    }
900    for entry in &dag.params {
901        let Some(_value_expr) = entry.default_expr.as_ref() else {
902            continue;
903        };
904        let entry_ctx = ctx.for_body(entry.src.resolve(src));
905        check_decl_expr_type(&entry_ctx, &entry.name, &entry.type_ann.span)?;
906    }
907
908    for entry in &dag.asserts {
909        let body_src = entry.src.resolve(src);
910        let entry_ctx = ctx.for_body(body_src);
911        let body = entry_ctx.hir_assert_body(&entry.name, entry.span)?;
912        let shape = check_hir_assert_body(&entry_ctx, body, entry.span)?;
913        if let Some(expected_fail) = dag.expected_fail.get(&entry.name) {
914            validate_expected_fail(expected_fail, &shape, body_src, entry.span)?;
915        }
916        // Assertion results are never displayed with units, so no position
917        // inside an assert body is display-effective.
918        match body {
919            crate::hir::expr::AssertBody::Expr(e) => {
920                check_ineffective_conversions(e, false, body_src)?;
921            }
922            crate::hir::expr::AssertBody::Tolerance {
923                actual,
924                expected,
925                tolerance,
926                ..
927            } => {
928                check_ineffective_conversions(actual, false, body_src)?;
929                check_ineffective_conversions(expected, false, body_src)?;
930                check_ineffective_conversions(tolerance, false, body_src)?;
931            }
932        }
933    }
934
935    plot::check_plot_properties_dag(&ctx)?;
936
937    check_domain_constraint_targets_dag(dag, src)?;
938    check_domain_constraint_dimensions_dag(dag, &declared_types, tir, registry, builtin_fns, src)?;
939
940    Ok(())
941}
942
943/// What a domain bound expression must infer to for a given target type.
944enum ExpectedBound {
945    /// Bound must be `Scalar(d)`. `Int` is also accepted when `d` is dimensionless.
946    Scalar(Dimension),
947    /// Bound must be unitless: `Int`, or `Scalar` with the dimensionless dimension.
948    Int,
949}
950
951/// Check that domain constraint bound expressions have the correct type.
952///
953/// For each param/node with `(min: ..., max: ...)` constraints whose target type
954/// is `Scalar(d)`, `Dimensionless`, or `Int`, infers the type of each bound
955/// expression using the regular type checker and verifies it matches:
956/// - `Scalar(d)` target: bound must be `Scalar(d)` (or `Int` if `d` is dimensionless).
957/// - `Dimensionless` target: bound must be `Scalar(dimensionless)` or `Int`.
958/// - `Int` target: bound must be `Int` or `Scalar(dimensionless)` — units forbidden.
959///
960/// Other targets (e.g., `Bool`) are skipped here and handled by
961/// `validate_constraint_target` in `exec_plan` (which raises `InvalidDomainTarget`).
962fn check_domain_constraint_dimensions_dag(
963    dag: &crate::tir::typed::DagTIR,
964    declared_types: &HashMap<ScopedName, DeclaredType>,
965    tir: &crate::tir::typed::TIR,
966    registry: &Registry,
967    builtin_fns: &HashMap<&str, crate::registry::builtins::BuiltinFunction>,
968    src: &NamedSource<Arc<String>>,
969) -> Result<(), GraphcalError> {
970    // A merged dependency declaration's domain bounds keep the dependency
971    // file's spans, so they are checked against that body's source (#868).
972    let decl_iter = dag
973        .consts
974        .iter()
975        .map(|e| (&e.name, &e.src))
976        .chain(dag.params.iter().map(|e| (&e.name, &e.src)))
977        .chain(dag.nodes.iter().map(|e| (&e.name, &e.src)));
978
979    for (name, body_provenance) in decl_iter {
980        let bounds = dag
981            .resolved_decl_key_for_local(name)
982            .and_then(|key| dag.semantic.domain_bounds.get(&key));
983        let Some(bounds) = bounds else {
984            continue;
985        };
986        let body_src = body_provenance.resolve(src);
987
988        let resolved = dag.resolved_decl_types.get(name);
989        let base_resolved = resolved.map(strip_indexed);
990        let expected = match base_resolved {
991            Some(crate::tir::typed::ResolvedTypeExpr::Scalar(dim)) => {
992                ExpectedBound::Scalar(dim.clone())
993            }
994            Some(crate::tir::typed::ResolvedTypeExpr::Dimensionless) => {
995                ExpectedBound::Scalar(Dimension::dimensionless())
996            }
997            Some(crate::tir::typed::ResolvedTypeExpr::Int) => ExpectedBound::Int,
998            _ => continue,
999        };
1000
1001        for bound in bounds {
1002            let inferred = infer::hir::infer_hir_type_with_owner(
1003                &bound.value,
1004                None,
1005                declared_types,
1006                dag,
1007                tir,
1008                registry,
1009                builtin_fns,
1010                body_src,
1011            )?;
1012            check_one_bound(name, bound, &inferred, &expected, registry, body_src)?;
1013        }
1014    }
1015
1016    Ok(())
1017}
1018
1019fn check_one_bound(
1020    name: &crate::syntax::names::ScopedName,
1021    bound: &crate::tir::typed::ResolvedDomainBound,
1022    inferred: &InferredType,
1023    expected: &ExpectedBound,
1024    registry: &Registry,
1025    src: &NamedSource<Arc<String>>,
1026) -> Result<(), GraphcalError> {
1027    match expected {
1028        ExpectedBound::Scalar(target_dim) => {
1029            let ok = match inferred {
1030                InferredType::Scalar(d) => d == target_dim,
1031                InferredType::Int => target_dim.is_dimensionless(),
1032                _ => false,
1033            };
1034            if ok {
1035                return Ok(());
1036            }
1037            let bound_dim_str = match inferred {
1038                InferredType::Scalar(d) => registry.dimensions.format_dimension(d),
1039                other => format_inferred_type(other, registry),
1040            };
1041            Err(GraphcalError::DomainDimensionMismatch {
1042                name: name.to_string(),
1043                type_dim: registry.dimensions.format_dimension(target_dim),
1044                bound_name: bound.kind.to_string(),
1045                bound_dim: bound_dim_str,
1046                src: src.clone(),
1047                span: bound.span.into(),
1048            })
1049        }
1050        ExpectedBound::Int => {
1051            let ok = match inferred {
1052                InferredType::Int => true,
1053                InferredType::Scalar(d) => d.is_dimensionless(),
1054                _ => false,
1055            };
1056            if ok {
1057                return Ok(());
1058            }
1059            Err(GraphcalError::IntDomainBoundNotUnitless {
1060                name: name.to_string(),
1061                bound_name: bound.kind.to_string(),
1062                bound_type: format_inferred_type(inferred, registry),
1063                src: src.clone(),
1064                span: bound.span.into(),
1065            })
1066        }
1067    }
1068}
1069
1070/// Reject domain constraints on base types that don't accept them.
1071///
1072/// Bool, Datetime, Label, and struct/generic types cannot carry numeric
1073/// `(min: …, max: …)` bounds. The check is a pure function of the resolved
1074/// declaration type — independent of any bound expression's value — so it
1075/// belongs in compile-time validation rather than runtime resolution.
1076fn check_domain_constraint_targets_dag(
1077    dag: &crate::tir::typed::DagTIR,
1078    src: &NamedSource<Arc<String>>,
1079) -> Result<(), GraphcalError> {
1080    let decl_iter = dag
1081        .consts
1082        .iter()
1083        .map(|e| (&e.name, &e.type_ann, e.span))
1084        .chain(dag.params.iter().map(|e| (&e.name, &e.type_ann, e.span)))
1085        .chain(dag.nodes.iter().map(|e| (&e.name, &e.type_ann, e.span)));
1086
1087    for (name, type_ann, decl_span) in decl_iter {
1088        if extract_domain_bounds(type_ann).is_empty() {
1089            continue;
1090        }
1091        let Some(resolved) = dag.resolved_decl_types.get(name) else {
1092            continue;
1093        };
1094        let type_kind = match strip_indexed(resolved) {
1095            crate::tir::typed::ResolvedTypeExpr::Bool => "Bool".to_string(),
1096            crate::tir::typed::ResolvedTypeExpr::Datetime(_) => "Datetime".to_string(),
1097            crate::tir::typed::ResolvedTypeExpr::IndexArg(index) => {
1098                format!("index {}", index.format_for_diagnostic())
1099            }
1100            crate::tir::typed::ResolvedTypeExpr::Struct(struct_name, _)
1101            | crate::tir::typed::ResolvedTypeExpr::GenericStruct {
1102                name: struct_name, ..
1103            } => format!("struct `{}`", struct_name.as_str()),
1104            crate::tir::typed::ResolvedTypeExpr::Scalar(_)
1105            | crate::tir::typed::ResolvedTypeExpr::Dimensionless
1106            | crate::tir::typed::ResolvedTypeExpr::Int
1107            | crate::tir::typed::ResolvedTypeExpr::GenericDimParam(_, _)
1108            | crate::tir::typed::ResolvedTypeExpr::GenericTypeParam(_, _)
1109            | crate::tir::typed::ResolvedTypeExpr::GenericDimExpr { .. }
1110            | crate::tir::typed::ResolvedTypeExpr::Indexed { .. } => continue,
1111        };
1112        return Err(GraphcalError::InvalidDomainTarget {
1113            type_kind,
1114            src: src.clone(),
1115            span: decl_span.into(),
1116        });
1117    }
1118    Ok(())
1119}
1120
1121/// Extract `DomainBound`s from a `TypeExpr`, handling indexed types.
1122///
1123/// For `Velocity(min: 0)[Maneuver]`, the constraints are on the base `Velocity`,
1124/// not on the outer `Indexed` wrapper.
1125fn extract_domain_bounds(
1126    type_ann: &crate::desugar::desugared_ast::TypeExpr,
1127) -> &[crate::desugar::desugared_ast::DomainBound] {
1128    if !type_ann.constraints.is_empty() {
1129        return &type_ann.constraints;
1130    }
1131    if let crate::desugar::desugared_ast::TypeExprKind::Indexed { base, .. } = &type_ann.kind {
1132        return &base.constraints;
1133    }
1134    &[]
1135}
1136
1137/// Reject domain constraints on struct/union fields whose target type
1138/// cannot carry numeric `(min: …, max: …)` bounds (Bool, Datetime, Label,
1139/// nested struct/union). Mirrors [`check_domain_constraint_targets_dag`]
1140/// for top-level decls.
1141///
1142/// Scans every `TypeDef` in the file's registry. Generic-param fields are
1143/// skipped (we don't know their concrete type at definition time).
1144fn check_field_domain_constraint_targets(
1145    tir: &crate::tir::typed::TIR,
1146    src: &NamedSource<Arc<String>>,
1147) -> Result<(), GraphcalError> {
1148    for type_def in tir.registry.types.all_types() {
1149        // Iterate over every variant's payload fields — the n-variant
1150        // model puts payload fields on the union's members.
1151        let members: &[crate::registry::types::UnionMemberDef] =
1152            type_def.union_members().unwrap_or(&[]);
1153        for field in members.iter().flat_map(|m| m.fields.iter()) {
1154            if extract_domain_bounds(&field.type_ann).is_empty() {
1155                continue;
1156            }
1157            let kind = field_constraint_target_kind(&field.type_ann, &tir.registry);
1158            if let Some(type_kind) = kind {
1159                return Err(GraphcalError::InvalidDomainTarget {
1160                    type_kind,
1161                    src: src.clone(),
1162                    span: field.type_ann.span.into(),
1163                });
1164            }
1165        }
1166    }
1167    Ok(())
1168}
1169
1170/// Classify a field's `TypeExpr` as either constraint-compatible (returns
1171/// `None`) or constraint-incompatible (returns `Some(kind_str)` describing
1172/// why it's incompatible). Strips an outer `Indexed` wrapper before
1173/// classifying — a `Velocity(min: 0)[Maneuver]` field is constraint-
1174/// compatible because the base `Velocity` is scalar.
1175fn field_constraint_target_kind(
1176    type_ann: &crate::desugar::desugared_ast::TypeExpr,
1177    registry: &Registry,
1178) -> Option<String> {
1179    use crate::desugar::desugared_ast::TypeExprKind;
1180    let base = match &type_ann.kind {
1181        TypeExprKind::Indexed { base, .. } => base.as_ref(),
1182        _ => type_ann,
1183    };
1184    match &base.kind {
1185        TypeExprKind::Bool => Some("Bool".to_string()),
1186        TypeExprKind::Datetime | TypeExprKind::DatetimeApplication { .. } => {
1187            Some("Datetime".to_string())
1188        }
1189        TypeExprKind::TypeApplication { name, .. } => {
1190            Some(format!("struct `{}`", name.value.display_path()))
1191        }
1192        // The outer `Indexed` wrapper was stripped above; a nested indexed
1193        // type at this depth is unusual but constraint-compatible (the base
1194        // dim is what carries the constraint).
1195        TypeExprKind::Dimensionless | TypeExprKind::Int | TypeExprKind::Indexed { .. } => None,
1196        TypeExprKind::DimExpr(dim_expr) => {
1197            // A bare single-name DimExpr could be a struct, an index name, or a
1198            // dimension. The registry distinguishes them: dim → constraint-
1199            // compatible scalar; struct → reject; index → reject as an index.
1200            if dim_expr.terms.len() == 1
1201                && dim_expr.terms[0].term.power.is_none()
1202                && let Some(item) = dim_expr.terms.first()
1203            {
1204                let Some(name) = item
1205                    .term
1206                    .name
1207                    .value
1208                    .as_bare()
1209                    .map(super::super::syntax::names::NameAtom::as_str)
1210                else {
1211                    // Qualified type-level references are rejected by type
1212                    // resolution; skip this compatibility classifier here.
1213                    return None;
1214                };
1215                if registry.dimensions.get_dimension(name).is_some() {
1216                    None
1217                } else if registry.types.get_type(name).is_some() {
1218                    Some(format!("struct `{name}`"))
1219                } else if registry.indexes.get_index(name).is_some() {
1220                    Some(format!("index `{name}`"))
1221                } else {
1222                    // Generic dim param or unknown name — skip; an unknown name
1223                    // would already error in type resolution.
1224                    None
1225                }
1226            } else {
1227                // Compound dim expression like `Length / Time` → constraint-
1228                // compatible scalar.
1229                None
1230            }
1231        }
1232    }
1233}
1234
1235/// Check that domain bound expressions on struct/union fields have the
1236/// correct type. Mirrors [`check_domain_constraint_dimensions_dag`] for
1237/// top-level decls.
1238///
1239/// Field bounds live in each DAG's semantic type defs (lowered to HIR at
1240/// type-resolution time); the same owner-qualified field can be referenced
1241/// from several DAGs, so a seen-set dedupes the checks.
1242fn check_field_domain_constraint_dimensions(
1243    tir: &crate::tir::typed::TIR,
1244    declared_types: &HashMap<ScopedName, DeclaredType>,
1245    registry: &Registry,
1246    builtin_fns: &HashMap<&str, crate::registry::builtins::BuiltinFunction>,
1247    src: &NamedSource<Arc<String>>,
1248) -> Result<(), GraphcalError> {
1249    let mut seen: std::collections::HashSet<&crate::tir::typed::ResolvedStructFieldTypeKey> =
1250        std::collections::HashSet::new();
1251    for (id, dag) in &tir.dags {
1252        if id != &tir.root_dag_id && id.parent().as_ref() != Some(&tir.root_dag_id) {
1253            continue;
1254        }
1255        for (key, bounds) in &dag.semantic.type_defs.field_bounds {
1256            if !seen.insert(key) {
1257                continue;
1258            }
1259            let Some(type_def) = dag.semantic.type_defs.struct_types.get(&key.owning_type) else {
1260                continue;
1261            };
1262            let Some((variant, field)) = type_def.union_members().and_then(|members| {
1263                members
1264                    .iter()
1265                    .flat_map(|m| m.fields.iter().map(move |f| (m, f)))
1266                    .find(|(m, f)| m.name == key.constructor && f.name == key.field)
1267            }) else {
1268                continue;
1269            };
1270            let Some(expected) = field_expected_bound(&field.type_ann, registry, src)? else {
1271                continue;
1272            };
1273            // For a single-variant collision (record-shape) the display
1274            // name is `Type.field`; for a true multi-variant union it's
1275            // `Type.Variant.field` so diagnostics disambiguate which
1276            // constructor a violating bound belongs to.
1277            let display_name = if variant.name.as_str() == type_def.name.as_str() {
1278                format!("{}.{}", type_def.name, field.name)
1279            } else {
1280                format!("{}.{}.{}", type_def.name, variant.name, field.name)
1281            };
1282            for bound in bounds {
1283                let inferred = infer::hir::infer_hir_type_with_owner(
1284                    &bound.value,
1285                    None,
1286                    declared_types,
1287                    dag,
1288                    tir,
1289                    registry,
1290                    builtin_fns,
1291                    src,
1292                )?;
1293                check_one_bound_with_display_name(
1294                    &display_name,
1295                    bound,
1296                    &inferred,
1297                    &expected,
1298                    registry,
1299                    src,
1300                )?;
1301            }
1302        }
1303    }
1304    Ok(())
1305}
1306
1307/// Compute the [`ExpectedBound`] for a struct field's `TypeExpr`. Returns
1308/// `Ok(None)` when the field's base type isn't `Scalar`/`Dimensionless`/`Int`
1309/// (in which case the target check has already rejected it, or it's a
1310/// generic param to be checked at instantiation), and `Err` if dimension
1311/// arithmetic overflows.
1312fn field_expected_bound(
1313    type_ann: &crate::desugar::desugared_ast::TypeExpr,
1314    registry: &Registry,
1315    src: &NamedSource<Arc<String>>,
1316) -> Result<Option<ExpectedBound>, GraphcalError> {
1317    use crate::desugar::desugared_ast::TypeExprKind;
1318    let base = match &type_ann.kind {
1319        TypeExprKind::Indexed { base, .. } => base.as_ref(),
1320        _ => type_ann,
1321    };
1322    match &base.kind {
1323        TypeExprKind::Dimensionless => Ok(Some(ExpectedBound::Scalar(Dimension::dimensionless()))),
1324        TypeExprKind::Int => Ok(Some(ExpectedBound::Int)),
1325        TypeExprKind::DimExpr(_) => Ok(registry
1326            .dimensions
1327            .resolve_type_expr(base)
1328            .map_err(|_| GraphcalError::DimensionOverflow {
1329                src: src.clone(),
1330                span: base.span.into(),
1331            })?
1332            .map(ExpectedBound::Scalar)),
1333        _ => Ok(None),
1334    }
1335}
1336
1337/// Variant of [`check_one_bound`] that takes a pre-formatted display name
1338/// for the constrained target (e.g. `"SatelliteSpec.mass"`) so a single
1339/// helper can serve both top-level decls and struct fields.
1340fn check_one_bound_with_display_name(
1341    display_name: &str,
1342    bound: &crate::tir::typed::ResolvedDomainBound,
1343    inferred: &InferredType,
1344    expected: &ExpectedBound,
1345    registry: &Registry,
1346    src: &NamedSource<Arc<String>>,
1347) -> Result<(), GraphcalError> {
1348    match expected {
1349        ExpectedBound::Scalar(target_dim) => {
1350            let ok = match inferred {
1351                InferredType::Scalar(d) => d == target_dim,
1352                InferredType::Int => target_dim.is_dimensionless(),
1353                _ => false,
1354            };
1355            if ok {
1356                return Ok(());
1357            }
1358            let bound_dim_str = match inferred {
1359                InferredType::Scalar(d) => registry.dimensions.format_dimension(d),
1360                other => format_inferred_type(other, registry),
1361            };
1362            Err(GraphcalError::DomainDimensionMismatch {
1363                name: display_name.to_string(),
1364                type_dim: registry.dimensions.format_dimension(target_dim),
1365                bound_name: bound.kind.to_string(),
1366                bound_dim: bound_dim_str,
1367                src: src.clone(),
1368                span: bound.span.into(),
1369            })
1370        }
1371        ExpectedBound::Int => {
1372            let ok = match inferred {
1373                InferredType::Int => true,
1374                InferredType::Scalar(d) => d.is_dimensionless(),
1375                _ => false,
1376            };
1377            if ok {
1378                return Ok(());
1379            }
1380            Err(GraphcalError::IntDomainBoundNotUnitless {
1381                name: display_name.to_string(),
1382                bound_name: bound.kind.to_string(),
1383                bound_type: format_inferred_type(inferred, registry),
1384                src: src.clone(),
1385                span: bound.span.into(),
1386            })
1387        }
1388    }
1389}
1390
1391/// Reject domain constraints on generic type-application arguments.
1392///
1393/// Generic args are erased at runtime, so a constraint on `D` in
1394/// `Vec3<Length(min: 0.0 m)>` has no enforcement site and unclear
1395/// semantics. Issue #450 Position 4: surface a clear compile-time error
1396/// directing the user to put the constraint on the field instead.
1397///
1398/// Walks every `TypeExpr` reachable through declarations and type-defs
1399/// in the file. (Type-args themselves can be `TypeApplication`s nested
1400/// inside other applications, so the walk recurses.)
1401fn check_no_constraints_on_generic_type_args(
1402    tir: &crate::tir::typed::TIR,
1403    src: &NamedSource<Arc<String>>,
1404) -> Result<(), GraphcalError> {
1405    let walk = |type_expr: &crate::desugar::desugared_ast::TypeExpr| -> Result<(), GraphcalError> {
1406        check_type_expr_for_generic_arg_constraints(type_expr, src)
1407    };
1408    for (id, dag) in &tir.dags {
1409        if id != &tir.root_dag_id && id.parent().as_ref() != Some(&tir.root_dag_id) {
1410            continue;
1411        }
1412        for entry in &dag.consts {
1413            walk(&entry.type_ann)?;
1414        }
1415        for entry in &dag.params {
1416            walk(&entry.type_ann)?;
1417        }
1418        for entry in &dag.nodes {
1419            walk(&entry.type_ann)?;
1420        }
1421    }
1422    for type_def in tir.registry.types.all_types() {
1423        for field in type_def.fields() {
1424            walk(&field.type_ann)?;
1425        }
1426    }
1427    Ok(())
1428}
1429
1430/// Recurse through a `TypeExpr` and reject any `DomainBound` found on a
1431/// `TypeApplication` argument. The outermost `TypeExpr` may itself carry
1432/// constraints (the legitimate placement); only constraints under a
1433/// `TypeApplication.type_args` slot are rejected.
1434fn check_type_expr_for_generic_arg_constraints(
1435    type_expr: &crate::desugar::desugared_ast::TypeExpr,
1436    src: &NamedSource<Arc<String>>,
1437) -> Result<(), GraphcalError> {
1438    use crate::desugar::desugared_ast::TypeExprKind;
1439    match &type_expr.kind {
1440        TypeExprKind::Indexed { base, .. } => {
1441            check_type_expr_for_generic_arg_constraints(base, src)
1442        }
1443        TypeExprKind::TypeApplication { type_args, .. }
1444        | TypeExprKind::DatetimeApplication { type_args } => {
1445            for arg in type_args {
1446                if let Some(bound) = arg.constraints.first() {
1447                    return Err(GraphcalError::GenericTypeArgDomainConstraint {
1448                        src: src.clone(),
1449                        span: bound.span.into(),
1450                    });
1451                }
1452                // Recurse so nested generics are checked too.
1453                check_type_expr_for_generic_arg_constraints(arg, src)?;
1454            }
1455            Ok(())
1456        }
1457        TypeExprKind::Dimensionless
1458        | TypeExprKind::Bool
1459        | TypeExprKind::Int
1460        | TypeExprKind::Datetime
1461        | TypeExprKind::DimExpr(_) => Ok(()),
1462    }
1463}
1464
1465/// Strip `Indexed` wrappers to get the base resolved type.
1466fn strip_indexed(
1467    resolved: &crate::tir::typed::ResolvedTypeExpr,
1468) -> &crate::tir::typed::ResolvedTypeExpr {
1469    match resolved {
1470        crate::tir::typed::ResolvedTypeExpr::Indexed { base, .. } => strip_indexed(base),
1471        other => other,
1472    }
1473}
1474
1475/// Check that an applied override has the correct dimension for the given param.
1476///
1477/// Overrides are spliced into the IR as the target param's default expression
1478/// before type resolution, so the override's HIR already lives in the root
1479/// DAG's semantic expressions; this checks that stored HIR against the param's
1480/// declared type.
1481///
1482/// # Errors
1483///
1484/// Returns a [`GraphcalError::DimensionMismatch`] if the override's inferred
1485/// dimension does not match the declared type of the param.
1486#[expect(
1487    clippy::implicit_hasher,
1488    reason = "internal API always uses default hasher"
1489)]
1490pub fn check_override_dimension(
1491    param_name: &str,
1492    declared_types: &HashMap<ScopedName, DeclaredType>,
1493    tir: &crate::tir::typed::TIR,
1494    registry: &Registry,
1495    src: &NamedSource<Arc<String>>,
1496) -> Result<(), GraphcalError> {
1497    let builtin_fns = builtin_functions();
1498
1499    // Override targets are addressed by their bare param name, which is always
1500    // a top-level local in the file being overridden.
1501    let param_key = ScopedName::local(param_name);
1502    let declared =
1503        declared_types
1504            .get(&param_key)
1505            .ok_or_else(|| GraphcalError::OverrideUnknownParam {
1506                name: crate::syntax::names::DeclName::new(param_name.to_string()),
1507            })?;
1508    let dag = tir.root();
1509    let hir_expr = dag
1510        .resolved_decl_key_for_local(&param_key)
1511        .and_then(|key| dag.semantic.expressions.param_defaults.get(&key))
1512        .ok_or_else(|| GraphcalError::InternalError {
1513            message: format!("override for `{param_name}` was not applied to the root DAG"),
1514            src: src.clone(),
1515            span: crate::syntax::span::Span::new(0, 0).into(),
1516        })?;
1517    let inferred = infer::hir::infer_hir_type_with_owner(
1518        hir_expr,
1519        Some(param_name),
1520        declared_types,
1521        dag,
1522        tir,
1523        registry,
1524        builtin_fns,
1525        src,
1526    )?;
1527
1528    if !types_match(declared, &inferred) {
1529        return Err(GraphcalError::DimensionMismatch {
1530            expected: format_declared_type(declared, registry),
1531            found: format_inferred_type(&inferred, registry),
1532            help: format!(
1533                "override for `{param_name}` must have dimension {}",
1534                format_declared_type(declared, registry)
1535            ),
1536            src: src.clone(),
1537            span: hir_expr.span.into(),
1538        });
1539    }
1540    Ok(())
1541}
1542
1543/// Detect cycles in the cross-dag inline-call graph.
1544///
1545/// A dag `A` that transitively inline-calls itself — directly or through a
1546/// chain `A → B → … → A` — would recurse unboundedly at evaluation time. We
1547/// reject such programs at compile time with
1548/// [`GraphcalError::CyclicDependency`] pointing at one dag involved in the
1549/// cycle (chosen deterministically by the DFS entry order).
1550///
1551/// Per the issue thread, a dag — not a file — is the semantic unit of
1552/// cycle detection, so the same check applies whether the cycle is within
1553/// a single file or spans multiple files.
1554enum DagCycleFrame {
1555    Enter(crate::dag_id::DagId),
1556    Leave(crate::dag_id::DagId),
1557}
1558
1559/// Collect inline dag call targets from a compiled DAG's semantic body.
1560fn collect_dag_call_targets_from_dag(
1561    dag: &crate::tir::typed::DagTIR,
1562    out: &mut std::collections::BTreeSet<crate::dag_id::DagId>,
1563) {
1564    out.extend(
1565        dag.semantic
1566            .inline_dag_refs
1567            .calls
1568            .values()
1569            .map(|call| call.target.clone()),
1570    );
1571}
1572
1573/// Detect cycles in same-file declaration dependencies.
1574///
1575/// A graph cycle is a topological property of source — knowable without
1576/// evaluating any value. This check rejects cyclic params/nodes (`runtime_deps`)
1577/// and cyclic consts (`const_deps`) at compile time so the diagnostic appears
1578/// under `graphcal check`, not only at evaluation. Mirrors the toposort-based
1579/// cycle detection in `graphcal-eval`'s `exec_plan::eval_consts_from_tir` and
1580/// `build_runtime_dag`, which now act as defense-in-depth backstops.
1581fn detect_decl_cycles(
1582    tir: &crate::tir::typed::TIR,
1583    src: &NamedSource<Arc<String>>,
1584) -> Result<(), GraphcalError> {
1585    use std::collections::BTreeSet;
1586
1587    use petgraph::algo::toposort;
1588    use petgraph::graph::DiGraph;
1589
1590    use crate::syntax::names::{ResolvedName, ScopedName, namespace};
1591
1592    type ResolvedDeclKey = ResolvedName<namespace::Decl>;
1593
1594    fn local_resolved_decl_key(
1595        dag: &crate::tir::typed::DagTIR,
1596        name: &ScopedName,
1597        span: crate::syntax::span::Span,
1598        src: &NamedSource<Arc<String>>,
1599    ) -> Result<ResolvedDeclKey, GraphcalError> {
1600        dag.resolved_decl_key_for_local(name)
1601            .ok_or_else(|| GraphcalError::InternalError {
1602                message: format!(
1603                    "semantic dependency metadata contains no local canonical key for declaration `{name}`"
1604                ),
1605                src: src.clone(),
1606                span: span.into(),
1607            })
1608    }
1609
1610    fn check_resolved<'a>(
1611        dag: &crate::tir::typed::DagTIR,
1612        names_with_spans: impl Iterator<Item = (&'a ScopedName, crate::syntax::span::Span)>,
1613        deps: &HashMap<ResolvedDeclKey, BTreeSet<ResolvedDeclKey>>,
1614        src: &NamedSource<Arc<String>>,
1615    ) -> Result<(), GraphcalError> {
1616        let mut graph = DiGraph::<ResolvedDeclKey, ()>::new();
1617        let mut index_map: HashMap<ResolvedDeclKey, petgraph::graph::NodeIndex> = HashMap::new();
1618        let mut local_name_by_key: HashMap<ResolvedDeclKey, ScopedName> = HashMap::new();
1619        let mut span_by_key: HashMap<ResolvedDeclKey, crate::syntax::span::Span> = HashMap::new();
1620        for (name, span) in names_with_spans {
1621            let key = local_resolved_decl_key(dag, name, span, src)?;
1622            let idx = graph.add_node(key.clone());
1623            index_map.insert(key.clone(), idx);
1624            local_name_by_key.insert(key.clone(), name.clone());
1625            span_by_key.insert(key, span);
1626        }
1627        if index_map.is_empty() {
1628            return Ok(());
1629        }
1630        for (name, dep_set) in deps {
1631            let Some(&to) = index_map.get(name) else {
1632                continue;
1633            };
1634            for dep in dep_set {
1635                if let Some(&from) = index_map.get(dep) {
1636                    graph.add_edge(from, to, ());
1637                }
1638            }
1639        }
1640        toposort(&graph, None).map(|_| ()).map_err(|cycle| {
1641            let cycle_node = &graph[cycle.node_id()];
1642            let span = span_by_key
1643                .get(cycle_node)
1644                .copied()
1645                .unwrap_or_else(|| crate::syntax::span::Span::new(0, 0));
1646            let name = local_name_by_key
1647                .get(cycle_node)
1648                .map_or_else(|| cycle_node.to_string(), std::string::ToString::to_string);
1649            GraphcalError::CyclicDependency {
1650                name,
1651                src: src.clone(),
1652                span: span.into(),
1653            }
1654        })
1655    }
1656
1657    for dag in tir.dags.values() {
1658        let deps = &dag.semantic.dependencies;
1659        check_resolved(
1660            dag,
1661            dag.consts.iter().map(|e| (&e.name, e.span)),
1662            &deps.const_deps,
1663            src,
1664        )?;
1665        check_resolved(
1666            dag,
1667            dag.params
1668                .iter()
1669                .map(|e| (&e.name, e.span))
1670                .chain(dag.nodes.iter().map(|e| (&e.name, e.span))),
1671            &deps.runtime_deps,
1672            src,
1673        )?;
1674    }
1675    Ok(())
1676}
1677
1678fn detect_cross_dag_cycles(
1679    tir: &crate::tir::typed::TIR,
1680    src: &NamedSource<Arc<String>>,
1681) -> Result<(), GraphcalError> {
1682    use std::collections::{BTreeMap, BTreeSet, HashSet};
1683
1684    use crate::dag_id::DagId;
1685
1686    let mut edges: BTreeMap<DagId, BTreeSet<DagId>> = BTreeMap::new();
1687    let mut spans: HashMap<DagId, crate::syntax::span::Span> = HashMap::new();
1688    for (key, dag_tir) in &tir.dags {
1689        let mut targets = BTreeSet::new();
1690        collect_dag_call_targets_from_dag(dag_tir, &mut targets);
1691        edges.insert(key.clone(), targets);
1692        // Best-effort span: for inline children of this file the parent's
1693        // registry entry has the AST span; cross-file merged dags fall
1694        // back to a zero span (no AST in the importer).
1695        let parent = key.parent();
1696        let span = if parent.as_ref() == Some(&tir.root_dag_id) {
1697            tir.registry
1698                .dags
1699                .get(key.name())
1700                .map_or_else(|| crate::syntax::span::Span::new(0, 0), |d| d.name.span)
1701        } else {
1702            crate::syntax::span::Span::new(0, 0)
1703        };
1704        spans.insert(key.clone(), span);
1705    }
1706
1707    let mut visited: HashSet<DagId> = HashSet::new();
1708    let mut on_stack: HashSet<DagId> = HashSet::new();
1709
1710    for start in edges.keys() {
1711        if visited.contains(start) {
1712            continue;
1713        }
1714        let mut work: Vec<DagCycleFrame> = vec![DagCycleFrame::Enter(start.clone())];
1715        while let Some(frame) = work.pop() {
1716            match frame {
1717                DagCycleFrame::Enter(key) => {
1718                    if visited.contains(&key) {
1719                        continue;
1720                    }
1721                    if on_stack.contains(&key) {
1722                        let span = spans
1723                            .get(&key)
1724                            .copied()
1725                            .unwrap_or_else(|| crate::syntax::span::Span::new(0, 0));
1726                        return Err(GraphcalError::CyclicDependency {
1727                            name: key.to_string(),
1728                            src: src.clone(),
1729                            span: span.into(),
1730                        });
1731                    }
1732                    on_stack.insert(key.clone());
1733                    work.push(DagCycleFrame::Leave(key.clone()));
1734                    if let Some(targets) = edges.get(&key) {
1735                        for t in targets {
1736                            if edges.contains_key(t) {
1737                                work.push(DagCycleFrame::Enter(t.clone()));
1738                            }
1739                        }
1740                    }
1741                }
1742                DagCycleFrame::Leave(key) => {
1743                    on_stack.remove(&key);
1744                    visited.insert(key);
1745                }
1746            }
1747        }
1748    }
1749
1750    Ok(())
1751}