Skip to main content

pharmsol_dsl/
semantic.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::fmt;
3use std::sync::Arc;
4
5use crate::ast as syntax;
6use crate::diagnostic::{
7    Applicability, Diagnostic, DiagnosticPhase, DiagnosticReport, DiagnosticSuggestion, Span,
8    TextEdit, DSL_SEMANTIC_GENERIC,
9};
10use crate::ir::*;
11use crate::name_match::{
12    common_prefix_len, edit_distance, is_high_confidence_match, is_single_adjacent_transposition,
13};
14use crate::{ModelKind, NUMERIC_OUTPUT_PREFIX, NUMERIC_ROUTE_PREFIX, RATE_FUNCTION_NAME};
15
16const RESERVED_NAMES: &[&str] = &[
17    "abs",
18    "bioavailability",
19    "carry_forward",
20    "ceil",
21    "ddt",
22    "exp",
23    "floor",
24    "lag",
25    "linear",
26    "ln",
27    "locf",
28    "log",
29    "log10",
30    "log2",
31    "max",
32    "min",
33    "noise",
34    "pow",
35    RATE_FUNCTION_NAME,
36    "round",
37    "sin",
38    "cos",
39    "tan",
40    "sqrt",
41];
42
43#[derive(Default)]
44struct SemanticAssist {
45    context_labels: Vec<(Span, String)>,
46    secondary_labels: Vec<(Span, String)>,
47    helps: Vec<String>,
48    suggestions: Vec<DiagnosticSuggestion>,
49}
50
51impl SemanticAssist {
52    fn context_label(mut self, span: Span, message: impl Into<String>) -> Self {
53        self.context_labels.push((span, message.into()));
54        self
55    }
56
57    fn help(mut self, help: impl Into<String>) -> Self {
58        self.helps.push(help.into());
59        self
60    }
61
62    fn replacement_suggestion(
63        mut self,
64        span: Span,
65        replacement: impl Into<String>,
66        message: impl Into<String>,
67        applicability: Applicability,
68    ) -> Self {
69        self.suggestions.push(DiagnosticSuggestion {
70            message: message.into(),
71            edits: vec![TextEdit {
72                span,
73                replacement: replacement.into(),
74            }],
75            applicability,
76        });
77        self
78    }
79
80    fn apply(self, mut error: SemanticError) -> SemanticError {
81        for (span, message) in self.context_labels {
82            error = error.with_context_label(span, message);
83        }
84        for (span, message) in self.secondary_labels {
85            error = error.with_secondary_label(span, message);
86        }
87        for help in self.helps {
88            error = error.with_help(help);
89        }
90        for suggestion in self.suggestions {
91            error = error.with_suggestion(suggestion);
92        }
93        error
94    }
95}
96
97struct SimilarNameCandidate {
98    lookup_name: String,
99    assist: SemanticAssist,
100}
101
102impl SimilarNameCandidate {
103    fn new(lookup_name: impl Into<String>, assist: SemanticAssist) -> Self {
104        Self {
105            lookup_name: lookup_name.into(),
106            assist,
107        }
108    }
109}
110
111pub fn analyze_module(module: &syntax::Module) -> Result<TypedModule, SemanticError> {
112    let mut models = Vec::with_capacity(module.models.len());
113    for model in &module.models {
114        models.push(analyze_model(model)?);
115    }
116    Ok(TypedModule {
117        models,
118        span: module.span,
119    })
120}
121
122pub fn analyze_model(model: &syntax::Model) -> Result<TypedModel, SemanticError> {
123    Analyzer::new(model).analyze()
124}
125
126#[derive(Clone, PartialEq, Eq)]
127pub struct SemanticError {
128    diagnostic: Box<Diagnostic>,
129    source: Option<Arc<str>>,
130}
131
132impl SemanticError {
133    pub fn new(message: impl Into<String>, span: Span) -> Self {
134        Self {
135            diagnostic: Box::new(Diagnostic::error(
136                DSL_SEMANTIC_GENERIC,
137                DiagnosticPhase::Semantic,
138                message,
139                span,
140            )),
141            source: None,
142        }
143    }
144
145    pub fn with_note(mut self, note: impl Into<String>) -> Self {
146        self.diagnostic.notes.push(note.into());
147        self
148    }
149
150    pub fn with_help(mut self, help: impl Into<String>) -> Self {
151        self.diagnostic.helps.push(help.into());
152        self
153    }
154
155    pub fn with_secondary_label(mut self, span: Span, message: impl Into<String>) -> Self {
156        self.diagnostic = Box::new(self.diagnostic.with_secondary_label(span, message));
157        self
158    }
159
160    pub fn with_context_label(mut self, span: Span, message: impl Into<String>) -> Self {
161        self.diagnostic = Box::new(self.diagnostic.with_context_label(span, message));
162        self
163    }
164
165    pub fn with_suggestion(mut self, suggestion: DiagnosticSuggestion) -> Self {
166        self.diagnostic = Box::new(self.diagnostic.with_suggestion(suggestion));
167        self
168    }
169
170    pub fn diagnostic(&self) -> &Diagnostic {
171        self.diagnostic.as_ref()
172    }
173
174    pub fn into_diagnostic(self) -> Diagnostic {
175        *self.diagnostic
176    }
177
178    pub fn render(&self, src: &str) -> String {
179        self.diagnostic.render(src)
180    }
181
182    pub fn diagnostic_report(&self, source_name: impl Into<String>) -> DiagnosticReport {
183        DiagnosticReport::from_diagnostics(
184            source_name,
185            self.source(),
186            std::slice::from_ref(self.diagnostic.as_ref()),
187        )
188    }
189
190    pub fn with_source(mut self, source: impl Into<Arc<str>>) -> Self {
191        self.source = Some(source.into());
192        self
193    }
194
195    pub fn source(&self) -> Option<&str> {
196        self.source.as_deref()
197    }
198}
199
200impl fmt::Debug for SemanticError {
201    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
202        fmt::Display::fmt(self, f)
203    }
204}
205
206impl fmt::Display for SemanticError {
207    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
208        if let Some(source) = self.source() {
209            return f.write_str(&self.render(source));
210        }
211        let span = self.diagnostic.primary_span();
212        write!(
213            f,
214            "{} at bytes {}..{}",
215            self.diagnostic.message, span.start, span.end
216        )
217    }
218}
219
220impl std::error::Error for SemanticError {}
221
222struct Analyzer<'a> {
223    model: &'a syntax::Model,
224    symbols: Vec<PendingSymbol>,
225    globals: Globals,
226}
227
228impl<'a> Analyzer<'a> {
229    fn new(model: &'a syntax::Model) -> Self {
230        Self {
231            model,
232            symbols: Vec::new(),
233            globals: Globals::default(),
234        }
235    }
236
237    fn analyze(mut self) -> Result<TypedModel, SemanticError> {
238        let sections = ModelSections::from_model(self.model)?;
239
240        let parameters = self.register_parameters(sections.parameters)?;
241        let constants = self.resolve_and_register_constants(sections.constants)?;
242        let covariates = self.register_covariates(sections.covariates)?;
243        let states = self.register_states(sections.states)?;
244        let routes = self.register_routes(sections.routes)?;
245
246        let derived = self.register_implicit_symbols(
247            sections.derive.map(|block| block.statements.as_slice()),
248            SymbolKind::Derived,
249        )?;
250        let outputs = self.register_implicit_symbols(
251            Some(
252                &sections
253                    .outputs
254                    .ok_or_else(|| {
255                        SemanticError::new(
256                            format!(
257                                "model `{}` is missing an `outputs` block",
258                                self.model.name.text
259                            ),
260                            self.model.span,
261                        )
262                    })?
263                    .statements,
264            ),
265            SymbolKind::Output,
266        )?;
267
268        self.validate_kind_requirements(&sections, &states)?;
269
270        let derive_result = if let Some(block) = sections.derive {
271            Some(self.analyze_statement_block(block, BlockContext::Derive, BTreeSet::new())?)
272        } else {
273            None
274        };
275        let available_derived = derive_result
276            .as_ref()
277            .map(|result| result.available_derived.clone())
278            .unwrap_or_default();
279
280        let dynamics = if let Some(block) = sections.dynamics {
281            Some(self.analyze_statement_block(
282                block,
283                BlockContext::Dynamics,
284                available_derived.clone(),
285            )?)
286        } else {
287            None
288        };
289        let init = if let Some(block) = sections.init {
290            Some(self.analyze_statement_block(
291                block,
292                BlockContext::Init,
293                available_derived.clone(),
294            )?)
295        } else {
296            None
297        };
298        let drift = if let Some(block) = sections.drift {
299            Some(self.analyze_statement_block(
300                block,
301                BlockContext::Drift,
302                available_derived.clone(),
303            )?)
304        } else {
305            None
306        };
307        let diffusion = if let Some(block) = sections.diffusion {
308            Some(self.analyze_statement_block(
309                block,
310                BlockContext::Diffusion,
311                available_derived.clone(),
312            )?)
313        } else {
314            None
315        };
316        let outputs_block = self.analyze_statement_block(
317            sections.outputs.expect("outputs checked above"),
318            BlockContext::Outputs,
319            available_derived,
320        )?;
321
322        self.validate_kind_blocks(
323            self.model.kind,
324            ModelKindBlocks {
325                dynamics: dynamics.as_ref(),
326                drift: drift.as_ref(),
327                diffusion: diffusion.as_ref(),
328                analytical: sections.analytical,
329                particles: sections.particles,
330            },
331            &states,
332        )?;
333
334        self.validate_output_assignments(&outputs, &outputs_block)?;
335        if let Some(result) = &dynamics {
336            self.validate_state_coverage(result, &states, "dynamics")?;
337        }
338        if let Some(result) = &drift {
339            self.validate_state_coverage(result, &states, "drift")?;
340        }
341
342        let particles = if let Some(decl) = sections.particles {
343            Some(self.expect_const_usize(&decl.value, "particles", true)?)
344        } else {
345            None
346        };
347
348        let analytical = if let Some(block) = sections.analytical {
349            let structure =
350                AnalyticalKernel::from_name(&block.structure.text).ok_or_else(|| {
351                    SemanticError::new(
352                        format!("unknown analytical structure `{}`", block.structure.text),
353                        block.structure.span,
354                    )
355                })?;
356            let state_components = states
357                .iter()
358                .map(|state| state.size.unwrap_or(1))
359                .sum::<usize>();
360            if state_components != structure.state_count() {
361                return Err(SemanticError::new(
362                    format!(
363                        "analytical structure `{}` expects {} state value(s), but model declares {}",
364                        block.structure.text,
365                        structure.state_count(),
366                        state_components
367                    ),
368                    block.structure.span,
369                ));
370            }
371            self.validate_analytical_structure_inputs(
372                structure,
373                block.structure.span,
374                &parameters,
375                &derived,
376                derive_result.as_ref(),
377            )?;
378            Some(TypedAnalytical {
379                structure,
380                span: block.span,
381            })
382        } else {
383            None
384        };
385
386        let model_name = self.model.name.text.clone();
387        let model_kind = self.model.kind;
388        let model_span = self.model.span;
389        let symbols = self.finalize_symbols()?;
390        Ok(TypedModel {
391            name: model_name,
392            kind: model_kind,
393            symbols,
394            parameters,
395            constants,
396            covariates,
397            states,
398            routes,
399            derived,
400            outputs,
401            particles,
402            analytical,
403            derive: derive_result.map(|result| result.block),
404            dynamics: dynamics.map(|result| result.block),
405            outputs_block: outputs_block.block,
406            init: init.map(|result| result.block),
407            drift: drift.map(|result| result.block),
408            diffusion: diffusion.map(|result| result.block),
409            span: model_span,
410        })
411    }
412
413    fn register_parameters(
414        &mut self,
415        block: Option<&syntax::ParametersBlock>,
416    ) -> Result<Vec<SymbolId>, SemanticError> {
417        let mut parameters = Vec::new();
418        if let Some(block) = block {
419            for ident in &block.items {
420                let id = self.insert_global_symbol(
421                    &ident.text,
422                    SymbolKind::Parameter,
423                    PendingSymbolType::Scalar(Some(ValueType::Real)),
424                    ident.span,
425                )?;
426                self.globals.parameters.insert(ident.text.clone(), id);
427                parameters.push(id);
428            }
429        }
430        Ok(parameters)
431    }
432
433    fn resolve_and_register_constants(
434        &mut self,
435        block: Option<&syntax::ConstantsBlock>,
436    ) -> Result<Vec<TypedConstant>, SemanticError> {
437        let Some(block) = block else {
438            return Ok(Vec::new());
439        };
440
441        let mut bindings = BTreeMap::new();
442        for binding in &block.items {
443            if let Some(existing) = bindings.insert(binding.name.text.clone(), binding) {
444                return Err(SemanticAssist::default()
445                    .context_label(
446                        existing.name.span,
447                        format!("constant `{}` first declared here", binding.name.text),
448                    )
449                    .help(format!(
450                        "rename this constant to a unique name such as `{}_2`",
451                        binding.name.text
452                    ))
453                    .replacement_suggestion(
454                        binding.name.span,
455                        format!("{}_2", binding.name.text),
456                        format!("rename this constant to `{}_2`", binding.name.text),
457                        Applicability::MaybeIncorrect,
458                    )
459                    .apply(SemanticError::new(
460                        format!("duplicate constant `{}`", binding.name.text),
461                        binding.name.span,
462                    )));
463            }
464        }
465
466        let mut visiting = BTreeSet::new();
467        let mut typed = Vec::new();
468        for binding in &block.items {
469            let value = self.evaluate_const_expr(&binding.value, &bindings, &mut visiting)?;
470            let id = self.insert_global_symbol(
471                &binding.name.text,
472                SymbolKind::Constant,
473                PendingSymbolType::Scalar(Some(value.value_type())),
474                binding.name.span,
475            )?;
476            self.globals.constants.insert(binding.name.text.clone(), id);
477            self.globals
478                .constant_values
479                .insert(binding.name.text.clone(), value.clone());
480            typed.push(TypedConstant {
481                symbol: id,
482                value,
483                span: binding.span,
484            });
485        }
486        Ok(typed)
487    }
488
489    fn register_covariates(
490        &mut self,
491        block: Option<&syntax::CovariatesBlock>,
492    ) -> Result<Vec<TypedCovariate>, SemanticError> {
493        let mut covariates = Vec::new();
494        if let Some(block) = block {
495            for covariate in &block.items {
496                let interpolation = match covariate
497                    .interpolation
498                    .as_ref()
499                    .map(|value| value.text.as_str())
500                {
501                    None => None,
502                    Some("linear") => Some(CovariateInterpolation::Linear),
503                    Some("locf") | Some("carry_forward") => Some(CovariateInterpolation::Locf),
504                    Some(other) => {
505                        return Err(SemanticError::new(
506                            format!("unknown covariate interpolation `{other}`"),
507                            covariate.interpolation.as_ref().unwrap().span,
508                        )
509                        .with_note("supported interpolation names are `linear`, `locf`, and `carry_forward`"));
510                    }
511                };
512                let id = self.insert_global_symbol(
513                    &covariate.name.text,
514                    SymbolKind::Covariate,
515                    PendingSymbolType::Scalar(Some(ValueType::Real)),
516                    covariate.name.span,
517                )?;
518                self.globals
519                    .covariates
520                    .insert(covariate.name.text.clone(), id);
521                covariates.push(TypedCovariate {
522                    symbol: id,
523                    interpolation,
524                    span: covariate.span,
525                });
526            }
527        }
528        Ok(covariates)
529    }
530
531    fn register_states(
532        &mut self,
533        block: Option<&syntax::StatesBlock>,
534    ) -> Result<Vec<TypedState>, SemanticError> {
535        let Some(block) = block else {
536            return Err(SemanticError::new(
537                format!(
538                    "model `{}` is missing a `states` block",
539                    self.model.name.text
540                ),
541                self.model.span,
542            ));
543        };
544
545        let mut states = Vec::new();
546        for state in &block.items {
547            let size = match &state.size {
548                Some(expr) => Some(self.expect_const_usize(expr, "state array size", true)?),
549                None => None,
550            };
551            let pending_type = match size {
552                Some(size) => PendingSymbolType::Array {
553                    element: ValueType::Real,
554                    size,
555                },
556                None => PendingSymbolType::Scalar(Some(ValueType::Real)),
557            };
558            let id = self.insert_global_symbol(
559                &state.name.text,
560                SymbolKind::State,
561                pending_type,
562                state.name.span,
563            )?;
564            self.globals
565                .states
566                .insert(state.name.text.clone(), StateEntry { symbol: id, size });
567            states.push(TypedState {
568                symbol: id,
569                size,
570                span: state.span,
571            });
572        }
573        Ok(states)
574    }
575
576    fn register_routes(
577        &mut self,
578        block: Option<&syntax::RoutesBlock>,
579    ) -> Result<Vec<TypedRoute>, SemanticError> {
580        let mut routes = Vec::new();
581        if let Some(block) = block {
582            for route in &block.routes {
583                self.validate_route_label_name(&route.input)?;
584                let id = self.insert_global_symbol(
585                    &route.input.text,
586                    SymbolKind::Route,
587                    PendingSymbolType::Route,
588                    route.input.span,
589                )?;
590                self.globals.routes.insert(route.input.text.clone(), id);
591                let destination = self.analyze_state_place_const(&route.destination)?;
592                let mut seen_props = BTreeMap::new();
593                let mut properties = Vec::new();
594                for property in &route.properties {
595                    let kind = match property.name.text.as_str() {
596                        "lag" => RoutePropertyKind::Lag,
597                        "bioavailability" => RoutePropertyKind::Bioavailability,
598                        other => {
599                            return Err(SemanticError::new(
600                                format!("unknown route property `{other}`"),
601                                property.name.span,
602                            )
603                            .with_note(
604                                "supported route properties are `lag` and `bioavailability`",
605                            ));
606                        }
607                    };
608                    if let Some(existing_span) = seen_props.insert(kind, property.name.span) {
609                        return Err(SemanticAssist::default()
610                            .context_label(
611                                existing_span,
612                                format!(
613                                    "route property `{}` first declared here",
614                                    property.name.text
615                                ),
616                            )
617                            .help(format!(
618                                "each route can declare `{}` at most once",
619                                property.name.text
620                            ))
621                            .apply(SemanticError::new(
622                                format!("duplicate route property `{}`", property.name.text),
623                                property.name.span,
624                            )));
625                    }
626                    let env = BlockEnv::new(BTreeSet::new());
627                    let value = self.analyze_expr(&property.value, &env)?;
628                    self.expect_numeric(&value, "route property", property.value.span)?;
629                    properties.push(TypedRouteProperty {
630                        kind,
631                        value,
632                        span: property.span,
633                    });
634                }
635                routes.push(TypedRoute {
636                    symbol: id,
637                    kind: route.kind,
638                    destination,
639                    properties,
640                    span: route.span,
641                });
642            }
643        }
644        Ok(routes)
645    }
646
647    fn register_implicit_symbols(
648        &mut self,
649        statements: Option<&[syntax::Stmt]>,
650        kind: SymbolKind,
651    ) -> Result<Vec<SymbolId>, SemanticError> {
652        let mut collected_idents = Vec::new();
653        let Some(statements) = statements else {
654            return Ok(Vec::new());
655        };
656
657        let mut seen = BTreeSet::new();
658        collect_bare_assignment_names(statements, &mut seen, &mut collected_idents);
659        let mut symbols = Vec::new();
660        for ident in collected_idents {
661            if matches!(kind, SymbolKind::Output) {
662                self.validate_output_label_name(&ident)?;
663            }
664            if matches!(kind, SymbolKind::Derived) {
665                if let Some(parameter) = self.globals.parameters.get(&ident.text).copied() {
666                    return Err(SemanticAssist::default()
667                        .context_label(
668                            self.symbol_span(parameter),
669                            self.symbol_declared_here(parameter),
670                        )
671                        .help(
672                            "names declared in `params` and derive-assigned names must be distinct",
673                        )
674                        .replacement_suggestion(
675                            ident.span,
676                            format!("{}_derived", ident.text),
677                            format!("rename this derive target to `{}_derived`", ident.text),
678                            Applicability::MaybeIncorrect,
679                        )
680                        .apply(SemanticError::new(
681                            format!(
682                                "derived name `{}` collides with primary parameter `{}`",
683                                ident.text, ident.text
684                            ),
685                            ident.span,
686                        )));
687                }
688            }
689            let id = self.insert_global_symbol(
690                &ident.text,
691                kind,
692                PendingSymbolType::Scalar(None),
693                ident.span,
694            )?;
695            match kind {
696                SymbolKind::Derived => {
697                    self.globals.derived.insert(ident.text.clone(), id);
698                }
699                SymbolKind::Output => {
700                    self.globals.outputs.insert(ident.text.clone(), id);
701                }
702                _ => unreachable!(),
703            }
704            symbols.push(id);
705        }
706        Ok(symbols)
707    }
708
709    fn analyze_statement_block(
710        &mut self,
711        block: &syntax::StatementBlock,
712        context: BlockContext,
713        available_derived: BTreeSet<SymbolId>,
714    ) -> Result<BlockAnalysis, SemanticError> {
715        let env = BlockEnv::new(available_derived);
716        let (statements, env, touched_states) =
717            self.analyze_stmt_list(&block.statements, context, env)?;
718        Ok(BlockAnalysis {
719            block: TypedStatementBlock {
720                context,
721                statements,
722                span: block.span,
723            },
724            available_derived: env.available_derived,
725            definite_targets: env.definite_targets,
726            touched_states,
727        })
728    }
729
730    fn analyze_stmt_list(
731        &mut self,
732        statements: &[syntax::Stmt],
733        context: BlockContext,
734        mut env: BlockEnv,
735    ) -> Result<(Vec<TypedStmt>, BlockEnv, BTreeSet<SymbolId>), SemanticError> {
736        let mut typed = Vec::with_capacity(statements.len());
737        let mut touched_states = BTreeSet::new();
738
739        for stmt in statements {
740            match &stmt.kind {
741                syntax::StmtKind::Let(let_stmt) => {
742                    let value = self.analyze_expr(&let_stmt.value, &env)?;
743                    let symbol = self.insert_local_symbol(
744                        &mut env,
745                        &let_stmt.name,
746                        value.ty,
747                        SymbolKind::Local,
748                    )?;
749                    typed.push(TypedStmt {
750                        kind: TypedStmtKind::Let(TypedLetStmt { symbol, value }),
751                        span: stmt.span,
752                    });
753                }
754                syntax::StmtKind::Assign(assign) => {
755                    let target = self.analyze_assign_target(&assign.target, context, &env)?;
756                    let value = self.analyze_expr(&assign.value, &env)?;
757                    self.expect_numeric(&value, "assignment value", assign.value.span)?;
758                    match &target.kind {
759                        TypedAssignTargetKind::Derived(symbol) => {
760                            self.merge_symbol_type(*symbol, value.ty, assign.value.span)?;
761                            env.available_derived.insert(*symbol);
762                            env.definite_targets.insert(*symbol);
763                        }
764                        TypedAssignTargetKind::Output(symbol) => {
765                            self.merge_symbol_type(*symbol, value.ty, assign.value.span)?;
766                            env.definite_targets.insert(*symbol);
767                        }
768                        TypedAssignTargetKind::StateInit(place)
769                        | TypedAssignTargetKind::Derivative(place)
770                        | TypedAssignTargetKind::Noise(place) => {
771                            touched_states.insert(place.state);
772                        }
773                    }
774                    typed.push(TypedStmt {
775                        kind: TypedStmtKind::Assign(TypedAssignStmt { target, value }),
776                        span: stmt.span,
777                    });
778                }
779                syntax::StmtKind::If(if_stmt) => {
780                    let condition = self.analyze_expr(&if_stmt.condition, &env)?;
781                    self.expect_bool(&condition, "if condition", if_stmt.condition.span)?;
782
783                    let then_env = env.child_scope();
784                    let (then_branch, then_env, then_states) =
785                        self.analyze_stmt_list(&if_stmt.then_branch, context, then_env)?;
786                    let mut branch_states = then_states;
787
788                    let (else_branch, next_available, next_targets) = if let Some(else_branch) =
789                        &if_stmt.else_branch
790                    {
791                        let else_env = env.child_scope();
792                        let (else_typed, else_env, else_states) =
793                            self.analyze_stmt_list(else_branch, context, else_env)?;
794                        branch_states.extend(else_states);
795                        let available = if context == BlockContext::Derive {
796                            intersect_sets(&then_env.available_derived, &else_env.available_derived)
797                        } else {
798                            env.available_derived.clone()
799                        };
800                        let targets =
801                            if matches!(context, BlockContext::Derive | BlockContext::Outputs) {
802                                intersect_sets(
803                                    &then_env.definite_targets,
804                                    &else_env.definite_targets,
805                                )
806                            } else {
807                                env.definite_targets.clone()
808                            };
809                        (Some(else_typed), available, targets)
810                    } else {
811                        let available = env.available_derived.clone();
812                        let targets = env.definite_targets.clone();
813                        (None, available, targets)
814                    };
815
816                    env.available_derived = next_available;
817                    env.definite_targets = next_targets;
818                    touched_states.extend(branch_states);
819                    typed.push(TypedStmt {
820                        kind: TypedStmtKind::If(TypedIfStmt {
821                            condition,
822                            then_branch,
823                            else_branch,
824                        }),
825                        span: stmt.span,
826                    });
827                }
828                syntax::StmtKind::For(for_stmt) => {
829                    let start = self.analyze_expr(&for_stmt.range.start, &env)?;
830                    let end = self.analyze_expr(&for_stmt.range.end, &env)?;
831                    self.expect_int(&start, "for-loop range start", for_stmt.range.start.span)?;
832                    self.expect_int(&end, "for-loop range end", for_stmt.range.end.span)?;
833
834                    let mut loop_env = env.child_scope();
835                    let binding = self.insert_local_symbol(
836                        &mut loop_env,
837                        &for_stmt.binding,
838                        ValueType::Int,
839                        SymbolKind::LoopBinding,
840                    )?;
841                    let (body, _loop_env, body_states) =
842                        self.analyze_stmt_list(&for_stmt.body, context, loop_env)?;
843                    touched_states.extend(body_states);
844                    typed.push(TypedStmt {
845                        kind: TypedStmtKind::For(TypedForStmt {
846                            binding,
847                            range: TypedRangeExpr {
848                                start,
849                                end,
850                                span: for_stmt.range.span,
851                            },
852                            body,
853                        }),
854                        span: stmt.span,
855                    });
856                }
857            }
858        }
859
860        Ok((typed, env, touched_states))
861    }
862
863    fn analyze_assign_target(
864        &mut self,
865        target: &syntax::AssignTarget,
866        context: BlockContext,
867        env: &BlockEnv,
868    ) -> Result<TypedAssignTarget, SemanticError> {
869        let kind = match context {
870            BlockContext::Derive => match &target.kind {
871                syntax::AssignTargetKind::Name(name) => {
872                    let Some(symbol) = self.globals.derived.get(&name.text).copied() else {
873                        return Err(SemanticError::new(
874                            format!("`{}` is not a valid derive target", name.text),
875                            name.span,
876                        ));
877                    };
878                    TypedAssignTargetKind::Derived(symbol)
879                }
880                _ => {
881                    return Err(SemanticError::new(
882                        "derive assignments must target a bare identifier",
883                        target.span,
884                    ))
885                }
886            },
887            BlockContext::Outputs => match &target.kind {
888                syntax::AssignTargetKind::Name(name) => {
889                    let Some(symbol) = self.globals.outputs.get(&name.text).copied() else {
890                        return Err(SemanticError::new(
891                            format!("`{}` is not a valid output target", name.text),
892                            name.span,
893                        ));
894                    };
895                    TypedAssignTargetKind::Output(symbol)
896                }
897                _ => {
898                    return Err(SemanticError::new(
899                        "outputs assignments must target a bare identifier",
900                        target.span,
901                    ))
902                }
903            },
904            BlockContext::Init => {
905                TypedAssignTargetKind::StateInit(self.analyze_runtime_state_place(target, env)?)
906            }
907            BlockContext::Dynamics | BlockContext::Drift => {
908                let place = self.expect_call_state_target(target, "ddt")?;
909                TypedAssignTargetKind::Derivative(
910                    self.analyze_runtime_state_place_expr(&place, env)?,
911                )
912            }
913            BlockContext::Diffusion => {
914                let place = self.expect_call_state_target(target, "noise")?;
915                TypedAssignTargetKind::Noise(self.analyze_runtime_state_place_expr(&place, env)?)
916            }
917        };
918        Ok(TypedAssignTarget {
919            kind,
920            span: target.span,
921        })
922    }
923
924    fn expect_call_state_target(
925        &self,
926        target: &syntax::AssignTarget,
927        expected: &str,
928    ) -> Result<syntax::Place, SemanticError> {
929        match &target.kind {
930            syntax::AssignTargetKind::Call { callee, args }
931                if callee.text == expected && args.len() == 1 =>
932            {
933                self.place_from_expr(&args[0])
934            }
935            syntax::AssignTargetKind::Call { callee, .. } => Err(SemanticError::new(
936                format!(
937                    "expected `{expected}(...)` assignment target, found `{}`",
938                    callee.text
939                ),
940                target.span,
941            )),
942            _ => Err(SemanticError::new(
943                format!("expected `{expected}(...)` assignment target"),
944                target.span,
945            )),
946        }
947    }
948
949    fn place_from_expr(&self, expr: &syntax::Expr) -> Result<syntax::Place, SemanticError> {
950        match &expr.kind {
951            syntax::ExprKind::Name(name) => Ok(syntax::Place {
952                name: name.clone(),
953                index: None,
954                span: expr.span,
955            }),
956            syntax::ExprKind::Index { target, index } => match &target.kind {
957                syntax::ExprKind::Name(name) => Ok(syntax::Place {
958                    name: name.clone(),
959                    index: Some((**index).clone()),
960                    span: expr.span,
961                }),
962                _ => Err(SemanticError::new(
963                    "indexed assignment targets must index a state identifier",
964                    expr.span,
965                )),
966            },
967            _ => Err(SemanticError::new(
968                "expected a state reference in assignment target",
969                expr.span,
970            )),
971        }
972    }
973
974    fn analyze_runtime_state_place(
975        &self,
976        target: &syntax::AssignTarget,
977        env: &BlockEnv,
978    ) -> Result<TypedStatePlace, SemanticError> {
979        let place = match &target.kind {
980            syntax::AssignTargetKind::Name(name) => syntax::Place {
981                name: name.clone(),
982                index: None,
983                span: target.span,
984            },
985            syntax::AssignTargetKind::Index { target, index } => syntax::Place {
986                name: target.clone(),
987                index: Some(index.clone()),
988                span: target.span,
989            },
990            syntax::AssignTargetKind::Call { .. } => {
991                return Err(SemanticError::new(
992                    "unexpected call target in runtime state assignment",
993                    target.span,
994                ))
995            }
996        };
997        self.analyze_runtime_state_place_expr(&place, env)
998    }
999
1000    fn analyze_runtime_state_place_expr(
1001        &self,
1002        place: &syntax::Place,
1003        env: &BlockEnv,
1004    ) -> Result<TypedStatePlace, SemanticError> {
1005        let state = self.globals.states.get(&place.name.text).ok_or_else(|| {
1006            let error = SemanticError::new(
1007                format!("unknown state `{}`", place.name.text),
1008                place.name.span,
1009            );
1010            match self.assist_for_unknown_state(&place.name) {
1011                Some(assist) => assist.apply(error),
1012                None => error,
1013            }
1014        })?;
1015        let index = match (&state.size, &place.index) {
1016            (Some(_), Some(index)) => {
1017                let index = self.analyze_expr(index, env)?;
1018                self.expect_int(&index, "state index", index.span)?;
1019                Some(Box::new(index))
1020            }
1021            (Some(_), None) => {
1022                return Err(SemanticError::new(
1023                    format!("state array `{}` requires an index", place.name.text),
1024                    place.span,
1025                ))
1026            }
1027            (None, Some(_)) => {
1028                return Err(SemanticError::new(
1029                    format!(
1030                        "state `{}` is scalar and cannot be indexed",
1031                        place.name.text
1032                    ),
1033                    place.span,
1034                ))
1035            }
1036            (None, None) => None,
1037        };
1038        Ok(TypedStatePlace {
1039            state: state.symbol,
1040            index,
1041            span: place.span,
1042        })
1043    }
1044
1045    fn analyze_state_place_const(
1046        &self,
1047        place: &syntax::Place,
1048    ) -> Result<TypedStatePlace, SemanticError> {
1049        let state = self.globals.states.get(&place.name.text).ok_or_else(|| {
1050            let error = SemanticError::new(
1051                format!("unknown state `{}`", place.name.text),
1052                place.name.span,
1053            );
1054            match self.assist_for_unknown_state(&place.name) {
1055                Some(assist) => assist.apply(error),
1056                None => error,
1057            }
1058        })?;
1059        let index = match (&state.size, &place.index) {
1060            (Some(_), Some(index)) => {
1061                let value = self.expect_const_usize(index, "route destination index", false)?;
1062                Some(Box::new(TypedExpr {
1063                    kind: TypedExprKind::Literal(ConstValue::Int(value as i64)),
1064                    ty: ValueType::Int,
1065                    constant: Some(ConstValue::Int(value as i64)),
1066                    span: index.span,
1067                }))
1068            }
1069            (Some(_), None) => {
1070                return Err(SemanticError::new(
1071                    format!("state array `{}` requires an index", place.name.text),
1072                    place.span,
1073                ))
1074            }
1075            (None, Some(_)) => {
1076                return Err(SemanticError::new(
1077                    format!(
1078                        "state `{}` is scalar and cannot be indexed",
1079                        place.name.text
1080                    ),
1081                    place.span,
1082                ))
1083            }
1084            (None, None) => None,
1085        };
1086        Ok(TypedStatePlace {
1087            state: state.symbol,
1088            index,
1089            span: place.span,
1090        })
1091    }
1092
1093    fn analyze_expr(
1094        &self,
1095        expr: &syntax::Expr,
1096        env: &BlockEnv,
1097    ) -> Result<TypedExpr, SemanticError> {
1098        match &expr.kind {
1099            syntax::ExprKind::Number(value) => {
1100                let constant = number_to_const(*value);
1101                Ok(TypedExpr {
1102                    kind: TypedExprKind::Literal(constant.clone()),
1103                    ty: constant.value_type(),
1104                    constant: Some(constant),
1105                    span: expr.span,
1106                })
1107            }
1108            syntax::ExprKind::Bool(value) => {
1109                let constant = ConstValue::Bool(*value);
1110                Ok(TypedExpr {
1111                    kind: TypedExprKind::Literal(constant.clone()),
1112                    ty: ValueType::Bool,
1113                    constant: Some(constant),
1114                    span: expr.span,
1115                })
1116            }
1117            syntax::ExprKind::Name(name) => self.analyze_name_expr(name, expr.span, env),
1118            syntax::ExprKind::Unary { op, expr: inner } => {
1119                let inner = self.analyze_expr(inner, env)?;
1120                let ty = match op {
1121                    syntax::UnaryOp::Not => {
1122                        self.expect_bool(&inner, "unary `!` operand", inner.span)?;
1123                        ValueType::Bool
1124                    }
1125                    syntax::UnaryOp::Plus | syntax::UnaryOp::Minus => {
1126                        self.expect_numeric(&inner, "unary numeric operand", inner.span)?;
1127                        inner.ty
1128                    }
1129                };
1130                let op = match op {
1131                    syntax::UnaryOp::Plus => TypedUnaryOp::Plus,
1132                    syntax::UnaryOp::Minus => TypedUnaryOp::Minus,
1133                    syntax::UnaryOp::Not => TypedUnaryOp::Not,
1134                };
1135                let constant = inner
1136                    .constant
1137                    .as_ref()
1138                    .and_then(|value| fold_unary(op, value));
1139                Ok(TypedExpr {
1140                    kind: TypedExprKind::Unary {
1141                        op,
1142                        expr: Box::new(inner),
1143                    },
1144                    ty,
1145                    constant,
1146                    span: expr.span,
1147                })
1148            }
1149            syntax::ExprKind::Binary { op, lhs, rhs } => {
1150                let lhs = self.analyze_expr(lhs, env)?;
1151                let rhs = self.analyze_expr(rhs, env)?;
1152                let op = map_binary_op(*op);
1153                let ty = self.binary_result_type(op, &lhs, &rhs, expr.span)?;
1154                let constant = match (&lhs.constant, &rhs.constant) {
1155                    (Some(lhs), Some(rhs)) => fold_binary(op, lhs, rhs),
1156                    _ => None,
1157                };
1158                Ok(TypedExpr {
1159                    kind: TypedExprKind::Binary {
1160                        op,
1161                        lhs: Box::new(lhs),
1162                        rhs: Box::new(rhs),
1163                    },
1164                    ty,
1165                    constant,
1166                    span: expr.span,
1167                })
1168            }
1169            syntax::ExprKind::Call { callee, args } => {
1170                self.analyze_call(callee, args, expr.span, env)
1171            }
1172            syntax::ExprKind::Index { target, index } => {
1173                let index = self.analyze_expr(index, env)?;
1174                self.expect_int(&index, "index expression", index.span)?;
1175                match &target.kind {
1176                    syntax::ExprKind::Name(name) => {
1177                        let state = self.globals.states.get(&name.text).ok_or_else(|| {
1178                            SemanticError::new(
1179                                format!(
1180                                    "only state arrays can be indexed; `{}` is not a state",
1181                                    name.text
1182                                ),
1183                                name.span,
1184                            )
1185                        })?;
1186                        if state.size.is_none() {
1187                            return Err(SemanticError::new(
1188                                format!("state `{}` is scalar and cannot be indexed", name.text),
1189                                expr.span,
1190                            ));
1191                        }
1192                        Ok(TypedExpr {
1193                            kind: TypedExprKind::StateValue(TypedStatePlace {
1194                                state: state.symbol,
1195                                index: Some(Box::new(index)),
1196                                span: expr.span,
1197                            }),
1198                            ty: ValueType::Real,
1199                            constant: None,
1200                            span: expr.span,
1201                        })
1202                    }
1203                    _ => Err(SemanticError::new(
1204                        "only state arrays can be indexed",
1205                        expr.span,
1206                    )),
1207                }
1208            }
1209        }
1210    }
1211
1212    fn analyze_name_expr(
1213        &self,
1214        name: &syntax::Ident,
1215        span: Span,
1216        env: &BlockEnv,
1217    ) -> Result<TypedExpr, SemanticError> {
1218        if let Some(symbol) = env.lookup_local(&name.text) {
1219            let ty = self.scalar_symbol_type(symbol).ok_or_else(|| {
1220                SemanticError::new(
1221                    format!("local `{}` does not resolve to a scalar value", name.text),
1222                    span,
1223                )
1224            })?;
1225            return Ok(TypedExpr {
1226                kind: TypedExprKind::Symbol(symbol),
1227                ty,
1228                constant: None,
1229                span,
1230            });
1231        }
1232
1233        if let Some(symbol) = self.globals.parameters.get(&name.text).copied() {
1234            return Ok(TypedExpr {
1235                kind: TypedExprKind::Symbol(symbol),
1236                ty: ValueType::Real,
1237                constant: None,
1238                span,
1239            });
1240        }
1241
1242        if let Some(symbol) = self.globals.constants.get(&name.text).copied() {
1243            let constant = self.globals.constant_values.get(&name.text).cloned();
1244            let ty = self
1245                .scalar_symbol_type(symbol)
1246                .expect("constant type must be known");
1247            return Ok(TypedExpr {
1248                kind: TypedExprKind::Symbol(symbol),
1249                ty,
1250                constant,
1251                span,
1252            });
1253        }
1254
1255        if let Some(symbol) = self.globals.covariates.get(&name.text).copied() {
1256            return Ok(TypedExpr {
1257                kind: TypedExprKind::Symbol(symbol),
1258                ty: ValueType::Real,
1259                constant: None,
1260                span,
1261            });
1262        }
1263
1264        if let Some(state) = self.globals.states.get(&name.text) {
1265            if state.size.is_some() {
1266                return Err(SemanticError::new(
1267                    format!("state array `{}` requires an index", name.text),
1268                    span,
1269                ));
1270            }
1271            let place = TypedStatePlace {
1272                state: state.symbol,
1273                index: None,
1274                span,
1275            };
1276            return Ok(TypedExpr {
1277                kind: TypedExprKind::StateValue(place),
1278                ty: ValueType::Real,
1279                constant: None,
1280                span,
1281            });
1282        }
1283
1284        if let Some(symbol) = self.globals.derived.get(&name.text).copied() {
1285            if !env.available_derived.contains(&symbol) {
1286                return Err(SemanticError::new(
1287                    format!(
1288                        "derived value `{}` is not definitely assigned at this point",
1289                        name.text
1290                    ),
1291                    span,
1292                ));
1293            }
1294            let ty = self.scalar_symbol_type(symbol).ok_or_else(|| {
1295                SemanticError::new(
1296                    format!(
1297                        "derived value `{}` does not have a resolved type yet",
1298                        name.text
1299                    ),
1300                    span,
1301                )
1302            })?;
1303            return Ok(TypedExpr {
1304                kind: TypedExprKind::Symbol(symbol),
1305                ty,
1306                constant: None,
1307                span,
1308            });
1309        }
1310
1311        if self.globals.routes.contains_key(&name.text) {
1312            let route = self.globals.routes[&name.text];
1313            return Err(self
1314                .assist_for_route_scalar(route, span)
1315                .apply(SemanticError::new(
1316                    format!(
1317                        "route `{}` cannot be used as a scalar value; use `rate({})`",
1318                        name.text, name.text
1319                    ),
1320                    span,
1321                )));
1322        }
1323
1324        if self.globals.outputs.contains_key(&name.text) {
1325            let output = self.globals.outputs[&name.text];
1326            return Err(self
1327                .assist_for_output_scope(output)
1328                .apply(SemanticError::new(
1329                    format!("output `{}` is not in expression scope", name.text),
1330                    span,
1331                )));
1332        }
1333
1334        let error = SemanticError::new(format!("unknown identifier `{}`", name.text), span);
1335        Err(match self.assist_for_unknown_identifier(name, span, env) {
1336            Some(assist) => assist.apply(error),
1337            None => error,
1338        })
1339    }
1340
1341    fn analyze_call(
1342        &self,
1343        callee: &syntax::Ident,
1344        args: &[syntax::Expr],
1345        span: Span,
1346        env: &BlockEnv,
1347    ) -> Result<TypedExpr, SemanticError> {
1348        if callee.text == RATE_FUNCTION_NAME {
1349            if args.len() != 1 {
1350                return Err(SemanticError::new(
1351                    format!(
1352                        "`rate` expects exactly one route argument, got {}",
1353                        args.len()
1354                    ),
1355                    callee.span,
1356                ));
1357            }
1358            if let syntax::ExprKind::Number(value) = &args[0].kind {
1359                if let Some(suffix) = numeric_label_literal_suffix(*value) {
1360                    return Err(self.bare_numeric_route_error(args[0].span, &suffix));
1361                }
1362            }
1363            let syntax::ExprKind::Name(route_name) = &args[0].kind else {
1364                return Err(SemanticError::new(
1365                    "`rate` expects a route identifier argument",
1366                    args[0].span,
1367                ));
1368            };
1369            self.validate_route_label_name(route_name)?;
1370            let route = self
1371                .globals
1372                .routes
1373                .get(&route_name.text)
1374                .copied()
1375                .ok_or_else(|| {
1376                    let error = SemanticError::new(
1377                        format!("unknown route `{}` in `rate(...)`", route_name.text),
1378                        route_name.span,
1379                    );
1380                    match self.assist_for_unknown_route(route_name) {
1381                        Some(assist) => assist.apply(error),
1382                        None => error,
1383                    }
1384                })?;
1385            return Ok(TypedExpr {
1386                kind: TypedExprKind::Call {
1387                    callee: TypedCall::Rate(route),
1388                    args: Vec::new(),
1389                },
1390                ty: ValueType::Real,
1391                constant: None,
1392                span,
1393            });
1394        }
1395
1396        let intrinsic = MathIntrinsic::from_name(&callee.text).ok_or_else(|| {
1397            let error =
1398                SemanticError::new(format!("unknown function `{}`", callee.text), callee.span);
1399            match self.assist_for_unknown_function(callee) {
1400                Some(assist) => assist.apply(error),
1401                None => error,
1402            }
1403        })?;
1404        let expected_arity = intrinsic.arity();
1405        match expected_arity {
1406            IntrinsicArity::Exact(expected) if expected != args.len() => {
1407                return Err(SemanticError::new(
1408                    format!(
1409                        "function `{}` expects {} argument(s), got {}",
1410                        callee.text,
1411                        expected,
1412                        args.len()
1413                    ),
1414                    callee.span,
1415                ))
1416            }
1417            _ => {}
1418        }
1419
1420        let mut typed_args = Vec::with_capacity(args.len());
1421        for arg in args {
1422            let typed = self.analyze_expr(arg, env)?;
1423            self.expect_numeric(&typed, &format!("`{}` argument", callee.text), arg.span)?;
1424            typed_args.push(typed);
1425        }
1426        let ty = call_result_type(intrinsic, &typed_args);
1427        let constant = typed_args
1428            .iter()
1429            .map(|arg| arg.constant.clone())
1430            .collect::<Option<Vec<_>>>()
1431            .and_then(|values| fold_call(intrinsic, &values));
1432        Ok(TypedExpr {
1433            kind: TypedExprKind::Call {
1434                callee: TypedCall::Math(intrinsic),
1435                args: typed_args,
1436            },
1437            ty,
1438            constant,
1439            span,
1440        })
1441    }
1442
1443    fn binary_result_type(
1444        &self,
1445        op: TypedBinaryOp,
1446        lhs: &TypedExpr,
1447        rhs: &TypedExpr,
1448        span: Span,
1449    ) -> Result<ValueType, SemanticError> {
1450        match op {
1451            TypedBinaryOp::Or | TypedBinaryOp::And => {
1452                self.expect_bool(lhs, "logical operand", lhs.span)?;
1453                self.expect_bool(rhs, "logical operand", rhs.span)?;
1454                Ok(ValueType::Bool)
1455            }
1456            TypedBinaryOp::Eq | TypedBinaryOp::NotEq => {
1457                if lhs.ty != rhs.ty {
1458                    return Err(SemanticError::new(
1459                        format!(
1460                            "equality comparison requires matching operand types, found {:?} and {:?}",
1461                            lhs.ty, rhs.ty
1462                        ),
1463                        span,
1464                    ));
1465                }
1466                Ok(ValueType::Bool)
1467            }
1468            TypedBinaryOp::Lt | TypedBinaryOp::LtEq | TypedBinaryOp::Gt | TypedBinaryOp::GtEq => {
1469                self.expect_numeric(lhs, "comparison operand", lhs.span)?;
1470                self.expect_numeric(rhs, "comparison operand", rhs.span)?;
1471                Ok(ValueType::Bool)
1472            }
1473            TypedBinaryOp::Add | TypedBinaryOp::Sub | TypedBinaryOp::Mul => {
1474                self.expect_numeric(lhs, "arithmetic operand", lhs.span)?;
1475                self.expect_numeric(rhs, "arithmetic operand", rhs.span)?;
1476                Ok(promote_numeric(lhs.ty, rhs.ty))
1477            }
1478            TypedBinaryOp::Div | TypedBinaryOp::Pow => {
1479                self.expect_numeric(lhs, "arithmetic operand", lhs.span)?;
1480                self.expect_numeric(rhs, "arithmetic operand", rhs.span)?;
1481                Ok(ValueType::Real)
1482            }
1483        }
1484    }
1485
1486    fn expect_numeric(
1487        &self,
1488        expr: &TypedExpr,
1489        context: &str,
1490        span: Span,
1491    ) -> Result<(), SemanticError> {
1492        if expr.ty.is_numeric() {
1493            Ok(())
1494        } else {
1495            Err(SemanticError::new(
1496                format!("{context} must be numeric, found {:?}", expr.ty),
1497                span,
1498            ))
1499        }
1500    }
1501
1502    fn expect_bool(
1503        &self,
1504        expr: &TypedExpr,
1505        context: &str,
1506        span: Span,
1507    ) -> Result<(), SemanticError> {
1508        if expr.ty == ValueType::Bool {
1509            Ok(())
1510        } else {
1511            Err(SemanticError::new(
1512                format!("{context} must be boolean, found {:?}", expr.ty),
1513                span,
1514            ))
1515        }
1516    }
1517
1518    fn expect_int(&self, expr: &TypedExpr, context: &str, span: Span) -> Result<(), SemanticError> {
1519        if expr.ty == ValueType::Int {
1520            Ok(())
1521        } else {
1522            Err(SemanticError::new(
1523                format!("{context} must be integer-valued, found {:?}", expr.ty),
1524                span,
1525            ))
1526        }
1527    }
1528
1529    fn expect_const_usize(
1530        &self,
1531        expr: &syntax::Expr,
1532        context: &str,
1533        strictly_positive: bool,
1534    ) -> Result<usize, SemanticError> {
1535        let value = self.evaluate_const_expr(expr, &BTreeMap::new(), &mut BTreeSet::new())?;
1536        let Some(value) = value.as_i64() else {
1537            return Err(SemanticError::new(
1538                format!("{context} must be an integer constant"),
1539                expr.span,
1540            ));
1541        };
1542        if value < 0 || (strictly_positive && value == 0) {
1543            return Err(SemanticError::new(
1544                format!(
1545                    "{context} must be {}",
1546                    if strictly_positive {
1547                        "positive"
1548                    } else {
1549                        "non-negative"
1550                    }
1551                ),
1552                expr.span,
1553            ));
1554        }
1555        Ok(value as usize)
1556    }
1557
1558    fn evaluate_const_expr(
1559        &self,
1560        expr: &syntax::Expr,
1561        bindings: &BTreeMap<String, &syntax::Binding>,
1562        visiting: &mut BTreeSet<String>,
1563    ) -> Result<ConstValue, SemanticError> {
1564        match &expr.kind {
1565            syntax::ExprKind::Number(value) => Ok(number_to_const(*value)),
1566            syntax::ExprKind::Bool(value) => Ok(ConstValue::Bool(*value)),
1567            syntax::ExprKind::Name(name) => {
1568                if let Some(value) = self.globals.constant_values.get(&name.text) {
1569                    return Ok(value.clone());
1570                }
1571                let binding = bindings.get(&name.text).ok_or_else(|| {
1572                    SemanticError::new(
1573                        format!(
1574                            "unknown constant `{}` in compile-time expression",
1575                            name.text
1576                        ),
1577                        name.span,
1578                    )
1579                })?;
1580                if !visiting.insert(name.text.clone()) {
1581                    return Err(SemanticError::new(
1582                        format!("constant `{}` forms a dependency cycle", name.text),
1583                        name.span,
1584                    ));
1585                }
1586                let value = self.evaluate_const_expr(&binding.value, bindings, visiting)?;
1587                visiting.remove(&name.text);
1588                Ok(value)
1589            }
1590            syntax::ExprKind::Unary { op, expr } => {
1591                let value = self.evaluate_const_expr(expr, bindings, visiting)?;
1592                let op = match op {
1593                    syntax::UnaryOp::Plus => TypedUnaryOp::Plus,
1594                    syntax::UnaryOp::Minus => TypedUnaryOp::Minus,
1595                    syntax::UnaryOp::Not => TypedUnaryOp::Not,
1596                };
1597                fold_unary(op, &value).ok_or_else(|| {
1598                    SemanticError::new("invalid constant unary operation", expr.span)
1599                })
1600            }
1601            syntax::ExprKind::Binary { op, lhs, rhs } => {
1602                let lhs = self.evaluate_const_expr(lhs, bindings, visiting)?;
1603                let rhs = self.evaluate_const_expr(rhs, bindings, visiting)?;
1604                fold_binary(map_binary_op(*op), &lhs, &rhs).ok_or_else(|| {
1605                    SemanticError::new("invalid constant binary operation", expr.span)
1606                })
1607            }
1608            syntax::ExprKind::Call { callee, args } => {
1609                if callee.text == RATE_FUNCTION_NAME {
1610                    return Err(SemanticError::new(
1611                        "`rate(...)` cannot appear in a compile-time expression",
1612                        callee.span,
1613                    ));
1614                }
1615                let intrinsic = MathIntrinsic::from_name(&callee.text).ok_or_else(|| {
1616                    SemanticError::new(
1617                        format!("unknown compile-time function `{}`", callee.text),
1618                        callee.span,
1619                    )
1620                })?;
1621                let mut values = Vec::with_capacity(args.len());
1622                for arg in args {
1623                    values.push(self.evaluate_const_expr(arg, bindings, visiting)?);
1624                }
1625                fold_call(intrinsic, &values).ok_or_else(|| {
1626                    SemanticError::new(
1627                        format!("invalid compile-time call to `{}`", callee.text),
1628                        expr.span,
1629                    )
1630                })
1631            }
1632            syntax::ExprKind::Index { .. } => Err(SemanticError::new(
1633                "indexing is not allowed in compile-time expressions",
1634                expr.span,
1635            )),
1636        }
1637    }
1638
1639    fn insert_global_symbol(
1640        &mut self,
1641        name: &str,
1642        kind: SymbolKind,
1643        ty: PendingSymbolType,
1644        span: Span,
1645    ) -> Result<SymbolId, SemanticError> {
1646        if RESERVED_NAMES.contains(&name) {
1647            return Err(SemanticAssist::default()
1648                .help(format!(
1649                    "rename `{name}` to a non-reserved identifier such as `{}_value`",
1650                    name
1651                ))
1652                .replacement_suggestion(
1653                    span,
1654                    format!("{}_value", name),
1655                    format!("rename `{name}` to `{}_value`", name),
1656                    Applicability::MaybeIncorrect,
1657                )
1658                .apply(SemanticError::new(
1659                    format!("`{name}` is reserved by the DSL and cannot be used as a symbol name"),
1660                    span,
1661                )));
1662        }
1663        if let Some(existing) = self.globals.all_names.get(name).copied() {
1664            let existing_kind = self.symbols.get(existing).expect("valid symbol id").kind;
1665            if !allows_route_output_name_overlap(existing_kind, kind) {
1666                return Err(SemanticAssist::default()
1667                    .context_label(
1668                        self.symbol_span(existing),
1669                        self.symbol_declared_here(existing),
1670                    )
1671                    .help(format!(
1672                        "rename this declaration to a unique name such as `{}_2`",
1673                        name
1674                    ))
1675                    .replacement_suggestion(
1676                        span,
1677                        format!("{}_2", name),
1678                        format!("rename this declaration to `{}_2`", name),
1679                        Applicability::MaybeIncorrect,
1680                    )
1681                    .apply(SemanticError::new(
1682                        format!(
1683                            "symbol name `{name}` collides with existing `{}`",
1684                            self.symbol_name(existing)
1685                        ),
1686                        span,
1687                    )));
1688            }
1689        }
1690        let id = self.symbols.len();
1691        self.symbols.push(PendingSymbol {
1692            id,
1693            name: name.to_string(),
1694            kind,
1695            ty,
1696            span,
1697        });
1698        self.globals.all_names.entry(name.to_string()).or_insert(id);
1699        Ok(id)
1700    }
1701
1702    fn validate_route_label_name(&self, label: &syntax::Ident) -> Result<(), SemanticError> {
1703        if let Some(suffix) = bare_numeric_label(&label.text) {
1704            return Err(self.bare_numeric_route_error(label.span, suffix));
1705        }
1706        if let Some(suffix) = canonical_numeric_suffix(&label.text, NUMERIC_OUTPUT_PREFIX) {
1707            return Err(self.wrong_prefix_route_error(label, suffix));
1708        }
1709        Ok(())
1710    }
1711
1712    fn validate_output_label_name(&self, label: &syntax::Ident) -> Result<(), SemanticError> {
1713        if let Some(suffix) = bare_numeric_label(&label.text) {
1714            return Err(self.bare_numeric_output_error(label.span, suffix));
1715        }
1716        if let Some(suffix) = canonical_numeric_suffix(&label.text, NUMERIC_ROUTE_PREFIX) {
1717            return Err(self.wrong_prefix_output_error(label, suffix));
1718        }
1719        Ok(())
1720    }
1721
1722    fn bare_numeric_route_error(&self, span: Span, suffix: &str) -> SemanticError {
1723        let replacement = format!("{NUMERIC_ROUTE_PREFIX}{suffix}");
1724        SemanticAssist::default()
1725            .help("numeric route labels must use the `input_<n>` form in authored DSL")
1726            .replacement_suggestion(
1727                span,
1728                replacement.clone(),
1729                format!("use `{replacement}`"),
1730                Applicability::Always,
1731            )
1732            .apply(SemanticError::new(
1733                format!(
1734                    "bare numeric route labels are not allowed in the DSL; use `{replacement}` instead"
1735                ),
1736                span,
1737            ))
1738    }
1739
1740    fn bare_numeric_output_error(&self, span: Span, suffix: &str) -> SemanticError {
1741        let replacement = format!("{NUMERIC_OUTPUT_PREFIX}{suffix}");
1742        SemanticAssist::default()
1743            .help("numeric output labels must use the `outeq_<n>` form in authored DSL")
1744            .replacement_suggestion(
1745                span,
1746                replacement.clone(),
1747                format!("use `{replacement}`"),
1748                Applicability::Always,
1749            )
1750            .apply(SemanticError::new(
1751                format!(
1752                    "bare numeric output labels are not allowed in the DSL; use `{replacement}` instead"
1753                ),
1754                span,
1755            ))
1756    }
1757
1758    fn wrong_prefix_route_error(&self, label: &syntax::Ident, suffix: &str) -> SemanticError {
1759        let replacement = format!("{NUMERIC_ROUTE_PREFIX}{suffix}");
1760        SemanticAssist::default()
1761            .help("numeric route labels use the `input_<n>` prefix")
1762            .replacement_suggestion(
1763                label.span,
1764                replacement.clone(),
1765                format!("use `{replacement}`"),
1766                Applicability::Always,
1767            )
1768            .apply(SemanticError::new(
1769                format!(
1770                    "`{}` is an output label and cannot be used as a route; use `{replacement}` here",
1771                    label.text
1772                ),
1773                label.span,
1774            ))
1775    }
1776
1777    fn wrong_prefix_output_error(&self, label: &syntax::Ident, suffix: &str) -> SemanticError {
1778        let replacement = format!("{NUMERIC_OUTPUT_PREFIX}{suffix}");
1779        SemanticAssist::default()
1780            .help("numeric output labels use the `outeq_<n>` prefix")
1781            .replacement_suggestion(
1782                label.span,
1783                replacement.clone(),
1784                format!("use `{replacement}`"),
1785                Applicability::Always,
1786            )
1787            .apply(SemanticError::new(
1788                format!(
1789                    "`{}` is a route label and cannot be used as an output target; use `{replacement}` here",
1790                    label.text
1791                ),
1792                label.span,
1793            ))
1794    }
1795
1796    fn insert_local_symbol(
1797        &mut self,
1798        env: &mut BlockEnv,
1799        ident: &syntax::Ident,
1800        ty: ValueType,
1801        kind: SymbolKind,
1802    ) -> Result<SymbolId, SemanticError> {
1803        if let Some(existing) = env
1804            .lookup_local(&ident.text)
1805            .or_else(|| self.globals.all_names.get(&ident.text).copied())
1806        {
1807            return Err(SemanticAssist::default()
1808                .context_label(
1809                    self.symbol_span(existing),
1810                    self.symbol_declared_here(existing),
1811                )
1812                .help(format!(
1813                    "rename this local binding to a unique name such as `{}_local`",
1814                    ident.text
1815                ))
1816                .replacement_suggestion(
1817                    ident.span,
1818                    format!("{}_local", ident.text),
1819                    format!("rename this local binding to `{}_local`", ident.text),
1820                    Applicability::MaybeIncorrect,
1821                )
1822                .apply(SemanticError::new(
1823                    format!(
1824                        "local symbol `{}` would shadow an existing symbol",
1825                        ident.text
1826                    ),
1827                    ident.span,
1828                )));
1829        }
1830        let id = self.symbols.len();
1831        self.symbols.push(PendingSymbol {
1832            id,
1833            name: ident.text.clone(),
1834            kind,
1835            ty: PendingSymbolType::Scalar(Some(ty)),
1836            span: ident.span,
1837        });
1838        env.insert_local(ident.text.clone(), id);
1839        Ok(id)
1840    }
1841
1842    fn merge_symbol_type(
1843        &mut self,
1844        symbol: SymbolId,
1845        ty: ValueType,
1846        span: Span,
1847    ) -> Result<(), SemanticError> {
1848        let entry = self.symbols.get_mut(symbol).expect("valid symbol id");
1849        match &mut entry.ty {
1850            PendingSymbolType::Scalar(slot) => match slot {
1851                None => *slot = Some(ty),
1852                Some(existing) if *existing == ty => {}
1853                Some(existing) if existing.is_numeric() && ty.is_numeric() => {
1854                    *slot = Some(promote_numeric(*existing, ty));
1855                }
1856                Some(existing) => {
1857                    return Err(SemanticError::new(
1858                        format!(
1859                            "symbol `{}` is assigned incompatible types {:?} and {:?}",
1860                            entry.name, existing, ty
1861                        ),
1862                        span,
1863                    ));
1864                }
1865            },
1866            PendingSymbolType::Array { .. } | PendingSymbolType::Route => {
1867                return Err(SemanticError::new(
1868                    format!(
1869                        "symbol `{}` is not assignable as a scalar target",
1870                        entry.name
1871                    ),
1872                    span,
1873                ));
1874            }
1875        }
1876        Ok(())
1877    }
1878
1879    fn scalar_symbol_type(&self, symbol: SymbolId) -> Option<ValueType> {
1880        match &self.symbols.get(symbol)?.ty {
1881            PendingSymbolType::Scalar(Some(ty)) => Some(*ty),
1882            PendingSymbolType::Scalar(None) => None,
1883            PendingSymbolType::Array { .. } | PendingSymbolType::Route => None,
1884        }
1885    }
1886
1887    fn symbol_name(&self, symbol: SymbolId) -> &str {
1888        &self.symbols[symbol].name
1889    }
1890
1891    fn symbol_span(&self, symbol: SymbolId) -> Span {
1892        self.symbols[symbol].span
1893    }
1894
1895    fn symbol_kind_label(&self, symbol: SymbolId) -> &'static str {
1896        match self.symbols[symbol].kind {
1897            SymbolKind::Parameter => "parameter",
1898            SymbolKind::Constant => "constant",
1899            SymbolKind::Covariate => "covariate",
1900            SymbolKind::State => "state",
1901            SymbolKind::Route => "route",
1902            SymbolKind::Derived => "derived value",
1903            SymbolKind::Output => "output",
1904            SymbolKind::Local => "local",
1905            SymbolKind::LoopBinding => "loop binding",
1906        }
1907    }
1908
1909    fn symbol_declared_here(&self, symbol: SymbolId) -> String {
1910        format!(
1911            "{} `{}` declared here",
1912            self.symbol_kind_label(symbol),
1913            self.symbol_name(symbol)
1914        )
1915    }
1916
1917    fn assist_for_symbol_replacement(&self, symbol: SymbolId, span: Span) -> SemanticAssist {
1918        let name = self.symbol_name(symbol).to_string();
1919        SemanticAssist::default()
1920            .context_label(self.symbol_span(symbol), self.symbol_declared_here(symbol))
1921            .replacement_suggestion(
1922                span,
1923                name.clone(),
1924                format!("did you mean `{name}`?"),
1925                Applicability::MaybeIncorrect,
1926            )
1927    }
1928
1929    fn assist_for_route_scalar(&self, route: SymbolId, span: Span) -> SemanticAssist {
1930        let name = self.symbol_name(route).to_string();
1931        SemanticAssist::default()
1932            .context_label(self.symbol_span(route), self.symbol_declared_here(route))
1933            .help(format!("route inputs are read through `rate({name})`"))
1934            .replacement_suggestion(
1935                span,
1936                format!("rate({name})"),
1937                format!("did you mean `rate({name})`?"),
1938                Applicability::MaybeIncorrect,
1939            )
1940    }
1941
1942    fn assist_for_output_scope(&self, output: SymbolId) -> SemanticAssist {
1943        SemanticAssist::default()
1944            .context_label(self.symbol_span(output), self.symbol_declared_here(output))
1945            .help(
1946                "outputs are assignment targets inside the `outputs` block and are not available as expression values",
1947            )
1948    }
1949
1950    fn assist_for_unknown_identifier(
1951        &self,
1952        name: &syntax::Ident,
1953        span: Span,
1954        env: &BlockEnv,
1955    ) -> Option<SemanticAssist> {
1956        let mut seen = BTreeSet::new();
1957        let mut candidates = Vec::new();
1958
1959        for scope in env.locals.iter().rev() {
1960            for (candidate_name, symbol) in scope {
1961                if seen.insert(candidate_name.clone()) {
1962                    candidates.push(SimilarNameCandidate::new(
1963                        candidate_name.clone(),
1964                        self.assist_for_symbol_replacement(*symbol, span),
1965                    ));
1966                }
1967            }
1968        }
1969
1970        for symbol in self
1971            .globals
1972            .parameters
1973            .values()
1974            .chain(self.globals.constants.values())
1975            .chain(self.globals.covariates.values())
1976            .chain(
1977                self.globals
1978                    .states
1979                    .values()
1980                    .filter(|entry| entry.size.is_none())
1981                    .map(|entry| &entry.symbol),
1982            )
1983        {
1984            let candidate_name = self.symbol_name(*symbol).to_string();
1985            if seen.insert(candidate_name.clone()) {
1986                candidates.push(SimilarNameCandidate::new(
1987                    candidate_name,
1988                    self.assist_for_symbol_replacement(*symbol, span),
1989                ));
1990            }
1991        }
1992
1993        for symbol in &env.available_derived {
1994            let candidate_name = self.symbol_name(*symbol).to_string();
1995            if seen.insert(candidate_name.clone()) {
1996                candidates.push(SimilarNameCandidate::new(
1997                    candidate_name,
1998                    self.assist_for_symbol_replacement(*symbol, span),
1999                ));
2000            }
2001        }
2002
2003        for symbol in self.globals.routes.values() {
2004            let candidate_name = self.symbol_name(*symbol).to_string();
2005            if seen.insert(candidate_name.clone()) {
2006                candidates.push(SimilarNameCandidate::new(
2007                    candidate_name,
2008                    self.assist_for_route_scalar(*symbol, span),
2009                ));
2010            }
2011        }
2012
2013        best_similar_name_assist(&name.text, candidates)
2014    }
2015
2016    fn assist_for_unknown_state(&self, state_name: &syntax::Ident) -> Option<SemanticAssist> {
2017        let candidates = self
2018            .globals
2019            .states
2020            .values()
2021            .map(|entry| {
2022                SimilarNameCandidate::new(
2023                    self.symbol_name(entry.symbol).to_string(),
2024                    self.assist_for_symbol_replacement(entry.symbol, state_name.span),
2025                )
2026            })
2027            .collect::<Vec<_>>();
2028        best_similar_name_assist(&state_name.text, candidates)
2029    }
2030
2031    fn assist_for_unknown_route(&self, route_name: &syntax::Ident) -> Option<SemanticAssist> {
2032        let candidates = self
2033            .globals
2034            .routes
2035            .values()
2036            .map(|symbol| {
2037                let name = self.symbol_name(*symbol).to_string();
2038                SimilarNameCandidate::new(
2039                    name.clone(),
2040                    SemanticAssist::default()
2041                        .context_label(
2042                            self.symbol_span(*symbol),
2043                            self.symbol_declared_here(*symbol),
2044                        )
2045                        .replacement_suggestion(
2046                            route_name.span,
2047                            name.clone(),
2048                            format!("did you mean `{name}`?"),
2049                            Applicability::MaybeIncorrect,
2050                        ),
2051                )
2052            })
2053            .collect::<Vec<_>>();
2054        best_similar_name_assist(&route_name.text, candidates)
2055    }
2056
2057    fn assist_for_unknown_function(&self, callee: &syntax::Ident) -> Option<SemanticAssist> {
2058        let mut candidates = MathIntrinsic::ALL
2059            .iter()
2060            .map(|intrinsic| {
2061                let name = intrinsic.name().to_string();
2062                SimilarNameCandidate::new(
2063                    name.clone(),
2064                    SemanticAssist::default().replacement_suggestion(
2065                        callee.span,
2066                        name.clone(),
2067                        format!("did you mean `{name}`?"),
2068                        Applicability::MaybeIncorrect,
2069                    ),
2070                )
2071            })
2072            .collect::<Vec<_>>();
2073        candidates.push(SimilarNameCandidate::new(
2074            RATE_FUNCTION_NAME,
2075            SemanticAssist::default()
2076                .help("`rate` reads route inputs as `rate(route)`")
2077                .replacement_suggestion(
2078                    callee.span,
2079                    RATE_FUNCTION_NAME,
2080                    "did you mean `rate`?",
2081                    Applicability::MaybeIncorrect,
2082                ),
2083        ));
2084        best_similar_name_assist(&callee.text, candidates)
2085    }
2086
2087    fn finalize_symbols(self) -> Result<Vec<Symbol>, SemanticError> {
2088        self.symbols
2089            .into_iter()
2090            .map(|symbol| {
2091                let ty = match symbol.ty {
2092                    PendingSymbolType::Scalar(Some(ty)) => SymbolType::Scalar(ty),
2093                    PendingSymbolType::Scalar(None) => {
2094                        return Err(SemanticError::new(
2095                            format!(
2096                                "symbol `{}` does not have a resolved scalar type",
2097                                symbol.name
2098                            ),
2099                            symbol.span,
2100                        ))
2101                    }
2102                    PendingSymbolType::Array { element, size } => {
2103                        SymbolType::Array { element, size }
2104                    }
2105                    PendingSymbolType::Route => SymbolType::Route,
2106                };
2107                Ok(Symbol {
2108                    id: symbol.id,
2109                    name: symbol.name,
2110                    kind: symbol.kind,
2111                    ty,
2112                    span: symbol.span,
2113                })
2114            })
2115            .collect()
2116    }
2117
2118    fn validate_kind_requirements(
2119        &self,
2120        sections: &ModelSections<'_>,
2121        states: &[TypedState],
2122    ) -> Result<(), SemanticError> {
2123        if states.is_empty() {
2124            return Err(SemanticError::new(
2125                format!(
2126                    "model `{}` must declare at least one state",
2127                    self.model.name.text
2128                ),
2129                self.model.span,
2130            ));
2131        }
2132        if sections.outputs.is_none() {
2133            return Err(SemanticError::new(
2134                format!(
2135                    "model `{}` is missing an `outputs` block",
2136                    self.model.name.text
2137                ),
2138                self.model.span,
2139            ));
2140        }
2141        Ok(())
2142    }
2143
2144    fn validate_kind_blocks(
2145        &self,
2146        kind: ModelKind,
2147        blocks: ModelKindBlocks<'_>,
2148        states: &[TypedState],
2149    ) -> Result<(), SemanticError> {
2150        match kind {
2151            ModelKind::Ode => {
2152                if blocks.dynamics.is_none() {
2153                    return Err(SemanticError::new(
2154                        "ODE models require a `dynamics` block",
2155                        self.model.span,
2156                    ));
2157                }
2158                if blocks.drift.is_some() || blocks.diffusion.is_some() {
2159                    return Err(SemanticError::new(
2160                        "ODE models cannot declare `drift` or `diffusion` blocks",
2161                        self.model.span,
2162                    ));
2163                }
2164                if blocks.analytical.is_some() {
2165                    return Err(SemanticError::new(
2166                        "ODE models cannot declare an `analytical` block",
2167                        self.model.span,
2168                    ));
2169                }
2170                if let Some(particles_decl) = blocks.particles {
2171                    return Err(SemanticError::new(
2172                        "ODE models cannot declare `particles`",
2173                        particles_decl.span,
2174                    ));
2175                }
2176            }
2177            ModelKind::Analytical => {
2178                if blocks.analytical.is_none() {
2179                    return Err(SemanticError::new(
2180                        "analytical models require an `analytical` block",
2181                        self.model.span,
2182                    ));
2183                }
2184                if blocks.dynamics.is_some() || blocks.drift.is_some() || blocks.diffusion.is_some()
2185                {
2186                    return Err(SemanticError::new(
2187                        "analytical models cannot declare `dynamics`, `drift`, or `diffusion` blocks",
2188                        self.model.span,
2189                    ));
2190                }
2191                if let Some(particles_decl) = blocks.particles {
2192                    return Err(SemanticError::new(
2193                        "analytical models cannot declare `particles`",
2194                        particles_decl.span,
2195                    ));
2196                }
2197            }
2198            ModelKind::Sde => {
2199                if blocks.drift.is_none() || blocks.diffusion.is_none() {
2200                    return Err(SemanticError::new(
2201                        "SDE models require both `drift` and `diffusion` blocks",
2202                        self.model.span,
2203                    ));
2204                }
2205                if blocks.dynamics.is_some() {
2206                    return Err(SemanticError::new(
2207                        "SDE models cannot declare a `dynamics` block",
2208                        self.model.span,
2209                    ));
2210                }
2211                if blocks.analytical.is_some() {
2212                    return Err(SemanticError::new(
2213                        "SDE models cannot declare an `analytical` block",
2214                        self.model.span,
2215                    ));
2216                }
2217                if blocks.particles.is_none() {
2218                    return Err(SemanticError::new(
2219                        "SDE models require `particles`",
2220                        self.model.span,
2221                    ));
2222                }
2223            }
2224        }
2225
2226        if states.is_empty() {
2227            return Err(SemanticError::new(
2228                "typed model validation requires at least one state",
2229                self.model.span,
2230            ));
2231        }
2232        Ok(())
2233    }
2234
2235    fn validate_output_assignments(
2236        &self,
2237        outputs: &[SymbolId],
2238        block: &BlockAnalysis,
2239    ) -> Result<(), SemanticError> {
2240        for output in outputs {
2241            if !block.definite_targets.contains(output) {
2242                return Err(SemanticError::new(
2243                    format!(
2244                        "output `{}` is not definitely assigned on all control-flow paths",
2245                        self.symbol_name(*output)
2246                    ),
2247                    block.block.span,
2248                ));
2249            }
2250        }
2251        Ok(())
2252    }
2253
2254    fn validate_analytical_structure_inputs(
2255        &self,
2256        structure: AnalyticalKernel,
2257        structure_span: Span,
2258        parameters: &[SymbolId],
2259        derived: &[SymbolId],
2260        derive_result: Option<&BlockAnalysis>,
2261    ) -> Result<(), SemanticError> {
2262        let plan = AnalyticalStructureInputPlan::for_kernel(
2263            structure,
2264            parameters.iter().map(|symbol| self.symbol_name(*symbol)),
2265            derived.iter().map(|symbol| self.symbol_name(*symbol)),
2266        )
2267        .map_err(|error| SemanticError::new(error.to_string(), structure_span))?;
2268
2269        let Some(derive_result) = derive_result else {
2270            return Ok(());
2271        };
2272
2273        let mut required_derived_symbols = Vec::new();
2274        match plan.kind() {
2275            AnalyticalStructureInputKind::AllPrimary { .. } => {}
2276            AnalyticalStructureInputKind::AllDerived { indices, .. } => {
2277                for (required_name, index) in structure
2278                    .required_parameter_names()
2279                    .iter()
2280                    .zip(indices.iter().copied())
2281                {
2282                    required_derived_symbols.push((*required_name, derived[index]));
2283                }
2284            }
2285            AnalyticalStructureInputKind::Mixed { bindings } => {
2286                for (required_name, binding) in structure
2287                    .required_parameter_names()
2288                    .iter()
2289                    .zip(bindings.iter())
2290                {
2291                    if binding.source == AnalyticalStructureInputSource::Derived {
2292                        required_derived_symbols.push((*required_name, derived[binding.index]));
2293                    }
2294                }
2295            }
2296        }
2297
2298        for (required_name, symbol) in required_derived_symbols {
2299            if !derive_result.available_derived.contains(&symbol) {
2300                return Err(SemanticError::new(
2301                    format!(
2302                        "derived value `{required_name}` is not definitely assigned on all control-flow paths before analytical structure `{}` uses it",
2303                        structure.name()
2304                    ),
2305                    derive_result.block.span,
2306                )
2307                .with_help(format!(
2308                    "assign `{required_name}` on every control-flow path in `derive` before the analytical structure runs"
2309                )));
2310            }
2311        }
2312
2313        Ok(())
2314    }
2315
2316    fn validate_state_coverage(
2317        &self,
2318        block: &BlockAnalysis,
2319        states: &[TypedState],
2320        block_name: &str,
2321    ) -> Result<(), SemanticError> {
2322        for state in states {
2323            if !block.touched_states.contains(&state.symbol) {
2324                return Err(SemanticError::new(
2325                    format!(
2326                        "{block_name} block does not assign `{}`",
2327                        self.symbol_name(state.symbol)
2328                    ),
2329                    block.block.span,
2330                ));
2331            }
2332        }
2333        Ok(())
2334    }
2335}
2336
2337fn allows_route_output_name_overlap(existing: SymbolKind, new: SymbolKind) -> bool {
2338    matches!(
2339        (existing, new),
2340        (SymbolKind::Route, SymbolKind::Output) | (SymbolKind::Output, SymbolKind::Route)
2341    )
2342}
2343
2344fn bare_numeric_label(src: &str) -> Option<&str> {
2345    (!src.is_empty() && src.chars().all(|ch| ch.is_ascii_digit())).then_some(src)
2346}
2347
2348fn canonical_numeric_suffix<'a>(src: &'a str, prefix: &str) -> Option<&'a str> {
2349    let suffix = src.strip_prefix(prefix)?;
2350    (!suffix.is_empty() && suffix.chars().all(|ch| ch.is_ascii_digit())).then_some(suffix)
2351}
2352
2353fn numeric_label_literal_suffix(value: f64) -> Option<String> {
2354    (value.is_finite() && value >= 0.0 && value.fract() == 0.0 && value <= usize::MAX as f64)
2355        .then(|| (value as usize).to_string())
2356}
2357
2358#[derive(Default)]
2359struct Globals {
2360    all_names: BTreeMap<String, SymbolId>,
2361    parameters: BTreeMap<String, SymbolId>,
2362    constants: BTreeMap<String, SymbolId>,
2363    constant_values: BTreeMap<String, ConstValue>,
2364    covariates: BTreeMap<String, SymbolId>,
2365    states: BTreeMap<String, StateEntry>,
2366    routes: BTreeMap<String, SymbolId>,
2367    derived: BTreeMap<String, SymbolId>,
2368    outputs: BTreeMap<String, SymbolId>,
2369}
2370
2371#[derive(Debug, Clone, Copy)]
2372struct StateEntry {
2373    symbol: SymbolId,
2374    size: Option<usize>,
2375}
2376
2377#[derive(Clone)]
2378struct BlockEnv {
2379    locals: Vec<BTreeMap<String, SymbolId>>,
2380    available_derived: BTreeSet<SymbolId>,
2381    definite_targets: BTreeSet<SymbolId>,
2382}
2383
2384impl BlockEnv {
2385    fn new(available_derived: BTreeSet<SymbolId>) -> Self {
2386        Self {
2387            locals: vec![BTreeMap::new()],
2388            available_derived,
2389            definite_targets: BTreeSet::new(),
2390        }
2391    }
2392
2393    fn child_scope(&self) -> Self {
2394        let mut next = self.clone();
2395        next.locals.push(BTreeMap::new());
2396        next
2397    }
2398
2399    fn insert_local(&mut self, name: String, symbol: SymbolId) {
2400        self.locals
2401            .last_mut()
2402            .expect("local scope")
2403            .insert(name, symbol);
2404    }
2405
2406    fn lookup_local(&self, name: &str) -> Option<SymbolId> {
2407        self.locals
2408            .iter()
2409            .rev()
2410            .find_map(|scope| scope.get(name).copied())
2411    }
2412}
2413
2414struct BlockAnalysis {
2415    block: TypedStatementBlock,
2416    available_derived: BTreeSet<SymbolId>,
2417    definite_targets: BTreeSet<SymbolId>,
2418    touched_states: BTreeSet<SymbolId>,
2419}
2420
2421#[derive(Clone)]
2422enum PendingSymbolType {
2423    Scalar(Option<ValueType>),
2424    Array { element: ValueType, size: usize },
2425    Route,
2426}
2427
2428struct PendingSymbol {
2429    id: SymbolId,
2430    name: String,
2431    kind: SymbolKind,
2432    ty: PendingSymbolType,
2433    span: Span,
2434}
2435
2436struct ModelKindBlocks<'a> {
2437    dynamics: Option<&'a BlockAnalysis>,
2438    drift: Option<&'a BlockAnalysis>,
2439    diffusion: Option<&'a BlockAnalysis>,
2440    analytical: Option<&'a syntax::AnalyticalBlock>,
2441    particles: Option<&'a syntax::ParticlesDecl>,
2442}
2443
2444#[derive(Default)]
2445struct ModelSections<'a> {
2446    parameters: Option<&'a syntax::ParametersBlock>,
2447    constants: Option<&'a syntax::ConstantsBlock>,
2448    covariates: Option<&'a syntax::CovariatesBlock>,
2449    states: Option<&'a syntax::StatesBlock>,
2450    routes: Option<&'a syntax::RoutesBlock>,
2451    derive: Option<&'a syntax::StatementBlock>,
2452    dynamics: Option<&'a syntax::StatementBlock>,
2453    outputs: Option<&'a syntax::StatementBlock>,
2454    analytical: Option<&'a syntax::AnalyticalBlock>,
2455    init: Option<&'a syntax::StatementBlock>,
2456    drift: Option<&'a syntax::StatementBlock>,
2457    diffusion: Option<&'a syntax::StatementBlock>,
2458    particles: Option<&'a syntax::ParticlesDecl>,
2459}
2460
2461impl<'a> ModelSections<'a> {
2462    fn from_model(model: &'a syntax::Model) -> Result<Self, SemanticError> {
2463        let mut sections = Self::default();
2464        for item in &model.items {
2465            match item {
2466                syntax::ModelItem::Parameters(block) => {
2467                    set_once(&mut sections.parameters, block, "parameters")?
2468                }
2469                syntax::ModelItem::Constants(block) => {
2470                    set_once(&mut sections.constants, block, "constants")?
2471                }
2472                syntax::ModelItem::Covariates(block) => {
2473                    set_once(&mut sections.covariates, block, "covariates")?
2474                }
2475                syntax::ModelItem::States(block) => {
2476                    set_once(&mut sections.states, block, "states")?
2477                }
2478                syntax::ModelItem::Routes(block) => {
2479                    set_once(&mut sections.routes, block, "routes")?
2480                }
2481                syntax::ModelItem::Derive(block) => {
2482                    set_once(&mut sections.derive, block, "derive")?
2483                }
2484                syntax::ModelItem::Dynamics(block) => {
2485                    set_once(&mut sections.dynamics, block, "dynamics")?
2486                }
2487                syntax::ModelItem::Outputs(block) => {
2488                    set_once(&mut sections.outputs, block, "outputs")?
2489                }
2490                syntax::ModelItem::Analytical(block) => {
2491                    set_once(&mut sections.analytical, block, "analytical")?
2492                }
2493                syntax::ModelItem::Init(block) => set_once(&mut sections.init, block, "init")?,
2494                syntax::ModelItem::Drift(block) => set_once(&mut sections.drift, block, "drift")?,
2495                syntax::ModelItem::Diffusion(block) => {
2496                    set_once(&mut sections.diffusion, block, "diffusion")?
2497                }
2498                syntax::ModelItem::Particles(block) => {
2499                    set_once(&mut sections.particles, block, "particles")?
2500                }
2501            }
2502        }
2503        Ok(sections)
2504    }
2505}
2506
2507fn set_once<'a, T>(slot: &mut Option<&'a T>, value: &'a T, name: &str) -> Result<(), SemanticError>
2508where
2509    T: HasSpan,
2510{
2511    if let Some(existing) = *slot {
2512        return Err(SemanticAssist::default()
2513            .context_label(
2514                existing.span(),
2515                format!("`{name}` section first declared here"),
2516            )
2517            .help(format!("each model can declare `{name}` at most once"))
2518            .apply(SemanticError::new(
2519                format!("duplicate `{name}` section in model body"),
2520                value.span(),
2521            )));
2522    }
2523    *slot = Some(value);
2524    Ok(())
2525}
2526
2527fn best_similar_name_assist(
2528    needle: &str,
2529    candidates: Vec<SimilarNameCandidate>,
2530) -> Option<SemanticAssist> {
2531    let original_needle = needle;
2532    let needle = needle.to_ascii_lowercase();
2533    let mut best: Option<((usize, usize, usize), SemanticAssist)> = None;
2534    let mut tied = false;
2535
2536    for candidate in candidates {
2537        if candidate.lookup_name == original_needle {
2538            continue;
2539        }
2540        let lookup = candidate.lookup_name.to_ascii_lowercase();
2541        let distance = if is_single_adjacent_transposition(&needle, &lookup) {
2542            1
2543        } else {
2544            edit_distance(&needle, &lookup)
2545        };
2546        let prefix = common_prefix_len(&needle, &lookup);
2547        if !is_high_confidence_match(&needle, &lookup, distance, prefix) {
2548            continue;
2549        }
2550        let score = (
2551            distance,
2552            usize::MAX - prefix,
2553            needle.len().abs_diff(lookup.len()),
2554        );
2555        match &best {
2556            None => {
2557                best = Some((score, candidate.assist));
2558                tied = false;
2559            }
2560            Some((best_score, _)) if score < *best_score => {
2561                best = Some((score, candidate.assist));
2562                tied = false;
2563            }
2564            Some((best_score, _)) if score == *best_score => tied = true,
2565            _ => {}
2566        }
2567    }
2568
2569    if tied {
2570        None
2571    } else {
2572        best.map(|(_, assist)| assist)
2573    }
2574}
2575
2576trait HasSpan {
2577    fn span(&self) -> Span;
2578}
2579
2580impl HasSpan for syntax::ParametersBlock {
2581    fn span(&self) -> Span {
2582        self.span
2583    }
2584}
2585impl HasSpan for syntax::ConstantsBlock {
2586    fn span(&self) -> Span {
2587        self.span
2588    }
2589}
2590impl HasSpan for syntax::CovariatesBlock {
2591    fn span(&self) -> Span {
2592        self.span
2593    }
2594}
2595impl HasSpan for syntax::StatesBlock {
2596    fn span(&self) -> Span {
2597        self.span
2598    }
2599}
2600impl HasSpan for syntax::RoutesBlock {
2601    fn span(&self) -> Span {
2602        self.span
2603    }
2604}
2605impl HasSpan for syntax::StatementBlock {
2606    fn span(&self) -> Span {
2607        self.span
2608    }
2609}
2610impl HasSpan for syntax::AnalyticalBlock {
2611    fn span(&self) -> Span {
2612        self.span
2613    }
2614}
2615impl HasSpan for syntax::ParticlesDecl {
2616    fn span(&self) -> Span {
2617        self.span
2618    }
2619}
2620
2621fn collect_bare_assignment_names(
2622    statements: &[syntax::Stmt],
2623    seen: &mut BTreeSet<String>,
2624    output: &mut Vec<syntax::Ident>,
2625) {
2626    for statement in statements {
2627        match &statement.kind {
2628            syntax::StmtKind::Assign(assign) => {
2629                if let syntax::AssignTargetKind::Name(name) = &assign.target.kind {
2630                    if seen.insert(name.text.clone()) {
2631                        output.push(name.clone());
2632                    }
2633                }
2634            }
2635            syntax::StmtKind::If(if_stmt) => {
2636                collect_bare_assignment_names(&if_stmt.then_branch, seen, output);
2637                if let Some(else_branch) = &if_stmt.else_branch {
2638                    collect_bare_assignment_names(else_branch, seen, output);
2639                }
2640            }
2641            syntax::StmtKind::For(for_stmt) => {
2642                collect_bare_assignment_names(&for_stmt.body, seen, output);
2643            }
2644            syntax::StmtKind::Let(_) => {}
2645        }
2646    }
2647}
2648
2649fn number_to_const(value: f64) -> ConstValue {
2650    if value.is_finite()
2651        && value.fract() == 0.0
2652        && value >= i64::MIN as f64
2653        && value <= i64::MAX as f64
2654    {
2655        ConstValue::Int(value as i64)
2656    } else {
2657        ConstValue::Real(value)
2658    }
2659}
2660
2661fn promote_numeric(lhs: ValueType, rhs: ValueType) -> ValueType {
2662    if lhs == ValueType::Real || rhs == ValueType::Real {
2663        ValueType::Real
2664    } else {
2665        ValueType::Int
2666    }
2667}
2668
2669fn intersect_sets(set_a: &BTreeSet<SymbolId>, set_b: &BTreeSet<SymbolId>) -> BTreeSet<SymbolId> {
2670    set_a.intersection(set_b).copied().collect()
2671}
2672
2673fn map_binary_op(op: syntax::BinaryOp) -> TypedBinaryOp {
2674    match op {
2675        syntax::BinaryOp::Or => TypedBinaryOp::Or,
2676        syntax::BinaryOp::And => TypedBinaryOp::And,
2677        syntax::BinaryOp::Eq => TypedBinaryOp::Eq,
2678        syntax::BinaryOp::NotEq => TypedBinaryOp::NotEq,
2679        syntax::BinaryOp::Lt => TypedBinaryOp::Lt,
2680        syntax::BinaryOp::LtEq => TypedBinaryOp::LtEq,
2681        syntax::BinaryOp::Gt => TypedBinaryOp::Gt,
2682        syntax::BinaryOp::GtEq => TypedBinaryOp::GtEq,
2683        syntax::BinaryOp::Add => TypedBinaryOp::Add,
2684        syntax::BinaryOp::Sub => TypedBinaryOp::Sub,
2685        syntax::BinaryOp::Mul => TypedBinaryOp::Mul,
2686        syntax::BinaryOp::Div => TypedBinaryOp::Div,
2687        syntax::BinaryOp::Pow => TypedBinaryOp::Pow,
2688    }
2689}
2690
2691fn call_result_type(intrinsic: MathIntrinsic, args: &[TypedExpr]) -> ValueType {
2692    match intrinsic {
2693        MathIntrinsic::Abs => args.first().map_or(ValueType::Real, |arg| arg.ty),
2694        MathIntrinsic::Min | MathIntrinsic::Max => args
2695            .iter()
2696            .map(|arg| arg.ty)
2697            .reduce(promote_numeric)
2698            .unwrap_or(ValueType::Real),
2699        MathIntrinsic::Floor
2700        | MathIntrinsic::Ceil
2701        | MathIntrinsic::Exp
2702        | MathIntrinsic::Ln
2703        | MathIntrinsic::Log
2704        | MathIntrinsic::Log10
2705        | MathIntrinsic::Log2
2706        | MathIntrinsic::Pow
2707        | MathIntrinsic::Round
2708        | MathIntrinsic::Sin
2709        | MathIntrinsic::Cos
2710        | MathIntrinsic::Tan
2711        | MathIntrinsic::Sqrt => ValueType::Real,
2712    }
2713}
2714
2715fn fold_unary(op: TypedUnaryOp, value: &ConstValue) -> Option<ConstValue> {
2716    match (op, value) {
2717        (TypedUnaryOp::Plus, ConstValue::Int(value)) => Some(ConstValue::Int(*value)),
2718        (TypedUnaryOp::Plus, ConstValue::Real(value)) => Some(ConstValue::Real(*value)),
2719        (TypedUnaryOp::Minus, ConstValue::Int(value)) => Some(ConstValue::Int(-value)),
2720        (TypedUnaryOp::Minus, ConstValue::Real(value)) => Some(ConstValue::Real(-value)),
2721        (TypedUnaryOp::Not, ConstValue::Bool(value)) => Some(ConstValue::Bool(!value)),
2722        _ => None,
2723    }
2724}
2725
2726fn fold_binary(op: TypedBinaryOp, lhs: &ConstValue, rhs: &ConstValue) -> Option<ConstValue> {
2727    match op {
2728        TypedBinaryOp::Or => Some(ConstValue::Bool(
2729            matches!(lhs, ConstValue::Bool(true)) || matches!(rhs, ConstValue::Bool(true)),
2730        )),
2731        TypedBinaryOp::And => Some(ConstValue::Bool(
2732            matches!(lhs, ConstValue::Bool(true)) && matches!(rhs, ConstValue::Bool(true)),
2733        )),
2734        TypedBinaryOp::Eq => Some(ConstValue::Bool(lhs == rhs)),
2735        TypedBinaryOp::NotEq => Some(ConstValue::Bool(lhs != rhs)),
2736        TypedBinaryOp::Lt => Some(ConstValue::Bool(lhs.as_f64()? < rhs.as_f64()?)),
2737        TypedBinaryOp::LtEq => Some(ConstValue::Bool(lhs.as_f64()? <= rhs.as_f64()?)),
2738        TypedBinaryOp::Gt => Some(ConstValue::Bool(lhs.as_f64()? > rhs.as_f64()?)),
2739        TypedBinaryOp::GtEq => Some(ConstValue::Bool(lhs.as_f64()? >= rhs.as_f64()?)),
2740        TypedBinaryOp::Add => fold_numeric(
2741            lhs,
2742            rhs,
2743            |left, right| left + right,
2744            |left, right| left + right,
2745        ),
2746        TypedBinaryOp::Sub => fold_numeric(
2747            lhs,
2748            rhs,
2749            |left, right| left - right,
2750            |left, right| left - right,
2751        ),
2752        TypedBinaryOp::Mul => fold_numeric(
2753            lhs,
2754            rhs,
2755            |left, right| left * right,
2756            |left, right| left * right,
2757        ),
2758        TypedBinaryOp::Div => Some(ConstValue::Real(lhs.as_f64()? / rhs.as_f64()?)),
2759        TypedBinaryOp::Pow => Some(ConstValue::Real(lhs.as_f64()?.powf(rhs.as_f64()?))),
2760    }
2761}
2762
2763fn fold_numeric(
2764    lhs: &ConstValue,
2765    rhs: &ConstValue,
2766    int_op: impl FnOnce(i64, i64) -> i64,
2767    real_op: impl FnOnce(f64, f64) -> f64,
2768) -> Option<ConstValue> {
2769    match (lhs, rhs) {
2770        (ConstValue::Int(lhs), ConstValue::Int(rhs)) => Some(ConstValue::Int(int_op(*lhs, *rhs))),
2771        _ => Some(ConstValue::Real(real_op(lhs.as_f64()?, rhs.as_f64()?))),
2772    }
2773}
2774
2775fn fold_call(intrinsic: MathIntrinsic, values: &[ConstValue]) -> Option<ConstValue> {
2776    match intrinsic {
2777        MathIntrinsic::Abs => match values.first()? {
2778            ConstValue::Int(value) => Some(ConstValue::Int(value.abs())),
2779            ConstValue::Real(value) => Some(ConstValue::Real(value.abs())),
2780            ConstValue::Bool(_) => None,
2781        },
2782        MathIntrinsic::Ceil => Some(ConstValue::Real(values.first()?.as_f64()?.ceil())),
2783        MathIntrinsic::Exp => Some(ConstValue::Real(values.first()?.as_f64()?.exp())),
2784        MathIntrinsic::Floor => Some(ConstValue::Real(values.first()?.as_f64()?.floor())),
2785        MathIntrinsic::Ln | MathIntrinsic::Log => {
2786            Some(ConstValue::Real(values.first()?.as_f64()?.ln()))
2787        }
2788        MathIntrinsic::Log10 => Some(ConstValue::Real(values.first()?.as_f64()?.log10())),
2789        MathIntrinsic::Log2 => Some(ConstValue::Real(values.first()?.as_f64()?.log2())),
2790        MathIntrinsic::Max => Some(ConstValue::Real(
2791            values.first()?.as_f64()?.max(values.get(1)?.as_f64()?),
2792        )),
2793        MathIntrinsic::Min => Some(ConstValue::Real(
2794            values.first()?.as_f64()?.min(values.get(1)?.as_f64()?),
2795        )),
2796        MathIntrinsic::Pow => Some(ConstValue::Real(
2797            values.first()?.as_f64()?.powf(values.get(1)?.as_f64()?),
2798        )),
2799        MathIntrinsic::Round => Some(ConstValue::Real(values.first()?.as_f64()?.round())),
2800        MathIntrinsic::Sin => Some(ConstValue::Real(values.first()?.as_f64()?.sin())),
2801        MathIntrinsic::Cos => Some(ConstValue::Real(values.first()?.as_f64()?.cos())),
2802        MathIntrinsic::Tan => Some(ConstValue::Real(values.first()?.as_f64()?.tan())),
2803        MathIntrinsic::Sqrt => Some(ConstValue::Real(values.first()?.as_f64()?.sqrt())),
2804    }
2805}
2806
2807#[cfg(test)]
2808mod tests {
2809    use super::*;
2810    use crate::test_fixtures::{
2811        RECOMMENDED_STYLE_AUTHORING, RECOMMENDED_STYLE_CANONICAL, STRUCTURED_BLOCK_CORPUS,
2812    };
2813    use crate::RouteKind;
2814    use crate::{parse_model, parse_module};
2815
2816    #[test]
2817    fn analyzes_structured_block_corpus() {
2818        let src = STRUCTURED_BLOCK_CORPUS;
2819        let module = parse_module(src).expect("structured-block fixture parses");
2820        let typed = analyze_module(&module).expect("structured-block fixture analyzes");
2821
2822        assert_eq!(typed.models.len(), 4);
2823        let transit = &typed.models[1];
2824        assert_eq!(transit.kind, ModelKind::Ode);
2825        assert_eq!(transit.states[0].size, Some(4));
2826        assert!(transit.dynamics.is_some());
2827
2828        let analytical = &typed.models[2];
2829        assert!(matches!(
2830            analytical.analytical.as_ref().map(|value| value.structure),
2831            Some(AnalyticalKernel::OneCompartmentWithAbsorption)
2832        ));
2833
2834        let sde = &typed.models[3];
2835        assert_eq!(sde.particles, Some(1000));
2836        assert!(sde.drift.is_some());
2837        assert!(sde.diffusion.is_some());
2838    }
2839
2840    #[test]
2841    fn derives_values_across_if_branches() {
2842        let src = STRUCTURED_BLOCK_CORPUS;
2843        let model = parse_model(src.split("\n\n\n").next().unwrap()).expect("single model parses");
2844        let typed = analyze_model(&model).expect("single model analyzes");
2845        let ke_symbol = typed
2846            .symbols
2847            .iter()
2848            .find(|symbol| symbol.name == "ke")
2849            .expect("derived symbol exists");
2850        assert!(matches!(ke_symbol.ty, SymbolType::Scalar(ValueType::Real)));
2851    }
2852
2853    #[test]
2854    fn analytical_model_accepts_straight_line_required_derived_assignment() {
2855        let src = r#"
2856model analytical_ok {
2857    kind analytical
2858    parameters { ka, ke0, v }
2859    states { depot, central }
2860    routes { oral -> depot }
2861    derive {
2862        ke = ke0
2863    }
2864    analytical {
2865        structure = one_compartment_with_absorption
2866    }
2867    outputs {
2868        cp = central / v
2869    }
2870}
2871"#;
2872
2873        let model = parse_model(src).expect("model parses");
2874        let typed = analyze_model(&model).expect("model analyzes");
2875        assert!(matches!(
2876            typed.analytical.as_ref().map(|value| value.structure),
2877            Some(AnalyticalKernel::OneCompartmentWithAbsorption)
2878        ));
2879    }
2880
2881    #[test]
2882    fn analytical_model_accepts_required_derived_assignment_across_if_else() {
2883        let src = r#"
2884model analytical_ok {
2885    kind analytical
2886    parameters { ka, ke0, v }
2887    states { depot, central }
2888    routes { oral -> depot }
2889    derive {
2890        if true {
2891            ke = ke0
2892        } else {
2893            ke = ke0 * 2.0
2894        }
2895    }
2896    analytical {
2897        structure = one_compartment_with_absorption
2898    }
2899    outputs {
2900        cp = central / v
2901    }
2902}
2903"#;
2904
2905        let model = parse_model(src).expect("model parses");
2906        analyze_model(&model).expect("model analyzes");
2907    }
2908
2909    #[test]
2910    fn analytical_model_accepts_loop_updates_after_initial_derived_assignment() {
2911        let src = r#"
2912model analytical_ok {
2913    kind analytical
2914    parameters { ka, ke0, v }
2915    states { depot, central }
2916    routes { oral -> depot }
2917    derive {
2918        ke = ke0
2919        for step in 0..2 {
2920            ke = ke + 0.0
2921        }
2922    }
2923    analytical {
2924        structure = one_compartment_with_absorption
2925    }
2926    outputs {
2927        cp = central / v
2928    }
2929}
2930"#;
2931
2932        let model = parse_model(src).expect("model parses");
2933        analyze_model(&model).expect("model analyzes");
2934    }
2935
2936    #[test]
2937    fn analytical_model_rejects_missing_required_structure_name_across_params_and_derived() {
2938        let src = r#"
2939model analytical_broken {
2940    kind analytical
2941    parameters { ka, kel, v }
2942    states { depot, central }
2943    routes { oral -> depot }
2944    analytical {
2945        structure = one_compartment_with_absorption
2946    }
2947    outputs {
2948        cp = central / v
2949    }
2950}
2951"#;
2952
2953        let model = parse_model(src).expect("model parses");
2954        let err = analyze_model(&model).expect_err("missing required structure name must fail");
2955        assert!(err
2956            .render(src)
2957            .contains("analytical structure `one_compartment_with_absorption` requires `ke`"));
2958        assert!(err
2959            .render(src)
2960            .contains("did you mean `ke` instead of `kel`?"));
2961    }
2962
2963    #[test]
2964    fn analytical_model_rejects_overlap_between_params_and_derive_assigned_names() {
2965        let src = r#"
2966model analytical_broken {
2967    kind analytical
2968    parameters { ka, ke, v }
2969    states { depot, central }
2970    routes { oral -> depot }
2971    derive {
2972        ke = ke
2973    }
2974    analytical {
2975        structure = one_compartment_with_absorption
2976    }
2977    outputs {
2978        cp = central / v
2979    }
2980}
2981"#;
2982
2983        let model = parse_model(src).expect("model parses");
2984        let err = analyze_model(&model).expect_err("param/derived overlap must fail");
2985        assert!(err
2986            .render(src)
2987            .contains("derived name `ke` collides with primary parameter `ke`"));
2988        assert!(err
2989            .render(src)
2990            .contains("names declared in `params` and derive-assigned names must be distinct"));
2991    }
2992
2993    #[test]
2994    fn analytical_model_rejects_non_bare_derive_target() {
2995        let src = r#"
2996model analytical_broken {
2997    kind analytical
2998    parameters { ka, ke0, v }
2999    states { depot, central }
3000    routes { oral -> depot }
3001    derive {
3002        ddt(central) = ke0
3003    }
3004    analytical {
3005        structure = one_compartment_with_absorption
3006    }
3007    outputs {
3008        cp = central / v
3009    }
3010}
3011"#;
3012
3013        let model = parse_model(src).expect("model parses");
3014        let err = analyze_model(&model).expect_err("non-bare derive target must fail");
3015        assert!(err
3016            .render(src)
3017            .contains("derive assignments must target a bare identifier"));
3018    }
3019
3020    #[test]
3021    fn analytical_model_rejects_conditionally_assigned_required_derived_name() {
3022        let src = r#"
3023model analytical_broken {
3024    kind analytical
3025    parameters { ka, ke0, v }
3026    states { depot, central }
3027    routes { oral -> depot }
3028    derive {
3029        if true {
3030            ke = ke0
3031        }
3032    }
3033    analytical {
3034        structure = one_compartment_with_absorption
3035    }
3036    outputs {
3037        cp = central / v
3038    }
3039}
3040"#;
3041
3042        let model = parse_model(src).expect("model parses");
3043        let err = analyze_model(&model)
3044            .expect_err("conditionally assigned required derived name must fail");
3045        assert!(err.render(src).contains(
3046                        "derived value `ke` is not definitely assigned on all control-flow paths before analytical structure `one_compartment_with_absorption` uses it"
3047                ));
3048        assert!(err
3049                        .render(src)
3050                        .contains("assign `ke` on every control-flow path in `derive` before the analytical structure runs"));
3051    }
3052
3053    #[test]
3054    fn analytical_model_rejects_loop_only_required_derived_assignment() {
3055        let src = r#"
3056model analytical_broken {
3057    kind analytical
3058    parameters { ka, ke0, v }
3059    states { depot, central }
3060    routes { oral -> depot }
3061    derive {
3062        for step in 0..2 {
3063            ke = ke0
3064        }
3065    }
3066    analytical {
3067        structure = one_compartment_with_absorption
3068    }
3069    outputs {
3070        cp = central / v
3071    }
3072}
3073"#;
3074
3075        let model = parse_model(src).expect("model parses");
3076        let err =
3077            analyze_model(&model).expect_err("loop-only required derived assignment must fail");
3078        assert!(err.render(src).contains(
3079                        "derived value `ke` is not definitely assigned on all control-flow paths before analytical structure `one_compartment_with_absorption` uses it"
3080                ));
3081    }
3082
3083    #[test]
3084    fn analytical_model_authoring_surface_accepts_declared_derived_assignment() {
3085        let src = r#"
3086        name = analytical_authoring
3087        kind = analytical
3088        params = ka, ke0, v
3089        derived = ke
3090        states = depot, central
3091        outputs = cp
3092
3093        bolus(oral) -> depot
3094
3095        ke = ke0
3096        structure = one_compartment_with_absorption
3097        out(cp) = central / v ~ continuous()
3098        "#;
3099
3100        let model = parse_model(src).expect("authoring model parses");
3101        analyze_model(&model).expect("authoring model analyzes");
3102    }
3103
3104    #[test]
3105    fn analytical_model_authoring_surface_rejects_undeclared_derived_assignment() {
3106        let src = r#"
3107        name = analytical_authoring
3108        kind = analytical
3109        params = ka, ke0, v
3110        derived = kel
3111        states = depot, central
3112        outputs = cp
3113
3114        bolus(oral) -> depot
3115
3116        ke = ke0
3117        structure = one_compartment_with_absorption
3118        out(cp) = central / v ~ continuous()
3119        "#;
3120
3121        let err = parse_model(src).expect_err("undeclared derived assignment must fail");
3122        assert!(err
3123            .render(src)
3124            .contains("derived value `ke` is not declared in `derived = ...`"));
3125    }
3126
3127    #[test]
3128    fn analytical_model_authoring_surface_rejects_param_derived_overlap() {
3129        let src = r#"
3130        name = analytical_authoring
3131        kind = analytical
3132        params = ka, ke, v
3133        derived = ke
3134        states = depot, central
3135        outputs = cp
3136
3137        bolus(oral) -> depot
3138
3139        structure = one_compartment_with_absorption
3140        out(cp) = central / v ~ continuous()
3141        "#;
3142
3143        let err = parse_model(src).expect_err("param/derived overlap must fail");
3144        assert!(err
3145            .render(src)
3146            .contains("derived name `ke` collides with primary parameter `ke`"));
3147        assert!(err
3148            .render(src)
3149            .contains("names declared in `params` and `derived` must be distinct"));
3150    }
3151
3152    #[test]
3153    fn authoring_fixture_preserves_route_kind_while_remaining_equivalent() {
3154        let authoring_surface = RECOMMENDED_STYLE_AUTHORING;
3155        let canonical = RECOMMENDED_STYLE_CANONICAL;
3156
3157        let authoring_model = parse_model(authoring_surface).expect("authoring model parses");
3158        let canonical_model = parse_model(canonical).expect("canonical model parses");
3159
3160        let authoring_typed = analyze_model(&authoring_model).expect("authoring model analyzes");
3161        let canonical_typed = analyze_model(&canonical_model).expect("canonical model analyzes");
3162
3163        assert_eq!(
3164            typed_model_signature(&authoring_typed),
3165            typed_model_signature(&canonical_typed)
3166        );
3167        assert_eq!(authoring_typed.routes[0].kind, Some(RouteKind::Bolus));
3168        assert_eq!(canonical_typed.routes[0].kind, None);
3169    }
3170
3171    #[test]
3172    fn rejects_unknown_route_in_rate_call() {
3173        let src = r#"
3174model broken {
3175  kind ode
3176  states { central }
3177  dynamics {
3178    ddt(central) = rate(oral)
3179  }
3180  outputs {
3181    cp = central
3182  }
3183}
3184"#;
3185        let model = parse_model(src).expect("model parses");
3186        let err = analyze_model(&model).expect_err("unknown route must fail");
3187        assert!(err.render(src).contains("unknown route `oral`"));
3188    }
3189
3190    #[test]
3191    fn suggests_similar_state_name_for_unknown_identifier() {
3192        let src = r#"
3193model broken {
3194    kind ode
3195    states { central }
3196    dynamics {
3197        ddt(central) = 0
3198    }
3199    outputs {
3200        cp = cental
3201    }
3202}
3203"#;
3204        let model = parse_model(src).expect("model parses");
3205        let err = analyze_model(&model).expect_err("unknown identifier must fail");
3206
3207        assert!(err
3208            .diagnostic()
3209            .suggestions
3210            .iter()
3211            .any(|suggestion| suggestion.message.contains("did you mean `central`?")));
3212        assert!(err
3213            .render(src)
3214            .contains("suggestion: did you mean `central`?"));
3215    }
3216
3217    #[test]
3218    fn suggests_case_variant_for_unknown_identifier() {
3219        let src = r#"
3220model broken {
3221    kind ode
3222    parameters { Ke }
3223    states { central }
3224    dynamics {
3225        ddt(central) = -ke * central
3226    }
3227    outputs {
3228        cp = central
3229    }
3230}
3231"#;
3232        let model = parse_model(src).expect("model parses");
3233        let err = analyze_model(&model).expect_err("case-mismatched identifier must fail");
3234
3235        assert!(err
3236            .diagnostic()
3237            .suggestions
3238            .iter()
3239            .any(|suggestion| suggestion.message.contains("did you mean `Ke`?")));
3240        assert!(err.render(src).contains("suggestion: did you mean `Ke`?"));
3241    }
3242
3243    #[test]
3244    fn suggests_similar_intrinsic_for_unknown_function() {
3245        let src = r#"
3246model broken {
3247    kind ode
3248    states { central }
3249    dynamics {
3250        ddt(central) = 0
3251    }
3252    outputs {
3253        cp = sqt(central)
3254    }
3255}
3256"#;
3257        let model = parse_model(src).expect("model parses");
3258        let err = analyze_model(&model).expect_err("unknown function must fail");
3259
3260        assert!(err
3261            .diagnostic()
3262            .suggestions
3263            .iter()
3264            .any(|suggestion| suggestion.message.contains("did you mean `sqrt`?")));
3265        assert!(err.render(src).contains("suggestion: did you mean `sqrt`?"));
3266    }
3267
3268    #[test]
3269    fn route_scalar_usage_reports_help_and_context() {
3270        let src = r#"
3271model broken {
3272    kind ode
3273    states { central }
3274    routes { oral -> central }
3275    dynamics {
3276        ddt(central) = oral
3277    }
3278    outputs {
3279        cp = central
3280    }
3281}
3282"#;
3283        let model = parse_model(src).expect("model parses");
3284        let err = analyze_model(&model).expect_err("route scalar usage must fail");
3285
3286        assert!(err
3287            .diagnostic()
3288            .helps
3289            .iter()
3290            .any(|help| help.contains("route inputs are read through `rate(oral)`")));
3291        assert!(err.render(src).contains("route `oral` declared here"));
3292        assert!(err
3293            .render(src)
3294            .contains("suggestion: did you mean `rate(oral)`?"));
3295    }
3296
3297    #[test]
3298    fn output_scope_violation_reports_help_and_context() {
3299        let src = r#"
3300model broken {
3301    kind ode
3302    states { central }
3303    dynamics {
3304        ddt(central) = cp
3305    }
3306    outputs {
3307        cp = central
3308    }
3309}
3310"#;
3311        let model = parse_model(src).expect("model parses");
3312        let err = analyze_model(&model).expect_err("output scope violation must fail");
3313
3314        assert!(
3315            err.diagnostic()
3316                .helps
3317                .iter()
3318                .any(|help| help
3319                    .contains("outputs are assignment targets inside the `outputs` block"))
3320        );
3321        assert!(err.render(src).contains("output `cp` declared here"));
3322    }
3323
3324    #[test]
3325    fn reserved_name_reports_rename_suggestion() {
3326        let src = r#"
3327model broken {
3328    kind ode
3329    parameters { log }
3330    states { central }
3331    dynamics {
3332        ddt(central) = 0
3333    }
3334    outputs {
3335        cp = central
3336    }
3337}
3338"#;
3339        let model = parse_model(src).expect("model parses");
3340        let err = analyze_model(&model).expect_err("reserved name must fail");
3341
3342        assert!(err.render(src).contains("rename `log` to `log_value`"));
3343        assert!(err
3344            .diagnostic()
3345            .suggestions
3346            .iter()
3347            .any(|suggestion| suggestion
3348                .edits
3349                .iter()
3350                .any(|edit| edit.replacement == "log_value")));
3351    }
3352
3353    #[test]
3354    fn duplicate_constant_points_to_first_declaration() {
3355        let src = r#"
3356model broken {
3357    kind ode
3358    constants {
3359        ka = 1
3360        ka = 2
3361    }
3362    states { central }
3363    dynamics {
3364        ddt(central) = 0
3365    }
3366    outputs {
3367        cp = central
3368    }
3369}
3370"#;
3371        let model = parse_model(src).expect("model parses");
3372        let err = analyze_model(&model).expect_err("duplicate constant must fail");
3373
3374        assert!(err
3375            .render(src)
3376            .contains("constant `ka` first declared here"));
3377        assert!(err.render(src).contains("rename this constant to `ka_2`"));
3378    }
3379
3380    #[test]
3381    fn rejects_missing_output_assignment_on_all_paths() {
3382        let src = r#"
3383model broken {
3384  kind ode
3385  states { central }
3386  dynamics {
3387    ddt(central) = 0
3388  }
3389  outputs {
3390    if true {
3391      cp = central
3392    }
3393  }
3394}
3395"#;
3396        let model = parse_model(src).expect("model parses");
3397        let err = analyze_model(&model).expect_err("partial output assignment must fail");
3398        assert!(err
3399            .render(src)
3400            .contains("output `cp` is not definitely assigned on all control-flow paths"));
3401    }
3402
3403    #[test]
3404    fn rejects_non_integer_array_size() {
3405        let src = r#"
3406model broken {
3407  kind ode
3408  constants { n = 1.5 }
3409  states { transit[n] }
3410  dynamics {
3411    ddt(transit[0]) = 0
3412  }
3413  outputs {
3414    cp = 0
3415  }
3416}
3417"#;
3418        let model = parse_model(src).expect("model parses");
3419        let err = analyze_model(&model).expect_err("non-integer array size must fail");
3420        assert!(err
3421            .render(src)
3422            .contains("state array size must be an integer constant"));
3423    }
3424
3425    fn typed_model_signature(model: &TypedModel) -> String {
3426        let mut lines = Vec::new();
3427        lines.push(format!("kind:{:?}", model.kind));
3428        lines.push(format!(
3429            "parameters:{}",
3430            join_names(model, &model.parameters)
3431        ));
3432        lines.push(format!("constants:{}", join_constants(model)));
3433        lines.push(format!("covariates:{}", join_covariates(model)));
3434        lines.push(format!("states:{}", join_states(model)));
3435        lines.push(format!("routes:{}", join_routes(model)));
3436        lines.push(format!("derived:{}", join_names(model, &model.derived)));
3437        lines.push(format!("outputs:{}", join_names(model, &model.outputs)));
3438        lines.push(format!("particles:{:?}", model.particles));
3439        lines.push(format!(
3440            "analytical:{:?}",
3441            model.analytical.as_ref().map(|value| value.structure)
3442        ));
3443        lines.push(format!(
3444            "derive:{}",
3445            model
3446                .derive
3447                .as_ref()
3448                .map(|block| block_signature(model, block))
3449                .unwrap_or_default()
3450        ));
3451        lines.push(format!(
3452            "dynamics:{}",
3453            model
3454                .dynamics
3455                .as_ref()
3456                .map(|block| block_signature(model, block))
3457                .unwrap_or_default()
3458        ));
3459        lines.push(format!(
3460            "init:{}",
3461            model
3462                .init
3463                .as_ref()
3464                .map(|block| block_signature(model, block))
3465                .unwrap_or_default()
3466        ));
3467        lines.push(format!(
3468            "drift:{}",
3469            model
3470                .drift
3471                .as_ref()
3472                .map(|block| block_signature(model, block))
3473                .unwrap_or_default()
3474        ));
3475        lines.push(format!(
3476            "diffusion:{}",
3477            model
3478                .diffusion
3479                .as_ref()
3480                .map(|block| block_signature(model, block))
3481                .unwrap_or_default()
3482        ));
3483        lines.push(format!(
3484            "outputs_block:{}",
3485            block_signature(model, &model.outputs_block)
3486        ));
3487        lines.join("\n")
3488    }
3489
3490    fn join_names(model: &TypedModel, ids: &[SymbolId]) -> String {
3491        ids.iter()
3492            .map(|id| symbol_name(model, *id))
3493            .collect::<Vec<_>>()
3494            .join(",")
3495    }
3496
3497    fn join_constants(model: &TypedModel) -> String {
3498        model
3499            .constants
3500            .iter()
3501            .map(|constant| {
3502                format!(
3503                    "{}={:?}",
3504                    symbol_name(model, constant.symbol),
3505                    constant.value
3506                )
3507            })
3508            .collect::<Vec<_>>()
3509            .join(",")
3510    }
3511
3512    fn join_covariates(model: &TypedModel) -> String {
3513        model
3514            .covariates
3515            .iter()
3516            .map(|covariate| {
3517                format!(
3518                    "{}@{:?}",
3519                    symbol_name(model, covariate.symbol),
3520                    covariate.interpolation
3521                )
3522            })
3523            .collect::<Vec<_>>()
3524            .join(",")
3525    }
3526
3527    fn join_states(model: &TypedModel) -> String {
3528        model
3529            .states
3530            .iter()
3531            .map(|state| format!("{}[{:#?}]", symbol_name(model, state.symbol), state.size))
3532            .collect::<Vec<_>>()
3533            .join(",")
3534    }
3535
3536    fn join_routes(model: &TypedModel) -> String {
3537        model
3538            .routes
3539            .iter()
3540            .map(|route| {
3541                let destination = state_place_signature(model, &route.destination);
3542                let properties = route
3543                    .properties
3544                    .iter()
3545                    .map(|property| {
3546                        format!(
3547                            "{:?}={}",
3548                            property.kind,
3549                            expr_signature(model, &property.value)
3550                        )
3551                    })
3552                    .collect::<Vec<_>>()
3553                    .join("|");
3554                format!(
3555                    "{}->{}{{{}}}",
3556                    symbol_name(model, route.symbol),
3557                    destination,
3558                    properties
3559                )
3560            })
3561            .collect::<Vec<_>>()
3562            .join(",")
3563    }
3564
3565    fn block_signature(model: &TypedModel, block: &TypedStatementBlock) -> String {
3566        block
3567            .statements
3568            .iter()
3569            .map(|stmt| stmt_signature(model, stmt))
3570            .collect::<Vec<_>>()
3571            .join(";")
3572    }
3573
3574    fn stmt_signature(model: &TypedModel, stmt: &TypedStmt) -> String {
3575        match &stmt.kind {
3576            TypedStmtKind::Let(value) => format!(
3577                "let({}:{})",
3578                symbol_name(model, value.symbol),
3579                expr_signature(model, &value.value)
3580            ),
3581            TypedStmtKind::Assign(value) => format!(
3582                "assign({}={})",
3583                assign_target_signature(model, &value.target),
3584                expr_signature(model, &value.value)
3585            ),
3586            TypedStmtKind::If(value) => format!(
3587                "if({}){{{}}}else{{{}}}",
3588                expr_signature(model, &value.condition),
3589                value
3590                    .then_branch
3591                    .iter()
3592                    .map(|stmt| stmt_signature(model, stmt))
3593                    .collect::<Vec<_>>()
3594                    .join(";"),
3595                value
3596                    .else_branch
3597                    .as_ref()
3598                    .map(|branch| branch
3599                        .iter()
3600                        .map(|stmt| stmt_signature(model, stmt))
3601                        .collect::<Vec<_>>()
3602                        .join(";"))
3603                    .unwrap_or_default()
3604            ),
3605            TypedStmtKind::For(value) => format!(
3606                "for({}:{}..{}){{{}}}",
3607                symbol_name(model, value.binding),
3608                expr_signature(model, &value.range.start),
3609                expr_signature(model, &value.range.end),
3610                value
3611                    .body
3612                    .iter()
3613                    .map(|stmt| stmt_signature(model, stmt))
3614                    .collect::<Vec<_>>()
3615                    .join(";")
3616            ),
3617        }
3618    }
3619
3620    fn assign_target_signature(model: &TypedModel, target: &TypedAssignTarget) -> String {
3621        match &target.kind {
3622            TypedAssignTargetKind::Derived(symbol) => {
3623                format!("derived:{}", symbol_name(model, *symbol))
3624            }
3625            TypedAssignTargetKind::Output(symbol) => {
3626                format!("output:{}", symbol_name(model, *symbol))
3627            }
3628            TypedAssignTargetKind::StateInit(place) => {
3629                format!("init:{}", state_place_signature(model, place))
3630            }
3631            TypedAssignTargetKind::Derivative(place) => {
3632                format!("ddt:{}", state_place_signature(model, place))
3633            }
3634            TypedAssignTargetKind::Noise(place) => {
3635                format!("noise:{}", state_place_signature(model, place))
3636            }
3637        }
3638    }
3639
3640    fn state_place_signature(model: &TypedModel, place: &TypedStatePlace) -> String {
3641        let name = symbol_name(model, place.state);
3642        match &place.index {
3643            Some(index) => format!("{}[{}]", name, expr_signature(model, index)),
3644            None => name,
3645        }
3646    }
3647
3648    fn expr_signature(model: &TypedModel, expr: &TypedExpr) -> String {
3649        match &expr.kind {
3650            TypedExprKind::Literal(value) => format!("lit:{value:?}:{:?}", expr.ty),
3651            TypedExprKind::Symbol(symbol) => {
3652                format!("sym:{}:{:?}", symbol_name(model, *symbol), expr.ty)
3653            }
3654            TypedExprKind::StateValue(place) => format!(
3655                "state:{}:{:?}",
3656                state_place_signature(model, place),
3657                expr.ty
3658            ),
3659            TypedExprKind::Unary { op, expr: inner } => {
3660                format!("un:{op:?}:{}", expr_signature(model, inner))
3661            }
3662            TypedExprKind::Binary { op, lhs, rhs } => format!(
3663                "bin:{op:?}:{}:{}:{:?}",
3664                expr_signature(model, lhs),
3665                expr_signature(model, rhs),
3666                expr.ty
3667            ),
3668            TypedExprKind::Call { callee, args } => format!(
3669                "call:{}({})",
3670                match callee {
3671                    TypedCall::Math(intrinsic) => format!("math:{intrinsic:?}"),
3672                    TypedCall::Rate(symbol) => format!("rate:{}", symbol_name(model, *symbol)),
3673                },
3674                args.iter()
3675                    .map(|arg| expr_signature(model, arg))
3676                    .collect::<Vec<_>>()
3677                    .join(",")
3678            ),
3679        }
3680    }
3681
3682    fn symbol_name(model: &TypedModel, symbol: SymbolId) -> String {
3683        model
3684            .symbols
3685            .iter()
3686            .find(|entry| entry.id == symbol)
3687            .map(|entry| entry.name.clone())
3688            .unwrap_or_else(|| format!("#{symbol}"))
3689    }
3690}