diff --git a/CHANGELOG.md b/CHANGELOG.md index de62077c1..335e1c642 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ High-level release summary by `0.x` line. Patch releases are rolled up into thei - Sharpened the editor and LSP experience with separate interactive/strict work lanes, binary library caching, and cleaner import diagnostics. - Restructured the developer CLI around target-first `cargo xtask` commands and moved CI onto the prebuilt dev container image. - Added an eFMI/GALEC Algorithm Code export path (`--target galec`) that emits schema-valid eFMU and eFMI Production Code containers, with a dedicated GALEC (`.alg`) language server wired into VS Code and an embedded-C equivalence harness. +- Extended the GALEC projection to Kalman-class estimators: matrix algebra (matrix products, `transpose`, `identity`), `initial equation` → `Startup` lowering with manifest-start mirroring, and the `Matrices.solve` → `solveLinearEquations` mapping with declared-by-construction escape sets (per-method manifest Signals; the embedded C methods return the ErrorSignalStatus word). +- Added a non-eFMI embedded Rust export (`--target embedded-rust-galec`): a `#![no_std]` crate root generated by a walking minijinja template over a language-neutral GALEC block context, with `Result<(), Signals>` methods and a C↔Rust↔reference Kalman-filter equivalence test (`examples/models/QuadrotorAltitudeKF.mo`). +- Moved every GALEC rendering onto walking templates over the language-neutral GALEC template IR (SPEC_0034 D16/D17): the `.alg` text itself (byte-identical to the typed printer, pinned by a parity test) and the embedded C track (the typed C printer was deleted; adding a target language is now adding a template). - Added neural-ODE and optimization support and unified the simulation session integration across the CLI, LSP, and wasm surfaces. - Continued improving MSL trace parity and performance with an event-driven baseline ratchet published as a release asset, and cut CI time by sharding the MSL gate behind a build-once Nix path. diff --git a/Cargo.lock b/Cargo.lock index 89ce0c762..20b7c760a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4471,6 +4471,7 @@ dependencies = [ name = "rumoca-galec-codegen" version = "0.9.20" dependencies = [ + "minijinja", "rumoca-core", "rumoca-ir-dae", "rumoca-ir-galec", diff --git a/crates/rumoca-compile/src/galec_api.rs b/crates/rumoca-compile/src/galec_api.rs index 2745a29ae..f418c02f4 100644 --- a/crates/rumoca-compile/src/galec_api.rs +++ b/crates/rumoca-compile/src/galec_api.rs @@ -147,6 +147,61 @@ pub fn render_galec_c_export( }) } +/// The language-neutral, template-walkable GALEC block context (SPEC_0034 +/// D16): serialized statement/expression trees the target's minijinja +/// template walks with recursive macros — exactly how the generic IR +/// targets consume their serialized IR. One context serves every +/// GALEC-template target (`embedded-rust-galec` today); each language is a +/// template, not a printer. +#[derive(Debug, Clone)] +pub struct GalecBlockContext { + /// Serialized `rumoca_galec_codegen::template_ir` tree: collision-checked + /// base identifiers, 0-based subscripts, T7-strict Real literal text, + /// kind-tagged expression nodes, dual whole-array value/element forms. + pub context: serde_json::Value, +} + +/// Render one GALEC-template target file (path or code template) against a +/// [`GalecBlockContext`]-shaped JSON context under the standard codegen +/// environment ([`rumoca_phase_codegen::render_template_with_json_context`]), +/// so walking templates get the exact filter/function toolbox the generic +/// IR targets have (D16). +/// +/// # Errors +/// +/// [`GalecExportError::CTemplate`] on render failure (strict-undefined +/// misses included). +pub fn render_galec_block_template( + context: &serde_json::Value, + template: &str, +) -> Result { + rumoca_phase_codegen::render_template_with_json_context(context, template).map_err(|error| { + GalecExportError::CTemplate { + detail: format!("render GALEC block template: {error}"), + } + }) +} + +/// Build the template-walkable GALEC block context for a compiled model — +/// the same projection every export shares, serialized through +/// `rumoca_galec_codegen::galec_template_context` (D16). +/// +/// # Errors +/// +/// [`GalecExportError::Projection`] with all collected projection +/// diagnostics, or [`GalecExportError::Render`] for validator/serializer +/// failures on the validated package (`ET018`/`ET022`/`ET023`). +pub fn galec_block_context( + dae: &Dae, + flat: &FlatModel, + model_name: &str, +) -> Result { + let package = lower_package(dae, flat, model_name)?; + Ok(GalecBlockContext { + context: rumoca_galec_codegen::galec_template_context(&package, model_name)?, + }) +} + // =========================================================================== // Switch-dispatch packaging plan (contract §9 WI-5) // =========================================================================== @@ -541,6 +596,8 @@ pub const GALEC_PRODUCTION_TARGET: &str = "galec-production"; /// (D10: the prelude is a fixed contract with the typed C printer; duplicating /// it would be drift-prone). pub const EMBEDDED_C_GALEC_TARGET: &str = "embedded-c-galec"; +/// The non-eFMI Rust track (SPEC_0034 GAL-030/D15). +pub const EMBEDDED_RUST_GALEC_TARGET: &str = "embedded-rust-galec"; /// Shared C-layout template names (`[[files]]` of the builtin target). const HEADER_TEMPLATE: &str = "model.h.jinja"; const SOURCE_TEMPLATE: &str = "model.c.jinja"; @@ -572,6 +629,18 @@ pub const EMBEDDED_C_GALEC_CONFORMANCE_LINES: &[&str] = &[ ]; pub const EMBEDDED_C_GALEC_CONFORMANCE_SUMMARY: &str = "NOT an eFMI Production Code container."; +/// The `embedded-rust-galec` conformance claim (GAL-030): the Beta-1 +/// ProductionCode XSD restricts `language` to C/C++, so a Rust artifact can +/// never claim the Production Code rung — the honest self-description is +/// baked into the generated file's doc comment. Comment-safe. +pub const EMBEDDED_RUST_GALEC_CONFORMANCE_LINES: &[&str] = &[ + "NOT an eFMI Production Code container: the eFMI Beta-1 ProductionCode", + "schema restricts `language` to C/C++, so no eFMU manifest mapping", + "exists for this artifact. Generated by rumoca --target embedded-rust-galec.", +]; +pub const EMBEDDED_RUST_GALEC_CONFORMANCE_SUMMARY: &str = + "NOT an eFMI Production Code container (Rust is outside the Beta-1 PC schema)."; + /// Resolve the shared builtin C-layout template bundle. Absence is a /// build-system invariant break (the templates are embedded at compile /// time), reported loudly per SPEC_0008. @@ -598,21 +667,23 @@ fn render_c_layout_template( .map_err(|error| GalecExportError::CTemplate { detail: format!("{error:#}"), })?; - let mut env = minijinja::Environment::new(); - env.set_undefined_behavior(minijinja::UndefinedBehavior::Strict); - register_manifest_filters(&mut env); - env.render_str( - &source, - minijinja::context! { - conformance_header => minijinja::context! { - lines => conformance_lines, - summary => conformance_summary, - }, - ..minijinja::Value::from_serialize(c_context) - }, - ) - .map_err(|error| GalecExportError::CTemplate { - detail: format!("render '{template}': {error}"), + // D16/D17: the C templates are walking templates over the GALEC block + // context; render under the standard codegen environment so they get + // the same filter/function toolbox (`fail`, …) as every other walker. + let mut context = c_context.clone(); + if let serde_json::Value::Object(map) = &mut context { + map.insert( + "conformance_header".to_owned(), + serde_json::json!({ + "lines": conformance_lines, + "summary": conformance_summary, + }), + ); + } + rumoca_phase_codegen::render_template_with_json_context(&context, &source).map_err(|error| { + GalecExportError::CTemplate { + detail: format!("render '{template}': {error}"), + } }) } @@ -655,7 +726,10 @@ pub fn render_galec_c_files_from_context( pub fn is_galec_target(target: &str) -> bool { matches!( target, - GALEC_TARGET | GALEC_PRODUCTION_TARGET | EMBEDDED_C_GALEC_TARGET + GALEC_TARGET + | GALEC_PRODUCTION_TARGET + | EMBEDDED_C_GALEC_TARGET + | EMBEDDED_RUST_GALEC_TARGET ) } @@ -728,18 +802,6 @@ pub fn render_galec_sources( }) } -/// Register the eFMI manifest render filters (contract §3b) on a bare -/// minijinja environment: `xml_escape` (autoescape is OFF, so every text -/// value is escaped explicitly) and `xs_double` (raw `f64` → valid `xs:double` -/// lexical). Reuses the filter functions re-exported by `rumoca-galec-codegen` so the -/// guarantee is defined once and shared by every manifest render env. -pub(crate) fn register_manifest_filters(env: &mut minijinja::Environment<'_>) { - env.add_filter("xml_escape", |text: String| { - rumoca_galec_codegen::xml_escape(&text) - }); - env.add_filter("xs_double", rumoca_galec_codegen::xs_double); -} - /// Build the [`ScalarTypeMap`] for a compiled model from Flat-side declared /// types (module docs). Generated condition/`__pre__` variables stay absent /// on purpose — the projection's `classify` fallbacks own them — and any diff --git a/crates/rumoca-compile/src/lib.rs b/crates/rumoca-compile/src/lib.rs index 28ab9765f..fe97b68ac 100644 --- a/crates/rumoca-compile/src/lib.rs +++ b/crates/rumoca-compile/src/lib.rs @@ -162,11 +162,14 @@ pub mod codegen { pub mod galec { pub use crate::galec_api::{ EMBEDDED_C_GALEC_CONFORMANCE_LINES, EMBEDDED_C_GALEC_CONFORMANCE_SUMMARY, - EMBEDDED_C_GALEC_TARGET, GALEC_PRODUCTION_TARGET, GALEC_TARGET, GalecCExport, - GalecExportError, GalecPackagingPlan, GalecSources, PRODUCTION_CONFORMANCE_LINES, + EMBEDDED_C_GALEC_TARGET, EMBEDDED_RUST_GALEC_CONFORMANCE_LINES, + EMBEDDED_RUST_GALEC_CONFORMANCE_SUMMARY, EMBEDDED_RUST_GALEC_TARGET, + GALEC_PRODUCTION_TARGET, GALEC_TARGET, GalecBlockContext, GalecCExport, GalecExportError, + GalecPackagingPlan, GalecSources, PRODUCTION_CONFORMANCE_LINES, PRODUCTION_CONFORMANCE_SUMMARY, build_scalar_type_map, dae_for_galec_projection, - is_galec_target, plan_galec_export, plan_galec_production_export, render_galec_c_export, - render_galec_c_files_from_context, render_galec_sources, + galec_block_context, is_galec_target, plan_galec_export, plan_galec_production_export, + render_galec_block_template, render_galec_c_export, render_galec_c_files_from_context, + render_galec_sources, }; pub use rumoca_galec_codegen::{GalecInput, GalecOptions, GalecTargetError, ScalarTypeMap}; pub use rumoca_ir_galec::ast::ScalarType; diff --git a/crates/rumoca-galec-codegen/Cargo.toml b/crates/rumoca-galec-codegen/Cargo.toml index 4d508d13e..915166461 100644 --- a/crates/rumoca-galec-codegen/Cargo.toml +++ b/crates/rumoca-galec-codegen/Cargo.toml @@ -10,6 +10,9 @@ repository.workspace = true rumoca-core = { workspace = true } rumoca-ir-dae = { workspace = true } rumoca-ir-galec = { workspace = true } +# D17: the `.alg` text renders from the embedded walking template +# (src/templates/alg.jinja) over the language-neutral template IR. +minijinja = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } sha1 = { workspace = true } diff --git a/crates/rumoca-galec-codegen/src/admissibility.rs b/crates/rumoca-galec-codegen/src/admissibility.rs index e2897bc72..27b74dc45 100644 --- a/crates/rumoca-galec-codegen/src/admissibility.rs +++ b/crates/rumoca-galec-codegen/src/admissibility.rs @@ -35,7 +35,6 @@ pub fn check_admissibility(input: &GalecInput<'_>) -> Result) { } } -/// (g) No initial equations (GAL-025 wording): `Startup` is built from -/// manifest `start` values only, so a non-empty initialization partition -/// would be silently ignored rather than lowered — reject it up front. -fn check_initialization(dae: &Dae, errors: &mut Vec) { - let equations = dae.initialization.equations.len(); - let structured_families = dae.initialization.structured_equations.len(); - if equations > 0 || structured_families > 0 { - errors.push(GalecTargetError::InitialEquations { - equations, - structured_families, - }); - } -} +// (g) — removed: the initialization partition now lowers into `Startup` +// (GAL-028, `crate::lower::initialization`); un-lowerable forms fail there +// with stable `unsupported-feature:` diagnostics instead of a blanket ET021. /// (b) No external functions (GAL-025 wording), one error per function so /// the report names every offender. +/// +/// Exemption (D13): the `Modelica.Math.Matrices` namespace maps by name to +/// GALEC catalog builtins (`solve` → `solveLinearEquations`) instead of +/// calling its LAPACK-external MSL bodies, so referencing it must not +/// reject up front. A `Matrices` function that does NOT map still fails +/// precisely during lowering (its external callee is never inlined). fn check_external_functions(dae: &Dae, errors: &mut Vec) { for function in dae.symbols.functions.values() { + if function + .name + .as_str() + .starts_with("Modelica.Math.Matrices.") + { + continue; + } if let Some(external) = &function.external { errors.push(GalecTargetError::ExternalFunction { function: function.name.as_str().to_owned(), diff --git a/crates/rumoca-galec-codegen/src/c_mangle.rs b/crates/rumoca-galec-codegen/src/c_mangle.rs index 4896df496..18326b738 100644 --- a/crates/rumoca-galec-codegen/src/c_mangle.rs +++ b/crates/rumoca-galec-codegen/src/c_mangle.rs @@ -354,7 +354,7 @@ impl CNameTable { } } -fn literal_dimensions(dimensions: &[Dimension]) -> Result, GalecTargetError> { +pub(crate) fn literal_dimensions(dimensions: &[Dimension]) -> Result, GalecTargetError> { dimensions .iter() .map(|dimension| match dimension { diff --git a/crates/rumoca-galec-codegen/src/c_print.rs b/crates/rumoca-galec-codegen/src/c_print.rs deleted file mode 100644 index e53ea86b8..000000000 --- a/crates/rumoca-galec-codegen/src/c_print.rs +++ /dev/null @@ -1,881 +0,0 @@ -//! GALEC AST → C99 printer for the `embedded-c-galec` export (SPEC_0034 -//! GAL-024). -//! -//! Scope: exactly the AST shape [`crate::lower`] emits today — sequential -//! [`Statement::Assignment`]s over single-part `self.` state references, -//! with expressions built from literals, references, the emittable §3.2.6 -//! builtin calls ([`crate::lower::emittable_builtin_targets`]), -//! parentheses, if-expressions, `not`, unary minus over references, binary -//! operations, and whole-array start literals. Anything outside that shape -//! fails with a typed `ET023` — never silently dropped (GAL-007). -//! -//! Semantics preserved in C: -//! -//! - `and`/`or`/`not` → `&&`/`||`/`!`; `<>` → `!=`; `^` → `pow(…)` -//! (GALEC `^` returns Real for numeric operands); -//! - every composite subexpression is parenthesized, so the AST shape — the -//! normative GALEC evaluation order (trap T6) — survives verbatim and -//! nested unary/binary forms can never re-associate; -//! - Real literals reuse the strict GALEC formatter (trap T7); its output -//! (`1.0e+5`) is a valid C `double` literal; -//! - GALEC subscripts are 1-based, C subscripts 0-based: literal indices -//! shift at print time, expression indices print as `(… - 1)`; -//! - Integer is `int32_t`, so literals outside its range are rejected -//! rather than truncated; -//! - builtins map per the table below; the few without a direct C99 -//! counterpart call `static inline` helpers owned by the -//! `embedded-c-galec` templates (`rumoca_ir_galec_sign`/`min`/`max`/ -//! `imin`/`imax` — a fixed name contract, compile-checked in CI per -//! GAL-012). - -use rumoca_ir_galec::ast::{ - BinaryOp, Expression, FunctionCall, IfExpression, Name, RefPart, Reference, Statement, -}; - -use crate::c_mangle::CNameTable; -use crate::diagnostic::GalecTargetError; - -/// How one GALEC builtin prints in C. -enum CBuiltin { - /// Plain C function call (libm or a template-owned helper). - Function(&'static str), - /// `real(i)` → `((double)(i))` explicit widening cast. - RealCast, - /// `divisionTowardsZero(a, b)` → `(a / b)`; C99 integer division - /// truncates toward zero, matching the catalog semantics. - IntegerDivision, -} - -/// The GALEC §3.2.6 → C mapping for every catalog name the lowering can -/// emit, with the call arity. Parity with -/// [`crate::lower::emittable_builtin_targets`] is pinned by a unit test -/// (GAL-005: accept/lower/render stay anchored to one catalog). -static C_BUILTIN_MAP: &[(&str, usize, CBuiltin)] = &[ - ("real", 1, CBuiltin::RealCast), - ("absolute", 1, CBuiltin::Function("fabs")), - ("sign", 1, CBuiltin::Function("rumoca_ir_galec_sign")), - ("sqrt", 1, CBuiltin::Function("sqrt")), - ("exp", 1, CBuiltin::Function("exp")), - ("ln", 1, CBuiltin::Function("log")), - ("lg", 1, CBuiltin::Function("log10")), - ("roundDown", 1, CBuiltin::Function("floor")), - ("roundUp", 1, CBuiltin::Function("ceil")), - ("sin", 1, CBuiltin::Function("sin")), - ("cos", 1, CBuiltin::Function("cos")), - ("tan", 1, CBuiltin::Function("tan")), - ("asin", 1, CBuiltin::Function("asin")), - ("acos", 1, CBuiltin::Function("acos")), - ("atan", 1, CBuiltin::Function("atan")), - ("atan2", 2, CBuiltin::Function("atan2")), - ("sinh", 1, CBuiltin::Function("sinh")), - ("cosh", 1, CBuiltin::Function("cosh")), - ("tanh", 1, CBuiltin::Function("tanh")), - // GALEC min/max are the relational two-argument forms (`u1 < u2` - // selects), NOT C `fmin`/`fmax` (which drop a qNaN operand instead of - // taking the `else` branch a false comparison implies, traps T9/T14). - ("min", 2, CBuiltin::Function("rumoca_ir_galec_min")), - ("max", 2, CBuiltin::Function("rumoca_ir_galec_max")), - ("imin", 2, CBuiltin::Function("rumoca_ir_galec_imin")), - ("imax", 2, CBuiltin::Function("rumoca_ir_galec_imax")), - ("divisionTowardsZero", 2, CBuiltin::IntegerDivision), -]; - -/// GALEC-statement/-expression → C printer over one package's collision -/// checked C name table. -pub struct CPrinter<'a> { - names: &'a CNameTable, -} - -impl<'a> CPrinter<'a> { - /// Printer over the package's C name table. - #[must_use] - pub fn new(names: &'a CNameTable) -> Self { - Self { names } - } - - /// Print one statement as C source lines (one line per emitted C - /// statement; whole-array assignments expand element-wise, row-major). - /// - /// # Errors - /// - /// `ET023` for statement kinds the current lowering never emits - /// (module docs); expression errors propagate. - pub fn statement_lines(&self, statement: &Statement) -> Result, GalecTargetError> { - match statement { - Statement::Assignment { target, value } => match value { - Expression::Array(elements) => { - let mut lines = Vec::new(); - self.array_assignment(&self.reference(target)?, elements, &mut lines)?; - Ok(lines) - } - // A whole-array copy `x := y` (array target, array-valued - // reference, e.g. the `'previous(x)' := x` pre-commit for an - // array discrete state): a C array is not assignable with `=`, - // so copy its contiguous storage with `memcpy`. Both operands - // have equal dimensions (the GALEC type validator enforces it), - // so `sizeof(dst)` is the exact copy length. - Expression::Ref(source) - if self.is_whole_array_reference(target) - && self.is_whole_array_reference(source) => - { - let dst = self.reference(target)?; - let src = self.reference(source)?; - Ok(vec![format!("memcpy({dst}, {src}, sizeof({dst}));")]) - } - scalar => { - if let Some(dimensions) = self.whole_array_dimensions(target) { - let dimensions = dimensions.to_vec(); - let mut lines = Vec::new(); - self.array_expression_assignment( - &self.reference(target)?, - &dimensions, - scalar, - &mut Vec::new(), - &mut lines, - )?; - return Ok(lines); - } - Ok(vec![format!( - "{} = {};", - self.reference(target)?, - self.expression(scalar)? - )]) - } - }, - Statement::MultiAssignment { .. } => { - Err(unsupported_statement("a multi-assignment statement")) - } - Statement::Call(_) => Err(unsupported_statement("a bare call statement")), - Statement::If(_) => Err(unsupported_statement("an if statement")), - Statement::For(_) => Err(unsupported_statement("a for loop")), - Statement::Limit(_) => Err(unsupported_statement("a limit statement")), - Statement::Signal(_) => Err(unsupported_statement("a signal statement")), - } - } - - /// `target[i][j]… = element;` lines for a whole-array literal, indices - /// 0-based in nesting order (GALEC constructors and C arrays are both - /// row-major). - fn array_assignment( - &self, - target: &str, - elements: &[Expression], - lines: &mut Vec, - ) -> Result<(), GalecTargetError> { - for (index, element) in elements.iter().enumerate() { - let path = format!("{target}[{index}]"); - match element { - Expression::Array(nested) => self.array_assignment(&path, nested, lines)?, - scalar => lines.push(format!("{path} = {};", self.expression(scalar)?)), - } - } - Ok(()) - } - - /// `target[i][j]… = indexed(value);` lines for a whole-array expression - /// whose GALEC AST stays array-native but whose C expression must be scalar - /// at each assignment site. - fn array_expression_assignment( - &self, - target: &str, - dimensions: &[i64], - value: &Expression, - indices: &mut Vec, - lines: &mut Vec, - ) -> Result<(), GalecTargetError> { - let Some((first, rest)) = dimensions.split_first() else { - let element = self.indexed_expression(value, indices)?; - lines.push(format!("{target} = {};", self.expression(&element)?)); - return Ok(()); - }; - let size = usize::try_from(*first) - .ok() - .filter(|size| *size >= 1) - .ok_or_else(|| GalecTargetError::LoweringInternal { - detail: format!("C export saw non-positive array dimension {first}"), - })?; - for index in 0..size { - let path = format!("{target}[{index}]"); - let one_based = - i64::try_from(index + 1).map_err(|_| GalecTargetError::LoweringInternal { - detail: "C export array index exceeds i64".to_owned(), - })?; - indices.push(one_based); - self.array_expression_assignment(&path, rest, value, indices, lines)?; - indices.pop(); - } - Ok(()) - } - - fn indexed_expression( - &self, - expression: &Expression, - indices: &[i64], - ) -> Result { - if indices.is_empty() { - return Ok(expression.clone()); - } - match expression { - Expression::Ref(reference) if self.is_whole_array_reference(reference) => Ok( - Expression::Ref(self.reference_with_static_subscripts(reference, indices)?), - ), - Expression::Ref(_) => Ok(expression.clone()), - Expression::Neg(reference) if self.is_whole_array_reference(reference) => Ok( - Expression::Neg(self.reference_with_static_subscripts(reference, indices)?), - ), - Expression::Neg(_) => Ok(expression.clone()), - Expression::Array(elements) => self.indexed_array_element(elements, indices), - Expression::If(if_expression) => Ok(Expression::If(IfExpression { - branches: if_expression - .branches - .iter() - .map(|(condition, value)| { - Ok(( - condition.clone(), - self.index_value_if_array(value, indices)?, - )) - }) - .collect::, GalecTargetError>>()?, - else_value: Box::new( - self.index_value_if_array(&if_expression.else_value, indices)?, - ), - })), - Expression::Paren(inner) if self.expression_needs_indexing(inner) => Ok( - Expression::Paren(Box::new(self.indexed_expression(inner, indices)?)), - ), - Expression::Binary { op, lhs, rhs } => Ok(Expression::Binary { - op: *op, - lhs: Box::new(self.index_value_if_array(lhs, indices)?), - rhs: Box::new(self.index_value_if_array(rhs, indices)?), - }), - Expression::Bool(_) - | Expression::Integer(_) - | Expression::Real(_) - | Expression::Call(_) - | Expression::Paren(_) - | Expression::Not(_) - | Expression::Size { .. } => Ok(expression.clone()), - } - } - - fn index_value_if_array( - &self, - expression: &Expression, - indices: &[i64], - ) -> Result { - if self.expression_needs_indexing(expression) { - self.indexed_expression(expression, indices) - } else { - Ok(expression.clone()) - } - } - - fn indexed_array_element( - &self, - elements: &[Expression], - indices: &[i64], - ) -> Result { - let Some((first, rest)) = indices.split_first() else { - return Err(GalecTargetError::LoweringInternal { - detail: "C export array element selection called without indices".to_owned(), - }); - }; - let index = usize::try_from(*first) - .ok() - .filter(|index| *index >= 1 && *index <= elements.len()) - .ok_or_else(|| GalecTargetError::LoweringInternal { - detail: format!( - "C export array constructor index {first} is outside 1..{}", - elements.len() - ), - })?; - let selected = &elements[index - 1]; - if rest.is_empty() { - return Ok(selected.clone()); - } - if matches!(selected, Expression::Array(_)) || self.expression_needs_indexing(selected) { - return self.indexed_expression(selected, rest); - } - Err(GalecTargetError::LoweringInternal { - detail: "C export array constructor rank does not match target dimensions".to_owned(), - }) - } - - fn expression_needs_indexing(&self, expression: &Expression) -> bool { - match expression { - Expression::Ref(reference) | Expression::Neg(reference) => { - self.is_whole_array_reference(reference) - } - Expression::Array(_) => true, - Expression::If(if_expression) => { - if_expression - .branches - .iter() - .any(|(_, value)| self.expression_needs_indexing(value)) - || self.expression_needs_indexing(&if_expression.else_value) - } - Expression::Paren(inner) | Expression::Not(inner) => { - self.expression_needs_indexing(inner) - } - Expression::Binary { lhs, rhs, .. } => { - self.expression_needs_indexing(lhs) || self.expression_needs_indexing(rhs) - } - Expression::Bool(_) - | Expression::Integer(_) - | Expression::Real(_) - | Expression::Call(_) - | Expression::Size { .. } => false, - } - } - - fn reference_with_static_subscripts( - &self, - reference: &Reference, - indices: &[i64], - ) -> Result { - let Reference::State(parts) = reference else { - return Err(GalecTargetError::LoweringInternal { - detail: "C export can only index whole-array state references".to_owned(), - }); - }; - let [part] = parts.as_slice() else { - return Err(GalecTargetError::LoweringInternal { - detail: "C export can only index single-part state references".to_owned(), - }); - }; - let mut part = part.clone(); - part.subscripts = indices - .iter() - .copied() - .map(Expression::Integer) - .collect::>(); - Ok(Reference::State(vec![part])) - } - - /// Print one expression as C, fully parenthesized (module docs). - /// - /// # Errors - /// - /// `ET023` for constructs outside the lowering's emitted shape; - /// `ET018` for catalog names the lowering cannot emit. - pub fn expression(&self, expression: &Expression) -> Result { - match expression { - Expression::Bool(value) => Ok(if *value { "true" } else { "false" }.to_owned()), - Expression::Integer(value) => integer_literal(*value), - Expression::Real(value) => real_literal(*value), - Expression::Ref(reference) => self.reference(reference), - Expression::Call(call) => self.call(call), - Expression::Paren(inner) => Ok(format!("({})", self.expression(inner)?)), - Expression::If(if_expression) => self.ternary(if_expression), - Expression::Neg(reference) => Ok(format!("(-{})", self.reference(reference)?)), - Expression::Not(inner) => Ok(format!("(!({}))", self.expression(inner)?)), - Expression::Binary { op, lhs, rhs } => self.binary(*op, lhs, rhs), - Expression::Size { .. } => Err(GalecTargetError::CExportUnsupported { - construct: "a `size(…)` expression", - detail: "the current DAE lowering never emits dimension queries".to_owned(), - }), - Expression::Array(_) => Err(GalecTargetError::CExportUnsupported { - construct: "an array constructor outside a whole-array assignment", - detail: "C has no array-valued expressions; only direct \ - `target := {…}` assignments expand element-wise" - .to_owned(), - }), - } - } - - fn binary( - &self, - op: BinaryOp, - lhs: &Expression, - rhs: &Expression, - ) -> Result { - let left = self.expression(lhs)?; - let right = self.expression(rhs)?; - if op == BinaryOp::Pow { - return Ok(format!("pow({left}, {right})")); - } - let token = match op { - BinaryOp::And => "&&", - BinaryOp::Or => "||", - BinaryOp::Ne => "!=", - // `+ - * / < > <= >= ==` share their C spelling. - other => other.token(), - }; - Ok(format!("({left} {token} {right})")) - } - - /// If-expression → right-nested C conditional, one `?:` per branch. - fn ternary(&self, if_expression: &IfExpression) -> Result { - let mut out = self.expression(&if_expression.else_value)?; - for (condition, value) in if_expression.branches.iter().rev() { - out = format!( - "({} ? {} : {})", - self.expression(condition)?, - self.expression(value)?, - out - ); - } - Ok(out) - } - - fn call(&self, call: &FunctionCall) -> Result { - let Name::Ident(function, _) = &call.function else { - return Err(GalecTargetError::LoweringInternal { - detail: "C export met a call to a quoted function name; the lowering \ - only emits plain-identifier catalog builtins" - .to_owned(), - }); - }; - let Some((_, arity, form)) = C_BUILTIN_MAP - .iter() - .find(|(name, _, _)| *name == function.as_str()) - else { - return Err(GalecTargetError::LoweringInternal { - detail: format!( - "C export has no mapping for the call target `{}`; the lowering \ - only emits the emittable §3.2.6 catalog subset", - function.as_str() - ), - }); - }; - if call.arguments.len() != *arity { - return Err(GalecTargetError::LoweringInternal { - detail: format!( - "C export met `{}` with {} argument(s), expected {arity}", - function.as_str(), - call.arguments.len() - ), - }); - } - let arguments = call - .arguments - .iter() - .map(|argument| self.expression(argument)) - .collect::, _>>()?; - Ok(match form { - CBuiltin::Function(c_name) => format!("{c_name}({})", arguments.join(", ")), - CBuiltin::RealCast => format!("((double)({}))", arguments[0]), - CBuiltin::IntegerDivision => format!("({} / {})", arguments[0], arguments[1]), - }) - } - - /// Literal dimensions when `reference` is a whole-array state reference: - /// a single-part `self.x` reference with NO subscripts whose declaration is - /// an array. Indexed elements and scalars return `None`. - fn whole_array_dimensions(&self, reference: &Reference) -> Option<&[i64]> { - let Reference::State(parts) = reference else { - return None; - }; - let [part] = parts.as_slice() else { - return None; - }; - if part.subscripts.is_empty() { - self.names.array_dimensions(&part.name) - } else { - None - } - } - - /// Whether `reference` is a whole-array state reference. - fn is_whole_array_reference(&self, reference: &Reference) -> bool { - self.whole_array_dimensions(reference).is_some() - } - - /// `self.x[i]` → `self->x[i-1]` struct member access on the block-state - /// pointer. - fn reference(&self, reference: &Reference) -> Result { - let Reference::State(parts) = reference else { - return Err(GalecTargetError::CExportUnsupported { - construct: "a local (non-`self.`) reference", - detail: "the current DAE lowering emits no method locals or loop \ - iterators" - .to_owned(), - }); - }; - let [part] = parts.as_slice() else { - return Err(GalecTargetError::CExportUnsupported { - construct: "a multi-part state reference", - detail: "the current DAE lowering emits no state compartments".to_owned(), - }); - }; - self.ref_part(part) - } - - fn ref_part(&self, part: &RefPart) -> Result { - let mut out = format!("self->{}", self.names.c_name(&part.name)?); - for subscript in &part.subscripts { - out.push('['); - out.push_str(&self.zero_based(subscript)?); - out.push(']'); - } - Ok(out) - } - - /// One GALEC 1-based subscript as a C 0-based index. - fn zero_based(&self, subscript: &Expression) -> Result { - match subscript { - Expression::Integer(value) => { - if *value < 1 { - return Err(GalecTargetError::LoweringInternal { - detail: format!( - "C export met the GALEC subscript {value}; valid subscripts \ - are 1-based positive integers" - ), - }); - } - Ok((value - 1).to_string()) - } - other => Ok(format!("({} - 1)", self.expression(other)?)), - } - } -} - -/// GALEC Integer is C `int32_t`: literals outside its range are rejected, -/// never truncated (SPEC_0008). -fn integer_literal(value: i64) -> Result { - if i32::try_from(value).is_err() { - return Err(GalecTargetError::CExportUnsupported { - construct: "an Integer literal beyond int32_t", - detail: format!("literal {value} does not fit the C Integer type int32_t"), - }); - } - if value < 0 { - Ok(format!("({value})")) - } else { - Ok(value.to_string()) - } -} - -/// Strict GALEC Real spelling (trap T7) doubles as the C literal; negative -/// values are parenthesized for operand safety. -fn real_literal(value: f64) -> Result { - let text = rumoca_ir_galec::format_real_literal(value).map_err(|error| { - GalecTargetError::LoweringInternal { - detail: format!("C export met an unprintable Real literal: {error}"), - } - })?; - if value.is_sign_negative() { - Ok(format!("({text})")) - } else { - Ok(text) - } -} - -fn unsupported_statement(construct: &'static str) -> GalecTargetError { - GalecTargetError::CExportUnsupported { - construct, - detail: "the current DAE lowering emits sequential assignments only \ - (crate::lower); this statement kind cannot have come from it" - .to_owned(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use rumoca_ir_galec::ast::{ - Block, Dimension, ProtectedEntity, ProtectedKind, ScalarType, VariableDeclaration, - }; - - fn table(names: &[&str]) -> CNameTable { - table_with_dims( - &names - .iter() - .map(|name| (*name, Vec::new())) - .collect::>(), - ) - } - - fn array_table(names: &[(&str, &[i64])]) -> CNameTable { - table_with_dims( - &names - .iter() - .map(|(name, dims)| (*name, dims.to_vec())) - .collect::>(), - ) - } - - fn table_with_dims(names: &[(&str, Vec)]) -> CNameTable { - let mut block = Block::new(Name::ident("M")); - block.protected = names - .iter() - .map(|(name, dims)| { - let mut decl = VariableDeclaration::scalar( - ScalarType::Real, - crate::mangle::galec_variable_name(name).unwrap(), - ); - decl.dimensions = dims - .iter() - .copied() - .map(|size| Dimension::Expr(Expression::Integer(size))) - .collect(); - ProtectedEntity { - kind: ProtectedKind::State, - decl, - start: None, - } - }) - .collect(); - CNameTable::build(&block).unwrap() - } - - fn state(name: &str) -> Expression { - Expression::Ref(Reference::State(vec![RefPart::plain( - crate::mangle::galec_variable_name(name).unwrap(), - )])) - } - - fn print(names: &[&str], expression: &Expression) -> String { - let table = table(names); - CPrinter::new(&table).expression(expression).unwrap() - } - - #[test] - fn operators_map_and_stay_fully_parenthesized() { - // (a + b) * c, as the lowering shapes it (AST order is normative). - let expr = Expression::binary( - BinaryOp::Mul, - Expression::binary(BinaryOp::Add, state("a"), state("b")), - state("c"), - ); - assert_eq!( - print(&["a", "b", "c"], &expr), - "((self->a + self->b) * self->c)" - ); - - let logic = Expression::binary( - BinaryOp::Or, - Expression::binary(BinaryOp::Ne, state("a"), state("b")), - Expression::Not(Box::new(Expression::binary( - BinaryOp::And, - Expression::Bool(true), - state("c"), - ))), - ); - assert_eq!( - print(&["a", "b", "c"], &logic), - "((self->a != self->b) || (!((true && self->c))))" - ); - } - - #[test] - fn power_prints_as_pow_call() { - let expr = Expression::binary(BinaryOp::Pow, state("a"), Expression::Real(2.0)); - assert_eq!(print(&["a"], &expr), "pow(self->a, 2.0)"); - } - - #[test] - fn nested_negation_forms_stay_grouped() { - // The trap-T4 rewrite `0.0 - (expr)` around a negated reference. - let expr = Expression::negated_real(Expression::binary( - BinaryOp::Sub, - Expression::negated_real(state("x")), - Expression::Real(1.0), - )); - assert_eq!(print(&["x"], &expr), "(0.0 - (((-self->x) - 1.0)))"); - } - - #[test] - fn if_expression_prints_as_right_nested_ternary() { - let expr = Expression::If(IfExpression { - branches: vec![ - (Expression::Bool(true), Expression::Real(1.0)), - (state("c"), Expression::Real(2.0)), - ], - else_value: Box::new(Expression::Real(3.0)), - }); - assert_eq!(print(&["c"], &expr), "(true ? 1.0 : (self->c ? 2.0 : 3.0))"); - } - - #[test] - fn literals_print_strictly() { - assert_eq!(print(&[], &Expression::Real(0.1)), "0.1"); - assert_eq!(print(&[], &Expression::Real(-1.5)), "(-1.5)"); - assert_eq!(print(&[], &Expression::Real(1.0e21)), "1.0e+21"); - assert_eq!(print(&[], &Expression::Integer(7)), "7"); - assert_eq!(print(&[], &Expression::Integer(-7)), "(-7)"); - assert_eq!(print(&[], &Expression::Bool(false)), "false"); - } - - #[test] - fn integer_literals_beyond_int32_are_rejected() { - let table = table(&[]); - let error = CPrinter::new(&table) - .expression(&Expression::Integer(i64::from(i32::MAX) + 1)) - .unwrap_err(); - assert_eq!(error.code(), "ET023", "{error}"); - } - - #[test] - fn subscripts_shift_to_zero_based() { - let expr = Expression::Ref(Reference::State(vec![RefPart { - name: Name::ident("x"), - subscripts: vec![Expression::Integer(2)], - span: rumoca_core::Span::DUMMY, - }])); - assert_eq!(print(&["x"], &expr), "self->x[1]"); - - let dynamic = Expression::Ref(Reference::State(vec![RefPart { - name: Name::ident("x"), - subscripts: vec![state("i")], - span: rumoca_core::Span::DUMMY, - }])); - assert_eq!(print(&["x", "i"], &dynamic), "self->x[(self->i - 1)]"); - } - - #[test] - fn quoted_names_print_through_the_mangled_table() { - // A scalarized hierarchical name declares as a quoted identifier - // and prints through its collision-checked C mangling. - assert_eq!( - print(&["motor.emf.v"], &state("motor.emf.v")), - "self->motor_emf_v" - ); - - let previous_name = crate::mangle::pre_state_name("y").unwrap(); - let mut block = Block::new(Name::ident("M")); - block.protected = vec![ProtectedEntity { - kind: ProtectedKind::State, - decl: VariableDeclaration::scalar(ScalarType::Real, previous_name.clone()), - start: None, - }]; - let table = CNameTable::build(&block).unwrap(); - let previous = Expression::Ref(Reference::State(vec![RefPart::plain(previous_name)])); - assert_eq!( - CPrinter::new(&table).expression(&previous).unwrap(), - "self->previous_y" - ); - } - - #[test] - fn every_emittable_builtin_has_a_c_mapping_with_matching_arity() { - let table = table(&["a", "b"]); - let printer = CPrinter::new(&table); - for (name, arity) in crate::lower::expr::emittable_builtin_targets() { - let (_, mapped_arity, _) = C_BUILTIN_MAP - .iter() - .find(|(mapped, _, _)| *mapped == name) - .unwrap_or_else(|| panic!("emittable builtin `{name}` has no C mapping")); - assert_eq!(*mapped_arity, arity, "arity drift for `{name}`"); - let call = Expression::Call(FunctionCall { - function: Name::ident(name), - arguments: (0..arity).map(|_| state("a")).collect(), - }); - let printed = printer.expression(&call).unwrap(); - assert!(!printed.is_empty()); - } - } - - #[test] - fn specific_builtins_take_their_c99_spelling() { - let one_arg = |name: &str| { - Expression::Call(FunctionCall { - function: Name::ident(name), - arguments: vec![state("a")], - }) - }; - assert_eq!(print(&["a"], &one_arg("absolute")), "fabs(self->a)"); - assert_eq!(print(&["a"], &one_arg("ln")), "log(self->a)"); - assert_eq!(print(&["a"], &one_arg("lg")), "log10(self->a)"); - assert_eq!(print(&["a"], &one_arg("roundDown")), "floor(self->a)"); - assert_eq!(print(&["a"], &one_arg("roundUp")), "ceil(self->a)"); - assert_eq!(print(&["a"], &one_arg("real")), "((double)(self->a))"); - assert_eq!( - print(&["a"], &one_arg("sign")), - "rumoca_ir_galec_sign(self->a)" - ); - let division = Expression::Call(FunctionCall { - function: Name::ident("divisionTowardsZero"), - arguments: vec![state("a"), state("b")], - }); - assert_eq!(print(&["a", "b"], &division), "(self->a / self->b)"); - } - - #[test] - fn unmapped_call_targets_are_a_loud_projection_bug() { - let table = table(&["a"]); - let call = Expression::Call(FunctionCall { - function: Name::ident("luFactorize"), - arguments: vec![state("a")], - }); - let error = CPrinter::new(&table).expression(&call).unwrap_err(); - assert_eq!(error.code(), "ET018", "{error}"); - } - - #[test] - fn whole_array_assignments_expand_row_major() { - let table = table(&["x"]); - let statement = Statement::Assignment { - target: Reference::State(vec![RefPart::plain(Name::ident("x"))]), - value: Expression::Array(vec![ - Expression::Array(vec![Expression::Real(1.0), Expression::Real(2.0)]), - Expression::Array(vec![Expression::Real(3.0), Expression::Real(4.0)]), - ]), - }; - assert_eq!( - CPrinter::new(&table).statement_lines(&statement).unwrap(), - vec![ - "self->x[0][0] = 1.0;", - "self->x[0][1] = 2.0;", - "self->x[1][0] = 3.0;", - "self->x[1][1] = 4.0;", - ] - ); - } - - #[test] - fn whole_array_binary_assignments_expand_by_target_dimensions() { - let table = array_table(&[("y", &[3]), ("a", &[3]), ("b", &[3])]); - let statement = Statement::Assignment { - target: Reference::state(Name::ident("y")), - value: Expression::binary(BinaryOp::Sub, state("a"), state("b")), - }; - assert_eq!( - CPrinter::new(&table).statement_lines(&statement).unwrap(), - vec![ - "self->y[0] = (self->a[0] - self->b[0]);", - "self->y[1] = (self->a[1] - self->b[1]);", - "self->y[2] = (self->a[2] - self->b[2]);", - ] - ); - } - - #[test] - fn whole_array_if_assignments_index_branch_values() { - let table = array_table(&[("y", &[2]), ("a", &[2]), ("b", &[2]), ("c", &[])]); - let statement = Statement::Assignment { - target: Reference::state(Name::ident("y")), - value: Expression::If(IfExpression { - branches: vec![(state("c"), state("a"))], - else_value: Box::new(state("b")), - }), - }; - assert_eq!( - CPrinter::new(&table).statement_lines(&statement).unwrap(), - vec![ - "self->y[0] = (self->c ? self->a[0] : self->b[0]);", - "self->y[1] = (self->c ? self->a[1] : self->b[1]);", - ] - ); - } - - #[test] - fn non_assignment_statements_are_rejected_with_et023() { - let table = table(&[]); - let statement = Statement::Signal(vec![rumoca_ir_galec::ast::Identifier::new("NAN")]); - let error = CPrinter::new(&table) - .statement_lines(&statement) - .unwrap_err(); - assert_eq!(error.code(), "ET023"); - assert!(error.to_string().contains("signal statement"), "{error}"); - } - - #[test] - fn array_constructors_outside_assignments_are_rejected() { - let table = table(&[]); - let error = CPrinter::new(&table) - .expression(&Expression::Array(vec![Expression::Real(1.0)])) - .unwrap_err(); - assert_eq!(error.code(), "ET023", "{error}"); - } -} diff --git a/crates/rumoca-galec-codegen/src/diagnostic.rs b/crates/rumoca-galec-codegen/src/diagnostic.rs index 1017bef4b..14a14fec2 100644 --- a/crates/rumoca-galec-codegen/src/diagnostic.rs +++ b/crates/rumoca-galec-codegen/src/diagnostic.rs @@ -238,21 +238,6 @@ pub enum GalecTargetError { construct: &'static str, detail: String, }, - - /// GAL-025: initial equations are a projection-scope rejection. Startup - /// is built from manifest `start` values (plus the dependent-parameter - /// recomputation) only, so admitting a non-empty initialization - /// partition would silently ignore the model's initial equations. - #[error( - "model has {equations} scalar initial equation(s) \ - ({structured_families} structured initial-equation famil(y/ies)); \ - initial equations are not yet supported by the Rumoca GALEC \ - projection (Startup initializes from `start` values only) [ET021]" - )] - InitialEquations { - equations: usize, - structured_families: usize, - }, } impl GalecTargetError { @@ -280,7 +265,9 @@ impl GalecTargetError { Self::LoweringInternal { .. } => "ET018", Self::UnknownVariableReference { .. } => "ET019", Self::LoweringTypeMismatch { .. } => "ET020", - Self::InitialEquations { .. } => "ET021", + // ET021 (blanket initial-equation rejection) retired by GAL-028: + // the initialization partition lowers into `Startup`; unsupported + // forms fail as ET017 `unsupported-feature:` diagnostics. Self::CNameCollision { .. } => "ET022", Self::CExportUnsupported { .. } => "ET023", } @@ -310,7 +297,6 @@ impl GalecTargetError { | Self::StartDependencyCycle { .. } | Self::Manifest { .. } | Self::LoweringInternal { .. } - | Self::InitialEquations { .. } | Self::CNameCollision { .. } | Self::CExportUnsupported { .. } => None, } diff --git a/crates/rumoca-galec-codegen/src/emit.rs b/crates/rumoca-galec-codegen/src/emit.rs index 1b8521d60..bf164fe85 100644 --- a/crates/rumoca-galec-codegen/src/emit.rs +++ b/crates/rumoca-galec-codegen/src/emit.rs @@ -17,8 +17,6 @@ //! C-mangled struct/function naming, per-variable C types + field names, //! and C-printed method statement lines. -use serde::Serialize; - use crate::manifest_context::algorithm_code_manifest::{ AlgorithmCodeManifest, AlgorithmCodeManifestParts, BlockMethod, BlockMethods, Clock, ErrorSignalStatus, Variable as ManifestVariable, @@ -28,10 +26,7 @@ use crate::manifest_context::production_code_manifest::TargetTypeKind; use crate::manifest_context::{ FilePath, Identifier, ManifestId, NameWithoutSlashes, NormalizedText, Sha1Hex, UtcTimestamp, }; -use rumoca_ir_galec::ast::{ - Block, Dimension, Expression, InterfaceKind, Name, ProtectedKind, ScalarType, Spanned, - Statement, TypeRef, VariableDeclaration, -}; +use rumoca_ir_galec::ast::{Block, Name, ScalarType}; use crate::diagnostic::GalecTargetError; use crate::package::AlgorithmCodePackage; @@ -77,10 +72,36 @@ pub(crate) fn validate_block(block: &Block) -> Result<(), GalecTargetError> { Ok(()) } +/// The embedded `.alg` walking template (SPEC_0034 D17): renders the +/// language-neutral template IR as conformant GALEC, byte-identical to the +/// `rumoca-ir-galec` typed printer (pinned by the parity test in +/// `tests/spec_0034_estimator.rs` — the printer stays the parser-facing +/// half of the language module; this template owns emission). +static ALG_TEMPLATE: &str = include_str!("templates/alg.jinja"); + pub(crate) fn render_block(block: &Block) -> Result { - rumoca_ir_galec::print_block(block).map_err(|error| GalecTargetError::LoweringInternal { - detail: format!("GALEC printer rejected the lowered block: {error}"), - }) + let context = crate::template_ir::galec_template_context_for_block( + block, + &block_display_name(&block.name), + )?; + let mut env = minijinja::Environment::new(); + env.set_undefined_behavior(minijinja::UndefinedBehavior::Strict); + // The typed printer ends every file with a newline; minijinja strips it + // by default (byte parity, D17). + env.set_keep_trailing_newline(true); + env.add_function( + "fail", + |message: String| -> Result { + Err(minijinja::Error::new( + minijinja::ErrorKind::InvalidOperation, + message, + )) + }, + ); + env.render_str(ALG_TEMPLATE, minijinja::Value::from_serialize(&context)) + .map_err(|error| GalecTargetError::LoweringInternal { + detail: format!("alg template rejected the lowered block: {error}"), + }) } /// Shared, minted-once packaging identity of one manifest (contract §2b / @@ -227,257 +248,38 @@ pub(crate) fn block_display_name(name: &Name) -> String { } // --------------------------------------------------------------------------- -// C template context (the `embedded-c-galec` target, GAL-024) +// C template context (the `embedded-c-galec` target, GAL-024/D17) // --------------------------------------------------------------------------- -/// Typed template context (serialized shape of [`c_template_context`]). -/// Every key is consumed by the `embedded-c-galec` `target.toml` path -/// templates or `model.h.jinja`/`model.c.jinja`; the templates stay thin -/// (iteration + interpolation only) while all C syntax intelligence lives -/// in [`crate::c_print`] (D2/GAL-008 split). -#[derive(Serialize)] -struct CContext { - /// CLI model identifier (used by the `[[files]]` path templates). - model_name: String, - /// Manifest spelling of the block name, comment-safe - /// ([`c_comment_text`]) — interpolated in the file header comments. - block_name: String, - /// C typedef name of the block-state struct. - struct_name: String, - /// Prefix of the three exported method names - /// (`_startup` / `_recalibrate` / `_dostep`). - function_prefix: String, - /// Header include-guard macro. - include_guard: String, - /// Manifest-listed variables — exactly the block-state struct fields. - variables: Vec, - /// Method bodies as C statement lines. - methods: CMethods, -} - -#[derive(Serialize)] -struct CVariable { - /// Manifest spelling (quoted-identifier content for quoted names), - /// comment-safe ([`c_comment_text`]) — the templates interpolate it - /// inside C block comments only. - name: String, - /// Manifest id (`V1`…). - id: String, - /// Manifest `blockCausality` literal. - causality: &'static str, - /// C scalar type the variable maps to. - c_type: &'static str, - /// Collision-checked C struct field name ([`crate::c_mangle`]). - c_name: String, - /// Dimension sizes (empty = scalar). - dimensions: Vec, -} - -#[derive(Serialize)] -struct CMethods { - startup: Vec, - recalibrate: Vec, - do_step: Vec, -} - -/// One lowered statement — an assignment, the only kind the lowering emits -/// ([`crate::lower`]); other kinds fail with `ET023`, never drop. -#[derive(Serialize)] -struct CStatement { - kind: &'static str, - /// GALEC-printed assignment target (e.g. `self.'previous(x)'`), - /// carried for traceability comments (comment-safe, - /// [`c_comment_text`]). - target: String, - /// GALEC-printed value expression (traceability, comment-safe). - value: String, - /// C statement lines ([`crate::c_print`]); whole-array assignments - /// expand to one line per element. - c_lines: Vec, -} - -/// Serialize the typed C-template context for the `embedded-c-galec` -/// target (module docs). The block is re-validated first, exactly as in -/// [`render_algorithm_code`] (GAL-004: no rendering path prints an -/// un-validated package). +/// The template-walkable context the `embedded-c-galec` walking templates +/// consume (SPEC_0034 D16/D17) — the same language-neutral GALEC block +/// context every GALEC-rendering target shares +/// ([`crate::template_ir::galec_template_context`]); the C templates own +/// every C spelling. /// /// # Errors /// -/// `ET022` on C-name collisions, `ET023` for GALEC constructs the C -/// export does not support, `ET018` for validator/printer rejections. +/// `ET022` on base-name collisions, `ET023` for GALEC constructs the +/// export shape does not cover, `ET018` for validator rejections. pub fn c_template_context( package: &AlgorithmCodePackage, model_name: &str, ) -> Result { - validate_block(&package.block)?; - ensure_c_exportable(&package.block)?; - let names = crate::c_mangle::CNameTable::build(&package.block)?; - let variables = package - .manifest - .variables - .iter() - .map(|variable| { - let common = variable.common(); - let spelling = common.name.as_str(); - Ok(CVariable { - name: c_comment_text(spelling), - id: common.id.as_str().to_owned(), - causality: common.block_causality.as_str(), - c_type: c_scalar_type(variable), - c_name: names.c_name_by_spelling(spelling)?.to_owned(), - dimensions: common.dimensions.clone(), - }) - }) - .collect::, GalecTargetError>>()?; - let printer = crate::c_print::CPrinter::new(&names); - let function_prefix = crate::c_mangle::c_identifier(&package.block.name)?; - let context = CContext { - model_name: model_name.to_owned(), - block_name: c_comment_text(&block_display_name(&package.block.name)), - struct_name: format!("{function_prefix}State"), - include_guard: format!("{}_GALEC_C_H", function_prefix.to_ascii_uppercase()), - function_prefix, - variables, - methods: CMethods { - startup: statements(&package.block.startup.statements, &printer)?, - recalibrate: statements(&package.block.recalibrate.statements, &printer)?, - do_step: statements(&package.block.do_step.statements, &printer)?, - }, - }; - serde_json::to_value(&context).map_err(|error| GalecTargetError::LoweringInternal { - detail: format!("C template context serialization failed: {error}"), - }) + crate::template_ir::galec_template_context(package, model_name) } -/// Serialize the same typed C-template context directly from parsed GALEC -/// Algorithm Code. This is the browser/editor path for `.alg -> .h/.c`: it -/// validates the edited block and uses the same C name table, C printer, and -/// C-layout templates as the package-based export, but it does not assemble an -/// eFMI Production Code manifest or container. +/// [`c_template_context`] directly from parsed GALEC Algorithm Code — the +/// browser/editor path for `.alg -> .h/.c` (positional ids and +/// declaration-kind causalities synthesized). /// /// # Errors /// -/// `ET018`/`ET022`/`ET023` for validator, C-name, or unsupported C-export -/// findings. Dimensioned variables are accepted when their dimensions are -/// literal positive integers, matching the shape the current projection emits. +/// Those of [`c_template_context`]. pub fn c_template_context_for_block( block: &Block, model_name: &str, ) -> Result { - validate_block(block)?; - ensure_c_exportable(block)?; - let names = crate::c_mangle::CNameTable::build(block)?; - let mut ordinal = 1usize; - let mut variables = Vec::new(); - for variable in &block.interface { - variables.push(c_variable_for_decl( - &variable.decl, - next_variable_id(&mut ordinal), - interface_causality(variable.kind), - &names, - )?); - } - for entity in &block.protected { - variables.push(c_variable_for_decl( - &entity.decl, - next_variable_id(&mut ordinal), - protected_causality(entity.kind), - &names, - )?); - } - let printer = crate::c_print::CPrinter::new(&names); - let function_prefix = crate::c_mangle::c_identifier(&block.name)?; - let context = CContext { - model_name: model_name.to_owned(), - block_name: c_comment_text(&block_display_name(&block.name)), - struct_name: format!("{function_prefix}State"), - include_guard: format!("{}_GALEC_C_H", function_prefix.to_ascii_uppercase()), - function_prefix, - variables, - methods: CMethods { - startup: statements(&block.startup.statements, &printer)?, - recalibrate: statements(&block.recalibrate.statements, &printer)?, - do_step: statements(&block.do_step.statements, &printer)?, - }, - }; - serde_json::to_value(&context).map_err(|error| GalecTargetError::LoweringInternal { - detail: format!("C template context serialization failed: {error}"), - }) -} - -fn next_variable_id(ordinal: &mut usize) -> String { - let id = format!("V{ordinal}"); - *ordinal += 1; - id -} - -fn interface_causality(kind: InterfaceKind) -> &'static str { - match kind { - InterfaceKind::Input => "input", - InterfaceKind::Output => "output", - InterfaceKind::TunableParameter => "tunableParameter", - } -} - -fn protected_causality(kind: ProtectedKind) -> &'static str { - match kind { - ProtectedKind::DependentParameter => "dependentParameter", - ProtectedKind::Constant => "constant", - ProtectedKind::State => "state", - } -} - -fn c_variable_for_decl( - decl: &VariableDeclaration, - id: String, - causality: &'static str, - names: &crate::c_mangle::CNameTable, -) -> Result { - let spelling = crate::mangle::manifest_name(&decl.name); - Ok(CVariable { - name: c_comment_text(spelling), - id, - causality, - c_type: c_scalar_type_for_decl(decl)?, - c_name: names.c_name_by_spelling(spelling)?.to_owned(), - dimensions: c_dimensions(&decl.dimensions)?, - }) -} - -fn c_scalar_type_for_decl(decl: &VariableDeclaration) -> Result<&'static str, GalecTargetError> { - match &decl.ty { - TypeRef::Primitive(scalar) => Ok(c_scalar_binding(*scalar).1), - TypeRef::Compartment(_) => Err(GalecTargetError::CExportUnsupported { - construct: "a state-compartment variable", - detail: "the standalone GALEC-to-C preview currently supports only primitive block variables" - .to_owned(), - }), - } -} - -fn c_dimensions(dimensions: &[Dimension]) -> Result, GalecTargetError> { - dimensions.iter().map(c_dimension).collect() -} - -fn c_dimension(dimension: &Dimension) -> Result { - match dimension { - Dimension::Expr(Expression::Integer(value)) if *value > 0 => { - u64::try_from(*value).map_err(|_| GalecTargetError::CExportUnsupported { - construct: "a too-large array dimension", - detail: "dimension literals must fit in the generated C declaration".to_owned(), - }) - } - Dimension::Expr(_) => Err(GalecTargetError::CExportUnsupported { - construct: "a non-literal array dimension", - detail: - "the standalone GALEC-to-C preview currently supports literal positive dimensions" - .to_owned(), - }), - Dimension::Derived => Err(GalecTargetError::CExportUnsupported { - construct: "a derived array dimension", - detail: "block variables need concrete dimensions for generated C fields".to_owned(), - }), - } + crate::template_ir::galec_template_context_for_block(block, model_name) } /// Reject block shapes the current lowering never produces before any C is @@ -487,32 +289,16 @@ fn c_dimension(dimension: &Dimension) -> Result { /// Production Code manifest builder ([`crate::production_manifest`]), whose /// three-void-functions-plus-`self` description assumes exactly this shape. pub(crate) fn ensure_c_exportable(block: &Block) -> Result<(), GalecTargetError> { - let unsupported = |construct: &'static str| GalecTargetError::CExportUnsupported { - construct, - detail: "the current DAE lowering (crate::lower) never emits this construct".to_owned(), - }; - if !block.compartments.is_empty() { - return Err(unsupported("record state compartments")); - } - if !block.error_signals.is_empty() { - return Err(unsupported("user-defined error signals")); - } - if !block.protected_functions.is_empty() || !block.public_functions.is_empty() { - return Err(unsupported("user-defined functions")); - } - for method in [&block.startup, &block.recalibrate, &block.do_step] { - if !method.signals.is_empty() { - return Err(unsupported("a block-method `signals` clause")); - } - if !method.locals.is_empty() { - return Err(unsupported("method-local variables")); - } - } - Ok(()) + crate::template_ir::reject_unrepresented(block) } -fn c_scalar_type(variable: &ManifestVariable) -> &'static str { - c_scalar_binding(manifest_scalar_type(variable)).1 +/// Whether the generated C methods return the 32-bit ErrorSignalStatus word +/// (GAL-029): any declared method escape switches the whole block to the +/// status ABI (uniform signatures; signal-free methods return 0). +pub(crate) fn status_abi(block: &Block) -> bool { + [&block.startup, &block.recalibrate, &block.do_step] + .iter() + .any(|method| !method.signals.is_empty()) } /// The GALEC scalar type of a manifest variable (the manifest variable kinds @@ -539,50 +325,6 @@ pub(crate) fn c_scalar_binding(scalar: ScalarType) -> (TargetTypeKind, &'static } } -fn statements( - block_statements: &[Spanned], - printer: &crate::c_print::CPrinter<'_>, -) -> Result, GalecTargetError> { - block_statements - .iter() - .map(|statement| { - let statement = &statement.node; - // Non-assignment kinds fail here with ET023 (the printer owns - // that rejection); a kind the printer someday accepts still - // needs a CStatement shape before it can pass below. - let c_lines = printer.statement_lines(statement)?; - match statement { - Statement::Assignment { target, value } => Ok(CStatement { - kind: "assignment", - target: c_comment_text(&print_reference(target)?), - value: c_comment_text(&print_expression(value)?), - c_lines, - }), - other => Err(GalecTargetError::CExportUnsupported { - construct: "a statement kind the C context does not model", - detail: format!("statement {other:?} has no CStatement shape yet"), - }), - } - }) - .collect() -} - -fn print_expression( - expression: &rumoca_ir_galec::ast::Expression, -) -> Result { - rumoca_ir_galec::print_expression(expression).map_err(|error| { - GalecTargetError::LoweringInternal { - detail: format!("GALEC printer rejected an expression: {error}"), - } - }) -} - -fn print_reference( - reference: &rumoca_ir_galec::ast::Reference, -) -> Result { - print_expression(&rumoca_ir_galec::ast::Expression::Ref(reference.clone())) -} - /// Make traceability text safe inside a C block comment by breaking both the /// comment-close `*/` (into `* /`) and the comment-open `/*` (into `/ *`). /// Modelica quoted identifiers may legally contain either sequence @@ -593,7 +335,7 @@ fn print_reference( /// (GAL-012: failures belong in rumoca, generated C must compile). Comments /// are non-normative, so the inserted spaces are display-only; C identifiers /// go through [`crate::c_mangle`] and never through this. -fn c_comment_text(text: &str) -> String { +pub(crate) fn c_comment_text(text: &str) -> String { text.replace("*/", "* /").replace("/*", "/ *") } diff --git a/crates/rumoca-galec-codegen/src/lib.rs b/crates/rumoca-galec-codegen/src/lib.rs index 4b3ed7aa0..873d01cbf 100644 --- a/crates/rumoca-galec-codegen/src/lib.rs +++ b/crates/rumoca-galec-codegen/src/lib.rs @@ -50,9 +50,10 @@ //! `__content.xml` models, checksums, id discipline), its data-integrity //! validators, and the `#[derive(Serialize)]` context views the packaging //! templates consume (the dissolved eFMI packaging crate; D3 amended); -//! - [`c_mangle`] / [`c_print`] — the embedded-C side of the projection: -//! collision-checked GALEC-name → C-identifier mangling and the GALEC -//! AST → C99 printer feeding [`c_template_context`]; +//! - [`template_ir`] — the language-neutral walkable GALEC block context +//! every rendering target consumes (D16/D17: `.alg`, C, and Rust are +//! walking templates); [`c_mangle`] keeps the C name policy the +//! Production Code manifest describes in lockstep with the C template; //! - [`production_manifest`] — [`assemble_production_manifest`]: the typed //! eFMI Production Code manifest describing the generated C files //! (`TargetTypes`/`CodeFiles`/`LogicalData` mapping every Algorithm Code @@ -66,7 +67,6 @@ pub mod admissibility; pub mod c_mangle; -pub mod c_print; pub mod classify; pub mod diagnostic; pub mod emit; @@ -77,10 +77,10 @@ pub mod manifest_context; pub mod manifest_vars; pub mod package; pub mod production_manifest; +pub mod template_ir; pub use admissibility::{AdmittedClock, check_admissibility}; pub use c_mangle::{CNameTable, c_identifier}; -pub use c_print::CPrinter; pub use classify::{Classification, ClassifiedVariable, VariableClass, classify_variables}; pub use diagnostic::GalecTargetError; pub use emit::{ @@ -99,3 +99,4 @@ pub use package::{AlgorithmCodePackage, ManifestFragment}; pub use production_manifest::{ EmittedCodeFile, assemble_production_manifest, assemble_production_manifest_with_identity, }; +pub use template_ir::{galec_template_context, galec_template_context_for_block}; diff --git a/crates/rumoca-galec-codegen/src/lower.rs b/crates/rumoca-galec-codegen/src/lower.rs index 2d3844260..120d35794 100644 --- a/crates/rumoca-galec-codegen/src/lower.rs +++ b/crates/rumoca-galec-codegen/src/lower.rs @@ -25,17 +25,17 @@ //! this projection and reported as an internal error (ET018), never //! shipped. //! -//! # D8 (Real relationals and escape sets, trap T9) +//! # D8 / GAL-029 (escape sets) //! -//! Per SPEC_0034 D8, slice 1 lowers Real relational operators with empty -//! escape-set accounting (matching the `rumoca-ir-galec` validator's -//! documented NAN deferral), while rejecting every construct whose escape -//! set would have to be non-empty to conform: Real→Integer narrowing -//! (`unsupported-feature:real-to-integer-conversion`, which would also need -//! the floor-vs-truncate rewrite of trap T8) and anything requiring the -//! signaling linear-solver builtins. Full NAN accounting (T9) is tracked -//! for slice 2, at which point the relational stance is revisited together -//! with the validator. +//! Slice 2a: signaling builtins lower with declared-by-construction escape +//! sets — `Matrices.solve` maps to `solveLinearEquations` (D13) and each +//! method's `signals` clause is computed by the validator's own dataflow +//! ([`rumoca_ir_galec::computed_method_escapes`]), so declared == computed +//! cannot drift; the manifest carries the per-method Signals and the C +//! track switches to the status ABI. Still deferred (slice 2b): Real +//! relational NAN accounting (trap T9) and Real→Integer narrowing +//! (`unsupported-feature:real-to-integer-conversion`, which also needs the +//! floor-vs-truncate rewrite of trap T8). use rumoca_core::component_path_trailing_index; use rumoca_ir_dae::Equation; @@ -52,6 +52,7 @@ pub(crate) mod clock; pub(crate) mod conditions; pub(crate) mod expr; pub(crate) mod guard; +pub(crate) mod initialization; pub(crate) mod methods; pub(crate) mod schedule; @@ -121,7 +122,12 @@ pub fn lower_to_algorithm_code( .cloned() .collect(), ); - let mut manifest = build_manifest_variables(&kept)?; + // GAL-028: orient source initial equations, record the projection-time + // manifest `start` overrides, then (below) lower them into `Startup`. + let oriented = initialization::orient_initial_equations(input.dae, &kept)?; + let mut env = crate::manifest_vars::const_eval::ConstEnv::from_classification(&kept); + initialization::record_start_overrides(&oriented, &kept, &mut env)?; + let mut manifest = build_manifest_variables(&kept, &env)?; let starts = methods::manifest_by_dae_name(&manifest); // 6. Block sections + methods (borrowing `starts` before clock wiring @@ -129,6 +135,12 @@ pub fn lower_to_algorithm_code( let (interface, mut protected) = methods::build_sections(&kept, &starts).map_err(|error| vec![error])?; let mut startup = methods::build_startup(&kept, &conditions, &input.dae.symbols, &starts)?; + startup.extend(initialization::lower_into_startup( + &oriented, + &kept, + &conditions, + &input.dae.symbols, + )?); let recalibrate = methods::build_recalibrate(&kept, &conditions, &input.dae.symbols)?; let commits = methods::build_pre_commits(&kept, &referenced)?; do_step.extend(commits); @@ -161,16 +173,23 @@ pub fn lower_to_algorithm_code( block.recalibrate.statements = recalibrate; block.do_step.statements = do_step; + // GAL-029 (slice 2a): declare each method's escape set by construction — + // the validator's own dataflow computes it, so declared == computed + // cannot drift. Only signaling builtins contribute today (NAN relational + // accounting is slice 2b). + let escapes = rumoca_ir_galec::computed_method_escapes(&block); + block.startup.signals = escapes.startup.clone(); + block.recalibrate.signals = escapes.recalibrate.clone(); + block.do_step.signals = escapes.do_step.clone(); + let package = AlgorithmCodePackage { block, manifest: ManifestFragment { variables: manifest.variables, clock_variable_ref_id: wired.variable_ref_id, - // Slice 1 emits nothing that can signal, and Real relationals - // lower with empty escape accounting (module docs, D8). - startup_signals: Vec::new(), - recalibrate_signals: Vec::new(), - do_step_signals: Vec::new(), + startup_signals: manifest_signals(&escapes.startup), + recalibrate_signals: manifest_signals(&escapes.recalibrate), + do_step_signals: manifest_signals(&escapes.do_step), }, alg_file_name, }; @@ -178,6 +197,25 @@ pub fn lower_to_algorithm_code( Ok(package) } +/// The manifest `ErrorSignal` list for a method's declared escape set. +fn manifest_signals( + signals: &[gast::PredefinedSignal], +) -> Vec { + use crate::manifest_context::algorithm_code_manifest::ErrorSignal; + use gast::PredefinedSignal; + signals + .iter() + .map(|signal| match signal { + PredefinedSignal::InvalidArgument => ErrorSignal::InvalidArgument, + PredefinedSignal::Overflow => ErrorSignal::Overflow, + PredefinedSignal::Nan => ErrorSignal::Nan, + PredefinedSignal::SolveLinearEquationsFailed => ErrorSignal::SolveLinearEquationsFailed, + PredefinedSignal::NoSolutionFound => ErrorSignal::NoSolutionFound, + PredefinedSignal::UnspecifiedError => ErrorSignal::UnspecifiedError, + }) + .collect() +} + /// One guarded `f_z`/`f_m` row → one flat `DoStep` assignment, carrying the /// originating Modelica equation span (D11). fn lower_update_row( diff --git a/crates/rumoca-galec-codegen/src/lower/expr.rs b/crates/rumoca-galec-codegen/src/lower/expr.rs index f3f0cde82..9a2dc27c0 100644 --- a/crates/rumoca-galec-codegen/src/lower/expr.rs +++ b/crates/rumoca-galec-codegen/src/lower/expr.rs @@ -303,13 +303,7 @@ impl<'a> ExprLowerer<'a> { return self.lower_vector_dot(lhs, rhs, span); } if !left.is_scalar() && !right.is_scalar() { - return Err(unsupported( - "array-multiplication".to_owned(), - "non-vector array `*` requires Modelica matrix/vector product semantics; \ - use element-wise `.*` or wait for explicit matrix-product lowering" - .to_owned(), - Some(span), - )); + return self.lower_array_multiplication(lhs, rhs, &left, &right, span); } self.arithmetic(BinaryOp::Mul, left, right, span) } @@ -396,6 +390,127 @@ impl<'a> ExprLowerer<'a> { }) } + /// Array×array `*` after the vector-dot case: the MLS §10.6.4 product + /// forms element-unroll (GAL-027); rank-compatible operands with + /// mismatched inner dimensions are a type error; anything else (rank ≥ 3) + /// has no Modelica `*` definition. + fn lower_array_multiplication( + &mut self, + lhs: &Expression, + rhs: &Expression, + left: &Typed, + right: &Typed, + span: Span, + ) -> Result { + match matrix_product_shape(left, right) { + Some(product) => self.lower_matrix_product(lhs, rhs, product, span), + None if matrix_product_ranks(left, right) => { + Err(GalecTargetError::LoweringTypeMismatch { + context: "matrix product operands".to_owned(), + expected: "matching inner dimensions", + found: "mismatched inner dimensions", + span: optional(span), + }) + } + None => Err(unsupported( + "array-multiplication".to_owned(), + "array `*` is defined for vector·vector, matrix×vector, \ + vector×matrix, and matrix×matrix operands only; use \ + element-wise `.*` for other shapes" + .to_owned(), + Some(span), + )), + } + } + + /// Modelica matrix/vector `*` (MLS §10.6.4) element-unrolled into nested + /// `{…}` constructors of ascending-index sum trees (GAL-027). The unroll + /// order is the normative evaluation order (trap T6): element `(i, j)` + /// is `a[i,1]*b[1,j] + a[i,2]*b[2,j] + …`, never re-associated. + fn lower_matrix_product( + &mut self, + lhs: &Expression, + rhs: &Expression, + product: MatrixProduct, + span: Span, + ) -> Result { + match product { + MatrixProduct::MatVec { rows, inner } => { + let mut elements = Vec::with_capacity(usize::try_from(rows).unwrap_or_default()); + for row in 1..=rows { + elements.push(self.product_sum(lhs, rhs, Some(row), None, inner, span)?); + } + typed_array(elements, vec![rows]) + } + MatrixProduct::VecMat { inner, cols } => { + let mut elements = Vec::with_capacity(usize::try_from(cols).unwrap_or_default()); + for col in 1..=cols { + elements.push(self.product_sum(lhs, rhs, None, Some(col), inner, span)?); + } + typed_array(elements, vec![cols]) + } + MatrixProduct::MatMat { rows, inner, cols } => { + let mut out_rows = Vec::with_capacity(usize::try_from(rows).unwrap_or_default()); + for row in 1..=rows { + out_rows.push(self.matrix_product_row(lhs, rhs, row, inner, cols, span)?); + } + typed_array(out_rows, vec![rows, cols]) + } + } + } + + /// One matrix×matrix result row: `cols` unrolled sum-tree elements. + fn matrix_product_row( + &mut self, + lhs: &Expression, + rhs: &Expression, + row: i64, + inner: i64, + cols: i64, + span: Span, + ) -> Result { + let mut elements = Vec::with_capacity(usize::try_from(cols).unwrap_or_default()); + for col in 1..=cols { + elements.push(self.product_sum(lhs, rhs, Some(row), Some(col), inner, span)?); + } + typed_array(elements, vec![cols]) + } + + /// One matrix-product element: `Σ_k lhs[row, k] * rhs[k, col]` with the + /// vector operand (row/col `None`) indexed by `k` alone. Operands are + /// re-lowered per element like [`Self::lower_vector_dot`]. + fn product_sum( + &mut self, + lhs: &Expression, + rhs: &Expression, + row: Option, + col: Option, + inner: i64, + span: Span, + ) -> Result { + let mut result = None; + for k in 1..=inner { + let left_subscripts = match row { + Some(row) => vec![Subscript::index(row, span), Subscript::index(k, span)], + None => vec![Subscript::index(k, span)], + }; + let right_subscripts = match col { + Some(col) => vec![Subscript::index(k, span), Subscript::index(col, span)], + None => vec![Subscript::index(k, span)], + }; + let left = self.lower(&indexed_expression(lhs.to_owned(), left_subscripts, span))?; + let right = self.lower(&indexed_expression(rhs.to_owned(), right_subscripts, span))?; + let product = self.arithmetic(BinaryOp::Mul, left, right, span)?; + result = Some(match result { + Some(acc) => self.arithmetic(BinaryOp::Add, acc, product, span)?, + None => product, + }); + } + result.ok_or_else(|| GalecTargetError::LoweringInternal { + detail: "matrix product over an empty inner dimension".to_owned(), + }) + } + /// `+`/`-`/`*`: Integer×Integer stays Integer; mixed operands widen to /// Real via explicit `real()` casts (trap T5). fn arithmetic( @@ -610,6 +725,11 @@ impl<'a> ExprLowerer<'a> { Some(span), )); } + // D13: MSL linear-algebra functions with LAPACK-external bodies map + // by name to GALEC catalog builtins instead of inlining. + if let Some(result) = self.lower_matrices_library_call(name.as_str(), args, span)? { + return Ok(result); + } let args = self.inline_expression_function_calls_in_slice(args)?; let Some(target) = self.inline_function_target(name.as_str()) else { return Err(unsupported( @@ -643,6 +763,81 @@ impl<'a> ExprLowerer<'a> { result } + /// Recognize `Modelica.Math.Matrices` calls whose MSL bodies are + /// LAPACK-external and can never inline (D13): `solve` maps to the + /// GALEC `solveLinearEquations` builtin (whose + /// `SOLVE_LINEAR_EQUATIONS_FAILED` escape the caller's method declares, + /// GAL-029); `inv` and `solve2` get targeted guidance. + fn lower_matrices_library_call( + &mut self, + name: &str, + args: &[Expression], + span: Span, + ) -> Result, GalecTargetError> { + match name { + "Modelica.Math.Matrices.solve" => self.lower_linear_solve(args, span).map(Some), + "Modelica.Math.Matrices.solve2" => Err(unsupported( + "matrix-solve2".to_owned(), + "`Matrices.solve2` (matrix right-hand side) has no GALEC builtin; \ + solve per column with `Matrices.solve`" + .to_owned(), + Some(span), + )), + "Modelica.Math.Matrices.inv" => Err(unsupported( + "matrix-inverse".to_owned(), + "`Matrices.inv` has no GALEC builtin; rewrite `inv(A) * b` as \ + `Matrices.solve(A, b)` (better numerics, maps to the \ + `solveLinearEquations` builtin)" + .to_owned(), + Some(span), + )), + _ => Ok(None), + } + } + + /// `Matrices.solve(A, b)` → `solveLinearEquations(A, b)`: `A` square + /// Real `[n, n]`, `b` Real `[n]`, result `[n]` (§3.2.6). + fn lower_linear_solve( + &mut self, + args: &[Expression], + span: Span, + ) -> Result { + let [a, b] = args else { + return Err(GalecTargetError::LoweringTypeMismatch { + context: "Matrices.solve call".to_owned(), + expected: "solve(A, b) with two arguments", + found: "different arity", + span: optional(span), + }); + }; + let a = self.lower(a)?; + let b = self.lower(b)?; + let ([rows, cols], [len]) = (&a.shape[..], &b.shape[..]) else { + return Err(GalecTargetError::LoweringTypeMismatch { + context: "Matrices.solve operands".to_owned(), + expected: "matrix A and vector b", + found: "other shapes", + span: optional(span), + }); + }; + if rows != cols || rows != len { + return Err(GalecTargetError::LoweringTypeMismatch { + context: "Matrices.solve operands".to_owned(), + expected: "square A[n, n] with matching b[n]", + found: "mismatched dimensions", + span: optional(span), + }); + } + let length = *len; + let a = widen_to_real(a, "Matrices.solve matrix", Some(span))?; + let b = widen_to_real(b, "Matrices.solve vector", Some(span))?; + Ok(Typed::array( + call("solveLinearEquations", vec![a, b]), + ScalarType::Real, + vec![length], + )) + } + fn inline_expression_function_calls_in_slice( &mut self, expressions: &[Expression], @@ -900,6 +1095,13 @@ impl<'a> ExprLowerer<'a> { args: &[Expression], span: Span, ) -> Result { + // Array-shape builtins need shape logic the data-driven table cannot + // express (GAL-027); they unroll to `{…}` constructors here. + match function { + BuiltinFunction::Transpose => return self.lower_transpose(function, args, span), + BuiltinFunction::Identity => return self.lower_identity(function, args, span), + _ => {} + } let mapping = BUILTIN_MAP .iter() .find(|(modelica, _)| *modelica == function) @@ -964,6 +1166,83 @@ impl<'a> ExprLowerer<'a> { } } + /// `transpose(A)` element-unrolls to a reindexed `{…}` constructor + /// (GAL-027): result element `(i, j)` re-lowers `A[j, i]`. + fn lower_transpose( + &mut self, + function: BuiltinFunction, + args: &[Expression], + span: Span, + ) -> Result { + let [arg] = args else { + return Err(arity_error(function, 1, args.len(), span)); + }; + let typed = self.lower(arg)?; + let [rows, cols] = typed.shape[..] else { + return Err(GalecTargetError::LoweringTypeMismatch { + context: "transpose argument".to_owned(), + expected: "rank-2 array", + found: if typed.is_scalar() { "scalar" } else { "array" }, + span: optional(span), + }); + }; + let mut out_rows = Vec::with_capacity(usize::try_from(cols).unwrap_or_default()); + for i in 1..=cols { + let mut elements = Vec::with_capacity(usize::try_from(rows).unwrap_or_default()); + for j in 1..=rows { + elements.push(self.lower(&indexed_expression( + arg.to_owned(), + vec![Subscript::index(j, span), Subscript::index(i, span)], + span, + ))?); + } + out_rows.push(typed_array(elements, vec![rows])?); + } + typed_array(out_rows, vec![cols, rows]) + } + + /// `identity(n)` with a statically-evaluable `n` becomes an Integer + /// `{…}` constructor of 1/0 literals (MLS: `identity` is Integer-typed; + /// Real contexts widen through the array-literal `real` distribution). + fn lower_identity( + &mut self, + function: BuiltinFunction, + args: &[Expression], + span: Span, + ) -> Result { + let [arg] = args else { + return Err(arity_error(function, 1, args.len(), span)); + }; + let Some(order) = self.static_integer_expression(arg) else { + return Err(unsupported( + "identity-dynamic-order".to_owned(), + "`identity` needs a statically-evaluable Integer order \ + (GALEC dimensions are literal, trap T11)" + .to_owned(), + Some(span), + )); + }; + if order < 1 { + return Err(unsupported( + "identity-dynamic-order".to_owned(), + format!("`identity` order {order} is not a positive Integer"), + Some(span), + )); + } + let mut out_rows = Vec::with_capacity(usize::try_from(order).unwrap_or_default()); + for i in 1..=order { + let mut elements = Vec::with_capacity(usize::try_from(order).unwrap_or_default()); + for j in 1..=order { + elements.push(Typed::new( + gast::Expression::Integer(i64::from(i == j)), + ScalarType::Integer, + )); + } + out_rows.push(typed_array(elements, vec![order])?); + } + typed_array(out_rows, vec![order, order]) + } + /// 2-argument min/max: `imin`/`imax` for Integer operands, `min`/`max` /// (widening) otherwise. The 1-argument Modelica form is the array /// reduction GALEC does not have (trap T8). @@ -1128,7 +1407,9 @@ static BUILTIN_MAP: &[(BuiltinFunction, BuiltinMapping)] = &[ /// `rumoca_ir_galec::builtins::BUILTINS`. #[must_use] pub fn emittable_builtin_targets() -> Vec<(&'static str, usize)> { - let mut targets = vec![("real", 1)]; + // `real` is the trap-T5 widening cast; `solveLinearEquations` is the + // D13 `Matrices.solve` mapping (`lower_linear_solve`). + let mut targets = vec![("real", 1), ("solveLinearEquations", 2)]; for (_, mapping) in BUILTIN_MAP { match mapping { BuiltinMapping::RealUnary(name) => targets.push((name, 1)), @@ -1175,18 +1456,50 @@ pub(crate) fn widen_to_real( match typed.ty { ScalarType::Real => Ok(typed.expr), ScalarType::Integer if typed.is_scalar() => Ok(call("real", vec![typed.expr])), - ScalarType::Integer => Err(unsupported( - "array-integer-real-promotion".to_owned(), - format!( - "{context} needs Integer-array to Real-array promotion, but the current \ - GALEC projection only inserts scalar `real(...)` casts" - ), - span, - )), + ScalarType::Integer => { + if let Some(widened) = widen_integer_array_literal(&typed.expr) { + return Ok(widened); + } + Err(unsupported( + "array-integer-real-promotion".to_owned(), + format!( + "{context} needs Integer-array to Real-array promotion, but the \ + current GALEC projection only widens scalars and Integer array \ + literals" + ), + span, + )) + } ScalarType::Boolean => Err(mismatch(context, "numeric", ScalarType::Boolean, span)), } } +/// Distribute Integer→Real widening over a `{…}` constructor: Integer +/// literals become Real literals; other scalar elements get explicit +/// `real(…)` casts (trap T5 — the conversion stays visible). Non-literal +/// arrays (e.g. whole-array references) stay unsupported. +fn widen_integer_array_literal(expr: &gast::Expression) -> Option { + match expr { + gast::Expression::Array(elements) => { + let widened = elements + .iter() + .map(widen_integer_array_element) + .collect::>>()?; + Some(gast::Expression::Array(widened)) + } + _ => None, + } +} + +#[allow(clippy::cast_precision_loss)] +fn widen_integer_array_element(expr: &gast::Expression) -> Option { + match expr { + gast::Expression::Array(_) => widen_integer_array_literal(expr), + gast::Expression::Integer(value) => Some(gast::Expression::Real(*value as f64)), + other => Some(call("real", vec![other.clone()])), + } +} + fn equalize_numeric( left: Typed, right: Typed, @@ -1248,6 +1561,76 @@ fn extend_index_combinations(prefixes: Vec>, values: &[i64]) -> Vec Option { + let positive = |dims: &[i64]| dims.iter().all(|&d| d >= 1); + match (left.shape.as_slice(), right.shape.as_slice()) { + ([rows, inner], [rhs_inner]) if inner == rhs_inner && positive(&[*rows, *inner]) => { + Some(MatrixProduct::MatVec { + rows: *rows, + inner: *inner, + }) + } + ([inner], [rhs_inner, cols]) if inner == rhs_inner && positive(&[*inner, *cols]) => { + Some(MatrixProduct::VecMat { + inner: *inner, + cols: *cols, + }) + } + ([rows, inner], [rhs_inner, cols]) + if inner == rhs_inner && positive(&[*rows, *inner, *cols]) => + { + Some(MatrixProduct::MatMat { + rows: *rows, + inner: *inner, + cols: *cols, + }) + } + _ => None, + } +} + +/// Whether the operand ranks alone form a Modelica `*` product (used to +/// distinguish an inner-dimension mismatch from an undefined shape combo). +fn matrix_product_ranks(left: &Typed, right: &Typed) -> bool { + matches!( + (left.rank(), right.rank()), + (2, 1) | (1, 2) | (2, 2) | (1, 1) + ) +} + +/// Assemble unrolled elements into one `{…}` constructor, requiring the +/// element types the arithmetic produced to agree (they always do — every +/// element lowers through the same operand expressions). +fn typed_array(elements: Vec, shape: Vec) -> Result { + let Some(ty) = elements.first().map(|element| element.ty) else { + return Err(GalecTargetError::LoweringInternal { + detail: "array assembly over zero elements".to_owned(), + }); + }; + if elements.iter().any(|element| element.ty != ty) { + return Err(GalecTargetError::LoweringInternal { + detail: "array assembly produced mixed element types".to_owned(), + }); + } + Ok(Typed::array( + gast::Expression::Array(elements.into_iter().map(|element| element.expr).collect()), + ty, + shape, + )) +} + fn vector_dot_shape(left: &Typed, right: &Typed) -> Option { (left.rank() == 1 && right.rank() == 1 diff --git a/crates/rumoca-galec-codegen/src/lower/expr/references.rs b/crates/rumoca-galec-codegen/src/lower/expr/references.rs index e2824d329..e6207ab40 100644 --- a/crates/rumoca-galec-codegen/src/lower/expr/references.rs +++ b/crates/rumoca-galec-codegen/src/lower/expr/references.rs @@ -503,7 +503,7 @@ impl ExprLowerer<'_> { Ok(values) } - fn static_integer_expression(&self, expr: &Expression) -> Option { + pub(super) fn static_integer_expression(&self, expr: &Expression) -> Option { match expr { Expression::Literal { value: Literal::Integer(value), @@ -772,6 +772,13 @@ impl ExprLowerer<'_> { subscripts, span, ), + // Array-shape builtins (`identity`, `transpose`, GAL-027) lower + // to `{…}` constructors; indexing selects from the lowered + // constructor (matrix-product operand re-lowering lands here). + Expression::BuiltinCall { .. } => { + let typed = self.lower(base)?; + self.select_lowered_element(typed, subscripts, span) + } _ => Err(unsupported( "indexed-expression-base".to_owned(), format!("indexed expression over {}", super::form_name(base)), @@ -780,6 +787,59 @@ impl ExprLowerer<'_> { } } + /// Select one scalar element of an already-lowered array-shaped value + /// whose expression is a `{…}` constructor (the shape array builtins + /// produce), by statically-evaluable subscripts. + fn select_lowered_element( + &mut self, + typed: super::Typed, + subscripts: &[Subscript], + span: Span, + ) -> Result { + if subscripts.len() != typed.rank() { + return Err(unsupported( + "array-partial-subscript".to_owned(), + format!( + "{} subscript(s) over a rank-{} lowered array expression", + subscripts.len(), + typed.rank() + ), + Some(span), + )); + } + let mut expr = typed.expr; + for (subscript, dimension) in subscripts.iter().zip(&typed.shape) { + let Some(index) = self.static_index_value(subscript) else { + return Err(unsupported( + "array-dynamic-subscript".to_owned(), + "non-static subscript over a lowered array expression".to_owned(), + Some(span), + )); + }; + if index < 1 || index > *dimension { + return Err(GalecTargetError::LoweringInternal { + detail: format!( + "subscript {index} is outside dimension {dimension} of a lowered \ + array expression" + ), + }); + } + let gast::Expression::Array(mut elements) = expr else { + return Err(GalecTargetError::LoweringInternal { + detail: "lowered array expression rank does not match its constructor \ + nesting" + .to_owned(), + }); + }; + let position = + usize::try_from(index - 1).map_err(|_| GalecTargetError::LoweringInternal { + detail: "lowered array subscript exceeds usize".to_owned(), + })?; + expr = elements.swap_remove(position); + } + Ok(super::Typed::new(expr, typed.ty)) + } + fn lower_nested_index( &mut self, inner_base: &Expression, @@ -914,6 +974,13 @@ impl ExprLowerer<'_> { Some(span), )); } + // A subscripted matrix product selects the product ELEMENT (the + // ascending-index sum, GAL-027) — distributing the subscripts into + // the operands would compute the elementwise product instead + // (chained products like `A*P*transpose(A)` land here). + if matches!(op, OpBinary::Mul) && !left.is_scalar() && !right.is_scalar() { + return self.matrix_product_element(lhs, rhs, &left, &right, subscripts, span); + } if left.is_scalar() && right.is_scalar() { return Err(unsupported( "scalar-indexed-expression".to_owned(), @@ -939,6 +1006,74 @@ impl ExprLowerer<'_> { }) } + /// One statically-subscripted element of a matrix product: resolves the + /// subscripts, validates them against the product shape, and emits the + /// element's ascending-index sum via [`super::ExprLowerer::product_sum`]. + fn matrix_product_element( + &mut self, + lhs: &Expression, + rhs: &Expression, + left: &Typed, + right: &Typed, + subscripts: &[Subscript], + span: Span, + ) -> Result { + let Some(product) = super::matrix_product_shape(left, right) else { + return Err(GalecTargetError::LoweringTypeMismatch { + context: "matrix product operands".to_owned(), + expected: "matching inner dimensions", + found: "mismatched inner dimensions", + span: (!span.is_dummy()).then_some(span), + }); + }; + let indices = subscripts + .iter() + .map(|subscript| self.static_index_value(subscript)) + .collect::>>() + .ok_or_else(|| { + unsupported( + "array-dynamic-subscript".to_owned(), + "non-static subscript over a matrix product".to_owned(), + Some(span), + ) + })?; + let element = |index: i64, extent: i64| -> Result { + if index < 1 || index > extent { + return Err(GalecTargetError::LoweringInternal { + detail: format!("subscript {index} is outside matrix-product extent {extent}"), + }); + } + Ok(index) + }; + match (product, indices.as_slice()) { + (super::MatrixProduct::MatVec { rows, inner }, &[row]) => { + let row = element(row, rows)?; + self.product_sum(lhs, rhs, Some(row), None, inner, span) + } + (super::MatrixProduct::VecMat { inner, cols }, &[col]) => { + let col = element(col, cols)?; + self.product_sum(lhs, rhs, None, Some(col), inner, span) + } + (super::MatrixProduct::MatMat { rows, inner, cols }, &[row, col]) => { + let row = element(row, rows)?; + let col = element(col, cols)?; + self.product_sum(lhs, rhs, Some(row), Some(col), inner, span) + } + _ => Err(unsupported( + "array-partial-subscript".to_owned(), + format!( + "{} subscript(s) over a matrix product of rank {}", + indices.len(), + match product { + super::MatrixProduct::MatMat { .. } => 2, + _ => 1, + } + ), + Some(span), + )), + } + } + fn lower_indexed_function_call( &mut self, name: &rumoca_core::Reference, diff --git a/crates/rumoca-galec-codegen/src/lower/initialization.rs b/crates/rumoca-galec-codegen/src/lower/initialization.rs new file mode 100644 index 000000000..fa8c935dd --- /dev/null +++ b/crates/rumoca-galec-codegen/src/lower/initialization.rs @@ -0,0 +1,416 @@ +//! `initial equation` → `Startup` lowering (GAL-028, D14). +//! +//! The DAE initialization partition carries two populations: +//! +//! - **fixed-start rows** synthesized by the DAE phase (MLS §8.6, explicit +//! `lhs`, origin `"fixed start initialization for "`) — these repeat +//! the variable's `start` attribute, which the literal `Startup` mirroring +//! already emits, so they are skipped here; +//! - **source `initial equation` rows** in residual form (`lhs == None`, +//! `rhs == a - b` where the source equation was `a = b`) — these orient to +//! an assignment, dependency-sort, and lower into `Startup` after the +//! literal mirroring and the inlined `Recalibrate` recomputation. +//! +//! Ordering inside `Startup` (D14): literal mirroring first, then the +//! dependency-sorted computed statements overwriting the computed subset, +//! then `'previous(x)'` re-seeding for initialized variables with kept pre +//! slots (the pre slot's snapshot start is stale once the base variable is +//! computed). +//! +//! The manifest `start` of every computed variable (and its pre slot) is +//! the projection-time constant evaluation of the computation under default +//! parameter values, recorded as a [`ConstEnv`] override before manifest +//! building — GAL-020 "start mirrors Startup" holds by construction. +//! +//! Rejections (stable `unsupported-feature:` ids, GAL-007): +//! +//! - `implicit-initial-equation` — residual that is not `variable - expr` +//! (or `expr - variable`) for a known writable variable; +//! - `partial-initial-equation` — element-indexed target (the manifest +//! `start` override needs the whole variable); +//! - `duplicate-initial-equation` — two computations for one variable; +//! - `initial-equation-reads-input` — control inputs are not valid before +//! the first tick (GAL-028); +//! - `initial-equation-target` — target is a parameter/constant/input; +//! - `initialization-cycle` — cyclic dependencies among computed variables. + +use std::collections::{HashMap, HashSet}; + +use rumoca_core::{Expression, OpBinary, Span}; +use rumoca_ir_dae::{Dae, DaeSymbolTable, Equation}; +use rumoca_ir_galec::ast::{self as gast, Statement}; + +use crate::classify::{Classification, ClassifiedVariable, VariableClass}; +use crate::diagnostic::GalecTargetError; +use crate::lower::conditions::ConditionTable; +use crate::lower::expr::ExprLowerer; +use crate::lower::methods; +use crate::manifest_vars::const_eval::{ConstEnv, StartShape}; + +/// The origin prefix the DAE phase stamps on synthesized MLS §8.6 rows +/// (`rumoca-phase-dae/src/initial.rs`); such rows repeat the `start` +/// attribute the literal mirroring already emits. +const FIXED_START_ORIGIN_PREFIX: &str = "fixed start initialization for "; + +/// One oriented source initial equation: `target := value` at `Startup`. +pub(crate) struct OrientedInitial<'a> { + /// DAE name of the assigned variable. + pub target: String, + /// The defining DAE expression. + pub value: &'a Expression, + /// Originating Modelica span (D11). + pub span: Span, +} + +/// Orient the initialization partition into `Startup` assignments, +/// dependency-sorted; collect-all diagnostics. +pub(crate) fn orient_initial_equations<'a>( + dae: &'a Dae, + classification: &Classification<'_>, +) -> Result>, Vec> { + let mut oriented = Vec::new(); + let mut errors = Vec::new(); + let mut seen = HashSet::new(); + for equation in &dae.initialization.equations { + match orient_one(equation, classification) { + Ok(None) => {} + Ok(Some(initial)) => { + if !seen.insert(initial.target.clone()) { + errors.push(unsupported( + "duplicate-initial-equation", + format!( + "variable `{}` is initialized more than once", + initial.target + ), + initial.span, + )); + continue; + } + if let Some(input) = reads_input(initial.value, classification) { + errors.push(unsupported( + "initial-equation-reads-input", + format!( + "initial equation for `{}` reads input `{input}`; control \ + inputs are not valid before the first tick", + initial.target + ), + initial.span, + )); + continue; + } + oriented.push(initial); + } + Err(error) => errors.push(error), + } + } + if !errors.is_empty() { + return Err(errors); + } + order_by_initialization_dependencies(oriented).map_err(|error| vec![error]) +} + +fn orient_one<'a>( + equation: &'a Equation, + classification: &Classification<'_>, +) -> Result>, GalecTargetError> { + if let Some(lhs) = &equation.lhs { + if equation.origin.starts_with(FIXED_START_ORIGIN_PREFIX) { + // MLS §8.6 fixed-start row: repeats the `start` attribute the + // literal mirroring already emits. + return Ok(None); + } + let target = resolve_writable_target(lhs.as_str(), classification, equation.span)?; + return Ok(Some(OrientedInitial { + target, + value: &equation.rhs, + span: equation.span, + })); + } + // Residual form `0 = a - b` from a source `a = b`. + if let Expression::Binary { + op: OpBinary::Sub, + lhs, + rhs, + .. + } = &equation.rhs + { + if let Some(target) = plain_target_name(lhs, classification) { + let target = resolve_writable_target(&target, classification, equation.span)?; + return Ok(Some(OrientedInitial { + target, + value: rhs, + span: equation.span, + })); + } + if let Some(target) = plain_target_name(rhs, classification) { + let target = resolve_writable_target(&target, classification, equation.span)?; + return Ok(Some(OrientedInitial { + target, + value: lhs, + span: equation.span, + })); + } + } + Err(unsupported( + "implicit-initial-equation", + format!( + "initial equation does not have the explicit form `variable = expression` \ + (origin: {})", + equation.origin + ), + equation.span, + )) +} + +/// The unsubscripted variable name a residual side names, when it is a plain +/// reference to a classified variable. +fn plain_target_name(expr: &Expression, classification: &Classification<'_>) -> Option { + let Expression::VarRef { + name, subscripts, .. + } = expr + else { + return None; + }; + if !subscripts.is_empty() { + return None; + } + classification + .find(name.as_str()) + .map(|classified| classified.variable.name.as_str().to_owned()) +} + +/// A target must be a whole (unsubscripted) state or output variable. +fn resolve_writable_target( + name: &str, + classification: &Classification<'_>, + span: Span, +) -> Result { + let Some(classified) = classification.find(name) else { + // A trailing element index means a scalarized element row. + if rumoca_core::component_path_trailing_index(name) + .and_then(|(base, _)| classification.find(&base)) + .is_some() + { + return Err(unsupported( + "partial-initial-equation", + format!( + "initial equation targets element `{name}`; only whole-variable \ + initialization lowers (the manifest start mirrors the whole value)" + ), + span, + )); + } + return Err(GalecTargetError::UnknownVariableReference { + name: name.to_owned(), + span: (!span.is_dummy()).then_some(span), + }); + }; + match classified.class { + VariableClass::State | VariableClass::Output => Ok(name.to_owned()), + VariableClass::Input + | VariableClass::TunableParameter + | VariableClass::DependentParameter + | VariableClass::Constant => Err(unsupported( + "initial-equation-target", + format!( + "initial equation targets `{name}` ({:?}); only states and outputs \ + take computed initialization", + classified.class + ), + span, + )), + } +} + +/// The first input variable an expression reads, if any (GAL-028). +fn reads_input(expr: &Expression, classification: &Classification<'_>) -> Option { + methods::referenced_names(expr).into_iter().find(|name| { + classification + .find(name) + .is_some_and(|classified| classified.class == VariableClass::Input) + }) +} + +/// Stable topological order over the computed variables (reads-before-writes +/// among the initialized set); cycles are a stable diagnostic. +fn order_by_initialization_dependencies( + oriented: Vec>, +) -> Result>, GalecTargetError> { + let index_by_target: HashMap<&str, usize> = oriented + .iter() + .enumerate() + .map(|(index, initial)| (initial.target.as_str(), index)) + .collect(); + let mut ordered = Vec::with_capacity(oriented.len()); + let mut state = vec![VisitState::Unvisited; oriented.len()]; + for index in 0..oriented.len() { + visit(index, &oriented, &index_by_target, &mut state, &mut ordered)?; + } + drop(index_by_target); + let mut slots: Vec>> = oriented.into_iter().map(Some).collect(); + Ok(ordered + .into_iter() + .filter_map(|index| slots[index].take()) + .collect()) +} + +#[derive(Clone, Copy, PartialEq)] +enum VisitState { + Unvisited, + Visiting, + Done, +} + +fn visit( + index: usize, + oriented: &[OrientedInitial<'_>], + index_by_target: &HashMap<&str, usize>, + state: &mut [VisitState], + ordered: &mut Vec, +) -> Result<(), GalecTargetError> { + match state[index] { + VisitState::Done => return Ok(()), + VisitState::Visiting => { + return Err(unsupported( + "initialization-cycle", + format!( + "initial equations form a dependency cycle through `{}`", + oriented[index].target + ), + oriented[index].span, + )); + } + VisitState::Unvisited => {} + } + state[index] = VisitState::Visiting; + for name in methods::referenced_names(oriented[index].value) { + if let Some(&dependency) = index_by_target.get(name.as_str()) { + visit(dependency, oriented, index_by_target, state, ordered)?; + } + } + state[index] = VisitState::Done; + ordered.push(index); + Ok(()) +} + +/// Record manifest `start` overrides: the projection-time evaluation of each +/// computation (in dependency order) under default parameter values, plus +/// the same shape for the variable's pre slot (its snapshot start is stale). +pub(crate) fn record_start_overrides( + oriented: &[OrientedInitial<'_>], + classification: &Classification<'_>, + env: &mut ConstEnv<'_>, +) -> Result<(), Vec> { + let mut errors = Vec::new(); + for initial in oriented { + let Some(classified) = classification.find(&initial.target) else { + continue; + }; + match env.evaluate_start_shape(initial.value) { + Ok(shape) => { + if let StartShape::Scalar(value) = &shape { + env.insert_computed(initial.target.clone(), *value); + } + if let Some(pre_slot) = pre_slot_name(classification, &initial.target) { + env.insert_start_override(pre_slot, shape.clone()); + } + env.insert_start_override(initial.target.clone(), shape); + } + Err(failure) => errors.push(failure.into_error(classified.variable, "start")), + } + } + if errors.is_empty() { + Ok(()) + } else { + Err(errors) + } +} + +/// The DAE name of the kept pre slot whose base is `target`, if any. +fn pre_slot_name(classification: &Classification<'_>, target: &str) -> Option { + classification + .variables + .iter() + .find(|classified| classified.pre_base.as_deref() == Some(target)) + .map(|classified| classified.variable.name.as_str().to_owned()) +} + +/// Lower the oriented computations into `Startup` statements (already +/// dependency-ordered), then re-seed `'previous(x)'` for computed variables +/// with kept pre slots. +pub(crate) fn lower_into_startup( + oriented: &[OrientedInitial<'_>], + classification: &Classification<'_>, + conditions: &ConditionTable<'_>, + functions: &DaeSymbolTable, +) -> Result>, Vec> { + let mut lowerer = ExprLowerer::new(classification, conditions, functions); + let mut statements = Vec::new(); + let mut seeds = Vec::new(); + let mut errors = Vec::new(); + for initial in oriented { + let Some(classified) = classification.find(&initial.target) else { + continue; + }; + match lower_one(initial, classified, &mut lowerer) { + Ok(statement) => { + statements.push(statement); + if let Some(seed) = pre_seed(classification, classified, &initial.target) { + seeds.push(seed); + } + } + Err(error) => errors.push(error), + } + } + if !errors.is_empty() { + return Err(errors); + } + statements.extend(seeds); + Ok(statements) +} + +fn lower_one( + initial: &OrientedInitial<'_>, + classified: &ClassifiedVariable<'_>, + lowerer: &mut ExprLowerer<'_>, +) -> Result, GalecTargetError> { + let typed = lowerer.lower(initial.value)?; + let value = methods::coerce_to(typed, classified.scalar_type, &initial.target)?; + Ok(gast::Spanned::new( + Statement::Assignment { + target: state_target(classified.galec_name.clone()), + value, + }, + initial.span, + )) +} + +/// `'previous(x)' := x` re-seed after the computed assignment (D14). +fn pre_seed( + classification: &Classification<'_>, + computed: &ClassifiedVariable<'_>, + target: &str, +) -> Option> { + let pre_name = pre_slot_name(classification, target)?; + let pre = classification.find(&pre_name)?; + Some(gast::Spanned::dummy(Statement::Assignment { + target: state_target(pre.galec_name.clone()), + value: crate::lower::expr::state_ref(computed.galec_name.clone(), Vec::new()), + })) +} + +fn state_target(name: gast::Name) -> gast::Reference { + gast::Reference::State(vec![gast::RefPart { + name, + subscripts: Vec::new(), + span: Span::DUMMY, + }]) +} + +fn unsupported(feature: &str, detail: String, span: Span) -> GalecTargetError { + GalecTargetError::UnsupportedFeature { + feature: feature.to_owned(), + detail, + span: (!span.is_dummy()).then_some(span), + } +} diff --git a/crates/rumoca-galec-codegen/src/lower/methods.rs b/crates/rumoca-galec-codegen/src/lower/methods.rs index e2da3f346..119ced426 100644 --- a/crates/rumoca-galec-codegen/src/lower/methods.rs +++ b/crates/rumoca-galec-codegen/src/lower/methods.rs @@ -291,7 +291,7 @@ fn visit<'c, 'a>( } /// All variable names read by an expression (for dependency ordering only). -fn referenced_names(expr: &rumoca_core::Expression) -> Vec { +pub(crate) fn referenced_names(expr: &rumoca_core::Expression) -> Vec { struct Collector(Vec); impl rumoca_core::ExpressionVisitor for Collector { fn visit_var_ref( diff --git a/crates/rumoca-galec-codegen/src/manifest_vars.rs b/crates/rumoca-galec-codegen/src/manifest_vars.rs index 7d45691a8..5661dd861 100644 --- a/crates/rumoca-galec-codegen/src/manifest_vars.rs +++ b/crates/rumoca-galec-codegen/src/manifest_vars.rs @@ -48,11 +48,12 @@ pub struct ManifestVariables { /// Build the manifest `Variables` list from a classification, collecting all /// failures. Projection-internal variables are excluded (see -/// [`crate::classify`] module docs). +/// [`crate::classify`] module docs). The caller owns the [`ConstEnv`] so +/// GAL-028 initialization overrides can be recorded before building. pub fn build_manifest_variables( classification: &Classification<'_>, + env: &ConstEnv<'_>, ) -> Result> { - let env = ConstEnv::from_classification(classification); let mut result = ManifestVariables::default(); let mut errors = Vec::new(); for classified in classification @@ -61,7 +62,7 @@ pub fn build_manifest_variables( .filter(|classified| !classified.projection_internal) { let id = format!("V{}", result.variables.len() + 1); - match build_one(classified, &id, &env) { + match build_one(classified, &id, env) { Ok(variable) => { result .ids_by_dae_name @@ -252,10 +253,16 @@ fn evaluate_start( }) .ok() }; - let Some(expr) = &variable.start else { - return coerce_reported(&mls_default, errors).map(StartValue::Scalar); + // GAL-028: a computed initialization overrides the declared `start` + // attribute — the manifest mirrors what `Startup` actually computes. + let evaluated = if let Some(shape) = env.start_override(variable.name.as_str()) { + Ok(shape.clone()) + } else if let Some(expr) = &variable.start { + env.evaluate_start_shape(expr) + } else { + Ok(const_eval::StartShape::Scalar(mls_default)) }; - match env.evaluate_start_shape(expr) { + match evaluated { Ok(const_eval::StartShape::Scalar(value)) => { coerce_reported(&value, errors).map(StartValue::Scalar) } diff --git a/crates/rumoca-galec-codegen/src/manifest_vars/const_eval.rs b/crates/rumoca-galec-codegen/src/manifest_vars/const_eval.rs index 3b9eea08a..1f69cb13e 100644 --- a/crates/rumoca-galec-codegen/src/manifest_vars/const_eval.rs +++ b/crates/rumoca-galec-codegen/src/manifest_vars/const_eval.rs @@ -136,10 +136,14 @@ impl EvalFailure { /// Evaluation environment: scalar parameter/constant defaults referencable /// by name (tunable parameters, dependent parameters, and constants — the -/// classes whose `start` carries a default expression). +/// classes whose `start` carries a default expression), plus an overlay of +/// already-computed scalar values (GAL-028: initialized variables evaluated +/// in dependency order feed later initialization expressions). #[derive(Debug, Default)] pub struct ConstEnv<'a> { definitions: HashMap<&'a str, &'a Variable>, + computed: HashMap, + start_overrides: HashMap, } impl<'a> ConstEnv<'a> { @@ -158,7 +162,30 @@ impl<'a> ConstEnv<'a> { }) .map(|classified| (classified.variable.name.as_str(), classified.variable)) .collect(); - Self { definitions } + Self { + definitions, + computed: HashMap::new(), + start_overrides: HashMap::new(), + } + } + + /// Record an already-computed scalar value; later evaluations resolve + /// the name through this overlay before the defaults (GAL-028). + pub fn insert_computed(&mut self, name: String, value: ConstValue) { + self.computed.insert(name, value); + } + + /// Record the projection-time evaluation of a variable's `Startup` + /// computation; the manifest `start` for that variable mirrors this + /// shape instead of the declared `start` attribute (GAL-028). + pub fn insert_start_override(&mut self, name: String, shape: StartShape) { + self.start_overrides.insert(name, shape); + } + + /// The manifest `start` override for a variable, if one was computed. + #[must_use] + pub fn start_override(&self, name: &str) -> Option<&StartShape> { + self.start_overrides.get(name) } /// Evaluate an expression that must fold to one scalar constant. @@ -166,19 +193,110 @@ impl<'a> ConstEnv<'a> { self.eval(expr, &mut Vec::new()) } - /// Evaluate a `start` expression preserving its scalar/array shape. - /// Array constructors are legal at the top level only and flatten - /// row-major (nested constructors are matrix rows). + /// Evaluate a `start` expression preserving its scalar/array shape, + /// row-major. Array values arise from constructors, `identity(n)`, and + /// elementwise arithmetic over them (scalar⊗array broadcast; equal-length + /// array `+`/`-`/`.*`/`./`; unary sign) — enough for computed + /// initializations like `p0*identity(n)` (GAL-028). A matrix *product* + /// in constant position stays not-evaluable (assign it via an initial + /// equation over intermediate variables instead). pub fn evaluate_start_shape(&self, expr: &Expression) -> Result { - if let Expression::Array { .. } = expr { - let mut values = Vec::new(); - self.flatten_array(expr, &mut values)?; - Ok(StartShape::Array(values)) - } else { - self.evaluate_scalar(expr).map(StartShape::Scalar) + match self.try_array_values(expr)? { + Some(values) => Ok(StartShape::Array(values)), + None => self.evaluate_scalar(expr).map(StartShape::Scalar), } } + /// The row-major element values of an array-shaped constant expression, + /// or `None` when the expression is scalar-shaped. + fn try_array_values(&self, expr: &Expression) -> Result>, EvalFailure> { + match expr { + Expression::Array { .. } => { + let mut values = Vec::new(); + self.flatten_array(expr, &mut values)?; + Ok(Some(values)) + } + Expression::BuiltinCall { + function: rumoca_core::BuiltinFunction::Identity, + args, + span, + } => self.identity_values(args, *span).map(Some), + Expression::Unary { op, rhs, span } => { + let Some(values) = self.try_array_values(rhs)? else { + return Ok(None); + }; + values + .into_iter() + .map(|value| eval_unary(op.clone(), value, *span)) + .collect::, _>>() + .map(Some) + } + Expression::Binary { op, lhs, rhs, span } => { + let left = self.try_array_values(lhs)?; + let right = self.try_array_values(rhs)?; + match (left, right) { + (None, None) => Ok(None), + (Some(values), None) => { + let scalar = self.evaluate_scalar(rhs)?; + values + .into_iter() + .map(|value| eval_binary(op.clone(), value, scalar, *span)) + .collect::, _>>() + .map(Some) + } + (None, Some(values)) => { + let scalar = self.evaluate_scalar(lhs)?; + values + .into_iter() + .map(|value| eval_binary(op.clone(), scalar, value, *span)) + .collect::, _>>() + .map(Some) + } + (Some(left), Some(right)) => { + elementwise_binary(op, left, right, *span).map(Some) + } + } + } + _ => Ok(None), + } + } + + /// `identity(n)` as row-major Integer element values. + fn identity_values( + &self, + args: &[Expression], + span: Span, + ) -> Result, EvalFailure> { + let [order] = args else { + return Err(EvalFailure::not_evaluable( + "`identity` takes exactly one argument", + optional(span), + )); + }; + let ConstValue::Integer(order) = self.evaluate_scalar(order)? else { + return Err(EvalFailure::not_evaluable( + "`identity` order must be an Integer constant", + optional(span), + )); + }; + let order = usize::try_from(order) + .ok() + .filter(|n| *n >= 1) + .ok_or_else(|| { + EvalFailure::not_evaluable( + "`identity` order must be a positive Integer", + optional(span), + ) + })?; + let mut values = Vec::with_capacity(order * order); + for row in 0..order { + for col in 0..order { + values.push(ConstValue::Integer(i64::from(row == col))); + } + } + Ok(values) + } + fn flatten_array( &self, expr: &Expression, @@ -239,6 +357,9 @@ impl<'a> ConstEnv<'a> { span, )); } + if let Some(value) = self.computed.get(name) { + return Ok(*value); + } let Some((key, definition)) = self.definitions.get_key_value(name) else { return Err(EvalFailure::not_evaluable( format!("`{name}` is not a scalar parameter or constant with a default"), @@ -273,6 +394,32 @@ fn optional(span: Span) -> Option { (!span.is_dummy()).then_some(span) } +/// Elementwise combination of two equal-length array values; a matrix +/// *product* stays not-evaluable (GAL-028 module docs). +fn elementwise_binary( + op: &rumoca_core::OpBinary, + left: Vec, + right: Vec, + span: Span, +) -> Result, EvalFailure> { + if matches!(op, rumoca_core::OpBinary::Mul) { + return Err(EvalFailure::not_evaluable( + "matrix product is not supported in constant evaluation", + optional(span), + )); + } + if left.len() != right.len() { + return Err(EvalFailure::not_evaluable( + "elementwise operands have different element counts", + optional(span), + )); + } + left.into_iter() + .zip(right) + .map(|(l, r)| eval_binary(op.clone(), l, r, span)) + .collect() +} + fn eval_literal(value: &Literal, span: Span) -> Result { match value { Literal::Real(value) => Ok(ConstValue::Real(*value)), diff --git a/crates/rumoca-galec-codegen/src/production_manifest.rs b/crates/rumoca-galec-codegen/src/production_manifest.rs index 5d084f473..ff8fecc66 100644 --- a/crates/rumoca-galec-codegen/src/production_manifest.rs +++ b/crates/rumoca-galec-codegen/src/production_manifest.rs @@ -24,24 +24,28 @@ //! precedent: self-checksum is impossible); //! - `TargetTypes` `TT_F64`/`TT_I32`/`TT_BOOL`/`TT_VOID` and their alias //! `Typedefs` `TD_F64`/`TD_I32`/`TD_BOOL`/`TD_VOID` (names = the literal C -//! tokens `double`/`int32_t`/`bool`/`void`); -//! - header `CodeFile` `CF_H`: the four aliases plus the block-state struct +//! tokens `double`/`int32_t`/`bool`/`void`); under the GAL-029 status ABI +//! additionally `TT_STATUS`/`TD_STATUS` (`uint32_t`, the 32-bit +//! ErrorSignalStatus word); +//! - header `CodeFile` `CF_H`: the aliases plus the block-state struct //! `Typedef` `TD_STATE` (one `Component` `CO_` per manifest variable //! `V`, with literal array dimensions passed through — arrays are //! first-class, D5); -//! - source `CodeFile` `CF_C` (includes `CF_H`): the three exported void +//! - source `CodeFile` `CF_C` (includes `CF_H`): the three exported //! block-method functions `FN_STARTUP`/`FN_RECALIBRATE`/`FN_DOSTEP` -//! (return parameters `RP_*` typed `TD_VOID`, one `self` formal parameter -//! `FP_*_SELF` pointing at `TD_STATE`). The `static inline` builtin -//! helpers of the C prelude are file-internal and deliberately **not** -//! published — the `Functions` list is for globally accessible entities; +//! (return parameters `RP_*` typed `TD_VOID`, or `TD_STATUS` when any +//! method declares a signal escape — the GAL-029 status ABI; one `self` +//! formal parameter `FP_*_SELF` pointing at `TD_STATE`). The +//! `static inline` builtin helpers of the C prelude are file-internal and +//! deliberately **not** published — the `Functions` list is for globally +//! accessible entities; //! - `LogicalData`: one `DataReference` per Algorithm Code variable, //! anchored on the DoStep `self` parameter (`FP_DOSTEP_SELF`) with the C //! field name as whole-field `componentIdentifier` (no indices — arrays //! map as the field; D5/D7), plus one `FunctionReference` per block -//! method. No `ErrorSignalStatus` mapping is emitted: the generated -//! methods return void and expose no status variable (D8; the -//! cross-validator permits, never requires, an ESS mapping). +//! method. No `ErrorSignalStatus` *variable* mapping is emitted: under the +//! status ABI the word is the method return value, not a state variable +//! (the cross-validator permits, never requires, an ESS mapping). //! //! Post-validation (GAL-004 idiom): the assembled manifest is checked by //! [`ProductionCodeManifest::new`] and then cross-validated against the @@ -82,6 +86,9 @@ const COMPONENT_ID_PREFIX: &str = "CO_"; /// `crate::emit::c_scalar_binding`; `void` is not a GALEC scalar type). const VOID_C_TYPE: &str = "void"; +/// C token of the GAL-029 status-ABI return type (32-bit ErrorSignalStatus). +const STATUS_C_TYPE: &str = "uint32_t"; + /// (`TargetType` id, alias `Typedef` id) per GALEC scalar type; the bound C /// token and eFMI kind come from `crate::emit::c_scalar_binding`. const fn scalar_type_ids(scalar: ScalarType) -> (&'static str, &'static str) { @@ -184,8 +191,11 @@ pub fn assemble_production_manifest_with_identity( let function_prefix = crate::c_mangle::c_identifier(&package.block.name)?; let (components, data_references) = variable_mappings(package, &names)?; - let (target_types, typedefs) = type_bindings(&format!("{function_prefix}State"), components)?; - let (functions, function_references) = method_functions(&function_prefix, ac_manifest)?; + let status_abi = crate::emit::status_abi(&package.block); + let (target_types, typedefs) = + type_bindings(&format!("{function_prefix}State"), components, status_abi)?; + let (functions, function_references) = + method_functions(&function_prefix, ac_manifest, status_abi)?; let parts = ProductionCodeManifestParts { attributes: production_attributes(package, identity)?, @@ -258,9 +268,10 @@ fn variable_mappings( fn type_bindings( struct_name: &str, components: Vec, + status_abi: bool, ) -> Result<(Vec, Vec), GalecTargetError> { - let mut target_types = Vec::with_capacity(SCALAR_TYPES.len() + 1); - let mut typedefs = Vec::with_capacity(SCALAR_TYPES.len() + 2); + let mut target_types = Vec::with_capacity(SCALAR_TYPES.len() + 2); + let mut typedefs = Vec::with_capacity(SCALAR_TYPES.len() + 3); for scalar in SCALAR_TYPES { let (kind, c_token) = crate::emit::c_scalar_binding(scalar); let (target_type_id, type_def_id) = scalar_type_ids(scalar); @@ -277,6 +288,16 @@ fn type_bindings( coded_type: NormalizedText::new(VOID_C_TYPE)?, }); typedefs.push(alias_typedef("TD_VOID", VOID_C_TYPE, "TT_VOID")?); + if status_abi { + // GAL-029 status ABI: the methods return the 32-bit + // ErrorSignalStatus word. + target_types.push(TargetType { + id: Identifier::new("TT_STATUS")?, + kind: TargetTypeKind::EfmiUnsignedInteger32, + coded_type: NormalizedText::new(STATUS_C_TYPE)?, + }); + typedefs.push(alias_typedef("TD_STATUS", STATUS_C_TYPE, "TT_STATUS")?); + } typedefs.push(Typedef { id: Identifier::new(STATE_TYPEDEF_ID)?, name: NormalizedText::new(struct_name)?, @@ -290,6 +311,7 @@ fn type_bindings( fn method_functions( function_prefix: &str, ac_manifest: &AlgorithmCodeManifest, + status_abi: bool, ) -> Result<(Vec, Vec), GalecTargetError> { let methods = &ac_manifest.parts().block_methods; let method_ids = [ @@ -297,15 +319,17 @@ fn method_functions( &methods.recalibrate.id, &methods.do_step.id, ]; + let return_typedef = if status_abi { "TD_STATUS" } else { "TD_VOID" }; let mut functions = Vec::with_capacity(METHOD_FUNCTIONS.len()); let mut function_references = Vec::with_capacity(METHOD_FUNCTIONS.len()); for ((function_id, return_id, self_id, suffix), method_id) in METHOD_FUNCTIONS.into_iter().zip(method_ids) { - functions.push(void_self_function( + functions.push(self_function( function_id, &format!("{function_prefix}_{suffix}"), return_id, + return_typedef, self_id, )?); function_references.push(FunctionReference { @@ -419,11 +443,14 @@ fn alias_typedef( }) } -/// One exported block-method function: `void ( *self)`. -fn void_self_function( +/// One exported block-method function: +/// ` ( *self)` — `TD_VOID`, or `TD_STATUS` +/// (the `uint32_t` ErrorSignalStatus word) under the GAL-029 status ABI. +fn self_function( id: &str, name: &str, return_id: &str, + return_typedef: &str, self_id: &str, ) -> Result { Ok(Function { @@ -431,7 +458,7 @@ fn void_self_function( name: NormalizedText::new(name)?, return_parameter: ParameterCore { id: Identifier::new(return_id)?, - type_def_ref_id: Identifier::new("TD_VOID")?, + type_def_ref_id: Identifier::new(return_typedef)?, constant: false, pointer: false, const_pointer: false, diff --git a/crates/rumoca-galec-codegen/src/template_ir.rs b/crates/rumoca-galec-codegen/src/template_ir.rs new file mode 100644 index 000000000..2b80c5d87 --- /dev/null +++ b/crates/rumoca-galec-codegen/src/template_ir.rs @@ -0,0 +1,846 @@ +//! Language-neutral, template-walkable context over a validated GALEC block +//! (SPEC_0034 D16/D17): the target template — not a typed printer — +//! generates the code by walking this tree with recursive minijinja macros, +//! exactly like the generic IR targets walk their serialized IR. Every +//! GALEC-rendering target consumes this one context: the `.alg` text +//! itself, the embedded C track, and the embedded Rust track. +//! +//! The pass owns everything semantic, so templates stay token-level: +//! +//! - names carry BOTH spellings: `galec_name` is the exact `.alg` token +//! (quoted identifiers keep their quotes, e.g. `'previous(x)'`) and +//! `base_name` is the collision-checked identifier stem for +//! curly-brace languages (non-alphanumeric → `_`, trailing `_` trimmed, +//! distinctness enforced). Keyword escaping is the template's job (each +//! language appends `_` against its own keyword list); because base +//! names never end in `_`, a template-appended `_` cannot re-collide; +//! - subscripts and projection indices are the GALEC truth — **1-based**; +//! 0-based languages subtract in the template (`[{{ i - 1 }}]`); +//! - Real literals arrive as T7-strict text (`1.0e+5` — valid in GALEC, C, +//! and Rust alike) plus a `negative` flag for operand parenthesization; +//! Integer literals are validated against the GALEC Integer range (i32); +//! - expressions are `kind`-tagged nodes (`binary` ops are abstract names — +//! `add`/`pow`/… — each template maps its own spellings and precedence); +//! - whole-array assignments carry BOTH the structural value tree and a +//! pre-projected element list (`indices` + scalar expression per +//! element), so a language with array values (GALEC, Rust) assigns +//! wholesale while a language without them (C) expands element-wise — +//! no projection logic in any template; +//! - the GAL-029 `solve` statement is its own kind, with the same dual +//! value/element forms for its matrix and vector operands (the `.alg` +//! template reconstructs the catalog call; embedded templates +//! materialize scratch and accumulate the status word); +//! - variables walk the block's declaration order and carry their section +//! (`interface`/`protected`) and GALEC declaration `prefix` +//! (`input `/`output `/`parameter `/`constant `/``) so the `.alg` +//! template reproduces the declaration lists exactly. +//! +//! Statement kinds: `assign` (scalar target), `assign_whole` (whole-array +//! target), `solve`. Expression kinds: `bool`, `int`, `real`, `ref`, +//! `neg`, `not`, `paren`, `binary`, `if`, `call` (GALEC §3.2.6 catalog +//! name), `array`. + +use std::collections::HashMap; + +use serde_json::{Value, json}; + +use rumoca_ir_galec::ast::{ + BinaryOp, Block, Expression, IfExpression, InterfaceKind, Name, ProtectedKind, Reference, + ScalarType, Spanned, Statement, TypeRef, VariableDeclaration, +}; + +use crate::diagnostic::GalecTargetError; +use crate::emit::{c_comment_text, status_abi, validate_block}; +use crate::mangle::manifest_name; +use crate::package::AlgorithmCodePackage; + +/// Serialize the template-walkable context for one Algorithm Code package +/// (D16), joining the authoritative manifest ids/causalities. The block is +/// re-validated first (GAL-004: no rendering path consumes an un-validated +/// package). +/// +/// # Errors +/// +/// `ET022` on base-identifier collisions, `ET023` for GALEC constructs the +/// export shape does not cover, `ET018` for validator rejections. +pub fn galec_template_context( + package: &AlgorithmCodePackage, + model_name: &str, +) -> Result { + let mut manifest_info = HashMap::new(); + for variable in &package.manifest.variables { + let common = variable.common(); + manifest_info.insert( + common.name.as_str().to_owned(), + ( + common.id.as_str().to_owned(), + common.block_causality.as_str(), + ), + ); + } + context_with_info(&package.block, model_name, |spelling| { + manifest_info + .get(spelling) + .cloned() + .ok_or_else(|| GalecTargetError::LoweringInternal { + detail: format!("declared variable `{spelling}` has no manifest entry to join"), + }) + }) +} + +/// [`galec_template_context`] directly from a (possibly hand-written, +/// already-parsed) GALEC block — the editor path: positional `V1…` ids and +/// declaration-kind causalities are synthesized exactly like the manifest +/// builder would. +/// +/// # Errors +/// +/// Those of [`galec_template_context`]. +pub fn galec_template_context_for_block( + block: &Block, + model_name: &str, +) -> Result { + let mut ordinal = 0usize; + let mut synthesized = HashMap::new(); + for (decl, _, causality) in declared_entities(block) { + ordinal += 1; + synthesized.insert( + manifest_name(&decl.name).to_owned(), + (format!("V{ordinal}"), causality), + ); + } + context_with_info(block, model_name, |spelling| { + synthesized + .get(spelling) + .cloned() + .ok_or_else(|| GalecTargetError::LoweringInternal { + detail: format!("declared variable `{spelling}` lost its synthesized id"), + }) + }) +} + +/// Declared entities in block order with their `.alg` declaration prefix +/// and manifest `blockCausality` literal. +fn declared_entities( + block: &Block, +) -> impl Iterator { + let interface = block.interface.iter().map(|variable| { + let (prefix, causality) = match variable.kind { + InterfaceKind::Input => ("input ", "input"), + InterfaceKind::Output => ("output ", "output"), + InterfaceKind::TunableParameter => ("parameter ", "tunableParameter"), + }; + ( + &variable.decl, + DeclarationPlace { + section: "interface", + prefix, + }, + causality, + ) + }); + let protected = block.protected.iter().map(|entity| { + let (prefix, causality) = match entity.kind { + ProtectedKind::DependentParameter => ("parameter ", "dependentParameter"), + ProtectedKind::Constant => ("constant ", "constant"), + ProtectedKind::State => ("", "state"), + }; + ( + &entity.decl, + DeclarationPlace { + section: "protected", + prefix, + }, + causality, + ) + }); + interface.chain(protected) +} + +#[derive(Clone, Copy)] +struct DeclarationPlace { + section: &'static str, + prefix: &'static str, +} + +fn context_with_info( + block: &Block, + model_name: &str, + info: impl Fn(&str) -> Result<(String, &'static str), GalecTargetError>, +) -> Result { + validate_block(block)?; + reject_unrepresented(block)?; + let names = BaseNames::build(block)?; + let mut variables = Vec::new(); + for (decl, place, _) in declared_entities(block) { + let spelling = manifest_name(&decl.name); + let TypeRef::Primitive(scalar) = &decl.ty else { + return Err(GalecTargetError::CExportUnsupported { + construct: "a compartment-typed declaration", + detail: "the current DAE lowering (crate::lower) never emits this construct" + .to_owned(), + }); + }; + let (id, causality) = info(spelling)?; + variables.push(json!({ + "name": c_comment_text(spelling), + "galec_name": galec_token(&decl.name), + "base_name": names.base_by_spelling(spelling)?, + "id": id, + "causality": causality, + "section": place.section, + "prefix": place.prefix, + "scalar": scalar_name(*scalar), + "dimensions": crate::c_mangle::literal_dimensions(&decl.dimensions)?, + })); + } + let walker = StatementWalker { names: &names }; + Ok(json!({ + "model_name": model_name, + "block_name": c_comment_text(&crate::emit::block_display_name(&block.name)), + "galec_name": galec_token(&block.name), + "base_name": base_identifier_of(&block.name), + "status_abi": status_abi(block), + "variables": variables, + "methods": { + "startup": walker.method(&block.startup.statements, &block.startup.signals)?, + "recalibrate": + walker.method(&block.recalibrate.statements, &block.recalibrate.signals)?, + "do_step": walker.method(&block.do_step.statements, &block.do_step.signals)?, + }, + })) +} + +/// Loud rejections for block features the context does not (yet) model — +/// the current lowering never emits them, and silently dropping them would +/// miscompile (GAL-007). +pub(crate) fn reject_unrepresented(block: &Block) -> Result<(), GalecTargetError> { + let unsupported = |construct: &'static str| GalecTargetError::CExportUnsupported { + construct, + detail: "the current DAE lowering (crate::lower) never emits this construct".to_owned(), + }; + if !block.compartments.is_empty() { + return Err(unsupported("record state compartments")); + } + if !block.error_signals.is_empty() { + return Err(unsupported("user-defined error signals")); + } + if !block.protected_functions.is_empty() || !block.public_functions.is_empty() { + return Err(unsupported("user-defined functions")); + } + for method in [&block.startup, &block.recalibrate, &block.do_step] { + if !method.locals.is_empty() { + return Err(unsupported("method-local variables")); + } + } + for (decl, _, _) in declared_entities(block) { + if !decl.range.is_empty() { + return Err(unsupported("declaration (min, max) range attributes")); + } + } + Ok(()) +} + +fn scalar_name(scalar: ScalarType) -> &'static str { + match scalar { + ScalarType::Real => "Real", + ScalarType::Integer => "Integer", + ScalarType::Boolean => "Boolean", + } +} + +/// The exact `.alg` token of a GALEC name: plain identifiers verbatim, +/// quoted identifiers with their quotes. +fn galec_token(name: &Name) -> String { + match name { + Name::Ident(ident, _) => ident.as_str().to_owned(), + Name::Quoted(content, _) => format!("'{content}'"), + } +} + +/// The language-neutral half of name mangling: normalize the manifest +/// spelling to a base identifier, or `None` when the spelling cannot begin +/// one (quoted GALEC names have looser lexical rules than identifiers — +/// such names print fine in `.alg` but embedded templates must `fail()` on +/// a `null` base name instead of emitting an illegal identifier). Keyword +/// escaping is deliberately absent (template-owned, module docs). +fn base_identifier_of(name: &Name) -> Option { + let spelling = manifest_name(name); + let first_is_letter = spelling + .chars() + .next() + .is_some_and(|first| first.is_ascii_alphabetic()); + if !first_is_letter { + return None; + } + let mut base: String = spelling + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect(); + while base.ends_with('_') { + base.pop(); + } + Some(base) +} + +/// Collision-checked base identifiers plus declared array dimensions over +/// one block's variables. +struct BaseNames { + by_spelling: HashMap>, + array_dimensions: HashMap>, +} + +impl BaseNames { + fn build(block: &Block) -> Result { + let mut by_spelling = HashMap::new(); + let mut array_dimensions = HashMap::new(); + let mut owners: HashMap = HashMap::new(); + for (decl, _, _) in declared_entities(block) { + let spelling = manifest_name(&decl.name).to_owned(); + let base = base_identifier_of(&decl.name); + if let Some(base) = &base { + claim_base(&mut owners, base, &spelling)?; + } + if !decl.dimensions.is_empty() { + array_dimensions.insert( + spelling.clone(), + crate::c_mangle::literal_dimensions(&decl.dimensions)?, + ); + } + by_spelling.insert(spelling, base); + } + Ok(Self { + by_spelling, + array_dimensions, + }) + } + + fn base_by_spelling(&self, spelling: &str) -> Result, GalecTargetError> { + self.by_spelling + .get(spelling) + .map(|base| base.as_deref()) + .ok_or_else(|| GalecTargetError::LoweringInternal { + detail: format!( + "template context met a reference to `{spelling}`, which the block \ + never declared" + ), + }) + } + + fn base(&self, name: &Name) -> Result, GalecTargetError> { + self.base_by_spelling(manifest_name(name)) + } + + fn dimensions(&self, name: &Name) -> Option<&[i64]> { + self.array_dimensions + .get(manifest_name(name)) + .map(Vec::as_slice) + } +} + +/// Record `base` as owned by `spelling`, failing on a cross-spelling +/// collision (ET022). +fn claim_base( + owners: &mut HashMap, + base: &str, + spelling: &str, +) -> Result<(), GalecTargetError> { + if let Some(first) = owners.get(base) + && first != spelling + { + return Err(GalecTargetError::CNameCollision { + first: first.clone(), + second: spelling.to_owned(), + c_name: base.to_owned(), + }); + } + owners.insert(base.to_owned(), spelling.to_owned()); + Ok(()) +} + +struct StatementWalker<'a> { + names: &'a BaseNames, +} + +impl StatementWalker<'_> { + fn method( + &self, + statements: &[Spanned], + signals: &[rumoca_ir_galec::ast::PredefinedSignal], + ) -> Result { + let statements = statements + .iter() + .map(|statement| self.statement(&statement.node)) + .collect::, _>>()?; + Ok(json!({ + "signals": signals.iter().map(|signal| signal.name()).collect::>(), + "statements": statements, + })) + } + + fn statement(&self, statement: &Statement) -> Result { + match statement { + Statement::Assignment { target, value } => { + if let Expression::Call(call) = value + && matches!(&call.function, Name::Ident(f, _) if f.as_str() == "solveLinearEquations") + { + return self.solve(target, &call.arguments); + } + match self.whole_array_dimensions(target) { + Some(dimensions) => { + let dimensions = dimensions.to_vec(); + Ok(json!({ + "kind": "assign_whole", + "target": self.reference(target)?, + "dimensions": dimensions, + "value": self.expression(value)?, + // Whole-array reference copy (never a scalar + // broadcast): languages with array values assign + // directly, C memcpys. + "copy": self.is_whole_array_copy(value), + "elements": self.projected_elements(&dimensions, value)?, + })) + } + None => Ok(json!({ + "kind": "assign", + "target": self.reference(target)?, + "value": self.expression(value)?, + })), + } + } + Statement::MultiAssignment { .. } => Err(unsupported("a multi-assignment statement")), + Statement::Call(_) => Err(unsupported("a bare call statement")), + Statement::If(_) => Err(unsupported("an if statement")), + Statement::For(_) => Err(unsupported("a for loop")), + Statement::Limit(_) => Err(unsupported("a limit statement")), + Statement::Signal(_) => Err(unsupported("a signal statement")), + } + } + + /// `x := solveLinearEquations(A, b);` (GAL-029): both operands carry + /// the dual value/element forms so embedded templates can materialize + /// scratch copies without projection logic, while the `.alg` template + /// reconstructs the catalog call from the value trees. + fn solve( + &self, + target: &Reference, + arguments: &[Expression], + ) -> Result { + let [a, b] = arguments else { + return Err(GalecTargetError::LoweringInternal { + detail: format!( + "template context met `solveLinearEquations` with {} argument(s), \ + expected 2", + arguments.len() + ), + }); + }; + let Some(&[n]) = self.whole_array_dimensions(target) else { + return Err(GalecTargetError::LoweringInternal { + detail: "template context needs a whole-vector target for \ + `solveLinearEquations`" + .to_owned(), + }); + }; + Ok(json!({ + "kind": "solve", + "n": n, + "target": self.reference(target)?, + "a": self.expression(a)?, + "a_copy": self.is_whole_array_copy(a), + "a_elements": self.projected_elements(&[n, n], a)?, + "b": self.expression(b)?, + "b_copy": self.is_whole_array_copy(b), + "b_elements": self.projected_elements(&[n], b)?, + })) + } + + /// Row-major element projection of a whole-array value: one + /// `{ indices: [1-based…], value: }` entry per element. + fn projected_elements( + &self, + dimensions: &[i64], + value: &Expression, + ) -> Result, GalecTargetError> { + let mut elements = Vec::new(); + self.project(dimensions, value, &mut Vec::new(), &mut elements)?; + Ok(elements) + } + + fn project( + &self, + dimensions: &[i64], + value: &Expression, + indices: &mut Vec, + elements: &mut Vec, + ) -> Result<(), GalecTargetError> { + let Some((first, rest)) = dimensions.split_first() else { + let projected = self.indexed_expression(value, indices)?; + elements.push(json!({ + "indices": indices.clone(), + "value": self.expression(&projected)?, + })); + return Ok(()); + }; + let size = usize::try_from(*first) + .ok() + .filter(|size| *size >= 1) + .ok_or_else(|| GalecTargetError::LoweringInternal { + detail: format!("template context saw non-positive array dimension {first}"), + })?; + for index in 1..=size { + indices.push( + i64::try_from(index).map_err(|_| GalecTargetError::LoweringInternal { + detail: "template context array index exceeds i64".to_owned(), + })?, + ); + self.project(rest, value, indices, elements)?; + indices.pop(); + } + Ok(()) + } + + /// Scalar projection of an array-native expression at 1-based `indices` + /// (the same rules the lowering uses: whole-array references subscript, + /// constructors select, binaries/ifs distribute, scalars pass through). + fn indexed_expression( + &self, + expression: &Expression, + indices: &[i64], + ) -> Result { + if indices.is_empty() { + return Ok(expression.clone()); + } + match expression { + Expression::Ref(reference) if self.is_whole_array_reference(reference) => Ok( + Expression::Ref(reference_with_static_subscripts(reference, indices)?), + ), + Expression::Ref(_) => Ok(expression.clone()), + Expression::Neg(reference) if self.is_whole_array_reference(reference) => Ok( + Expression::Neg(reference_with_static_subscripts(reference, indices)?), + ), + Expression::Neg(_) => Ok(expression.clone()), + Expression::Array(elements) => self.indexed_array_element(elements, indices), + Expression::If(if_expression) => Ok(Expression::If(IfExpression { + branches: if_expression + .branches + .iter() + .map(|(condition, value)| { + Ok(( + condition.clone(), + self.index_value_if_array(value, indices)?, + )) + }) + .collect::, GalecTargetError>>()?, + else_value: Box::new( + self.index_value_if_array(&if_expression.else_value, indices)?, + ), + })), + Expression::Paren(inner) if self.expression_needs_indexing(inner) => Ok( + Expression::Paren(Box::new(self.indexed_expression(inner, indices)?)), + ), + Expression::Binary { op, lhs, rhs } => Ok(Expression::Binary { + op: *op, + lhs: Box::new(self.index_value_if_array(lhs, indices)?), + rhs: Box::new(self.index_value_if_array(rhs, indices)?), + }), + Expression::Bool(_) + | Expression::Integer(_) + | Expression::Real(_) + | Expression::Call(_) + | Expression::Paren(_) + | Expression::Not(_) + | Expression::Size { .. } => Ok(expression.clone()), + } + } + + fn index_value_if_array( + &self, + expression: &Expression, + indices: &[i64], + ) -> Result { + if self.expression_needs_indexing(expression) { + self.indexed_expression(expression, indices) + } else { + Ok(expression.clone()) + } + } + + fn indexed_array_element( + &self, + elements: &[Expression], + indices: &[i64], + ) -> Result { + let Some((first, rest)) = indices.split_first() else { + return Err(GalecTargetError::LoweringInternal { + detail: "template context array element selection called without indices" + .to_owned(), + }); + }; + let element = usize::try_from(*first) + .ok() + .and_then(|index| index.checked_sub(1)) + .and_then(|index| elements.get(index)) + .ok_or_else(|| GalecTargetError::LoweringInternal { + detail: format!( + "template context array element index {first} is outside the constructor" + ), + })?; + if rest.is_empty() && !self.expression_needs_indexing(element) { + return Ok(element.clone()); + } + if matches!(element, Expression::Array(_)) || self.expression_needs_indexing(element) { + return self.indexed_expression(element, rest); + } + Err(GalecTargetError::LoweringInternal { + detail: "template context array constructor rank does not match target dimensions" + .to_owned(), + }) + } + + fn expression_needs_indexing(&self, expression: &Expression) -> bool { + match expression { + Expression::Ref(reference) | Expression::Neg(reference) => { + self.is_whole_array_reference(reference) + } + Expression::Array(_) => true, + Expression::If(if_expression) => { + if_expression + .branches + .iter() + .any(|(_, value)| self.expression_needs_indexing(value)) + || self.expression_needs_indexing(&if_expression.else_value) + } + Expression::Paren(inner) | Expression::Not(inner) => { + self.expression_needs_indexing(inner) + } + Expression::Binary { lhs, rhs, .. } => { + self.expression_needs_indexing(lhs) || self.expression_needs_indexing(rhs) + } + Expression::Bool(_) + | Expression::Integer(_) + | Expression::Real(_) + | Expression::Call(_) + | Expression::Size { .. } => false, + } + } + + fn whole_array_dimensions(&self, reference: &Reference) -> Option<&[i64]> { + let Reference::State(parts) = reference else { + return None; + }; + let [part] = parts.as_slice() else { + return None; + }; + if part.subscripts.is_empty() { + self.names.dimensions(&part.name) + } else { + None + } + } + + fn is_whole_array_reference(&self, reference: &Reference) -> bool { + self.whole_array_dimensions(reference).is_some() + } + + /// Whether a whole-assignment value is a whole-array reference (a + /// direct copy — never a scalar broadcast). + fn is_whole_array_copy(&self, value: &Expression) -> bool { + matches!(value, Expression::Ref(reference) if self.is_whole_array_reference(reference)) + } + + // ----------------------------------------------------------------- + // Expression nodes + // ----------------------------------------------------------------- + + fn expression(&self, expression: &Expression) -> Result { + match expression { + Expression::Bool(value) => Ok(json!({ "kind": "bool", "value": value })), + Expression::Integer(value) => { + // GALEC Integer is 32-bit (§3.1.6); reject over-range + // literals once for every target language (SPEC_0008: + // never truncated). + if i32::try_from(*value).is_err() { + return Err(GalecTargetError::CExportUnsupported { + construct: "an Integer literal beyond the GALEC Integer range", + detail: format!("literal {value} does not fit 32 bits"), + }); + } + Ok(json!({ "kind": "int", "value": value })) + } + Expression::Real(value) => { + let text = rumoca_ir_galec::format_real_literal(*value).map_err(|error| { + GalecTargetError::LoweringInternal { + detail: format!( + "template context met an unprintable Real literal: {error}" + ), + } + })?; + Ok(json!({ + "kind": "real", + "text": text, + "negative": value.is_sign_negative(), + })) + } + Expression::Ref(reference) => self.reference(reference), + Expression::Neg(reference) => Ok(json!({ + "kind": "neg", + "value": self.reference(reference)?, + })), + Expression::Not(inner) => Ok(json!({ + "kind": "not", + "value": self.expression(inner)?, + })), + Expression::Paren(inner) => Ok(json!({ + "kind": "paren", + "value": self.expression(inner)?, + })), + Expression::Binary { op, lhs, rhs } => Ok(json!({ + "kind": "binary", + "op": op_name(*op), + "lhs": self.expression(lhs)?, + "rhs": self.expression(rhs)?, + })), + Expression::If(if_expression) => Ok(json!({ + "kind": "if", + "branches": if_expression + .branches + .iter() + .map(|(condition, value)| { + Ok(json!({ + "condition": self.expression(condition)?, + "value": self.expression(value)?, + })) + }) + .collect::, GalecTargetError>>()?, + "else": self.expression(&if_expression.else_value)?, + })), + Expression::Call(call) => { + let Name::Ident(function, _) = &call.function else { + return Err(GalecTargetError::LoweringInternal { + detail: "template context met a call to a quoted function name".to_owned(), + }); + }; + Ok(json!({ + "kind": "call", + "builtin": function.as_str(), + "args": call + .arguments + .iter() + .map(|argument| self.expression(argument)) + .collect::, GalecTargetError>>()?, + })) + } + Expression::Array(elements) => Ok(json!({ + "kind": "array", + "elements": elements + .iter() + .map(|element| self.expression(element)) + .collect::, GalecTargetError>>()?, + })), + Expression::Size { .. } => Err(unsupported("a `size(…)` expression")), + } + } + + /// `self.x[i]` → `{ kind: "ref", base_name, galec_name, indices: [i] }` + /// (indices 1-based, the GALEC truth). + fn reference(&self, reference: &Reference) -> Result { + let Reference::State(parts) = reference else { + return Err(unsupported("a local (non-`self.`) reference")); + }; + let [part] = parts.as_slice() else { + return Err(unsupported("a multi-part state reference")); + }; + let indices = part + .subscripts + .iter() + .map(|subscript| match subscript { + Expression::Integer(value) if *value >= 1 => Ok(*value), + other => Err(GalecTargetError::LoweringInternal { + detail: format!( + "template context met a non-literal GALEC subscript {other:?}; \ + the lowering emits literal 1-based subscripts only" + ), + }), + }) + .collect::, GalecTargetError>>()?; + Ok(json!({ + "kind": "ref", + "base_name": self.names.base(&part.name)?, + "galec_name": galec_token(&part.name), + "indices": indices, + })) + } +} + +/// Abstract operator names — each template maps its own spellings and +/// precedence classes. +fn op_name(op: BinaryOp) -> &'static str { + match op { + BinaryOp::Add => "add", + BinaryOp::Sub => "sub", + BinaryOp::Mul => "mul", + BinaryOp::Div => "div", + BinaryOp::Pow => "pow", + BinaryOp::Lt => "lt", + BinaryOp::Le => "le", + BinaryOp::Gt => "gt", + BinaryOp::Ge => "ge", + BinaryOp::Eq => "eq", + BinaryOp::Ne => "ne", + BinaryOp::And => "and", + BinaryOp::Or => "or", + } +} + +fn reference_with_static_subscripts( + reference: &Reference, + indices: &[i64], +) -> Result { + let Reference::State(parts) = reference else { + return Err(GalecTargetError::LoweringInternal { + detail: "template context can only index whole-array state references".to_owned(), + }); + }; + let [part] = parts.as_slice() else { + return Err(GalecTargetError::LoweringInternal { + detail: "template context can only index single-part state references".to_owned(), + }); + }; + let mut part = part.clone(); + part.subscripts = indices + .iter() + .copied() + .map(Expression::Integer) + .collect::>(); + Ok(Reference::State(vec![part])) +} + +fn unsupported(construct: &'static str) -> GalecTargetError { + GalecTargetError::CExportUnsupported { + construct, + detail: "the current DAE lowering (crate::lower) never emits this construct".to_owned(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn base_identifiers_never_end_in_underscore() { + let name = crate::mangle::pre_state_name("y").unwrap(); + assert_eq!(base_identifier_of(&name).as_deref(), Some("previous_y")); + assert_eq!( + base_identifier_of(&Name::quoted("a.b[2]")).as_deref(), + Some("a_b_2") + ); + assert_eq!(base_identifier_of(&Name::quoted("__shadow")), None); + } + + #[test] + fn galec_tokens_keep_their_quotes() { + assert_eq!(galec_token(&Name::ident("y")), "y"); + assert_eq!(galec_token(&Name::quoted("previous(y)")), "'previous(y)'"); + } +} diff --git a/crates/rumoca-galec-codegen/src/templates/alg.jinja b/crates/rumoca-galec-codegen/src/templates/alg.jinja new file mode 100644 index 000000000..e26ad4819 --- /dev/null +++ b/crates/rumoca-galec-codegen/src/templates/alg.jinja @@ -0,0 +1,97 @@ +{#- ================================================================== + GALEC `.alg` walking template (SPEC_0034 D17): renders the + language-neutral GALEC template IR (template_ir.rs) as conformant + GALEC Algorithm Code, byte-identical to the rumoca-ir-galec typed + printer (pinned by the parity test) — GAL-019 minimal + parenthesization included: cross-precedence-class mixes always + parenthesize (trap T6), same-class chains print bare only on the + associative side, unary operations and negative literals + parenthesize as operands. + =================================================================== -#} +{%- set classes = { + "pow": "power", + "mul": "multiplicative", "div": "multiplicative", + "add": "additive", "sub": "additive", + "lt": "relational", "gt": "relational", "le": "relational", "ge": "relational", + "eq": "equality", "ne": "equality", + "and": "logical_and", "or": "logical_or" +} -%} +{%- set tokens = { + "pow": "^", "mul": "*", "div": "/", "add": "+", "sub": "-", + "lt": "<", "gt": ">", "le": "<=", "ge": ">=", "eq": "==", "ne": "<>", + "and": "and", "or": "or" +} -%} +{#- `^` is right-associative; every other class is left-associative. -#} +{%- macro assoc_side(class) -%} +{%- if class == "power" -%}right{%- else -%}left{%- endif -%} +{%- endmacro -%} +{%- macro ref(r) -%} +self.{{ r.galec_name }}{% if r.indices %}[{% for i in r.indices %}{{ i }}{% if not loop.last %}, {% endif %}{% endfor %}]{% endif %} +{%- endmacro -%} +{%- macro operand(child, parent_class, side) -%} +{%- if child.kind == "binary" -%} +{%- if classes[child.op] != parent_class or side != assoc_side(parent_class) -%} +({{ expr(child) }}) +{%- else -%} +{{ expr(child) }} +{%- endif -%} +{%- elif child.kind == "neg" or child.kind == "not" -%} +({{ expr(child) }}) +{%- elif child.kind == "int" and child.value < 0 -%} +({{ expr(child) }}) +{%- elif child.kind == "real" and child.negative -%} +({{ expr(child) }}) +{%- else -%} +{{ expr(child) }} +{%- endif -%} +{%- endmacro -%} +{%- macro expr(e) -%} +{%- if e.kind == "bool" -%}{{ e.value }} +{%- elif e.kind == "int" -%}{{ e.value }} +{%- elif e.kind == "real" -%}{{ e.text }} +{%- elif e.kind == "ref" -%}{{ ref(e) }} +{%- elif e.kind == "neg" -%}-{{ ref(e.value) }} +{%- elif e.kind == "not" -%} +{%- if e.value.kind == "if" or e.value.kind == "paren" -%} +not {{ expr(e.value) }} +{%- else -%} +not ({{ expr(e.value) }}) +{%- endif -%} +{%- elif e.kind == "paren" -%}({{ expr(e.value) }}) +{%- elif e.kind == "binary" -%} +{{ operand(e.lhs, classes[e.op], "left") }} {{ tokens[e.op] }} {{ operand(e.rhs, classes[e.op], "right") }} +{%- elif e.kind == "if" -%} +({% for branch in e.branches %}{% if loop.first %}if{% else %} elseif{% endif %} {{ expr(branch.condition) }} then {{ expr(branch.value) }}{% endfor %} else {{ expr(e["else"]) }}) +{%- elif e.kind == "array" -%} +{{ "{" }}{% for element in e.elements %}{{ expr(element) }}{% if not loop.last %}, {% endif %}{% endfor %}{{ "}" }} +{%- elif e.kind == "call" -%} +{{ e.builtin }}({% for a in e.args %}{{ expr(a) }}{% if not loop.last %}, {% endif %}{% endfor %}) +{%- else -%} +{{ fail("alg template met unknown expression kind `" ~ e.kind ~ "`") }} +{%- endif -%} +{%- endmacro -%} +{%- macro declaration(v) -%} +{{ v.prefix }}{{ v.scalar }} {{ v.galec_name }}{% if v.dimensions %}[{% for d in v.dimensions %}{{ d }}{% if not loop.last %}, {% endif %}{% endfor %}]{% endif %}; +{%- endmacro -%} +{%- macro stmt(s) -%} +{%- if s.kind == "assign" or s.kind == "assign_whole" -%} +{{ ref(s.target) }} := {{ expr(s.value) }}; +{%- elif s.kind == "solve" -%} +{{ ref(s.target) }} := solveLinearEquations({{ expr(s.a) }}, {{ expr(s.b) }}); +{%- else -%} +{{ fail("alg template met unknown statement kind `" ~ s.kind ~ "`") }} +{%- endif -%} +{%- endmacro -%} +{%- macro method(kind, m) -%} +{{ " " }}method {{ kind }} +{% if m.signals %} signals {{ m.signals | join(", ") }}; +{% endif %} algorithm +{% for s in m.statements %} {{ stmt(s) }} +{% endfor %} end {{ kind }}; +{% endmacro -%} +block {{ galec_name }} +{% for v in variables %}{% if v.section == "interface" %} {{ declaration(v) }} +{% endif %}{% endfor %}protected +{% for v in variables %}{% if v.section == "protected" %} {{ declaration(v) }} +{% endif %}{% endfor %}public +{{ method("Startup", methods.startup) }}{{ method("Recalibrate", methods.recalibrate) }}{{ method("DoStep", methods.do_step) }}end {{ galec_name }}; diff --git a/crates/rumoca-galec-codegen/tests/efmi_discrete_pid_lowering.rs b/crates/rumoca-galec-codegen/tests/efmi_discrete_pid_lowering.rs index bd5b711fb..06436223c 100644 --- a/crates/rumoca-galec-codegen/tests/efmi_discrete_pid_lowering.rs +++ b/crates/rumoca-galec-codegen/tests/efmi_discrete_pid_lowering.rs @@ -721,47 +721,49 @@ fn manifest_context_carries_the_clock_wiring() { } #[test] -fn c_template_context_serializes_the_c_export_shape() { +fn c_template_context_serializes_the_walkable_block_shape() { + // D16/D17: the C target consumes the language-neutral GALEC block + // context; the walking template owns every C spelling. let package = lower_pid(); let context = c_template_context(&package, "EfmiDiscretePid").expect("context serializes"); assert_eq!(context["model_name"], "EfmiDiscretePid"); assert_eq!(context["block_name"], "EfmiDiscretePid"); - assert_eq!(context["struct_name"], "EfmiDiscretePidState"); - assert_eq!(context["function_prefix"], "EfmiDiscretePid"); - assert_eq!(context["include_guard"], "EFMIDISCRETEPID_GALEC_C_H"); + assert_eq!(context["galec_name"], "EfmiDiscretePid"); + assert_eq!(context["base_name"], "EfmiDiscretePid"); assert!( context["variables"] .as_array() .expect("variables array") .iter() .any(|variable| variable["name"] == "vMotor" - && variable["c_type"] == "double" - && variable["c_name"] == "vMotor") + && variable["scalar"] == "Real" + && variable["base_name"] == "vMotor") ); - let do_step = context["methods"]["do_step"] + let do_step = context["methods"]["do_step"]["statements"] .as_array() .expect("do_step statements"); assert!(!do_step.is_empty()); for statement in do_step { - assert_eq!(statement["kind"], "assignment"); - let lines = statement["c_lines"].as_array().expect("c_lines array"); assert!( - lines - .iter() - .all(|line| line.as_str().is_some_and(|text| text.ends_with(';'))), - "every C line is a terminated statement: {statement}" + matches!( + statement["kind"].as_str(), + Some("assign" | "assign_whole" | "solve") + ), + "{statement}" ); } - // The end-of-DoStep pre-commit surfaces with the mangled C field name. + // The end-of-DoStep pre-commit surfaces with both spellings: the GALEC + // quoted token and the identifier stem the C/Rust templates escape. assert!( do_step.iter().any(|statement| { - statement["c_lines"] - .as_array() - .expect("c_lines array") - .iter() - .any(|line| line.as_str().is_some_and(|text| text.contains("previous_"))) + statement["target"]["galec_name"] + .as_str() + .is_some_and(|token| token.starts_with("'previous(")) + && statement["target"]["base_name"] + .as_str() + .is_some_and(|base| base.starts_with("previous_")) }), - "expected a 'previous(x)' commit in C form: {do_step:#?}" + "expected a 'previous(x)' commit target: {do_step:#?}" ); } diff --git a/crates/rumoca-galec-codegen/tests/projection_front_half.rs b/crates/rumoca-galec-codegen/tests/projection_front_half.rs index c2977fdb6..52d43cc00 100644 --- a/crates/rumoca-galec-codegen/tests/projection_front_half.rs +++ b/crates/rumoca-galec-codegen/tests/projection_front_half.rs @@ -254,42 +254,15 @@ mod admissibility { assert!(codes.contains(&"ET005"), "{codes:?}"); } - /// Startup is built from `start` values only; a model with initial - /// equations must be rejected up front (ET021, GAL-025 wording), never - /// projected with its initialization partition silently ignored. + /// GAL-028: the initialization partition lowers into `Startup`, so + /// admissibility admits it (ET021 retired); un-lowerable forms fail + /// during lowering with stable `unsupported-feature:` diagnostics + /// (covered by the spec_0034 battery), never silently ignored. #[test] - fn initial_equations_rejected_never_silently_ignored() { + fn initial_equations_are_admissible_for_startup_lowering() { let mut model = base_dae(); model.initialization.equations.push(equation("n")); - let errors = check_admissibility(&GalecInput::new(&model, "M")).unwrap_err(); - assert_eq!(codes(&errors), vec!["ET021"]); - assert!( - errors[0] - .to_string() - .contains("not yet supported by the Rumoca GALEC projection"), - "{errors:#?}" - ); - - // Structured families alone (a broken scalar-view invariant) still - // reject rather than slip through. - let mut structured_only = base_dae(); - structured_only - .initialization - .structured_equations - .push(dae::StructuredEquationFamily { - domain: rumoca_core::StructuredIndexDomain { - binders: Vec::new(), - }, - first_equation_index: 0, - equations_per_point: 1, - span: Span::DUMMY, - origin: "test".to_owned(), - regular: None, - template: None, - interiors_materialized: true, - }); - let errors = check_admissibility(&GalecInput::new(&structured_only, "M")).unwrap_err(); - assert_eq!(codes(&errors), vec!["ET021"]); + check_admissibility(&GalecInput::new(&model, "M")).expect("admissible under GAL-028"); } } @@ -742,7 +715,11 @@ mod manifest_variables { let (model, types) = classified_model(|_| {}); let input = GalecInput::new(&model, "M").with_scalar_types(&types); let classification = classify_variables(&input).expect("classifies"); - let manifest = build_manifest_variables(&classification).expect("builds"); + let manifest = build_manifest_variables( + &classification, + &ConstEnv::from_classification(&classification), + ) + .expect("builds"); assert_eq!(manifest.variables.len(), 2); let MVar::Real(min) = &manifest.variables[1] else { @@ -787,7 +764,11 @@ mod manifest_variables { let input = GalecInput::new(&model, "M").with_scalar_types(&types); let classification = classify_variables(&input).expect("classifies"); - let errors = build_manifest_variables(&classification).unwrap_err(); + let errors = build_manifest_variables( + &classification, + &ConstEnv::from_classification(&classification), + ) + .unwrap_err(); assert!(codes(&errors).contains(&"ET015"), "{errors:?}"); } @@ -806,7 +787,11 @@ mod manifest_variables { let input = GalecInput::new(&model, "M").with_scalar_types(&types); let classification = classify_variables(&input).expect("classifies"); - let errors = build_manifest_variables(&classification).unwrap_err(); + let errors = build_manifest_variables( + &classification, + &ConstEnv::from_classification(&classification), + ) + .unwrap_err(); assert_eq!(codes(&errors), vec!["ET014"]); } @@ -829,7 +814,11 @@ mod manifest_variables { let input = GalecInput::new(&model, "M").with_scalar_types(&types); let classification = classify_variables(&input).expect("classifies"); - let manifest = build_manifest_variables(&classification).expect("builds"); + let manifest = build_manifest_variables( + &classification, + &ConstEnv::from_classification(&classification), + ) + .expect("builds"); let find = |name: &str| { manifest @@ -884,7 +873,11 @@ mod manifest_variables { let input = GalecInput::new(&model, "M").with_scalar_types(&types); let classification = classify_variables(&input).expect("classifies"); - let manifest = build_manifest_variables(&classification).expect("builds"); + let manifest = build_manifest_variables( + &classification, + &ConstEnv::from_classification(&classification), + ) + .expect("builds"); let find = |name: &str| { manifest .variables @@ -917,7 +910,11 @@ mod manifest_variables { let input = GalecInput::new(&model, "M").with_scalar_types(&types); let classification = classify_variables(&input).expect("classifies"); - let manifest = build_manifest_variables(&classification).expect("builds"); + let manifest = build_manifest_variables( + &classification, + &ConstEnv::from_classification(&classification), + ) + .expect("builds"); let MVar::Real(v_motor) = manifest .variables .iter() @@ -948,7 +945,11 @@ mod manifest_variables { let input = GalecInput::new(&model, "M").with_scalar_types(&types); let classification = classify_variables(&input).expect("classifies"); - let errors = build_manifest_variables(&classification).unwrap_err(); + let errors = build_manifest_variables( + &classification, + &ConstEnv::from_classification(&classification), + ) + .unwrap_err(); assert_eq!(codes(&errors), vec!["ET013"]); } @@ -966,7 +967,11 @@ mod manifest_variables { let input = GalecInput::new(&model, "M").with_scalar_types(&types); let classification = classify_variables(&input).expect("classifies"); - let manifest = build_manifest_variables(&classification).expect("builds"); + let manifest = build_manifest_variables( + &classification, + &ConstEnv::from_classification(&classification), + ) + .expect("builds"); assert!( manifest .variables diff --git a/crates/rumoca-galec-codegen/tests/spec_0034_battery.rs b/crates/rumoca-galec-codegen/tests/spec_0034_battery.rs index 9df64be97..f7acf6f59 100644 --- a/crates/rumoca-galec-codegen/tests/spec_0034_battery.rs +++ b/crates/rumoca-galec-codegen/tests/spec_0034_battery.rs @@ -287,6 +287,41 @@ fn assert_unsupported(errors: &[GalecTargetError], feature: &str) { const GAL_025_WORDING: &str = "not yet supported by the Rumoca GALEC projection"; +fn add_real_vector(model: &mut dae::Dae, name: &str, len: i64) { + let mut vector = variable(name); + vector.dims = vec![len]; + vector.start = Some(real(0.0)); + model + .variables + .discrete_reals + .insert(vector.name.clone(), vector); +} + +fn add_real_matrix(model: &mut dae::Dae, name: &str, rows: i64, cols: i64) { + let mut matrix = variable(name); + matrix.dims = vec![rows, cols]; + matrix.start = Some(real(0.0)); + model + .variables + .discrete_reals + .insert(matrix.name.clone(), matrix); +} + +fn make_y_vector(model: &mut dae::Dae, len: i64) { + model + .variables + .discrete_reals + .get_mut(&VarName::new("y")) + .expect("y exists") + .dims = vec![len]; + model + .variables + .parameters + .get_mut(&VarName::new("__pre__.y")) + .expect("pre y exists") + .dims = vec![len]; +} + // --------------------------------------------------------------------- // 1. Scope rejections through the public API (GAL-025, GAL-016) // --------------------------------------------------------------------- @@ -651,26 +686,6 @@ mod array_vector_regressions { subscript_expr(range(start, end)) } - fn add_real_vector(model: &mut dae::Dae, name: &str, len: i64) { - let mut vector = variable(name); - vector.dims = vec![len]; - vector.start = Some(real(0.0)); - model - .variables - .discrete_reals - .insert(vector.name.clone(), vector); - } - - fn add_real_matrix(model: &mut dae::Dae, name: &str, rows: i64, cols: i64) { - let mut matrix = variable(name); - matrix.dims = vec![rows, cols]; - matrix.start = Some(real(0.0)); - model - .variables - .discrete_reals - .insert(matrix.name.clone(), matrix); - } - fn vector_model(body: Expression) -> dae::Dae { let mut model = model_with_body(body); add_real_vector(&mut model, "a", 3); @@ -705,21 +720,6 @@ mod array_vector_regressions { .insert(waypoints.name.clone(), waypoints); } - fn make_y_vector(model: &mut dae::Dae, len: i64) { - model - .variables - .discrete_reals - .get_mut(&VarName::new("y")) - .expect("y exists") - .dims = vec![len]; - model - .variables - .parameters - .get_mut(&VarName::new("__pre__.y")) - .expect("pre y exists") - .dims = vec![len]; - } - #[test] fn dynamic_array_subscript_lowers_to_static_index_selection() { let mut model = model_with_body(Expression::VarRef { @@ -986,15 +986,23 @@ mod array_vector_regressions { let whole = render_algorithm_code(&whole_package).expect("whole vector render"); assert!(whole.contains("self.y := self.a - self.b;"), "{whole}"); - let c_lines = production_c_lines(&whole_package); - assert!( - c_lines.contains("self->y[0]") && c_lines.contains("(self->a[0] - self->b[0])"), - "{c_lines}" - ); - assert!( - !c_lines.contains("self->y ="), - "C arrays must be assigned element-wise:\n{c_lines}" - ); + // D16: the walkable context pre-projects whole-array values so the + // C template can expand element-wise without projection logic (the + // rendered C itself is compile-checked by the CLI suites). + let context = c_template_context(&whole_package, "Battery").expect("C context"); + let assign = context["methods"]["do_step"]["statements"] + .as_array() + .expect("statements array") + .iter() + .find(|statement| statement["target"]["base_name"] == "y") + .expect("whole-array assignment to y"); + assert_eq!(assign["kind"], "assign_whole", "{assign}"); + assert_eq!(assign["copy"], false, "{assign}"); + let elements = assign["elements"].as_array().expect("elements"); + assert_eq!(elements.len(), 3, "{assign}"); + assert_eq!(elements[0]["indices"][0], 1, "{assign}"); + assert_eq!(elements[0]["value"]["op"], "sub", "{assign}"); + assert_eq!(elements[0]["value"]["lhs"]["indices"][0], 1, "{assign}"); let scalarized = render_algorithm_code(&lower( &vector_model(index( @@ -1093,7 +1101,7 @@ mod array_vector_regressions { } #[test] - fn non_vector_array_multiplication_is_rejected_not_elementwise() { + fn matrix_multiplication_unrolls_to_ascending_index_sum_trees() { let mut model = model_with_body(binary(OpBinary::Mul, var("a"), var("b"))); add_real_matrix(&mut model, "a", 2, 2); add_real_matrix(&mut model, "b", 2, 2); @@ -1113,8 +1121,160 @@ mod array_vector_regressions { types.insert(VarName::new("a"), ScalarType::Real); types.insert(VarName::new("b"), ScalarType::Real); + let alg = render_algorithm_code(&lower(&model, &types)).expect("renders"); + // Element (1, 1): ascending inner index, no re-association (GAL-027); + // the printer parenthesizes the cross-class `*`-in-`+` mix (T6). + assert!( + alg.contains("(self.a[1, 1] * self.b[1, 1]) + (self.a[1, 2] * self.b[2, 1])"), + "{alg}" + ); + // Element (2, 2). + assert!( + alg.contains("(self.a[2, 1] * self.b[1, 2]) + (self.a[2, 2] * self.b[2, 2])"), + "{alg}" + ); + assert!(!alg.contains("self.a * self.b"), "{alg}"); + } + + #[test] + fn chained_matrix_product_takes_product_elements_not_elementwise() { + // `(a*b)*a`: indexing into the inner product must select the inner + // product ELEMENT (its sum), never distribute the subscripts + // elementwise — the covariance-propagation shape `A*P*transpose(A)`. + let mut model = model_with_body(binary( + OpBinary::Mul, + binary(OpBinary::Mul, var("a"), var("b")), + var("a"), + )); + add_real_matrix(&mut model, "a", 2, 2); + add_real_matrix(&mut model, "b", 2, 2); + model + .variables + .discrete_reals + .get_mut(&VarName::new("y")) + .expect("y exists") + .dims = vec![2, 2]; + model + .variables + .parameters + .get_mut(&VarName::new("__pre__.y")) + .expect("pre y exists") + .dims = vec![2, 2]; + let mut types = base_types(); + types.insert(VarName::new("a"), ScalarType::Real); + types.insert(VarName::new("b"), ScalarType::Real); + + let alg = render_algorithm_code(&lower(&model, &types)).expect("renders"); + // Element (1, 1) opens with the inner product element (a*b)[1,1]. + assert!( + alg.contains("((self.a[1, 1] * self.b[1, 1]) + (self.a[1, 2] * self.b[2, 1]))"), + "{alg}" + ); + // The elementwise-distribution bug would produce this 2-term shape. + assert!( + !alg.contains("{(self.a[1, 1] * self.b[1, 1] * self.a[1, 1])"), + "{alg}" + ); + } + + #[test] + fn matrix_product_inner_dimension_mismatch_is_a_type_error() { + let mut model = model_with_body(binary(OpBinary::Mul, var("a"), var("b"))); + add_real_matrix(&mut model, "a", 2, 3); + add_real_matrix(&mut model, "b", 2, 2); + model + .variables + .discrete_reals + .get_mut(&VarName::new("y")) + .expect("y exists") + .dims = vec![2, 2]; + model + .variables + .parameters + .get_mut(&VarName::new("__pre__.y")) + .expect("pre y exists") + .dims = vec![2, 2]; + let mut types = base_types(); + types.insert(VarName::new("a"), ScalarType::Real); + types.insert(VarName::new("b"), ScalarType::Real); + let errors = lower_err(&model, &types); - assert_unsupported(&errors, "array-multiplication"); + assert!( + errors.iter().any(|error| matches!( + error, + GalecTargetError::LoweringTypeMismatch { context, .. } + if context == "matrix product operands" + )), + "{errors:?}" + ); + } + + #[test] + fn matrix_vector_product_unrolls_per_row() { + let mut model = model_with_body(binary(OpBinary::Mul, var("a"), var("b"))); + add_real_matrix(&mut model, "a", 2, 3); + add_real_vector(&mut model, "b", 3); + make_y_vector(&mut model, 2); + let mut types = base_types(); + types.insert(VarName::new("a"), ScalarType::Real); + types.insert(VarName::new("b"), ScalarType::Real); + + let alg = render_algorithm_code(&lower(&model, &types)).expect("renders"); + let row1 = + "(self.a[1, 1] * self.b[1]) + (self.a[1, 2] * self.b[2]) + (self.a[1, 3] * self.b[3])"; + assert!(alg.contains(row1), "{alg}"); + assert!(alg.contains("(self.a[2, 1] * self.b[1])"), "{alg}"); + } + + #[test] + fn transpose_reindexes_into_a_matrix_constructor() { + let mut model = model_with_body(builtin(BuiltinFunction::Transpose, vec![var("a")])); + add_real_matrix(&mut model, "a", 2, 3); + model + .variables + .discrete_reals + .get_mut(&VarName::new("y")) + .expect("y exists") + .dims = vec![3, 2]; + model + .variables + .parameters + .get_mut(&VarName::new("__pre__.y")) + .expect("pre y exists") + .dims = vec![3, 2]; + let mut types = base_types(); + types.insert(VarName::new("a"), ScalarType::Real); + + let alg = render_algorithm_code(&lower(&model, &types)).expect("renders"); + // Result row 1 is the first source column: A[1,1], A[2,1]. + assert!(alg.contains("self.a[1, 1], self.a[2, 1]"), "{alg}"); + assert!(alg.contains("self.a[1, 3], self.a[2, 3]"), "{alg}"); + } + + #[test] + fn identity_widens_to_real_literals_in_real_context() { + let mut model = model_with_body(binary( + OpBinary::MulElem, + real(0.5), + builtin(BuiltinFunction::Identity, vec![integer(2)]), + )); + model + .variables + .discrete_reals + .get_mut(&VarName::new("y")) + .expect("y exists") + .dims = vec![2, 2]; + model + .variables + .parameters + .get_mut(&VarName::new("__pre__.y")) + .expect("pre y exists") + .dims = vec![2, 2]; + let types = base_types(); + + let alg = render_algorithm_code(&lower(&model, &types)).expect("renders"); + assert!(alg.contains("{1.0, 0.0}"), "{alg}"); + assert!(alg.contains("{0.0, 1.0}"), "{alg}"); } #[test] @@ -1321,25 +1481,6 @@ mod array_vector_regressions { }); model.symbols.functions.insert(split.name.clone(), split); } - - fn production_c_lines(package: &AlgorithmCodePackage) -> String { - let context = c_template_context(package, "Battery").expect("C context"); - ["startup", "recalibrate", "do_step"] - .into_iter() - .flat_map(|method| { - context["methods"][method] - .as_array() - .expect("method statements are an array") - }) - .flat_map(|statement| { - statement["c_lines"] - .as_array() - .expect("statement c_lines are an array") - }) - .map(|line| line.as_str().expect("C line is a string").to_owned()) - .collect::>() - .join("\n") - } } // --------------------------------------------------------------------- diff --git a/crates/rumoca-galec-codegen/tests/spec_0034_estimator.rs b/crates/rumoca-galec-codegen/tests/spec_0034_estimator.rs new file mode 100644 index 000000000..1001d2736 --- /dev/null +++ b/crates/rumoca-galec-codegen/tests/spec_0034_estimator.rs @@ -0,0 +1,529 @@ +//! SPEC_0034 estimator-scope battery: `initial equation` → `Startup` +//! lowering (GAL-028/D14) and the `Matrices.solve` → +//! `solveLinearEquations` mapping with declared-by-construction escape +//! sets (GAL-029/D13). Split from `spec_0034_battery.rs` per the +//! SPEC_0021 file-size limit; the fixture section below is the minimal +//! self-contained subset that suite's fixture builders (each helper here +//! is used by these modules — the zero-dead-code discipline). + +use std::collections::HashMap; + +use rumoca_core::{Expression, Literal, OpBinary, Reference, Span, Subscript, VarName}; +use rumoca_galec_codegen::input::ScalarTypeMap; +use rumoca_galec_codegen::{ + AlgorithmCodePackage, GalecInput, GalecOptions, GalecTargetError, lower_to_algorithm_code, + render_algorithm_code, +}; +use rumoca_ir_dae as dae; +use rumoca_ir_galec::ast::ScalarType; + +// --------------------------------------------------------------------- +// Fixture: minimal admissible discrete model with one guarded update +// (mirrors spec_0034_battery.rs) +// --------------------------------------------------------------------- + +fn real(value: f64) -> Expression { + Expression::Literal { + value: Literal::Real(value), + span: Span::DUMMY, + } +} + +fn integer(value: i64) -> Expression { + Expression::Literal { + value: Literal::Integer(value), + span: Span::DUMMY, + } +} + +fn boolean(value: bool) -> Expression { + Expression::Literal { + value: Literal::Boolean(value), + span: Span::DUMMY, + } +} + +fn var(name: &str) -> Expression { + Expression::VarRef { + name: Reference::new(name), + subscripts: Vec::new(), + span: Span::DUMMY, + } +} + +fn indexed(name: &str, index: i64) -> Expression { + Expression::VarRef { + name: Reference::new(name), + subscripts: vec![Subscript::index(index, Span::DUMMY)], + span: Span::DUMMY, + } +} + +fn binary(op: OpBinary, lhs: Expression, rhs: Expression) -> Expression { + Expression::Binary { + op, + lhs: Box::new(lhs), + rhs: Box::new(rhs), + span: Span::DUMMY, + } +} + +fn if_expr(branches: Vec<(Expression, Expression)>, else_branch: Expression) -> Expression { + Expression::If { + branches, + else_branch: Box::new(else_branch), + span: Span::DUMMY, + } +} + +fn sample_call() -> Expression { + Expression::FunctionCall { + name: Reference::generated(rumoca_core::INTERNAL_SAMPLE_FUNCTION_NAME), + args: vec![real(0.0), var("samplePeriod")], + is_constructor: false, + span: Span::DUMMY, + } +} + +fn variable(name: &str) -> dae::Variable { + let mut variable = dae::Variable::empty_with_span(Span::DUMMY); + variable.name = VarName::new(name); + variable +} + +fn add_pre_slot(model: &mut dae::Dae, base: &str, start: Expression, dims: Vec) { + let mut slot = variable(&format!("__pre__.{base}")); + slot.causality = dae::VariableCausality::CalculatedParameter; + slot.origin = dae::VariableOrigin::Generated; + slot.fixed = Some(true); + slot.start = Some(start); + slot.dims = dims; + model.variables.parameters.insert(slot.name.clone(), slot); +} + +fn add_state(model: &mut dae::Dae, name: &str) { + let mut z = variable(name); + z.start = Some(real(0.0)); + model.variables.discrete_reals.insert(z.name.clone(), z); + add_pre_slot(model, name, real(0.0), Vec::new()); +} + +/// The canonical guarded row: fires on the sample-tick when-edge of +/// condition 1, holds `if Initial() then else __pre__.` +/// (byte-for-byte the `spec_0034_battery.rs` shape `guard.rs` recognizes). +fn guarded_update(target: &str, body: Expression) -> dae::Equation { + let edge = binary( + OpBinary::And, + indexed("c", 1), + Expression::Unary { + op: rumoca_core::OpUnary::Not, + rhs: Box::new(indexed("__pre__.c", 1)), + span: Span::DUMMY, + }, + ); + let hold = if_expr( + vec![( + Expression::BuiltinCall { + function: rumoca_core::BuiltinFunction::Initial, + args: Vec::new(), + span: Span::DUMMY, + }, + var(target), + )], + var(&format!("__pre__.{target}")), + ); + dae::Equation { + lhs: Some(Reference::new(target)), + rhs: if_expr(vec![(edge, body)], hold), + span: Span::DUMMY, + origin: format!("when sample then {target}"), + scalar_count: 1, + } +} + +/// Minimal real-compiler-shaped model (mirrors `spec_0034_battery.rs`): +/// inputs `u`/`x2`, Integer tunables `i1`/`i2`, constant `samplePeriod`, +/// discrete state `y` updated by `body` on the sample tick. +fn model_with_body(body: Expression) -> dae::Dae { + let mut model = dae::Dae::default(); + for name in ["u", "x2"] { + let mut input = variable(name); + input.causality = dae::VariableCausality::Input; + input.start = Some(real(0.0)); + model.variables.inputs.insert(input.name.clone(), input); + } + for (name, start) in [("i1", 3), ("i2", 4)] { + let mut parameter = variable(name); + parameter.causality = dae::VariableCausality::Parameter; + parameter.is_tunable = true; + parameter.start = Some(integer(start)); + model + .variables + .parameters + .insert(parameter.name.clone(), parameter); + } + let mut sample_period = variable("samplePeriod"); + sample_period.unit = Some("s".to_owned()); + sample_period.start = Some(real(1e-3)); + model + .variables + .constants + .insert(sample_period.name.clone(), sample_period); + + add_state(&mut model, "y"); + + let mut condition = variable("c"); + condition.origin = dae::VariableOrigin::Generated; + condition.dims = vec![1]; + model + .variables + .discrete_valued + .insert(condition.name.clone(), condition); + add_pre_slot(&mut model, "c", boolean(false), vec![1]); + + model.conditions.relations.push(sample_call()); + model.conditions.equations.push(dae::Equation { + lhs: Some(Reference::new("c[1]")), + rhs: sample_call(), + span: Span::DUMMY, + origin: "condition equation 1".to_owned(), + scalar_count: 1, + }); + + model.discrete.real_updates.push(guarded_update("y", body)); + model.clocks.schedules.push(dae::ClockSchedule { + period_seconds: 1e-3, + phase_seconds: 0.0, + source_span: Span::DUMMY, + }); + model +} + +fn base_types() -> ScalarTypeMap { + let mut types = HashMap::new(); + types.insert(VarName::new("samplePeriod"), ScalarType::Real); + types.insert(VarName::new("i1"), ScalarType::Integer); + types.insert(VarName::new("i2"), ScalarType::Integer); + types +} + +fn lower(model: &dae::Dae, types: &ScalarTypeMap) -> AlgorithmCodePackage { + let input = GalecInput::new(model, "Battery").with_scalar_types(types); + match lower_to_algorithm_code(&input, &GalecOptions::default()) { + Ok(package) => package, + Err(errors) => panic!("lowering failed: {errors:#?}"), + } +} + +fn lower_err(model: &dae::Dae, types: &ScalarTypeMap) -> Vec { + let input = GalecInput::new(model, "Battery").with_scalar_types(types); + lower_to_algorithm_code(&input, &GalecOptions::default()) + .map(|_| ()) + .expect_err("lowering must fail") +} + +fn assert_unsupported(errors: &[GalecTargetError], feature: &str) { + let marker = format!("unsupported-feature:{feature}]"); + assert!( + errors + .iter() + .any(|error| error.code() == "ET017" && error.to_string().contains(&marker)), + "expected `{marker}` among: {errors:#?}" + ); +} + +fn add_real_vector(model: &mut dae::Dae, name: &str, len: i64) { + let mut vector = variable(name); + vector.dims = vec![len]; + vector.start = Some(real(0.0)); + model + .variables + .discrete_reals + .insert(vector.name.clone(), vector); +} + +fn add_real_matrix(model: &mut dae::Dae, name: &str, rows: i64, cols: i64) { + let mut matrix = variable(name); + matrix.dims = vec![rows, cols]; + matrix.start = Some(real(0.0)); + model + .variables + .discrete_reals + .insert(matrix.name.clone(), matrix); +} + +fn make_y_vector(model: &mut dae::Dae, len: i64) { + model + .variables + .discrete_reals + .get_mut(&VarName::new("y")) + .expect("y exists") + .dims = vec![len]; + model + .variables + .parameters + .get_mut(&VarName::new("__pre__.y")) + .expect("pre y exists") + .dims = vec![len]; +} + +fn resize_y_matrix(model: &mut dae::Dae, rows: i64, cols: i64) { + model + .variables + .discrete_reals + .get_mut(&VarName::new("y")) + .expect("y exists") + .dims = vec![rows, cols]; + model + .variables + .parameters + .get_mut(&VarName::new("__pre__.y")) + .expect("pre y exists") + .dims = vec![rows, cols]; +} + +// --------------------------------------------------------------------- +// D17: the `.alg` walking template is byte-identical to the typed printer +// --------------------------------------------------------------------- + +mod alg_template_parity_d17 { + use super::*; + + /// The strongest drift guard between the two `.alg` producers: the + /// D17 walking template (emission) and the `rumoca-ir-galec` typed + /// printer (the parser-facing half of the language module) must agree + /// byte-for-byte on lowered packages — signals clauses, quoted + /// `'previous(x)'` names, arrays, solve calls, and the GAL-019 + /// minimal parenthesization included. + #[test] + fn template_and_typed_printer_agree_byte_for_byte() { + // Reads `pre(y)` (quoted `'previous(y)'` names + end-of-DoStep + // commit), solves with a composite vector argument (call + array + // arithmetic + cross-class parenthesization), declares the + // GAL-029 escape. + let mut model = model_with_body(Expression::FunctionCall { + name: Reference::new("Modelica.Math.Matrices.solve"), + args: vec![ + var("a"), + binary( + OpBinary::Add, + var("b"), + binary(OpBinary::Mul, var("u"), var("__pre__.y")), + ), + ], + is_constructor: false, + span: Span::DUMMY, + }); + add_real_matrix(&mut model, "a", 2, 2); + add_real_vector(&mut model, "b", 2); + make_y_vector(&mut model, 2); + + let package = lower(&model, &base_types()); + let template_text = render_algorithm_code(&package).expect("template renders"); + let printer_text = + rumoca_ir_galec::print_block(&package.block).expect("typed printer renders"); + assert_eq!(template_text, printer_text); + } +} + +// --------------------------------------------------------------------- +// GAL-029/D13: Matrices.solve → solveLinearEquations +// --------------------------------------------------------------------- + +mod linear_solve_gal_029 { + use super::*; + + fn solve_call(matrix: &str, vector: Expression) -> Expression { + Expression::FunctionCall { + name: Reference::new("Modelica.Math.Matrices.solve"), + args: vec![var(matrix), vector], + is_constructor: false, + span: Span::DUMMY, + } + } + + fn solve_model(call: Expression) -> dae::Dae { + let mut model = model_with_body(call); + add_real_matrix(&mut model, "a", 2, 2); + add_real_vector(&mut model, "b", 2); + make_y_vector(&mut model, 2); + model + } + + #[test] + fn matrices_solve_maps_to_the_builtin_and_declares_the_escape() { + let package = lower(&solve_model(solve_call("a", var("b"))), &base_types()); + let alg = render_algorithm_code(&package).expect("renders"); + assert!( + alg.contains("self.y := solveLinearEquations(self.a, self.b);"), + "{alg}" + ); + // Declared by construction on DoStep only (GAL-029)… + assert!( + alg.contains("signals SOLVE_LINEAR_EQUATIONS_FAILED;"), + "{alg}" + ); + // …and mirrored into the manifest fragment. + use rumoca_galec_codegen::manifest_context::algorithm_code_manifest::ErrorSignal; + assert_eq!( + package.manifest.do_step_signals, + vec![ErrorSignal::SolveLinearEquationsFailed] + ); + assert!(package.manifest.startup_signals.is_empty()); + assert!(package.manifest.recalibrate_signals.is_empty()); + } + + #[test] + fn matrices_inv_gets_solve_guidance() { + let mut model = model_with_body(Expression::FunctionCall { + name: Reference::new("Modelica.Math.Matrices.inv"), + args: vec![var("a")], + is_constructor: false, + span: Span::DUMMY, + }); + add_real_matrix(&mut model, "a", 2, 2); + resize_y_matrix(&mut model, 2, 2); + let errors = lower_err(&model, &base_types()); + assert_unsupported(&errors, "matrix-inverse"); + } + + #[test] + fn solve_with_mismatched_dimensions_is_a_type_error() { + let mut model = solve_model(solve_call("a", var("b"))); + model + .variables + .discrete_reals + .get_mut(&VarName::new("b")) + .expect("b exists") + .dims = vec![3]; + let errors = lower_err(&model, &base_types()); + assert!( + errors.iter().any(|error| matches!( + error, + GalecTargetError::LoweringTypeMismatch { context, .. } + if context == "Matrices.solve operands" + )), + "{errors:?}" + ); + } +} + +// --------------------------------------------------------------------- +// GAL-028/D14: initial equation → Startup +// --------------------------------------------------------------------- + +mod initialization_gal_028 { + use super::*; + + /// A source `initial equation = ` in DAE residual form. + fn residual(target: &str, value: Expression) -> dae::Equation { + dae::Equation { + lhs: None, + rhs: binary(OpBinary::Sub, var(target), value), + span: Span::DUMMY, + origin: format!("initial equation for {target}"), + scalar_count: 1, + } + } + + #[test] + fn initial_equation_lowers_into_startup_and_overrides_manifest_start() { + // The update reads `pre(y)` so the pre slot is kept and needs the + // GAL-028 re-seed. + let mut model = model_with_body(var("__pre__.y")); + // y0 = 2.0 * i1 (i1 tunable Integer, default 3) => 6.0 at defaults. + model + .initialization + .equations + .push(residual("y", binary(OpBinary::Mul, real(2.0), var("i1")))); + + let alg = render_algorithm_code(&lower(&model, &base_types())).expect("renders"); + // Literal mirroring uses the GAL-028 override (6.0), then the + // computed statement overwrites symbolically, then the pre slot + // re-seeds from the computed value (D14 ordering). + let mirrored = alg.find("self.y := 6.0;").expect("mirrored literal"); + let computed = alg + .find("self.y := 2.0 * real(self.i1);") + .expect("computed statement"); + let seeded = alg + .find("self.'previous(y)' := self.y;") + .expect("pre-slot re-seed"); + assert!(mirrored < computed && computed < seeded, "{alg}"); + // The pre slot's mirrored literal takes the override too. + assert!(alg.contains("self.'previous(y)' := 6.0;"), "{alg}"); + } + + #[test] + fn fixed_start_rows_are_skipped_as_mirroring_duplicates() { + let mut model = model_with_body(var("u")); + model.initialization.equations.push(dae::Equation { + lhs: Some(Reference::new("y")), + rhs: real(0.0), + span: Span::DUMMY, + origin: "fixed start initialization for y".to_owned(), + scalar_count: 1, + }); + + let alg = render_algorithm_code(&lower(&model, &base_types())).expect("renders"); + // Exactly the mirroring assignment — no duplicate computed row. + assert_eq!(alg.matches("self.y := 0.0;").count(), 1, "{alg}"); + } + + #[test] + fn initial_equation_reading_an_input_is_rejected() { + let mut model = model_with_body(var("u")); + model.initialization.equations.push(residual("y", var("u"))); + let errors = lower_err(&model, &base_types()); + assert_unsupported(&errors, "initial-equation-reads-input"); + } + + #[test] + fn unoriented_initial_equation_is_rejected() { + let mut model = model_with_body(var("u")); + model.initialization.equations.push(dae::Equation { + lhs: None, + rhs: binary(OpBinary::Add, var("y"), real(1.0)), + span: Span::DUMMY, + origin: "initial equation".to_owned(), + scalar_count: 1, + }); + let errors = lower_err(&model, &base_types()); + assert_unsupported(&errors, "implicit-initial-equation"); + } + + #[test] + fn duplicate_initialization_is_rejected() { + let mut model = model_with_body(var("u")); + model + .initialization + .equations + .push(residual("y", real(1.0))); + model + .initialization + .equations + .push(residual("y", real(2.0))); + let errors = lower_err(&model, &base_types()); + assert_unsupported(&errors, "duplicate-initial-equation"); + } + + #[test] + fn cyclic_initialization_is_rejected() { + let mut model = model_with_body(var("u")); + add_state(&mut model, "z"); + model.initialization.equations.push(residual("y", var("z"))); + model.initialization.equations.push(residual("z", var("y"))); + let errors = lower_err(&model, &base_types()); + assert_unsupported(&errors, "initialization-cycle"); + } + + #[test] + fn parameter_initialization_target_is_rejected() { + let mut model = model_with_body(var("u")); + model + .initialization + .equations + .push(residual("i1", integer(7))); + let errors = lower_err(&model, &base_types()); + assert_unsupported(&errors, "initial-equation-target"); + } +} diff --git a/crates/rumoca-ir-galec/src/lib.rs b/crates/rumoca-ir-galec/src/lib.rs index 5ea9a30f9..149b24091 100644 --- a/crates/rumoca-ir-galec/src/lib.rs +++ b/crates/rumoca-ir-galec/src/lib.rs @@ -48,4 +48,6 @@ pub use builtins::{BUILTINS, Builtin, is_reserved_name}; pub use diagnostic::{GalecError, Location, PathSegment}; pub use lexical::{is_legal_plain_identifier, plain_identifier_shape_error}; pub use print::{format_real_literal, is_conformant_real_literal, print_block, print_expression}; -pub use validate::{SymbolInfo, span_of, symbol_at, validate}; +pub use validate::{ + MethodEscapes, SymbolInfo, computed_method_escapes, span_of, symbol_at, validate, +}; diff --git a/crates/rumoca-ir-galec/src/validate.rs b/crates/rumoca-ir-galec/src/validate.rs index 51df161b5..c5e98c739 100644 --- a/crates/rumoca-ir-galec/src/validate.rs +++ b/crates/rumoca-ir-galec/src/validate.rs @@ -42,6 +42,7 @@ mod types; pub use locate::span_of; pub use navigate::{SymbolInfo, symbol_at}; +pub use signals::{MethodEscapes, computed_method_escapes}; /// Run all six analyses over `block`, collecting every finding. /// diff --git a/crates/rumoca-ir-galec/src/validate/signals.rs b/crates/rumoca-ir-galec/src/validate/signals.rs index 273bb4917..75bfffd1c 100644 --- a/crates/rumoca-ir-galec/src/validate/signals.rs +++ b/crates/rumoca-ir-galec/src/validate/signals.rs @@ -32,8 +32,8 @@ //! restructuring. use crate::ast::{ - BinaryOp, Condition, Expression, FunctionCall, Identifier, IfStatement, SignalCheck, Spanned, - Statement, + BinaryOp, Condition, Expression, FunctionCall, Identifier, IfStatement, PredefinedSignal, + SignalCheck, Spanned, Statement, }; use crate::diagnostic::{GalecError, PathSegment}; @@ -42,6 +42,48 @@ use super::context::{BlockContext, BodyView, Cursor, SignalSet, SignalTable, res /// User-defined signal budget in the 32-bit encoding (§3.2.5 §1.6). const MAX_USER_SIGNALS: usize = 16; +/// Computed escape sets of the three block-interface methods, in normative +/// encoding order. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct MethodEscapes { + pub startup: Vec, + pub recalibrate: Vec, + pub do_step: Vec, +} + +/// Compute the escape set of each block-interface method (the §3.2.5 +/// dataflow this validator enforces), so producers can declare signal +/// clauses by construction — declared == computed cannot drift (SPEC_0034 +/// GAL-029). Diagnostics encountered on the way are discarded; callers run +/// [`crate::validate`] separately for the full analysis. +#[must_use] +pub fn computed_method_escapes(block: &crate::ast::Block) -> MethodEscapes { + let ctx = BlockContext::new(block); + let mut escapes = MethodEscapes::default(); + for body in ctx.bodies() { + let Some(kind) = body.method else { continue }; + let mut scratch = Vec::new(); + let mut walker = SignalWalker { + ctx: &ctx, + cursor: Cursor::for_body(&ctx, &body), + closures: Vec::new(), + diags: &mut scratch, + }; + let computed = walker.statements(body.statements, SignalSet::default()); + let signals: Vec = PredefinedSignal::ALL + .iter() + .copied() + .filter(|signal| computed.contains(SignalTable::predefined_bit(*signal))) + .collect(); + match kind { + crate::ast::BlockMethodKind::Startup => escapes.startup = signals, + crate::ast::BlockMethodKind::Recalibrate => escapes.recalibrate = signals, + crate::ast::BlockMethodKind::DoStep => escapes.do_step = signals, + } + } + escapes +} + pub(super) fn check(ctx: &BlockContext<'_>, diags: &mut Vec) { if ctx.signals.user_count() > MAX_USER_SIGNALS { diags.push(GalecError::TooManyUserSignals { diff --git a/crates/rumoca-phase-codegen/src/codegen/mod.rs b/crates/rumoca-phase-codegen/src/codegen/mod.rs index 409a69610..0e4910155 100644 --- a/crates/rumoca-phase-codegen/src/codegen/mod.rs +++ b/crates/rumoca-phase-codegen/src/codegen/mod.rs @@ -860,6 +860,25 @@ fn render_ast_context( } /// Render any supported IR using a template string. +/// Render a template string against an arbitrary serialized JSON context +/// under the standard codegen environment (strict-undefined, all custom +/// filters/functions registered). The context object's top-level keys become +/// template variables. +/// +/// This is the render path for targets whose context is a projection-owned +/// serialized tree rather than a canonical IR — e.g. the GALEC block +/// context of `embedded-rust-galec` (SPEC_0034 D16): the template walks the +/// tree with recursive macros exactly like the IR-keyed targets walk theirs. +pub fn render_template_with_json_context( + context: &serde_json::Value, + template: &str, +) -> Result { + let mut env = create_environment(); + env.add_template("inline", template)?; + let tmpl = env.get_template("inline")?; + Ok(tmpl.render(Value::from_serialize(context))?) +} + pub fn render_template_for_input( input: CodegenInput<'_>, template: &str, diff --git a/crates/rumoca-phase-codegen/src/lib.rs b/crates/rumoca-phase-codegen/src/lib.rs index 2a1a97ed1..0db10a495 100644 --- a/crates/rumoca-phase-codegen/src/lib.rs +++ b/crates/rumoca-phase-codegen/src/lib.rs @@ -63,8 +63,8 @@ pub use codegen::{ render_ast_template, render_ast_template_with_name, render_flat_template_with_name, render_solve_template_with_name, render_template, render_template_file, render_template_for_input, render_template_with_dae_json, - render_template_with_dae_json_and_name, render_template_with_name, - render_template_with_name_for_input, + render_template_with_dae_json_and_name, render_template_with_json_context, + render_template_with_name, render_template_with_name_for_input, }; pub use errors::CodegenError; diff --git a/crates/rumoca-phase-codegen/src/templates/embedded-c-galec/model.c.jinja b/crates/rumoca-phase-codegen/src/templates/embedded-c-galec/model.c.jinja index 6282bde00..314c9fd20 100644 --- a/crates/rumoca-phase-codegen/src/templates/embedded-c-galec/model.c.jinja +++ b/crates/rumoca-phase-codegen/src/templates/embedded-c-galec/model.c.jinja @@ -1,7 +1,117 @@ +{#- ================================================================== + embedded-c-galec source: C rendering of the language-neutral GALEC + block context (SPEC_0034 GAL-024/D16/D17). This template OWNS the C + language — expressions fully parenthesize (the GALEC AST shape is the + normative evaluation order, trap T6, and full parens preserve it in + C); GALEC 1-based subscripts print 0-based; whole-array reference + copies memcpy; array-valued assignments expand element-wise from the + context's pre-projected element lists. + =================================================================== -#} +{%- set c_keywords = [ + "auto", "break", "case", "char", "const", "continue", "default", "do", + "double", "else", "enum", "extern", "float", "for", "goto", "if", + "inline", "int", "long", "register", "restrict", "return", "short", + "signed", "sizeof", "static", "struct", "switch", "typedef", "union", + "unsigned", "void", "volatile", "while", + "bool", "true", "false", "memcpy", "self", + "rumoca_status", "rumoca_solve_a", "rumoca_solve_b" +] -%} +{%- macro ident(name) -%} +{%- if name is none -%}{{ fail("GALEC name cannot form a C identifier") }} +{%- elif name in c_keywords -%}{{ name }}_{%- else -%}{{ name }}{%- endif -%} +{%- endmacro -%} +{#- Operator spellings; `pow` maps to libm pow (both operands Real by + lowering, trap T5). -#} +{%- set ops = { + "add": "+", "sub": "-", "mul": "*", "div": "/", + "lt": "<", "le": "<=", "gt": ">", "ge": ">=", "eq": "==", "ne": "!=", + "and": "&&", "or": "||" +} -%} +{#- GALEC §3.2.6 catalog -> C99/libm. min/max are the GALEC relational + forms, NOT fmin/fmax (which drop a qNaN operand instead of taking the + `else` branch a false comparison implies, traps T9/T14). -#} +{%- set builtins = { + "absolute": "fabs", "sign": "rumoca_ir_galec_sign", + "min": "rumoca_ir_galec_min", "max": "rumoca_ir_galec_max", + "imin": "rumoca_ir_galec_imin", "imax": "rumoca_ir_galec_imax", + "sqrt": "sqrt", "exp": "exp", "ln": "log", "lg": "log10", + "roundDown": "floor", "roundUp": "ceil", + "sin": "sin", "cos": "cos", "tan": "tan", + "asin": "asin", "acos": "acos", "atan": "atan", "atan2": "atan2", + "sinh": "sinh", "cosh": "cosh", "tanh": "tanh" +} -%} +{#- Template IR indices are the GALEC truth (1-based); C is 0-based. -#} +{%- macro ref(r) -%} +self->{{ ident(r.base_name) }}{% for i in r.indices %}[{{ i - 1 }}]{% endfor %} +{%- endmacro -%} +{%- macro expr(e) -%} +{%- if e.kind == "bool" -%}{{ e.value }} +{%- elif e.kind == "int" -%}{% if e.value < 0 %}({{ e.value }}){% else %}{{ e.value }}{% endif %} +{%- elif e.kind == "real" -%}{% if e.negative %}({{ e.text }}){% else %}{{ e.text }}{% endif %} +{%- elif e.kind == "ref" -%}{{ ref(e) }} +{%- elif e.kind == "neg" -%}(-{{ ref(e.value) }}) +{%- elif e.kind == "not" -%}(!({{ expr(e.value) }})) +{%- elif e.kind == "paren" -%}({{ expr(e.value) }}) +{%- elif e.kind == "binary" -%} +{%- if e.op == "pow" -%}pow({{ expr(e.lhs) }}, {{ expr(e.rhs) }}) +{%- else -%}({{ expr(e.lhs) }} {{ ops[e.op] }} {{ expr(e.rhs) }}){%- endif -%} +{%- elif e.kind == "if" -%} +{{ ternary(e.branches, e["else"], 0) }} +{%- elif e.kind == "call" -%} +{%- if e.builtin == "real" -%}((double)({{ expr(e.args[0]) }})) +{%- elif e.builtin == "divisionTowardsZero" -%}({{ expr(e.args[0]) }} / {{ expr(e.args[1]) }}) +{%- elif e.builtin in builtins -%}{{ builtins[e.builtin] }}({% for a in e.args %}{{ expr(a) }}{% if not loop.last %}, {% endif %}{% endfor %}) +{%- else -%}{{ fail("embedded-c-galec has no mapping for GALEC builtin `" ~ e.builtin ~ "`") }} +{%- endif -%} +{%- else -%}{{ fail("embedded-c-galec met unknown expression kind `" ~ e.kind ~ "` (array constructors only print via whole-assignment element lists)") }} +{%- endif -%} +{%- endmacro -%} +{#- If-expression -> right-nested C conditionals, one `?:` per branch. -#} +{%- macro ternary(branches, else_value, i) -%} +{%- if i >= branches | length -%}{{ expr(else_value) }} +{%- else -%}({{ expr(branches[i].condition) }} ? {{ expr(branches[i].value) }} : {{ ternary(branches, else_value, i + 1) }}){%- endif -%} +{%- endmacro -%} +{#- Scratch materialization for the solve statement: whole-array reference + copies memcpy; everything else assigns the pre-projected elements. -#} +{%- macro materialize(name, value, copy, elements) -%} +{%- if copy %} + memcpy({{ name }}, {{ ref(value) }}, sizeof({{ name }})); +{%- else %} +{%- for element in elements %} + {{ name }}{% for i in element.indices %}[{{ i - 1 }}]{% endfor %} = {{ expr(element.value) }}; +{%- endfor %} +{%- endif %} +{%- endmacro -%} +{%- macro stmt(s) -%} +{%- if s.kind == "assign" %} + {{ ref(s.target) }} = {{ expr(s.value) }}; +{%- elif s.kind == "assign_whole" %} +{%- if s.copy %} + memcpy({{ ref(s.target) }}, {{ ref(s.value) }}, sizeof({{ ref(s.target) }})); +{%- else %} +{%- for element in s.elements %} + {{ ref(s.target) }}{% for i in element.indices %}[{{ i - 1 }}]{% endfor %} = {{ expr(element.value) }}; +{%- endfor %} +{%- endif %} +{%- elif s.kind == "solve" %} + { + double rumoca_solve_a[{{ s.n }}][{{ s.n }}]; + double rumoca_solve_b[{{ s.n }}]; +{{- materialize("rumoca_solve_a", s.a, s.a_copy, s.a_elements) }} +{{- materialize("rumoca_solve_b", s.b, s.b_copy, s.b_elements) }} + rumoca_status |= rumoca_ir_galec_solve_linear_equations({{ s.n }}, &rumoca_solve_a[0][0], rumoca_solve_b, &{{ ref(s.target) }}[0]); + } +{%- else -%} +{{ fail("embedded-c-galec met unknown statement kind `" ~ s.kind ~ "`") }} +{%- endif -%} +{%- endmacro -%} +{%- macro method_body(m) -%} +{%- for s in m.statements %}{{ stmt(s) }}{% endfor %} +{%- endmacro -%} /* {{ block_name }} — GALEC-derived embedded C export (SPEC_0034 GAL-024). - * {{ conformance_header.summary }} Method bodies below are printed - * from the validated GALEC block by the typed C printer - * (rumoca-galec-codegen::c_print); this template only lays out the file. */ + * {{ conformance_header.summary }} Method bodies below are generated by + * walking the language-neutral GALEC block context (SPEC_0034 D16/D17); + * this template owns every C spelling. */ #include "{{ model_name }}.h" #include @@ -19,9 +129,9 @@ #endif /* GALEC §3.2.6 builtins without a direct C99 counterpart. The names are a - * fixed contract with the typed C printer (compile-checked in CI). min/max use - * the GALEC relational definition (`u1 < u2` selects), NOT fmin/fmax: a false - * qNaN comparison must take the else operand (eFMI traps T9/T14). */ + * fixed contract with the walking template above. min/max use the GALEC + * relational definition (`u1 < u2` selects), NOT fmin/fmax: a false qNaN + * comparison must take the else operand (eFMI traps T9/T14). */ RUMOCA_MAYBE_UNUSED static inline double rumoca_ir_galec_sign(double x) { return (double)((x > 0.0) - (x < 0.0)); } @@ -37,30 +147,96 @@ RUMOCA_MAYBE_UNUSED static inline int32_t rumoca_ir_galec_imin(int32_t u1, int32 RUMOCA_MAYBE_UNUSED static inline int32_t rumoca_ir_galec_imax(int32_t u1, int32_t u2) { return u1 > u2 ? u1 : u2; } +{%- if status_abi %} -void {{ function_prefix }}_startup({{ struct_name }} *self) { +/* GALEC `solveLinearEquations` (§3.2.6): solve a*x = b by Gaussian + * elimination with partial pivoting. `a` (row-major n*n) and `b` are + * caller-owned scratch and are destroyed; returns 0 or the + * SOLVE_LINEAR_EQUATIONS_FAILED status bit (bit 3) on a singular matrix + * (SPEC_0034 GAL-029). */ +RUMOCA_MAYBE_UNUSED static uint32_t rumoca_ir_galec_solve_linear_equations( + int n, double *a, double *b, double *x) { + int col; + for (col = 0; col < n; ++col) { + int pivot = col; + int row; + int k; + double best = fabs(a[col * n + col]); + for (row = col + 1; row < n; ++row) { + double magnitude = fabs(a[row * n + col]); + if (magnitude > best) { + best = magnitude; + pivot = row; + } + } + if (!(best > 0.0)) { + return (uint32_t)1u << 3; /* SOLVE_LINEAR_EQUATIONS_FAILED */ + } + if (pivot != col) { + double swap; + for (k = col; k < n; ++k) { + swap = a[col * n + k]; + a[col * n + k] = a[pivot * n + k]; + a[pivot * n + k] = swap; + } + swap = b[col]; + b[col] = b[pivot]; + b[pivot] = swap; + } + for (row = col + 1; row < n; ++row) { + double factor = a[row * n + col] / a[col * n + col]; + a[row * n + col] = 0.0; + for (k = col + 1; k < n; ++k) { + a[row * n + k] -= factor * a[col * n + k]; + } + b[row] -= factor * b[col]; + } + } + for (col = n - 1; col >= 0; --col) { + int k; + double sum = b[col]; + for (k = col + 1; k < n; ++k) { + sum -= a[col * n + k] * x[k]; + } + x[col] = sum / a[col * n + col]; + } + return 0u; +} + +uint32_t {{ ident(base_name) }}_startup({{ ident(base_name) }}State *self) { + uint32_t rumoca_status = 0u; (void)self; -{%- for statement in methods.startup %} -{%- for line in statement.c_lines %} - {{ line }} -{%- endfor %} -{%- endfor %} +{{- method_body(methods.startup) }} + return rumoca_status; } -void {{ function_prefix }}_recalibrate({{ struct_name }} *self) { +uint32_t {{ ident(base_name) }}_recalibrate({{ ident(base_name) }}State *self) { + uint32_t rumoca_status = 0u; (void)self; -{%- for statement in methods.recalibrate %} -{%- for line in statement.c_lines %} - {{ line }} -{%- endfor %} -{%- endfor %} +{{- method_body(methods.recalibrate) }} + return rumoca_status; } -void {{ function_prefix }}_dostep({{ struct_name }} *self) { +uint32_t {{ ident(base_name) }}_dostep({{ ident(base_name) }}State *self) { + uint32_t rumoca_status = 0u; (void)self; -{%- for statement in methods.do_step %} -{%- for line in statement.c_lines %} - {{ line }} -{%- endfor %} -{%- endfor %} +{{- method_body(methods.do_step) }} + return rumoca_status; +} +{%- else %} + +void {{ ident(base_name) }}_startup({{ ident(base_name) }}State *self) { + (void)self; +{{- method_body(methods.startup) }} +} + +void {{ ident(base_name) }}_recalibrate({{ ident(base_name) }}State *self) { + (void)self; +{{- method_body(methods.recalibrate) }} +} + +void {{ ident(base_name) }}_dostep({{ ident(base_name) }}State *self) { + (void)self; +{{- method_body(methods.do_step) }} } +{%- endif %} diff --git a/crates/rumoca-phase-codegen/src/templates/embedded-c-galec/model.h.jinja b/crates/rumoca-phase-codegen/src/templates/embedded-c-galec/model.h.jinja index d86aa72cd..2008c3fda 100644 --- a/crates/rumoca-phase-codegen/src/templates/embedded-c-galec/model.h.jinja +++ b/crates/rumoca-phase-codegen/src/templates/embedded-c-galec/model.h.jinja @@ -1,9 +1,32 @@ +{#- ================================================================== + embedded-c-galec header: C rendering of the language-neutral GALEC + block context (SPEC_0034 GAL-024/D16/D17). This template OWNS the C + language: keyword escaping, type spellings, layout. The context owns + the semantics (rumoca-galec-codegen::template_ir). The keyword list + below stays in lockstep with c_mangle.rs, which computes the same + names for the Production Code manifest's LogicalData mapping (pinned + by test). + =================================================================== -#} +{%- set c_keywords = [ + "auto", "break", "case", "char", "const", "continue", "default", "do", + "double", "else", "enum", "extern", "float", "for", "goto", "if", + "inline", "int", "long", "register", "restrict", "return", "short", + "signed", "sizeof", "static", "struct", "switch", "typedef", "union", + "unsigned", "void", "volatile", "while", + "bool", "true", "false", "memcpy", "self", + "rumoca_status", "rumoca_solve_a", "rumoca_solve_b" +] -%} +{%- macro ident(name) -%} +{%- if name is none -%}{{ fail("GALEC name cannot form a C identifier") }} +{%- elif name in c_keywords -%}{{ name }}_{%- else -%}{{ name }}{%- endif -%} +{%- endmacro -%} +{%- set c_types = {"Real": "double", "Integer": "int32_t", "Boolean": "bool"} -%} /* {{ block_name }} — GALEC-derived embedded C export (SPEC_0034 GAL-024). {%- for line in conformance_header.lines %} * {{ line }} {%- endfor %} */ -#ifndef {{ include_guard }} -#define {{ include_guard }} +#ifndef {{ ident(base_name) | upper }}_GALEC_C_H +#define {{ ident(base_name) | upper }}_GALEC_C_H #include #include @@ -13,9 +36,9 @@ * the end of dostep (eFMI trap T2). */ typedef struct { {%- for variable in variables %} - {{ variable.c_type }} {{ variable.c_name }}{% for size in variable.dimensions %}[{{ size }}]{% endfor %}; /* {{ variable.name }} ({{ variable.causality }}) */ + {{ c_types[variable.scalar] }} {{ ident(variable.base_name) }}{% for size in variable.dimensions %}[{{ size }}]{% endfor %}; /* {{ variable.name }} ({{ variable.causality }}) */ {%- endfor %} -} {{ struct_name }}; +} {{ ident(base_name) }}State; /* The three GALEC block-interface methods (eFMI §3.1.3): parameter-free, * all I/O through the block state. @@ -23,9 +46,18 @@ typedef struct { * - recalibrate: recomputes dependent parameters after tunable-parameter * changes (parameters stay tunable at runtime); * - dostep: one fixed-sample control-cycle tick. */ -void {{ function_prefix }}_startup({{ struct_name }} *self); -void {{ function_prefix }}_recalibrate({{ struct_name }} *self); -void {{ function_prefix }}_dostep({{ struct_name }} *self); +{%- if status_abi %} +/* Status ABI (SPEC_0034 GAL-029): each method returns the 32-bit eFMI + * ErrorSignalStatus word (bits 0-5 = the predefined signals in encoding + * order, e.g. bit 3 = SOLVE_LINEAR_EQUATIONS_FAILED); 0 = no signal. */ +uint32_t {{ ident(base_name) }}_startup({{ ident(base_name) }}State *self); +uint32_t {{ ident(base_name) }}_recalibrate({{ ident(base_name) }}State *self); +uint32_t {{ ident(base_name) }}_dostep({{ ident(base_name) }}State *self); +{%- else %} +void {{ ident(base_name) }}_startup({{ ident(base_name) }}State *self); +void {{ ident(base_name) }}_recalibrate({{ ident(base_name) }}State *self); +void {{ ident(base_name) }}_dostep({{ ident(base_name) }}State *self); +{%- endif %} /* Project-neutral convenience wrappers for applications that want a stable * eFMI-shaped call surface independent of the generated model prefix. */ @@ -42,4 +74,4 @@ void {{ function_prefix }}_dostep({{ struct_name }} *self); # define EFMI_STEP(model, state) model##_dostep(state) #endif -#endif /* {{ include_guard }} */ +#endif /* {{ ident(base_name) | upper }}_GALEC_C_H */ diff --git a/crates/rumoca-phase-codegen/src/templates/embedded-c-galec/target.toml b/crates/rumoca-phase-codegen/src/templates/embedded-c-galec/target.toml index ace495789..ea32aee8f 100644 --- a/crates/rumoca-phase-codegen/src/templates/embedded-c-galec/target.toml +++ b/crates/rumoca-phase-codegen/src/templates/embedded-c-galec/target.toml @@ -26,10 +26,13 @@ Compile: cc -Wall -Werror -c {{ out_dir }}/{{ model_name }}.c""" [capabilities] continuous_states = false residual_equations = false -external_functions = false +# Delegated to the projection: D13 maps `Modelica.Math.Matrices.solve` to the +# GALEC `solveLinearEquations` builtin instead of calling its LAPACK-external +# MSL body; any *other* external function still rejects precisely (ET002). +external_functions = true external_tables = false random = false -initialization = false +initialization = true events = true runtime_events = false clocks = true diff --git a/crates/rumoca-phase-codegen/src/templates/embedded-rust-galec/model.rs.jinja b/crates/rumoca-phase-codegen/src/templates/embedded-rust-galec/model.rs.jinja new file mode 100644 index 000000000..88623a03c --- /dev/null +++ b/crates/rumoca-phase-codegen/src/templates/embedded-rust-galec/model.rs.jinja @@ -0,0 +1,290 @@ +{#- ================================================================== + embedded-rust-galec: Rust rendering of the language-neutral GALEC + block context (SPEC_0034 GAL-030/D16). This template OWNS the Rust + language: identifier keyword-escaping, operator/builtin spellings, + literals, statement forms. The context owns the semantics: validated + block, collision-checked base names, 0-based indices, T7-strict Real + literal text, kind-tagged expression trees, and pre-projected + whole-array element lists (see rumoca-galec-codegen::template_ir). + =================================================================== -#} +{#- Rust keywords + names this file itself declares. Base names never end + in `_`, so appending one cannot re-collide (template_ir module docs). -#} +{%- set rust_keywords = [ + "as", "break", "const", "continue", "crate", "dyn", "else", "enum", + "extern", "false", "fn", "for", "if", "impl", "in", "let", "loop", + "match", "mod", "move", "mut", "pub", "ref", "return", "self", "Self", + "static", "struct", "trait", "true", "type", "unsafe", "use", "where", + "while", "async", "await", "abstract", "become", "box", "do", "final", + "macro", "override", "priv", "typeof", "unsized", "virtual", "yield", + "try", "gen", "union", + "Signals", "status", "galec_abs", "galec_sign", "galec_min", + "galec_max", "galec_imin", "galec_imax", "galec_solve_linear_equations", + "galec_solve_a", "galec_solve_b" +] -%} +{%- macro ident(name) -%} +{%- if name in rust_keywords -%}{{ name }}_{%- else -%}{{ name }}{%- endif -%} +{%- endmacro -%} +{%- set scalar_types = {"Real": "f64", "Integer": "i32", "Boolean": "bool"} -%} +{%- set scalar_zeros = {"Real": "0.0", "Integer": "0", "Boolean": "false"} -%} +{%- macro rust_type(scalar, dims, i) -%} +{%- if i >= dims | length -%}{{ scalar_types[scalar] }} +{%- else -%}[{{ rust_type(scalar, dims, i + 1) }}; {{ dims[i] }}]{%- endif -%} +{%- endmacro -%} +{%- macro rust_zero(scalar, dims, i) -%} +{%- if i >= dims | length -%}{{ scalar_zeros[scalar] }} +{%- else -%}[{{ rust_zero(scalar, dims, i + 1) }}; {{ dims[i] }}]{%- endif -%} +{%- endmacro -%} +{#- Operator spellings; `pow` is special-cased to f64::powf (both operands + are Real by lowering, GALEC trap T5). -#} +{%- set ops = { + "add": "+", "sub": "-", "mul": "*", "div": "/", + "lt": "<", "le": "<=", "gt": ">", "ge": ">=", "eq": "==", "ne": "!=", + "and": "&&", "or": "||" +} -%} +{#- GALEC §3.2.6 catalog -> Rust. min/max are the GALEC relational forms, + NOT f64::min/max (qNaN handling, traps T9/T14); transcendentals need + libm (`core` has no float math) — a model avoiding them has no deps. -#} +{%- set builtins = { + "absolute": "galec_abs", "sign": "galec_sign", + "min": "galec_min", "max": "galec_max", + "imin": "galec_imin", "imax": "galec_imax", + "sqrt": "libm::sqrt", "exp": "libm::exp", "ln": "libm::log", + "lg": "libm::log10", "roundDown": "libm::floor", "roundUp": "libm::ceil", + "sin": "libm::sin", "cos": "libm::cos", "tan": "libm::tan", + "asin": "libm::asin", "acos": "libm::acos", "atan": "libm::atan", + "atan2": "libm::atan2", "sinh": "libm::sinh", "cosh": "libm::cosh", + "tanh": "libm::tanh" +} -%} +{#- Template IR indices are the GALEC truth (1-based); Rust is 0-based. -#} +{%- macro ref(r) -%} +self.{{ ident(r.base_name) }}{% for i in r.indices %}[{{ i - 1 }}]{% endfor %} +{%- endmacro -%} +{%- macro expr(e) -%} +{%- if e.kind == "bool" -%}{{ e.value }} +{%- elif e.kind == "int" -%}{% if e.value < 0 %}({{ e.value }}){% else %}{{ e.value }}{% endif %} +{%- elif e.kind == "real" -%}{% if e.negative %}({{ e.text }}){% else %}{{ e.text }}{% endif %} +{%- elif e.kind == "ref" -%}{{ ref(e) }} +{%- elif e.kind == "neg" -%}(-{{ expr(e.value) }}) +{%- elif e.kind == "not" -%}(!({{ expr(e.value) }})) +{%- elif e.kind == "paren" -%}({{ expr(e.value) }}) +{%- elif e.kind == "binary" -%} +{%- if e.op == "pow" -%}f64::powf({{ expr(e.lhs) }}, {{ expr(e.rhs) }}) +{%- else -%}({{ expr(e.lhs) }} {{ ops[e.op] }} {{ expr(e.rhs) }}){%- endif -%} +{%- elif e.kind == "if" -%} +({% for branch in e.branches %}if {{ expr(branch.condition) }} { {{ expr(branch.value) }} } else {% endfor %}{ {{ expr(e["else"]) }} }) +{%- elif e.kind == "array" -%} +[{% for element in e.elements %}{{ expr(element) }}{% if not loop.last %}, {% endif %}{% endfor %}] +{%- elif e.kind == "call" -%} +{%- if e.builtin == "real" -%}(({{ expr(e.args[0]) }}) as f64) +{%- elif e.builtin == "divisionTowardsZero" -%}({{ expr(e.args[0]) }} / {{ expr(e.args[1]) }}) +{%- elif e.builtin in builtins -%}{{ builtins[e.builtin] }}({% for a in e.args %}{{ expr(a) }}{% if not loop.last %}, {% endif %}{% endfor %}) +{%- else -%}{{ fail("embedded-rust-galec has no mapping for GALEC builtin `" ~ e.builtin ~ "`") }} +{%- endif -%} +{%- else -%}{{ fail("embedded-rust-galec met unknown expression kind `" ~ e.kind ~ "`") }} +{%- endif -%} +{%- endmacro -%} +{#- Scratch materialization for the solve statement: array/ref values are + Rust array values and copy wholesale; everything else zero-inits and + assigns the pre-projected elements. -#} +{%- macro materialize(name, ty, zero, value, copy, elements) -%} +{%- if value.kind == "array" or copy %} + let mut {{ name }}: {{ ty }} = {{ expr(value) }}; +{%- else %} + let mut {{ name }}: {{ ty }} = {{ zero }}; +{%- for element in elements %} + {{ name }}{% for i in element.indices %}[{{ i - 1 }}]{% endfor %} = {{ expr(element.value) }}; +{%- endfor %} +{%- endif %} +{%- endmacro -%} +{%- macro stmt(s) -%} +{%- if s.kind == "assign" %} + {{ ref(s.target) }} = {{ expr(s.value) }}; +{%- elif s.kind == "assign_whole" %} +{%- if s.value.kind == "array" or s.copy %} + {{ ref(s.target) }} = {{ expr(s.value) }}; +{%- else %} +{%- for element in s.elements %} + {{ ref(s.target) }}{% for i in element.indices %}[{{ i - 1 }}]{% endfor %} = {{ expr(element.value) }}; +{%- endfor %} +{%- endif %} +{%- elif s.kind == "solve" %} + { + {{- materialize("galec_solve_a", "[[f64; " ~ s.n ~ "]; " ~ s.n ~ "]", "[[0.0f64; " ~ s.n ~ "]; " ~ s.n ~ "]", s.a, s.a_copy, s.a_elements) }} + {{- materialize("galec_solve_b", "[f64; " ~ s.n ~ "]", "[0.0f64; " ~ s.n ~ "]", s.b, s.b_copy, s.b_elements) }} + status |= galec_solve_linear_equations(&mut galec_solve_a, &mut galec_solve_b, &mut {{ ref(s.target) }}); + } +{%- else -%} +{{ fail("embedded-rust-galec met unknown statement kind `" ~ s.kind ~ "`") }} +{%- endif -%} +{%- endmacro -%} +{%- macro method_body(m) -%} +{%- for s in m.statements %}{{ stmt(s) }}{% endfor %} +{%- endmacro -%} +//! {{ block_name }} — GALEC-derived embedded Rust export (SPEC_0034 GAL-030). +{%- for line in conformance_header.lines %} +//! {{ line }} +{%- endfor %} +//! +//! Generated by walking the language-neutral GALEC block context +//! (SPEC_0034 D16); this template owns every Rust spelling. Transcendental +//! builtins reference `libm`; a model that avoids them has no dependencies. +#![no_std] +#![allow(non_snake_case)] +#![allow(unused_parens)] +// `status` stays unmutated in methods whose escape set needs no +// accumulation. +#![allow(unused_mut)] +#![allow(clippy::all)] +{% if status_abi %} +/// The 32-bit eFMI ErrorSignalStatus word (SPEC_0034 GAL-029): bits 0-5 are +/// the predefined signals in encoding order (bit 3 = +/// `SOLVE_LINEAR_EQUATIONS_FAILED`); non-zero means at least one signal +/// escaped the method. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Signals(pub u32); +{% endif %} +/// Block state: every manifest-listed variable of the projected GALEC +/// block, including the 'previous(x)' discrete-state slots committed at +/// the end of do_step (eFMI trap T2). +#[derive(Clone, Copy, Debug)] +pub struct {{ ident(base_name) }}State { +{%- for variable in variables %} +{%- if variable.base_name is none %}{{ fail("GALEC name `" ~ variable.name ~ "` cannot form a Rust identifier") }}{% endif %} + /// {{ variable.name }} ({{ variable.causality }}, manifest {{ variable.id }}) + pub {{ ident(variable.base_name) }}: {{ rust_type(variable.scalar, variable.dimensions, 0) }}, +{%- endfor %} +} + +impl Default for {{ ident(base_name) }}State { + fn default() -> Self { + Self { +{%- for variable in variables %} + {{ ident(variable.base_name) }}: {{ rust_zero(variable.scalar, variable.dimensions, 0) }}, +{%- endfor %} + } + } +} + +// GALEC §3.2.6 builtins without a `core` counterpart. min/max use the GALEC +// relational definition (`u1 < u2` selects), NOT `f64::min`/`f64::max` +// (which drop a qNaN operand instead of taking the `else` branch a false +// comparison implies, eFMI traps T9/T14). `galec_abs` clears the sign bit +// (`fabs` semantics) without needing `std`. +#[allow(dead_code)] +#[inline] +fn galec_abs(x: f64) -> f64 { + f64::from_bits(x.to_bits() & !(1u64 << 63)) +} +#[allow(dead_code)] +#[inline] +fn galec_sign(x: f64) -> f64 { + ((x > 0.0) as i32 - (x < 0.0) as i32) as f64 +} +#[allow(dead_code)] +#[inline] +fn galec_min(u1: f64, u2: f64) -> f64 { + if u1 < u2 { u1 } else { u2 } +} +#[allow(dead_code)] +#[inline] +fn galec_max(u1: f64, u2: f64) -> f64 { + if u1 > u2 { u1 } else { u2 } +} +#[allow(dead_code)] +#[inline] +fn galec_imin(u1: i32, u2: i32) -> i32 { + if u1 < u2 { u1 } else { u2 } +} +#[allow(dead_code)] +#[inline] +fn galec_imax(u1: i32, u2: i32) -> i32 { + if u1 > u2 { u1 } else { u2 } +} +{%- if status_abi %} + +/// GALEC `solveLinearEquations` (§3.2.6): solve `a * x = b` by Gaussian +/// elimination with partial pivoting. `a` and `b` are caller-owned scratch +/// and are destroyed; returns 0 or the `SOLVE_LINEAR_EQUATIONS_FAILED` +/// status bit (bit 3) on a singular matrix (SPEC_0034 GAL-029). +#[allow(dead_code)] +fn galec_solve_linear_equations( + a: &mut [[f64; N]; N], + b: &mut [f64; N], + x: &mut [f64; N], +) -> u32 { + for col in 0..N { + let mut pivot = col; + let mut best = galec_abs(a[col][col]); + for row in (col + 1)..N { + let magnitude = galec_abs(a[row][col]); + if magnitude > best { + best = magnitude; + pivot = row; + } + } + if !(best > 0.0) { + return 1u32 << 3; // SOLVE_LINEAR_EQUATIONS_FAILED + } + if pivot != col { + a.swap(col, pivot); + b.swap(col, pivot); + } + for row in (col + 1)..N { + let factor = a[row][col] / a[col][col]; + a[row][col] = 0.0; + for k in (col + 1)..N { + a[row][k] -= factor * a[col][k]; + } + b[row] -= factor * b[col]; + } + } + for col in (0..N).rev() { + let mut sum = b[col]; + for k in (col + 1)..N { + sum -= a[col][k] * x[k]; + } + x[col] = sum / a[col][col]; + } + 0 +} +{%- endif %} + +/// The three GALEC block-interface methods (eFMI §3.1.3): parameter-free, +/// all I/O through the block state. +/// - `startup`: initializes every writable variable (call once first); +/// - `recalibrate`: recomputes dependent parameters after tunable-parameter +/// changes (parameters stay tunable at runtime); +/// - `do_step`: one fixed-sample control-cycle tick. +impl {{ ident(base_name) }}State { +{%- if status_abi %} + pub fn startup(&mut self) -> Result<(), Signals> { + let mut status: u32 = 0; +{{- method_body(methods.startup) }} + if status == 0 { Ok(()) } else { Err(Signals(status)) } + } + + pub fn recalibrate(&mut self) -> Result<(), Signals> { + let mut status: u32 = 0; +{{- method_body(methods.recalibrate) }} + if status == 0 { Ok(()) } else { Err(Signals(status)) } + } + + pub fn do_step(&mut self) -> Result<(), Signals> { + let mut status: u32 = 0; +{{- method_body(methods.do_step) }} + if status == 0 { Ok(()) } else { Err(Signals(status)) } + } +{%- else %} + pub fn startup(&mut self) { +{{- method_body(methods.startup) }} + } + + pub fn recalibrate(&mut self) { +{{- method_body(methods.recalibrate) }} + } + + pub fn do_step(&mut self) { +{{- method_body(methods.do_step) }} + } +{%- endif %} +} diff --git a/crates/rumoca-phase-codegen/src/templates/embedded-rust-galec/target.toml b/crates/rumoca-phase-codegen/src/templates/embedded-rust-galec/target.toml new file mode 100644 index 000000000..725a123a8 --- /dev/null +++ b/crates/rumoca-phase-codegen/src/templates/embedded-rust-galec/target.toml @@ -0,0 +1,54 @@ +version = 1 +ir = "dae" +name = "embedded-rust-galec" +# Conformance (SPEC_0034 GAL-030, mirroring the GAL-024 two-track rule): +# this target is a non-eFMI track — a GALEC-derived embedded Rust export. +# It is NOT an eFMI Production Code container (the Beta-1 ProductionCode +# XSD restricts `language` to C/C++): no LogicalData/BlockMethods manifest +# mapping, no checksummed ManifestReference, no co-emitted Algorithm Code +# container. The eFMI rungs are the separate `galec`/`galec-production` +# targets. +description = "GALEC-derived embedded Rust export (#![no_std] block-state struct + startup/recalibrate/do_step over the projected GALEC block) for fixed-sample discrete models — NOT an eFMI Production Code container (SPEC_0034 GAL-030)" +execution_mode = "compiled" +deployment_class = "cpu" +readiness_level = 2 +completion_message = """GALEC-derived embedded Rust source compiled to: {{ out_dir }} + {{ model_name }}.rs #![no_std] crate root: block-state struct + startup/recalibrate/do_step +This is a GALEC-derived embedded Rust export, NOT an eFMI Production Code +container (SPEC_0034 GAL-030): the Beta-1 ProductionCode XSD restricts +`language` to C/C++, so no eFMU manifest mapping is emitted. Transcendental +builtins (sin/exp/…) reference the `libm` crate; models without them have no +dependencies at all. +Compile: rustc --edition 2021 --crate-type lib -D warnings {{ out_dir }}/{{ model_name }}.rs""" + +# Generic DAE capability gates (SPEC_0034 GAL-006), copied verbatim from +# the galec/embedded-c-galec targets: all three consume the same +# DAE → GALEC projection, so admissibility is identical. +[capabilities] +continuous_states = false +residual_equations = false +# Delegated to the projection: D13 maps `Modelica.Math.Matrices.solve` to the +# GALEC `solveLinearEquations` builtin instead of calling its LAPACK-external +# MSL body; any *other* external function still rejects precisely (ET002). +external_functions = true +external_tables = false +random = false +initialization = true +events = true +runtime_events = false +clocks = true +dynamic_ranges = false +dynamic_derivative_subscripts = false +forward_ad = false +reverse_ad = false +dynamic_control_flow = false +host_callbacks = false + +# GAL-008 analog: this template owns the Rust FILE LAYOUT only (attributes, +# struct/impl skeletons, the fixed helper prelude); every Rust expression/ +# statement and identifier is printed by the typed printer in +# rumoca-galec-codegen (rust_mangle/rust_print) and arrives pre-printed in +# the template context (`variables[*].rust_name`, `methods.*.rust_lines`). +[[files]] +path = "{{ model_name }}.rs" +template = "model.rs.jinja" diff --git a/crates/rumoca-phase-codegen/src/templates/galec-production/target.toml b/crates/rumoca-phase-codegen/src/templates/galec-production/target.toml index 0b36e6e2d..8e951d789 100644 --- a/crates/rumoca-phase-codegen/src/templates/galec-production/target.toml +++ b/crates/rumoca-phase-codegen/src/templates/galec-production/target.toml @@ -43,10 +43,13 @@ method; the co-emitted GALEC also round-trip parses ("GALEC language conformance [capabilities] continuous_states = false residual_equations = false -external_functions = false +# Delegated to the projection: D13 maps `Modelica.Math.Matrices.solve` to the +# GALEC `solveLinearEquations` builtin instead of calling its LAPACK-external +# MSL body; any *other* external function still rejects precisely (ET002). +external_functions = true external_tables = false random = false -initialization = false +initialization = true events = true runtime_events = false clocks = true diff --git a/crates/rumoca-phase-codegen/src/templates/galec/target.toml b/crates/rumoca-phase-codegen/src/templates/galec/target.toml index 689137a4b..bf4470433 100644 --- a/crates/rumoca-phase-codegen/src/templates/galec/target.toml +++ b/crates/rumoca-phase-codegen/src/templates/galec/target.toml @@ -38,10 +38,13 @@ eFMI Production Code export (see --target galec-production).""" [capabilities] continuous_states = false residual_equations = false -external_functions = false +# Delegated to the projection: D13 maps `Modelica.Math.Matrices.solve` to the +# GALEC `solveLinearEquations` builtin instead of calling its LAPACK-external +# MSL body; any *other* external function still rejects precisely (ET002). +external_functions = true external_tables = false random = false -initialization = false +initialization = true events = true runtime_events = false clocks = true diff --git a/crates/rumoca/src/target_manifest.rs b/crates/rumoca/src/target_manifest.rs index 3dd58af99..e34170aea 100644 --- a/crates/rumoca/src/target_manifest.rs +++ b/crates/rumoca/src/target_manifest.rs @@ -713,6 +713,11 @@ enum ManifestRenderer { /// projection context (SPEC_0034 GAL-024/D2) — never the generic DAE /// JSON context. GalecC(rumoca_compile::galec::GalecCExport), + /// GALEC-template targets (`embedded-rust-galec`, SPEC_0034 GAL-030/D16) + /// render self-contained walking templates over the language-neutral + /// GALEC block context — the template owns the language, exactly like + /// the generic IR targets own theirs. + GalecBlock(rumoca_compile::galec::GalecBlockContext), } /// Resolve the renderer for one non-eFMU target invocation (module docs on @@ -739,6 +744,14 @@ fn resolve_manifest_renderer( .context("GALEC C export for target 'embedded-c-galec'")?; return Ok(ManifestRenderer::GalecC(export)); } + if manifest.ir == TargetTemplateIr::Dae + && manifest.name.as_deref() == Some("embedded-rust-galec") + { + let context = + rumoca_compile::galec::galec_block_context(&result.dae, &result.flat, model_identifier) + .context("GALEC block context for target 'embedded-rust-galec'")?; + return Ok(ManifestRenderer::GalecBlock(context)); + } Ok(ManifestRenderer::Ir(template_ir_to_cli(manifest.ir))) } @@ -777,6 +790,7 @@ impl ManifestRenderer { .render_solve_template_str_without_dae(template, model_identifier) .map_err(Into::into), Self::GalecC(export) => render_galec_c_template(export, template), + Self::GalecBlock(context) => render_galec_block_template(context, template), } } } @@ -816,15 +830,6 @@ struct CConformanceHeader { summary: &'static str, } -impl CConformanceHeader { - fn context_value(&self) -> minijinja::Value { - minijinja::context! { - lines => self.lines, - summary => self.summary, - } - } -} - /// The `embedded-c-galec` claim: the non-eFMI track of GAL-024 must /// self-describe as NOT an eFMI Production Code container (pinned by the /// CLI honesty test `export_self_describes_as_not_an_efmi_production_code_container`). @@ -849,16 +854,45 @@ fn render_galec_c_template( export: &rumoca_compile::galec::GalecCExport, template: &str, ) -> Result { - let mut env = minijinja::Environment::new(); - env.set_undefined_behavior(minijinja::UndefinedBehavior::Strict); - env.render_str( - template, - minijinja::context! { - conformance_header => EMBEDDED_C_GALEC_CONFORMANCE_HEADER.context_value(), - ..minijinja::Value::from_serialize(&export.context) - }, - ) - .context("Render embedded-c-galec target template") + // D16/D17: the C templates walk the GALEC block context under the + // standard codegen environment (same toolbox as every other walker). + let mut context = export.context.clone(); + if let serde_json::Value::Object(map) = &mut context { + map.insert( + "conformance_header".to_owned(), + serde_json::json!({ + "lines": EMBEDDED_C_GALEC_CONFORMANCE_HEADER.lines, + "summary": EMBEDDED_C_GALEC_CONFORMANCE_HEADER.summary, + }), + ); + } + rumoca_compile::galec::render_galec_block_template(&context, template) + .context("Render embedded-c-galec target template") +} + +/// Render a GALEC-template target file (path or code) from the invocation's +/// language-neutral [`rumoca_compile::galec::GalecBlockContext`] +/// (SPEC_0034 D16). Rendering goes through the standard codegen +/// environment ([`rumoca_phase_codegen::render_template_with_json_context`]) +/// so these walking templates get the exact filter/function toolbox the +/// generic IR targets have; the renderer adds the one key that is target +/// identity rather than projection data — the honesty header (GAL-030). +fn render_galec_block_template( + block: &rumoca_compile::galec::GalecBlockContext, + template: &str, +) -> Result { + let mut context = block.context.clone(); + if let serde_json::Value::Object(map) = &mut context { + map.insert( + "conformance_header".to_owned(), + serde_json::json!({ + "lines": rumoca_compile::galec::EMBEDDED_RUST_GALEC_CONFORMANCE_LINES, + "summary": rumoca_compile::galec::EMBEDDED_RUST_GALEC_CONFORMANCE_SUMMARY, + }), + ); + } + rumoca_compile::galec::render_galec_block_template(&context, template) + .context("Render embedded-rust-galec target template") } #[cfg(feature = "scheduled-sim")] diff --git a/crates/rumoca/tests/cli_target_embedded_rust_galec.rs b/crates/rumoca/tests/cli_target_embedded_rust_galec.rs new file mode 100644 index 000000000..2a2855e5b --- /dev/null +++ b/crates/rumoca/tests/cli_target_embedded_rust_galec.rs @@ -0,0 +1,252 @@ +//! End-to-end CLI coverage for `rumoca compile --target embedded-rust-galec` +//! (SPEC_0034 GAL-011/GAL-012/GAL-030/D16). +//! +//! Invokes the real binary so the whole chain is exercised: CLI dispatch → +//! generic capability gate → GALEC projection → language-neutral block +//! context (`template_ir`) → the walking Rust template. The emitted source +//! is compiled with `rustc --edition 2021 -D warnings` both standalone +//! (`--crate-type lib`, proving the `#![no_std]` file is a self-contained +//! crate root) and as an rlib LINKED against a generated driver whose +//! execution checks the discrete dynamics tick for tick (GAL-012: +//! generated code is compile-checked, never skip-and-mark-covered). +//! `rustc` is the toolchain building this very test, so it is always +//! present — a spawn failure is a hard failure, never a skip. +//! +//! This target is a non-eFMI track (GAL-030): a GALEC-derived embedded +//! Rust export that must self-describe as NOT an eFMI Production Code +//! container (Rust is outside the Beta-1 ProductionCode schema) — pinned +//! in both the emitted file and the CLI completion message. + +use std::fs; +use std::path::Path; +use std::process::{Command, Output}; + +use tempfile::tempdir; + +#[path = "galec_cli_support/cli.rs"] +mod cli_support; + +use cli_support::{run_compile_target, strip_ansi, write_fixture}; + +/// Fixed-sample discrete fixture (mirrors `cli_target_embedded_c_galec.rs` +/// so the two tracks pin identical dynamics). +const DISCRETE_FIXTURE: &str = "\ +model EmbeddedRustGalecSmoke + constant Real samplePeriod = 0.1; + parameter Real gain = 2.0; + discrete output Real y(start = 0.0); +equation + when sample(0.0, samplePeriod) then + y = gain * (pre(y) + 1.0); + end when; +end EmbeddedRustGalecSmoke; +"; + +const MODEL: &str = "EmbeddedRustGalecSmoke"; + +/// Continuous model the capability gate must reject (GAL-006). +const CONTINUOUS_FIXTURE: &str = "\ +model EmbeddedRustGalecContinuous + Real x(start = 1.0); + parameter Real k = 2.0; +equation + der(x) = -k * x; +end EmbeddedRustGalecContinuous; +"; + +/// Driver exercising the generated block: startup, recalibrate, then three +/// do_step ticks of `y = gain * (pre(y) + 1)` with `gain = 2`, `y0 = 0` +/// (expected 2, 6, 14). +const DRIVER_MAIN: &str = "\ +fn main() { + let mut state = EmbeddedRustGalecSmoke::EmbeddedRustGalecSmokeState::default(); + state.startup(); + state.recalibrate(); + for _ in 0..3 { + state.do_step(); + println!(\"{:.1}\", state.y); + } +} +"; + +fn run_compile_embedded_rust_galec(file: &Path, out_dir: &Path) -> Output { + run_compile_target(file, "embedded-rust-galec", out_dir) +} + +/// Compile the discrete fixture into `out_dir`, failing loudly on any CLI +/// error, and return the CLI stderr for message assertions. +fn build_source(work_dir: &Path, out_dir: &Path) -> String { + let file = write_fixture(work_dir, MODEL, DISCRETE_FIXTURE); + let output = run_compile_embedded_rust_galec(&file, out_dir); + assert!( + output.status.success(), + "`compile --target embedded-rust-galec` failed (status {:?}).\nstdout:\n{}\nstderr:\n{}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stderr).into_owned() +} + +fn rustc() -> Command { + Command::new("rustc") +} + +/// The emitted source compiles standalone under `-D warnings` as a +/// `#![no_std]` lib crate root, links against a generated driver as an +/// rlib, and the executed block reproduces the discrete dynamics tick for +/// tick. +#[test] +fn emitted_rust_compiles_links_and_reproduces_the_discrete_dynamics() { + let dir = tempdir().expect("tempdir"); + let out_dir = dir.path().join("out"); + build_source(dir.path(), &out_dir); + + let source = out_dir.join(format!("{MODEL}.rs")); + assert!(source.is_file(), "missing {}", source.display()); + + // Standalone: the file is a self-contained `#![no_std]` crate root + // with zero dependencies — `-D warnings` keeps it lint-clean. + let standalone = rustc() + .arg("--edition") + .arg("2021") + .arg("--crate-type") + .arg("lib") + .arg("-D") + .arg("warnings") + .arg(&source) + .arg("--out-dir") + .arg(out_dir.join("standalone")) + .output() + .expect("run rustc (standalone lib)"); + assert!( + standalone.status.success(), + "rustc -D warnings failed.\nstderr:\n{}\nsource:\n{}", + String::from_utf8_lossy(&standalone.stderr), + fs::read_to_string(&source).unwrap_or_default() + ); + + // Linked: rlib + driver, then run the block. + let rlib_dir = out_dir.join("rlib"); + let rlib = rustc() + .arg("--edition") + .arg("2021") + .arg("--crate-type") + .arg("rlib") + .arg("-D") + .arg("warnings") + .arg(&source) + .arg("--out-dir") + .arg(&rlib_dir) + .output() + .expect("run rustc (rlib)"); + assert!( + rlib.status.success(), + "rustc rlib build failed.\nstderr:\n{}", + String::from_utf8_lossy(&rlib.stderr) + ); + let driver = out_dir.join("main.rs"); + fs::write(&driver, DRIVER_MAIN).expect("write driver"); + let program = out_dir.join("smoke"); + let link = rustc() + .arg("--edition") + .arg("2021") + .arg(&driver) + .arg("--extern") + .arg(format!( + "{MODEL}={}", + rlib_dir.join(format!("lib{MODEL}.rlib")).display() + )) + .arg("-o") + .arg(&program) + .output() + .expect("run rustc (driver)"); + assert!( + link.status.success(), + "driver link failed.\nstderr:\n{}", + String::from_utf8_lossy(&link.stderr) + ); + + let run = Command::new(&program) + .output() + .expect("run generated block"); + assert!( + run.status.success(), + "generated block driver exited with {:?}", + run.status.code() + ); + assert_eq!( + // Normalize CRLF: Windows text-mode stdio emits `\n` as `\r\n`. + String::from_utf8_lossy(&run.stdout).replace("\r\n", "\n"), + "2.0\n6.0\n14.0\n", + "three do_step ticks of y := gain * (previous(y) + 1) with gain = 2" + ); +} + +/// GAL-030 honesty: the emitted file and the CLI completion message both +/// self-describe as NOT an eFMI Production Code container. +#[test] +fn export_self_describes_as_not_an_efmi_production_code_container() { + let dir = tempdir().expect("tempdir"); + let out_dir = dir.path().join("out"); + let stderr = build_source(dir.path(), &out_dir); + + let source = fs::read_to_string(out_dir.join(format!("{MODEL}.rs"))).expect("read source"); + assert!( + source.contains("NOT an eFMI Production Code container"), + "source must carry the GAL-030 self-description:\n{source}" + ); + assert!( + source.contains("#![no_std]"), + "source must be a no_std crate root (D15):\n{source}" + ); + assert!( + strip_ansi(&stderr).contains("NOT an eFMI Production Code"), + "completion message must carry the GAL-030 self-description, got:\n{stderr}" + ); +} + +#[test] +fn continuous_model_is_rejected_by_the_capability_gate() { + let dir = tempdir().expect("tempdir"); + let file = write_fixture( + dir.path(), + "EmbeddedRustGalecContinuous", + CONTINUOUS_FIXTURE, + ); + let out_dir = dir.path().join("out"); + + let output = run_compile_embedded_rust_galec(&file, &out_dir); + assert!( + !output.status.success(), + "`compile --target embedded-rust-galec` must fail for a continuous model.\nstdout:\n{}", + String::from_utf8_lossy(&output.stdout) + ); + let stderr = strip_ansi(&String::from_utf8_lossy(&output.stderr)); + assert!( + stderr.contains("unsupported-feature:continuous_states"), + "expected the generic capability diagnostic (GAL-006), got stderr:\n{stderr}" + ); + assert!( + !out_dir.exists(), + "capability rejection must happen before the output directory is created" + ); +} + +#[test] +fn targets_listing_includes_embedded_rust_galec() { + let output = Command::new(env!("CARGO_BIN_EXE_rumoca")) + .arg("targets") + .output() + .expect("run rumoca targets"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + output.status.success(), + "`rumoca targets` failed.\nstdout:\n{stdout}\nstderr:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + stdout.contains("embedded-rust-galec"), + "targets listing must include embedded-rust-galec:\n{stdout}" + ); +} diff --git a/crates/rumoca/tests/cli_target_galec_kalman.rs b/crates/rumoca/tests/cli_target_galec_kalman.rs new file mode 100644 index 000000000..9c34f767c --- /dev/null +++ b/crates/rumoca/tests/cli_target_galec_kalman.rs @@ -0,0 +1,340 @@ +//! End-to-end quadrotor Kalman-filter coverage for the GALEC export tracks +//! (SPEC_0034 GAL-027/028/029/030 — the estimator scope those rules were +//! written for): the `examples/models/QuadrotorAltitudeKF.mo` fixture +//! exercises matrix products (including the chained `A*P*transpose(A)`), +//! `identity`, a computed `initial equation`, and the +//! `Matrices.solve` → `solveLinearEquations` mapping with its +//! `SOLVE_LINEAR_EQUATIONS_FAILED` escape. +//! +//! The test is hermetic: `Modelica.Math.Matrices.solve` maps **by name** +//! (D13) before any body lookup, so a one-function MSL stub package stands +//! in for the real MSL — no library download. +//! +//! Checks: both embedded exports compile (`cc -Wall -Werror`, +//! `rustc -D warnings`), run 25 ticks on identical inputs, agree with each +//! other **exactly** (same evaluation order, both IEEE double, no +//! re-association — GAL-027/T6 end to end), and agree with an +//! independently-written reference filter to 1e-9. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use tempfile::tempdir; + +#[path = "galec_cli_support/cc.rs"] +mod cc_support; + +use cc_support::cc; + +const MODEL: &str = "QuadrotorAltitudeKF"; +const TICKS: usize = 25; + +/// Hermetic stand-in for the MSL: only the resolved NAME matters — the +/// GALEC projection intercepts `Modelica.Math.Matrices.solve` before any +/// inlining (D13), so the body is never consumed. +const MSL_STUB: &str = "\ +package Modelica \"Hermetic MSL stub (D13: Matrices.solve maps by name)\" + package Math + package Matrices + function solve + input Real A[:, :]; + input Real b[:]; + output Real x[size(b, 1)]; + algorithm + x := b; + end solve; + end Matrices; + end Math; +end Modelica; +"; + +const C_DRIVER: &str = "\ +#include +#include \"QuadrotorAltitudeKF.h\" + +int main(void) { + QuadrotorAltitudeKFState s; + uint32_t status = QuadrotorAltitudeKF_startup(&s); + for (int k = 0; k < 25; ++k) { + s.u = 0.3 - 0.01 * (double)k; + s.z_meas = 0.05 * (double)k; + s.vz_meas = 0.05; + status |= QuadrotorAltitudeKF_dostep(&s); + printf(\"%d,%.17g,%.17g,%.17g,%.17g\\n\", k, s.z_hat, s.vz_hat, s.P[0][0], s.P[1][1]); + } + return status == 0u ? 0 : 2; +} +"; + +const RUST_DRIVER: &str = "\ +fn main() { + let mut s = QuadrotorAltitudeKF::QuadrotorAltitudeKFState::default(); + let mut failed = s.startup().is_err(); + for k in 0..25usize { + s.u = 0.3 - 0.01 * (k as f64); + s.z_meas = 0.05 * (k as f64); + s.vz_meas = 0.05; + failed |= s.do_step().is_err(); + println!(\"{k},{:.17e},{:.17e},{:.17e},{:.17e}\", s.z_hat, s.vz_hat, s.P[0][0], s.P[1][1]); + } + if failed { + std::process::exit(2); + } +} +"; + +fn fixture_path() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../examples/models") + .join(format!("{MODEL}.mo")) +} + +/// `rumoca compile --target -o ` with the stub +/// package on the source path. +fn compile_with_stub(target: &str, stub_root: &Path, out_dir: &Path) -> Output { + Command::new(env!("CARGO_BIN_EXE_rumoca")) + .arg("compile") + .arg(fixture_path()) + .arg("--source-root") + .arg(stub_root) + .arg("--target") + .arg(target) + .arg("-o") + .arg(out_dir) + .output() + .unwrap_or_else(|error| panic!("run rumoca compile --target {target}: {error}")) +} + +fn assert_success(output: &Output, what: &str) { + assert!( + output.status.success(), + "{what} failed (status {:?}).\nstdout:\n{}\nstderr:\n{}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +/// Parse `k,z_hat,vz_hat,P11,P22` CSV lines. +fn parse_ticks(stdout: &[u8], what: &str) -> Vec<[f64; 4]> { + let text = String::from_utf8_lossy(stdout).replace("\r\n", "\n"); + let ticks: Vec<[f64; 4]> = text + .lines() + .enumerate() + .map(|(index, line)| { + let fields: Vec<&str> = line.split(',').collect(); + assert_eq!(fields.len(), 5, "{what} line {index}: `{line}`"); + assert_eq!( + fields[0].parse::().ok(), + Some(index), + "{what} dropped or duplicated a tick at line {index}" + ); + [1, 2, 3, 4].map(|field| { + fields[field] + .parse::() + .unwrap_or_else(|error| panic!("{what} line {index}: {error}")) + }) + }) + .collect(); + assert_eq!(ticks.len(), TICKS, "{what} tick count"); + ticks +} + +/// Independently-written reference filter (same math, its own code): the +/// fixture's parameters and update equations, with the same partial-pivot +/// solve the generated helpers implement. +fn reference_ticks() -> Vec<[f64; 4]> { + let t = 0.02f64; + let (q_accel, r_alt, r_vel, p0) = (0.05f64, 0.04f64, 0.09f64, 1.0f64); + let a = [[1.0, t], [0.0, 1.0]]; + let b = [t * t / 2.0, t]; + let q = [[q_accel * t * t, 0.0], [0.0, q_accel]]; + let r = [[r_alt, 0.0], [0.0, r_vel]]; + let mut x = [0.0f64, 0.0]; + let mut p = [[p0, 0.0], [0.0, p0]]; + let mut out = Vec::with_capacity(TICKS); + for k in 0..TICKS { + let u = 0.3 - 0.01 * (k as f64); + let (zm, vm) = (0.05 * (k as f64), 0.05); + let x_pred = [ + a[0][0] * x[0] + a[0][1] * x[1] + b[0] * u, + a[1][0] * x[0] + a[1][1] * x[1] + b[1] * u, + ]; + // P_pred = A*P*A' + Q. + let mut ap = [[0.0f64; 2]; 2]; + for i in 0..2 { + for j in 0..2 { + ap[i][j] = a[i][0] * p[0][j] + a[i][1] * p[1][j]; + } + } + let mut p_pred = [[0.0f64; 2]; 2]; + for i in 0..2 { + for j in 0..2 { + p_pred[i][j] = ap[i][0] * a[j][0] + ap[i][1] * a[j][1] + q[i][j]; + } + } + let s = [ + [p_pred[0][0] + r[0][0], p_pred[0][1] + r[0][1]], + [p_pred[1][0] + r[1][0], p_pred[1][1] + r[1][1]], + ]; + let k_row1 = solve2(s, [p_pred[0][0], p_pred[1][0]]); + let k_row2 = solve2(s, [p_pred[0][1], p_pred[1][1]]); + let (innov_z, innov_v) = (zm - x_pred[0], vm - x_pred[1]); + x = [ + x_pred[0] + k_row1[0] * innov_z + k_row1[1] * innov_v, + x_pred[1] + k_row2[0] * innov_z + k_row2[1] * innov_v, + ]; + let ik = [[1.0 - k_row1[0], -k_row1[1]], [-k_row2[0], 1.0 - k_row2[1]]]; + let mut p_next = [[0.0f64; 2]; 2]; + for i in 0..2 { + for j in 0..2 { + p_next[i][j] = ik[i][0] * p_pred[0][j] + ik[i][1] * p_pred[1][j]; + } + } + p = p_next; + out.push([x[0], x[1], p[0][0], p[1][1]]); + } + out +} + +/// 2x2 `a*x = b` by Gaussian elimination with partial pivoting (the same +/// scheme the generated helpers use). +fn solve2(mut a: [[f64; 2]; 2], mut b: [f64; 2]) -> [f64; 2] { + if a[1][0].abs() > a[0][0].abs() { + a.swap(0, 1); + b.swap(0, 1); + } + let factor = a[1][0] / a[0][0]; + let a11 = a[1][1] - factor * a[0][1]; + let b1 = b[1] - factor * b[0]; + let x1 = b1 / a11; + [(b[0] - a[0][1] * x1) / a[0][0], x1] +} + +#[test] +fn kalman_filter_exports_agree_across_c_rust_and_reference() { + let dir = tempdir().expect("tempdir"); + let stub_root = dir.path().join("msl-stub"); + fs::create_dir_all(&stub_root).expect("mkdir stub"); + fs::write(stub_root.join("Modelica.mo"), MSL_STUB).expect("write stub"); + + // --- C track --- + let c_out = dir.path().join("c"); + assert_success( + &compile_with_stub("embedded-c-galec", &stub_root, &c_out), + "compile --target embedded-c-galec", + ); + let c_source = c_out.join(format!("{MODEL}.c")); + fs::write(c_out.join("main.c"), C_DRIVER).expect("write C driver"); + let c_program = c_out.join("kf"); + let compile = cc() + .arg("-Wall") + .arg("-Werror") + .arg("-o") + .arg(&c_program) + .arg(c_out.join("main.c")) + .arg(&c_source) + .arg("-lm") + .output() + .expect("run cc"); + assert_success(&compile, "cc -Wall -Werror"); + let c_run = Command::new(&c_program).output().expect("run C block"); + assert_success(&c_run, "C driver (0 = no signal escaped)"); + let c_ticks = parse_ticks(&c_run.stdout, "C output"); + + // --- Rust track --- + let rust_out = dir.path().join("rust"); + assert_success( + &compile_with_stub("embedded-rust-galec", &stub_root, &rust_out), + "compile --target embedded-rust-galec", + ); + let rlib_dir = rust_out.join("rlib"); + let rlib = Command::new("rustc") + .arg("--edition") + .arg("2021") + .arg("--crate-type") + .arg("rlib") + .arg("-D") + .arg("warnings") + .arg(rust_out.join(format!("{MODEL}.rs"))) + .arg("--out-dir") + .arg(&rlib_dir) + .output() + .expect("run rustc (rlib)"); + assert_success(&rlib, "rustc -D warnings (rlib)"); + fs::write(rust_out.join("main.rs"), RUST_DRIVER).expect("write Rust driver"); + let rust_program = rust_out.join("kf"); + let link = Command::new("rustc") + .arg("--edition") + .arg("2021") + .arg(rust_out.join("main.rs")) + .arg("--extern") + .arg(format!( + "{MODEL}={}", + rlib_dir.join(format!("lib{MODEL}.rlib")).display() + )) + .arg("-o") + .arg(&rust_program) + .output() + .expect("run rustc (driver)"); + assert_success(&link, "rustc (driver)"); + let rust_run = Command::new(&rust_program) + .output() + .expect("run Rust block"); + assert_success(&rust_run, "Rust driver (Ok = no signal escaped)"); + let rust_ticks = parse_ticks(&rust_run.stdout, "Rust output"); + + // --- Equivalence --- + // C and Rust render the same evaluation order from the same GALEC AST + // (no re-association, GAL-027/T6), so IEEE-754 doubles agree exactly. + for (tick, (c, rust)) in c_ticks.iter().zip(&rust_ticks).enumerate() { + for (field, (cv, rv)) in c.iter().zip(rust).enumerate() { + assert!( + cv == rv, + "C/Rust divergence at tick {tick} field {field}: {cv} vs {rv}" + ); + } + } + // Both agree with the independently-written reference filter. + for (tick, (got, want)) in c_ticks.iter().zip(reference_ticks()).enumerate() { + for (field, (gv, wv)) in got.iter().zip(want).enumerate() { + assert!( + (gv - wv).abs() <= 1e-9 * wv.abs().max(1.0), + "reference divergence at tick {tick} field {field}: {gv} vs {wv}" + ); + } + } +} + +/// The Algorithm Code eFMU export of the same fixture declares the +/// GAL-029 escape on DoStep and carries per-method Signals in the manifest. +#[test] +fn kalman_filter_algorithm_code_declares_the_solve_escape() { + let dir = tempdir().expect("tempdir"); + let stub_root = dir.path().join("msl-stub"); + fs::create_dir_all(&stub_root).expect("mkdir stub"); + fs::write(stub_root.join("Modelica.mo"), MSL_STUB).expect("write stub"); + + let out = dir.path().join("efmu"); + assert_success( + &compile_with_stub("galec", &stub_root, &out), + "compile --target galec", + ); + let alg = fs::read_to_string( + out.join(MODEL) + .join("AlgorithmCode") + .join(format!("{MODEL}.alg")), + ) + .expect("read .alg"); + assert!( + alg.contains("signals SOLVE_LINEAR_EQUATIONS_FAILED;"), + "DoStep must declare the solve escape (GAL-029):\n{alg}" + ); + let manifest = fs::read_to_string(out.join(MODEL).join("AlgorithmCode").join("manifest.xml")) + .expect("read manifest"); + assert!( + manifest.contains(""), + "manifest must carry the DoStep Signals element:\n{manifest}" + ); +} diff --git a/crates/rumoca/tests/cli_target_galec_production.rs b/crates/rumoca/tests/cli_target_galec_production.rs index c64b074a0..5674be12b 100644 --- a/crates/rumoca/tests/cli_target_galec_production.rs +++ b/crates/rumoca/tests/cli_target_galec_production.rs @@ -351,6 +351,72 @@ fn build_container(work_dir: &Path, out_dir: &Path) -> BuiltContainer { } } +/// D16/D17 lockstep pin: keyword escaping lives in the C walking template's +/// keyword list, while `c_mangle` computes the same names for the Production +/// Code manifest's LogicalData mapping. A fixture whose variables collide +/// with C keywords must yield identical spellings on both sides — every +/// `componentIdentifier` in the PC manifest must appear as a struct field in +/// the generated header, and the C must still compile under +/// `-Wall -Werror`. +#[test] +fn keyword_named_variables_stay_in_lockstep_between_template_and_manifest() { + const KEYWORD_FIXTURE: &str = "\ +model GalecKeywordSmoke + constant Real samplePeriod = 0.1; + parameter Real double = 2.0; + parameter Real union = 0.5; + discrete output Real register(start = 0.0); +equation + when sample(0.0, samplePeriod) then + register = double * (pre(register) + union); + end when; +end GalecKeywordSmoke; +"; + let dir = tempdir().expect("tempdir"); + let file = write_fixture(dir.path(), "GalecKeywordSmoke", KEYWORD_FIXTURE); + let out_dir = dir.path().join("out"); + let output = run_compile_galec_production(&file, &out_dir); + assert!( + output.status.success(), + "keyword fixture failed.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let root = out_dir.join("GalecKeywordSmoke").join("ProductionCode"); + let header = fs::read_to_string(root.join("GalecKeywordSmoke.h")).expect("read header"); + let identifiers = attribute_values(&root.join("manifest.xml"), "componentIdentifier"); + assert!(!identifiers.is_empty(), "no componentIdentifier attributes"); + for identifier in &identifiers { + assert!( + header.contains(&format!(" {identifier}")), + "manifest componentIdentifier `{identifier}` must be a header struct \ + field (template/c_mangle keyword lockstep):\n{header}" + ); + } + for escaped in ["double_", "union_", "register_"] { + assert!( + identifiers.iter().any(|identifier| identifier == escaped), + "expected keyword-escaped identifier `{escaped}` among {identifiers:?}" + ); + } + let compile = cc() + .arg("-Wall") + .arg("-Werror") + .arg("-c") + .arg(root.join("GalecKeywordSmoke.c")) + .arg("-I") + .arg(&root) + .arg("-o") + .arg(out_dir.join("keyword_smoke.o")) + .output() + .expect("run cc"); + assert!( + compile.status.success(), + "cc -Wall -Werror failed on keyword-escaped C.\nstderr:\n{}", + String::from_utf8_lossy(&compile.stderr) + ); +} + #[test] fn algebraic_component_output_read_by_sampled_parent_compiles() { let dir = tempdir().expect("tempdir"); diff --git a/docs/user-guide/src/codegen/galec-efmi.md b/docs/user-guide/src/codegen/galec-efmi.md index 36a997bb2..654478d7b 100644 --- a/docs/user-guide/src/codegen/galec-efmi.md +++ b/docs/user-guide/src/codegen/galec-efmi.md @@ -5,9 +5,9 @@ Algorithm Code — the GALEC (Guarded Algorithmic Language for Embedded Control) `.alg` representation — and package it as a schema-valid eFMU container. -## The three targets +## The four targets -All three GALEC targets consume the `dae` IR and accept **fixed-sample +All GALEC targets consume the `dae` IR and accept **fixed-sample discrete models only** — models with no continuous states and no `der()`. | Target | Output | eFMI container? | @@ -15,6 +15,7 @@ discrete models only** — models with no continuous states and no `der()`. | `galec` | eFMI Algorithm Code eFMU: `AlgorithmCode/Model.alg` + `manifest.xml`, plus `__content.xml` and `schemas/` | Yes | | `galec-production` | eFMI Production Code eFMU: adds `ProductionCode/` C99 + LogicalData manifest, co-emits the `AlgorithmCode/` representation | Yes | | `embedded-c-galec` | GALEC-derived embedded C (`.h` + `.c`): block-state struct with `startup`/`recalibrate`/`dostep` | No — not an eFMI container | +| `embedded-rust-galec` | GALEC-derived embedded Rust (`.rs`): self-contained `#![no_std]` crate root with `startup`/`recalibrate`/`do_step` returning `Result<(), Signals>` when the block declares signal escapes | No — not an eFMI container (Rust is outside the Beta-1 ProductionCode schema) | The **eFMI container?** column describes the CLI packaging step. The GUI's Generate Code (below) renders the inspectable `.alg`/`.h`/`.c` sources for any @@ -26,6 +27,7 @@ target but does not itself build the container. rumoca compile Model.mo --target galec -o out/ rumoca compile Model.mo --target galec-production -o out/ rumoca compile Model.mo --target embedded-c-galec -o out/ +rumoca compile Model.mo --target embedded-rust-galec -o out/ ``` The `galec` and `galec-production` targets write the eFMU container in two @@ -42,7 +44,14 @@ out/ ``` The `embedded-c-galec` target instead writes plain `out/Model.h` and -`out/Model.c` — no manifest and no container. +`out/Model.c`, and `embedded-rust-galec` writes `out/Model.rs` — no manifest +and no container. Models whose GALEC block declares signal escapes (e.g. a +Kalman filter using `Modelica.Math.Matrices.solve`, which maps to the +`solveLinearEquations` builtin) get the status ABI: the C methods return the +32-bit ErrorSignalStatus word and the Rust methods return +`Result<(), Signals>`. See `examples/models/QuadrotorAltitudeKF.mo` for a +full estimator exercising matrix algebra, computed initialization, and the +solve builtin across every target. ## Code generation in the GUI diff --git a/examples/models/QuadrotorAltitudeKF.mo b/examples/models/QuadrotorAltitudeKF.mo new file mode 100644 index 000000000..2fdffa6c5 --- /dev/null +++ b/examples/models/QuadrotorAltitudeKF.mo @@ -0,0 +1,55 @@ +model QuadrotorAltitudeKF + "Discrete-time altitude/climb-rate Kalman filter for a quadrotor. + + Clocked discrete-time Modelica (no auto-discretization, SPEC_0034 D12): + the linearized hover dynamics at the sample rate are part of the filter + design. State x = [z; vz], input u = commanded vertical acceleration, + measurements y = [z_meas; vz_meas] (C = I). The gain solve uses + Modelica.Math.Matrices.solve, which the GALEC projection maps to the + solveLinearEquations builtin (D13) - DoStep declares the + SOLVE_LINEAR_EQUATIONS_FAILED escape (GAL-029). The covariance is + initialized by a computed initial equation (GAL-028). Exports via + --target galec / embedded-c-galec / embedded-rust-galec." + constant Real samplePeriod = 0.02; + parameter Real q_accel = 0.05 "Process noise (acceleration) variance"; + parameter Real r_alt = 0.04 "Altitude measurement variance"; + parameter Real r_vel = 0.09 "Climb-rate measurement variance"; + parameter Real p0 = 1.0 "Initial covariance"; + input Real u "Vertical acceleration input [m/s2]"; + input Real z_meas "Measured altitude [m]"; + input Real vz_meas "Measured climb rate [m/s]"; + discrete output Real z_hat(start = 0.0) "Estimated altitude [m]"; + discrete output Real vz_hat(start = 0.0) "Estimated climb rate [m/s]"; + discrete Real P[2, 2] "Covariance estimate"; + discrete Real x_pred[2]; + discrete Real P_pred[2, 2]; + discrete Real S[2, 2] "Innovation covariance"; + discrete Real K_row1[2] "Kalman gain row 1"; + discrete Real K_row2[2] "Kalman gain row 2"; +protected + parameter Real A[2, 2] = [1.0, samplePeriod; 0.0, 1.0]; + parameter Real B[2] = {samplePeriod*samplePeriod/2.0, samplePeriod}; + parameter Real Q[2, 2] = [ + q_accel*samplePeriod*samplePeriod, 0.0; + 0.0, q_accel]; + parameter Real R[2, 2] = [r_alt, 0.0; 0.0, r_vel]; +initial equation + P = p0*identity(2); +equation + when sample(0.0, samplePeriod) then + // Prediction. + x_pred = A*{pre(z_hat), pre(vz_hat)} + B*u; + P_pred = A*pre(P)*transpose(A) + Q; + // Innovation covariance (C = I). + S = P_pred + R; + // Gain: row j of K = solve(S, column j of P_pred) since + // K = P_pred*inv(S) with symmetric S and P_pred. + K_row1 = Modelica.Math.Matrices.solve(S, {P_pred[1, 1], P_pred[2, 1]}); + K_row2 = Modelica.Math.Matrices.solve(S, {P_pred[1, 2], P_pred[2, 2]}); + // Measurement update. + z_hat = x_pred[1] + K_row1[1]*(z_meas - x_pred[1]) + K_row1[2]*(vz_meas - x_pred[2]); + vz_hat = x_pred[2] + K_row2[1]*(z_meas - x_pred[1]) + K_row2[2]*(vz_meas - x_pred[2]); + // Covariance update: P = (I - K*C)*P_pred with C = I. + P = (identity(2) - [K_row1[1], K_row1[2]; K_row2[1], K_row2[2]])*P_pred; + end when; +end QuadrotorAltitudeKF; diff --git a/spec/SPEC_0034_GALEC_EFMI_EXPORT.md b/spec/SPEC_0034_GALEC_EFMI_EXPORT.md index 22079e906..caf25aa2b 100644 --- a/spec/SPEC_0034_GALEC_EFMI_EXPORT.md +++ b/spec/SPEC_0034_GALEC_EFMI_EXPORT.md @@ -8,10 +8,17 @@ Design contract; `--target galec` (Algorithm Code) and `--target galec-productio conformance is **Earned** (parser round-trip under `--features parse`, plus the `.alg` language server). +Extension in progress (estimator/Kalman-filter scope, GAL-027–GAL-030, +D12–D15): matrix-algebra lowering, `initial equation` → `Startup` lowering, +error-signal slice 2 (closes D8), and the non-eFMI Rust export track +`embedded-rust-galec`. + ## Summary Rumoca exports eFMI Algorithm Code and Production Code (GALEC `.alg`, C99, and XML manifests in an eFMU container) as a target-language projection over -canonical artifacts; GALEC is never a canonical IR stage. +canonical artifacts; GALEC is never a canonical IR stage. A non-eFMI Rust +export (`embedded-rust-galec`, GAL-030) renders the same GALEC AST as +`#![no_std]` Rust. ## Pipeline Placement @@ -61,6 +68,10 @@ rumoca crate generic container/checksum build step + vendored schemas (BSD-3 ve | GAL-024 | Embedded C is two-track: `embedded-c-galec` is a non-eFMI export ("NOT an eFMI Production Code container"); `galec-production` (**landed**) earns the "eFMI Production Code export" rung. Neither fabricates the claim below its rung. | `rumoca-galec-codegen` | **Why** below. | | GAL-025 | v1 scope rejections (continuous states, external functions, runtime events) are labeled "not yet supported by the Rumoca GALEC projection" — never "unsupported by eFMI". | `rumoca-galec-codegen` | §3.2.1(b), §1.3.3: eFMI expects discretized models. | | GAL-026 | GALEC AST, manifest model, printer, and validator are array-native (dimensions, row-major `start`, for-loops, lifted builtins, indexed quoted identifiers); scalarized lowering is an implementation stage, never a language-layer assumption. | `rumoca-ir-galec` + `rumoca-galec-codegen` | Scalarization curtails Production Code optimization. | +| GAL-027 | Matrix algebra (matrix×matrix and matrix×vector `*`, `transpose`, `identity`) lowers to whole-array assignments whose values are element-unrolled sums over literal bounds (consistent with the existing vector-dot/slice unrolling); accumulation order is ascending-index and part of the projection contract (T6: no re-association is *introduced* — the unroll order is the defined order); `.*` stays element-wise; no runtime-size codepaths (T11). For-loop emission is a future optimization once lowering grows a statement sink (the AST already supports it, GAL-026). | `rumoca-galec-codegen` | Kalman-class estimators need matrix products. | +| GAL-028 | The DAE initialization partition lowers into `Startup`: dependency-sorted statements emitted after literal `start` mirroring; `Startup` stays builtins-only (GAL-017); initial equations reading control inputs are rejected (inputs are not valid before the first tick); manifest `start` mirrors the projection-time evaluation of the `Startup` computation under default parameter values (GAL-020 mirroring holds by construction). | `rumoca-galec-codegen` | Computed initial state (e.g. initial covariance) is core estimator practice, not a hack. | +| GAL-029 | Slice-2 error signaling, staged. **2a (this slice):** lowering computes non-empty escape sets for signaling builtins (`solveLinearEquations` → `SOLVE_LINEAR_EQUATIONS_FAILED`), declares them by construction on emitted methods (via the validator's public escape computation — declared == computed cannot drift), and surfaces per-method Signals + ErrorSignalStatus in the manifest; the C track's methods return the 32-bit status word. **2b (still deferred):** Real relationals → `NAN` (T9) and `integer()` — flipping these marks every comparison-bearing model, so they land together with relational helpers across validator, both code tracks, and the equivalence harness. Default policy: signals escape to `DoStep` and the manifest; silent catching is never emitted. | `rumoca-galec-codegen` + `rumoca-ir-galec` | The signaling builtins (T14) are unusable without producer-side escape sets. | +| GAL-030 | `embedded-rust-galec` is a non-eFMI export ("NOT an eFMI Production Code container" — the Beta-1 ProductionCode XSD restricts `language` to C/C++): a self-contained walking template over the language-neutral GALEC block context (D16); generated code is `#![no_std]`, allocation-free, static arrays; method escape sets map to `Result<(), Signals>`; CI compile-checks with `rustc` (GAL-012 analog). | `rumoca-galec-codegen` (`template_ir`) + templates | GAL-024 honesty: never fabricate a conformance claim the XSD forbids. | **Why (GAL-016):** GALEC has no `previous()`/`sample()` (T2); `pre(x)` becomes protected state `'previous(x)'` committed at end of DoStep; the sample period is a @@ -81,10 +92,16 @@ non-conformant (§2.2). | D5 | Manifest `renderer` extension | Rejected: covered by D1. | | D6 | Clock strictness | XSD-strict (GAL-016): `constant`, seconds; Beta-1's `tunableParameter` examples are nonconforming. | | D7 | Beta-1 grammar gaps | AST adopts `(min=,max=)`, the error-signal statement, input/output prefixes; emitter rejects `//` comments and unsigned exponents. | -| D8 | Slice-1 signal scope | Full signal machinery in AST + validator; lowering emits Real relationals with empty escape sets and rejects constructs needing non-empty sets; NAN accounting (T9) is slice 2. | +| D8 | Slice-1 signal scope | Full signal machinery in AST + validator; lowering emits Real relationals with empty escape sets and rejects constructs needing non-empty sets; NAN accounting (T9) is slice 2. **Slice 2 is now specified by GAL-029.** | | D9 | Embedded-C sequencing | GAL-024: non-eFMI C export after the projection crate; PC container after AC packaging. | | D10 | XSD vendoring | `crates/rumoca/assets/efmi-schemas/` (GAL-023). | | D11 | GALEC AST source spans | GALEC AST nodes carry `rumoca_core::Span` (the *foundation* crate, not an IR stage — GAL-001/GAL-010 intent holds). Parsed nodes span `.alg` bytes; generated nodes carry the originating Modelica span or `Span::DUMMY`. Spans are provenance, not identity (round-trip equality is span-insensitive). Unlocks positioned diagnostics and the `.alg` LSP. | +| D12 | Auto-discretization (inline integration) | **Permanently out of scope.** Discrete-time clocked Modelica (MLS ch. 16) is the supported input; the tool never invents sample-time semantics. ET001 becomes a permanent scope rejection whose wording points at clocked discrete-time modeling (GAL-025 honesty retained: never "unsupported by eFMI"). | +| D13 | Matrix-product strategy | For-loop scalar accumulation, ascending-index order (GAL-027). `Modelica.Math.Matrices.solve`/`solve2` map by name to the `solveLinearEquations` builtin (their MSL bodies are LAPACK-external and never inlined); `Matrices.inv` stays rejected with a diagnostic recommending `solve` (invert-then-multiply is bad numerics and has no GALEC builtin). | +| D14 | Initial-equation `Startup` lowering | GAL-028. Ordering inside `Startup`: literal `start` mirroring of all writable variables first, then dependency-sorted initialization statements overwriting the computed subset, then `'previous(x)'` seeding. Initial equations referencing inputs ⇒ stable diagnostic; cyclic initialization ⇒ the existing algebraic-loop rejection. | +| D15 | Rust track shape | GAL-030. Target `embedded-rust-galec` emits a self-contained `#![no_std]` crate root: one struct (block variables as fields, `[[f64; N]; M]` arrays), `startup`/`recalibrate`/`do_step` methods returning `Result<(), Signals>` (u32 newtype mirroring ErrorSignalStatus), no dependencies (transcendental builtins reference `libm` when used). Co-emitting with `galec-production` gives conformant C and Rust from one GALEC AST; the equivalence harness diffs their numerics. | +| D16 | GALEC-template targets mirror the generic IR targets | The `template_ir` pass serializes the validated block into a language-neutral walkable tree — collision-checked base identifiers (keyword escaping is template-owned; base names never end in `_`, so an appended `_` cannot re-collide; names that cannot begin an identifier carry a `null` base name embedded templates `fail()` on), GALEC-faithful `galec_name` tokens (quoted identifiers keep their quotes) and **1-based** subscripts (0-based languages subtract in the template), T7-strict Real literal text, kind-tagged expression nodes with abstract operator names, and dual whole-array value/element forms plus a `copy` flag (array-valued languages assign wholesale, element-wise languages expand or `memcpy` — no projection logic in templates). Rendering goes through the standard codegen environment (`render_template_with_json_context`), so walking templates get the same filter/function toolbox as the generic IR targets, and **adding a language is adding a template**. The `ir` manifest value stays `dae` (GAL-001 holds — GALEC is not a canonical stage; the block context is projection output). | +| D17 | Everything renders from the GALEC template IR — including `.alg` and C | **Supersedes the D1/D2 typed-printer emission split.** The `.alg` text renders from the embedded walking template (`rumoca-galec-codegen/src/templates/alg.jinja`) over the template IR, byte-identical to the `rumoca-ir-galec` typed printer (pinned by the parity test in `spec_0034_estimator.rs` — the printer remains the parser-facing half of the language module, used by round-trip tests and the `.alg` LSP; the parity pin is the drift guard between the two producers). The C track's `model.h.jinja`/`model.c.jinja` are walking templates owning every C spelling (`c_print` deleted); `c_mangle` survives solely as the name policy the Production Code manifest's LogicalData describes, kept in lockstep with the C template's keyword list by the keyword-fixture test in `cli_target_galec_production.rs`. GAL-008/GAL-009 are amended accordingly: templates own generated text end to end; the printer exception narrows to the language module's internal uses. | ### Conformance Ladder (GAL-021, GAL-024) @@ -94,6 +111,7 @@ non-conformant (§2.2). | "eFMI Algorithm Code export" | Schema-valid eFMU: `__content.xml` + `schemas/` + Algorithm Code container; correct SHA-1s, UUID/ids, strict UTC timestamps | Earned (`galec`) | | "GALEC language conformance" | Above + round-trip parse of emitted `.alg`: print∘parse∘print idempotence | Earned (`galec`; `rumoca-ir-galec/tests/roundtrip.rs`, `--features parse`) | | "eFMI Production Code export" | Schema-valid eFMU co-emitting Algorithm Code **and** Production Code (§2.2); PC `manifest.xml` xmllint-valid; LogicalData maps every AC variable + all three BlockMethods once; PC `ManifestReference@checksum` = SHA-1 of the AC manifest bytes, `@manifestRefId` = AC root UUID; whole SHA-1 web recomputed from written bytes, no placeholders | Earned (`galec-production`) | +| "GALEC-derived Rust export" | `embedded-rust-galec` renders; honest non-eFMI self-description (GAL-030); `rustc` compile check; numeric equivalence vs the C track on the golden fixtures | In progress (D15) | ### Variable Classification (GAL-020, normative) @@ -154,9 +172,17 @@ array sizes rejected. | Manifest XSD-validate + SHA-1 recompute + id uniqueness; full-container validation (all XMLs vs XSDs, all checksums); negative schema cases (missing element, wrong order, bad enum, malformed UUID/timestamp, dim < 1) | GAL-021 | | `--target galec` CLI smoke + real template-CI render | GAL-011/012 | | Generated-C compile check (`cc -Wall -Werror`, temp dir) when C output exists | GAL-012/024 | +| Matrix-lowering parity: Kalman-step fixture numerics vs an f64 reference; accumulation-order golden `.alg` | GAL-027/D13 | +| `initial equation` goldens (computed initial covariance in `Startup`); negative: input-reading and cyclic initialization ⇒ stable diagnostics | GAL-028/D14 | +| Escape-set round-trip: emitted `signals` clauses validate (declared == computed); manifest Signals/ErrorSignalStatus golden | GAL-029 | +| Generated-Rust compile check (`rustc --edition 2021 -D warnings`, temp dir); C↔Rust numeric equivalence on golden fixtures | GAL-030/D15 | +| Quadrotor estimator end-to-end: clocked discrete fixture exports via `galec`, `galec-production`, `embedded-rust-galec`; SIL loop against the continuous plant | GAL-027–030 | ## Non-Goals +- No automatic discretization of continuous models (inline integration): + discrete-time clocked Modelica is the supported input (D12); continuous + dynamics remain a permanent, honestly-worded scope rejection (ET001). - GALEC does not replace DAE/Solve; export does not change Modelica semantics or authorize target-specific canonical-DAE rewrites. - No Behavioral Model (ch. 4; an eFMU is valid without one), FMU embedding, or