From 0a31df54ed350268e11e4f6fb60577190fd326a0 Mon Sep 17 00:00:00 2001 From: James Goppert Date: Thu, 20 Aug 2026 08:16:35 -0400 Subject: [PATCH 001/189] Define compiler architecture and verification contracts Establish the repository specifications, contract fixtures, CI workflows, documentation, and developer commands that govern strict compiler phase ownership and reproducible verification. Signed-off-by: James Goppert --- .github/pull_request_template.md | 2 +- .github/scripts/msl-baseline-ratchet.mjs | 518 ++- .github/scripts/msl-baseline-ratchet.test.mjs | 302 +- .github/workflows/ci.yml | 682 +++- .github/workflows/nightly.yml | 893 +++++ .gitignore | 8 +- AGENTS.md | 14 +- CONTRIBUTING.md | 84 +- Cargo.lock | 623 ++- Cargo.toml | 59 +- README.md | 35 +- TODO.md | 11 - .../rumoca-contracts/data/contract_cases.toml | 263 +- crates/rumoca-contracts/data/contracts.toml | 107 +- .../data/formal_statements.toml | 1299 ++++++ crates/rumoca-contracts/src/lib.rs | 52 +- .../rumoca-contracts/src/registry/formal.rs | 678 ++++ crates/rumoca-contracts/src/registry/mod.rs | 59 +- crates/rumoca-contracts/src/test_support.rs | 131 +- .../rumoca-contracts/tests/alg_contracts.rs | 2 +- .../rumoca-contracts/tests/arr_contracts.rs | 603 ++- .../rumoca-contracts/tests/conn_contracts.rs | 493 ++- .../rumoca-contracts/tests/decl_contracts.rs | 13 +- .../rumoca-contracts/tests/eqn_contracts.rs | 192 +- .../rumoca-contracts/tests/expr_contracts.rs | 38 +- .../tests/formal_statement_invariants.rs | 605 +++ .../rumoca-contracts/tests/func_contracts.rs | 358 +- .../rumoca-contracts/tests/inst_contracts.rs | 318 +- .../rumoca-contracts/tests/oprec_contracts.rs | 47 +- .../tests/registry_invariants.rs | 54 + .../rumoca-contracts/tests/sim_contracts.rs | 920 ++++- .../rumoca-contracts/tests/type_contracts.rs | 79 +- crates/xtask/Cargo.toml | 8 +- .../src/bin/rumoca-traversal-policy-check.rs | 417 +- crates/xtask/src/docs_cmd.rs | 19 +- crates/xtask/src/lib.rs | 1 - crates/xtask/src/lsp_benchmark_cmd.rs | 6 + crates/xtask/src/lsp_benchmark_cmd/render.rs | 2 +- crates/xtask/src/lsp_benchmark_cmd/runtime.rs | 164 +- .../lsp_benchmark_cmd/surface_contracts.rs | 4 +- crates/xtask/src/main.rs | 4 + crates/xtask/src/main_tests.rs | 55 +- crates/xtask/src/resource_budget.rs | 537 +++ crates/xtask/src/test_cmd.rs | 46 +- crates/xtask/src/traversal_policy_check.rs | 408 ++ crates/xtask/src/verify_cmd.rs | 959 ++--- crates/xtask/src/verify_cmd/fuzz.rs | 125 + crates/xtask/src/verify_cmd/kani.rs | 779 ++++ .../src/verify_cmd/msl_cargo_setup_timing.rs | 17 +- crates/xtask/src/verify_cmd/msl_local_run.rs | 360 ++ .../src/verify_cmd/msl_quality_baseline.rs | 889 ++++- .../src/verify_cmd/msl_results_cleanup.rs | 193 + crates/xtask/src/verify_cmd/parity_budgets.rs | 96 + .../xtask/src/verify_cmd/parity_comparator.rs | 317 ++ .../src/verify_cmd/template_runtime_tests.rs | 308 ++ crates/xtask/src/verify_cmd/tests.rs | 473 +++ crates/xtask/src/vscode_cmd.rs | 10 +- docs/dev-guide/src/SUMMARY.md | 1 - docs/dev-guide/src/compiler/front-end.md | 2 +- docs/dev-guide/src/compiler/irs.md | 4 +- .../src/compiler/pipeline-overview.md | 4 +- docs/dev-guide/src/compiler/solve.md | 8 +- .../src/contributing/specs-process.md | 2 +- docs/dev-guide/src/runtime/codegen.md | 14 +- .../src/runtime/simulation-runtime.md | 2 +- .../dev-guide/src/tooling/msl-quality-gate.md | 97 + .../src/tooling/workspace-scenario-roadmap.md | 297 -- docs/user-guide/live/rumoca-live.js | 6 +- docs/user-guide/src/codegen/custom-targets.md | 9 +- docs/user-guide/src/codegen/targets.md | 50 +- .../src/getting-started/quickstart.md | 4 +- docs/user-guide/src/language/arrays-pde.md | 2 +- docs/user-guide/src/simulation/inspect.md | 4 +- .../src/simulation/scenario-tomls.md | 2 +- docs/user-guide/src/simulation/solvers.md | 7 +- docs/user-guide/src/tools/cli.md | 4 +- docs/user-guide/src/tools/python.md | 26 +- docs/user-guide/src/troubleshooting.md | 3 +- examples/README.md | 3 +- examples/codegen/README.md | 28 +- examples/codegen/checked_dae_report/README.md | 18 + .../checked_dae_report.txt.jinja | 23 + .../codegen/checked_dae_report/target.toml | 24 + examples/codegen/custom_casadi.jinja | 153 - .../codegen/custom_checked_variables.jinja | 5 + ...toml => rumoca-scenario.ball_jax_ode.toml} | 2 +- ...=> rumoca-scenario.sympy_decay_c_ode.toml} | 4 +- ...nario.sympy_decay_checked_dae_report.toml} | 4 +- ...sympy_decay_custom_checked_variables.toml} | 4 +- examples/codegen/standalone_web/README.md | 14 - .../codegen/standalone_web/javascript.jinja | 945 ----- .../standalone_web/standalone_html.jinja | 3467 ----------------- examples/codegen/standalone_web/target.toml | 14 - .../reusable_booster/rumoca-scenario.toml | 233 +- examples/models/FmiTensorDecay.mo | 5 + examples/requirements.txt | 2 +- flake.nix | 301 +- packages/playground/src/main.js | 239 +- .../src/modules/default_workspace.js | 17 +- .../playground/src/modules/monaco_setup.js | 156 +- .../playground/tests/gpu_schedule.test.mjs | 469 --- .../playground/tests/monaco_setup.test.mjs | 56 +- .../tests/scenario_interface_smoke.mjs | 10 +- packages/rumoca-web/runtime/rumoca_gpu.js | 72 +- .../rumoca-web/viz/visualization_shared.js | 20 +- packages/vscode/package.json | 4 +- packages/vscode/src/extension.ts | 2 +- packages/vscode/src/galec_client.ts | 8 +- rust-toolchain-kani.toml | 7 + rust-toolchain.toml | 2 +- spec/README.md | 65 +- spec/SPEC_0000_SPEC_GUIDELINES.md | 23 +- spec/SPEC_0001_DEFID.md | 11 +- spec/SPEC_0002_SCOPE_TREE.md | 13 +- spec/SPEC_0007_IR_PIPELINE.md | 379 +- spec/SPEC_0008_PHASE_ERRORS.md | 218 +- spec/SPEC_0018_TOOL_CONFIG.md | 35 +- spec/SPEC_0021_CODE_COMPLEXITY.md | 59 +- spec/SPEC_0022_MLS_COMPILER_COMPLIANCE.md | 195 +- spec/SPEC_0025_PR_REVIEW_PROCESS.md | 43 +- spec/SPEC_0029_CRATE_BOUNDARIES.md | 288 +- spec/SPEC_0031_COMPILER_PHILOSOPHY.md | 85 +- spec/SPEC_0032_DEVELOPMENT_PROCESS.md | 104 - spec/SPEC_0032_RANGE_PRESERVING_TENSORS.md | 109 +- spec/SPEC_0033_DEVELOPMENT_PROCESS.md | 172 + spec/SPEC_0034_GALEC_EFMI_EXPORT.md | 174 +- spec/SPEC_0035_COMPLEX_NUMERIC_TYPES.md | 196 + spec/SPEC_0036_VALID_BY_CONSTRUCTION_IR.md | 319 ++ spec/SPEC_0037_FORMALLY_VERIFIED_COMPILER.md | 311 ++ spec/SPEC_0038_UNIFIED_FMI_EXECUTION.md | 241 ++ spec/SPEC_0039_PROOF_CARRYING_SPARSITY.md | 153 + spec/SPEC_0040_IR_STAGE_CONTRACT_CATALOG.md | 130 + spec/SPEC_0041_CRATE_OWNERSHIP_CATALOG.md | 143 + spec/SPEC_0042_GALEC_LANGUAGE_CATALOG.md | 67 + spec/SPEC_0043_CONSTRUCTION_CATALOG.md | 271 ++ spec/SPEC_0044_FMI_EXECUTION_CATALOG.md | 482 +++ ...OLVE_EXECUTABLE_VOCABULARY_AND_PROFILES.md | 165 + .../SPEC_0046_SCHEDULED_DISCRETE_OWNERSHIP.md | 184 + ...047_SOLVE_EXECUTABLE_VOCABULARY_CATALOG.md | 552 +++ ...TARGET_REFINEMENT_AND_PREPARED_PRODUCTS.md | 114 + spec/SPEC_0049_SOLVE_GRAMMAR_CATALOG.md | 216 + .../SPEC_0028_CERTIFICATION_CODEGEN.md | 51 +- 142 files changed, 19102 insertions(+), 9064 deletions(-) create mode 100644 .github/workflows/nightly.yml delete mode 100644 TODO.md create mode 100644 crates/rumoca-contracts/data/formal_statements.toml create mode 100644 crates/rumoca-contracts/src/registry/formal.rs create mode 100644 crates/rumoca-contracts/tests/formal_statement_invariants.rs create mode 100644 crates/xtask/src/resource_budget.rs create mode 100644 crates/xtask/src/traversal_policy_check.rs create mode 100644 crates/xtask/src/verify_cmd/fuzz.rs create mode 100644 crates/xtask/src/verify_cmd/kani.rs create mode 100644 crates/xtask/src/verify_cmd/msl_local_run.rs create mode 100644 crates/xtask/src/verify_cmd/msl_results_cleanup.rs create mode 100644 crates/xtask/src/verify_cmd/parity_budgets.rs create mode 100644 crates/xtask/src/verify_cmd/parity_comparator.rs create mode 100644 crates/xtask/src/verify_cmd/template_runtime_tests.rs create mode 100644 crates/xtask/src/verify_cmd/tests.rs delete mode 100644 docs/dev-guide/src/tooling/workspace-scenario-roadmap.md create mode 100644 examples/codegen/checked_dae_report/README.md create mode 100644 examples/codegen/checked_dae_report/checked_dae_report.txt.jinja create mode 100644 examples/codegen/checked_dae_report/target.toml delete mode 100644 examples/codegen/custom_casadi.jinja create mode 100644 examples/codegen/custom_checked_variables.jinja rename examples/codegen/{rumoca-scenario.ball_jax.toml => rumoca-scenario.ball_jax_ode.toml} (87%) rename examples/codegen/{rumoca-scenario.sympy_decay_sympy.toml => rumoca-scenario.sympy_decay_c_ode.toml} (67%) rename examples/codegen/{rumoca-scenario.sympy_decay_custom_casadi.toml => rumoca-scenario.sympy_decay_checked_dae_report.toml} (58%) rename examples/codegen/{rumoca-scenario.sympy_decay_standalone_web.toml => rumoca-scenario.sympy_decay_custom_checked_variables.toml} (52%) delete mode 100644 examples/codegen/standalone_web/README.md delete mode 100644 examples/codegen/standalone_web/javascript.jinja delete mode 100644 examples/codegen/standalone_web/standalone_html.jinja delete mode 100644 examples/codegen/standalone_web/target.toml create mode 100644 examples/models/FmiTensorDecay.mo create mode 100644 rust-toolchain-kani.toml delete mode 100644 spec/SPEC_0032_DEVELOPMENT_PROCESS.md create mode 100644 spec/SPEC_0033_DEVELOPMENT_PROCESS.md create mode 100644 spec/SPEC_0035_COMPLEX_NUMERIC_TYPES.md create mode 100644 spec/SPEC_0036_VALID_BY_CONSTRUCTION_IR.md create mode 100644 spec/SPEC_0037_FORMALLY_VERIFIED_COMPILER.md create mode 100644 spec/SPEC_0038_UNIFIED_FMI_EXECUTION.md create mode 100644 spec/SPEC_0039_PROOF_CARRYING_SPARSITY.md create mode 100644 spec/SPEC_0040_IR_STAGE_CONTRACT_CATALOG.md create mode 100644 spec/SPEC_0041_CRATE_OWNERSHIP_CATALOG.md create mode 100644 spec/SPEC_0042_GALEC_LANGUAGE_CATALOG.md create mode 100644 spec/SPEC_0043_CONSTRUCTION_CATALOG.md create mode 100644 spec/SPEC_0044_FMI_EXECUTION_CATALOG.md create mode 100644 spec/SPEC_0045_SOLVE_EXECUTABLE_VOCABULARY_AND_PROFILES.md create mode 100644 spec/SPEC_0046_SCHEDULED_DISCRETE_OWNERSHIP.md create mode 100644 spec/SPEC_0047_SOLVE_EXECUTABLE_VOCABULARY_CATALOG.md create mode 100644 spec/SPEC_0048_TARGET_REFINEMENT_AND_PREPARED_PRODUCTS.md create mode 100644 spec/SPEC_0049_SOLVE_GRAMMAR_CATALOG.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 1c32284e5..7cf335eee 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -67,5 +67,5 @@ If `net_added_lines` is positive, add: - [ ] New APIs are required and minimal. - [ ] Old/new parallel paths removed unless explicitly migrating. - [ ] No `#[allow(clippy::...)]` added outside generated code. -- [ ] Every commit signed off (`git commit -s`); no `Co-Authored-By` for AI. +- [ ] Every commit signed off (`git commit -s`); no named AI assistant/session references or AI `Co-Authored-By` trailers. - [ ] External material (if any) attributed and Apache-2.0 compatible. diff --git a/.github/scripts/msl-baseline-ratchet.mjs b/.github/scripts/msl-baseline-ratchet.mjs index 2e6139070..13443ca0e 100644 --- a/.github/scripts/msl-baseline-ratchet.mjs +++ b/.github/scripts/msl-baseline-ratchet.mjs @@ -2,19 +2,121 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; -import { pathToFileURL } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; -const EXPECTED_QUALITY_GATE_VERSION = 1; +const EXPECTED_QUALITY_GATE_VERSION = 2; +const DEFAULT_CHECKED_IN_BASELINE_PATH = fileURLToPath( + new URL( + '../../crates/rumoca-test-msl/tests/msl_tests/msl_quality_baseline.json', + import.meta.url, + ), +); +const V2_FLATTEN_MODELS_BEFORE = 565; +const V2_FLATTEN_MODELS_AFTER = 555; +const V2_REATTRIBUTED_ERROR_CODE = 'ER002'; +const V2_REATTRIBUTED_MODELS = [ + 'Modelica.Fluid.Examples.AST_BatchPlant.BatchPlant_StandardWater', + 'Modelica.Fluid.Examples.AST_BatchPlant.Test.OneTank', + 'Modelica.Fluid.Examples.AST_BatchPlant.Test.TankWithEmptyingPipe1', + 'Modelica.Fluid.Examples.AST_BatchPlant.Test.TankWithEmptyingPipe2', + 'Modelica.Fluid.Examples.AST_BatchPlant.Test.TanksWithEmptyingPipe1', + 'Modelica.Fluid.Examples.AST_BatchPlant.Test.TanksWithEmptyingPipe2', + 'Modelica.Fluid.Examples.AST_BatchPlant.Test.TwoTanks', + 'Modelica.Fluid.Examples.Explanatory.MeasuringTemperature', + 'Modelica.Fluid.Examples.Explanatory.MomentumBalanceFittings', + 'Modelica.Fluid.Examples.InverseParameterization', +]; +const CHECKED_DAE_CONTRACT_FROM = 'permissive-dae-v1'; +const CHECKED_DAE_CONTRACT_TO = 'checked-dae-v1'; +const CHECKED_DAE_EVIDENCE_COMMIT = '3fc9a6cb9c60e1137eb6151f29cb87e9ad35064b'; +const CHECKED_DAE_STAGE_COUNTS_BEFORE = { + parse_models: 566, + flatten_models: 555, + dae_models: 545, + compiled_models: 545, + solve_models: 446, + balanced_models: 532, + unbalanced_models: 0, + partial_models: 13, + balance_denominator: 532, + initial_balanced_models: 532, + initial_unbalanced_models: 0, + sim_attempted: 496, + ic_attempted: 267, + ic_ok: 252, + ic_solver_fail: 15, + sim_ok: 207, +}; +const CHECKED_DAE_STAGE_COUNTS_AFTER = { + parse_models: 566, + flatten_models: 444, + dae_models: 228, + compiled_models: 228, + solve_models: 202, + balanced_models: 217, + unbalanced_models: 0, + partial_models: 11, + balance_denominator: 217, + initial_balanced_models: 217, + initial_unbalanced_models: 0, + sim_attempted: 210, + ic_attempted: 150, + ic_ok: 146, + ic_solver_fail: 4, + sim_ok: 122, +}; +const CHECKED_DAE_PHASE_FAILURE_COUNTS = { + Flatten: 82, + Instantiate: 9, + Resolve: 25, + ToDae: 216, + Typecheck: 6, +}; +const CHECKED_DAE_ERROR_CODE_COUNTS = { + ED001: 24, + ED008: 7, + ED009: 3, + ED010: 14, + ED013: 22, + ED018: 29, + ED019: 111, + ED020: 1, + ED021: 5, + EF004: 24, + EF005: 11, + EF016: 16, + EF020: 1, + EF024: 16, + EF025: 12, + EI007: 2, + EI012: 6, + EI027: 1, + EL005: 60, + EMSL_TIMEOUT_MODEL_ATTEMPT: 11, + ER066: 23, + ER130: 2, + ET000: 1, + ET004: 4, + EX001: 6, + EX002: 13, +}; -const HIGHER_IS_BETTER = [ +const CONTEXT_INDEPENDENT_HIGHER_IS_BETTER = [ ['parse models', ['parse_models']], ['flatten models', ['flatten_models']], + ['DAE models', ['dae_models']], ['compiled models', ['compiled_models']], ['solve models', ['solve_models']], ['balanced models', ['balanced_models']], + ['balance denominator', ['balance_denominator']], ['initial balanced models', ['initial_balanced_models']], + ['simulation attempts', ['sim_attempted']], + ['initial-condition attempts', ['ic_attempted']], ['initial-condition solves', ['ic_ok']], ['successful simulations', ['sim_ok']], +]; + +const OMC_DEPENDENT_HIGHER_IS_BETTER = [ ['trace models compared', ['trace_accuracy_stats', 'models_compared']], ['high trace agreement', ['trace_accuracy_stats', 'agreement_high']], [ @@ -23,10 +125,14 @@ const HIGHER_IS_BETTER = [ ], ]; -const LOWER_IS_BETTER = [ +const CONTEXT_INDEPENDENT_LOWER_IS_BETTER = [ ['partial models', ['partial_models']], ['unbalanced models', ['unbalanced_models']], + ['initial unbalanced models', ['initial_unbalanced_models']], ['initial-condition solver failures', ['ic_solver_fail']], +]; + +const OMC_DEPENDENT_LOWER_IS_BETTER = [ ['trace deviation models', ['trace_accuracy_stats', 'agreement_deviation']], ['trace bad channels', ['trace_accuracy_stats', 'bad_channels_total']], ['trace severe channels', ['trace_accuracy_stats', 'severe_channels_total']], @@ -60,14 +166,24 @@ const LOWER_FLOAT_IS_BETTER = [ ], ]; -export function promoteBaselineIfImproved({ sourcePath, baselinePath, log = console.log }) { +export function promoteBaselineIfImproved({ + sourcePath, + baselinePath, + checkedInBaselinePath = DEFAULT_CHECKED_IN_BASELINE_PATH, + log = console.log, +}) { const sourceText = fs.readFileSync(sourcePath, 'utf8'); const baselineText = fs.readFileSync(baselinePath, 'utf8'); const source = parseJson(sourceText, sourcePath); const baseline = parseJson(baselineText, baselinePath); ensurePromotableSnapshot(source, sourcePath); + const checkedInBaseline = loadCheckedInBaselineForOmcMigration({ + source, + baseline, + checkedInBaselinePath, + }); - const decision = ratchetDecision(source, baseline); + const decision = ratchetDecision(source, baseline, checkedInBaseline); if (!decision.promote) { log(`MSL quality baseline not promoted: ${decision.reason}`); return decision; @@ -106,16 +222,97 @@ export function ensurePromotableSnapshot(snapshot, sourceName = 'source snapshot } } -export function ratchetDecision(current, baseline) { +export function ratchetDecision(current, baseline, checkedInBaseline = null) { ensureSameContext(current, baseline, ['simulatable_attempted']); ensureSameContext(current, baseline, ['sim_target_models']); + const contractDeclaration = Object.hasOwn(current, 'compiler_contract_migration') + ? valueAt(current, ['compiler_contract_migration']) + : null; + const schemaMigration = validatedSchemaMigration(current, baseline, contractDeclaration); + const currentOmc = nonEmptyStringAt(current, ['omc_version'], 'current snapshot'); + const baselineOmc = nonEmptyStringAt(baseline, ['omc_version'], 'baseline snapshot'); + const omcContextChanged = currentOmc !== baselineOmc; + if (omcContextChanged) { + validateOmcContextMigration(current, baseline, checkedInBaseline); + } + const contractMigration = schemaMigration === null + ? null + : validatedCompilerContractMigration( + current, + baseline, + checkedInBaseline, + contractDeclaration, + ); + const comparisonBaseline = contractMigration === null ? baseline : checkedInBaseline; + const comparisonOmc = nonEmptyStringAt( + comparisonBaseline, + ['omc_version'], + 'comparison baseline', + ); + const comparisonOmcChanged = currentOmc !== comparisonOmc; + const skippedPaths = new Set( + schemaMigration === null || contractMigration !== null ? [] : ['flatten_models'], + ); const improvements = []; const regressions = []; - compareIntegerMetrics(HIGHER_IS_BETTER, current, baseline, true, improvements, regressions); - compareIntegerMetrics(LOWER_IS_BETTER, current, baseline, false, improvements, regressions); - compareFloatMetrics(LOWER_FLOAT_IS_BETTER, current, baseline, improvements, regressions); - compareDerivedMetrics(current, baseline, improvements, regressions); + compareIntegerMetrics( + CONTEXT_INDEPENDENT_HIGHER_IS_BETTER, + current, + comparisonBaseline, + true, + improvements, + regressions, + skippedPaths, + ); + compareIntegerMetrics( + CONTEXT_INDEPENDENT_LOWER_IS_BETTER, + current, + comparisonBaseline, + false, + improvements, + regressions, + skippedPaths, + ); + if (!comparisonOmcChanged) { + compareIntegerMetrics( + OMC_DEPENDENT_HIGHER_IS_BETTER, + current, + comparisonBaseline, + true, + improvements, + regressions, + ); + compareIntegerMetrics( + OMC_DEPENDENT_LOWER_IS_BETTER, + current, + comparisonBaseline, + false, + improvements, + regressions, + ); + compareFloatMetrics( + LOWER_FLOAT_IS_BETTER, + current, + comparisonBaseline, + improvements, + regressions, + ); + compareDerivedMetrics(current, comparisonBaseline, improvements, regressions); + compareRuntimeSpeedups(current, comparisonBaseline, improvements, regressions); + } else { + improvements.push(`OMC context: ${comparisonOmc} -> ${currentOmc}`); + } + if (schemaMigration !== null) { + improvements.push( + `quality schema: ${schemaMigration.from_quality_gate_version} -> ${schemaMigration.to_quality_gate_version}`, + ); + } + if (contractMigration !== null) { + improvements.push( + `compiler contract: ${contractMigration.from_contract} -> ${contractMigration.to_contract}`, + ); + } if (regressions.length > 0) { return { @@ -136,6 +333,270 @@ export function ratchetDecision(current, baseline) { return { promote: true, improvements, regressions }; } +function loadCheckedInBaselineForOmcMigration({ source, baseline, checkedInBaselinePath }) { + const sourceOmc = nonEmptyStringAt(source, ['omc_version'], 'source snapshot'); + const baselineOmc = nonEmptyStringAt(baseline, ['omc_version'], 'baseline snapshot'); + if (sourceOmc === baselineOmc) { + return null; + } + const checkedInText = fs.readFileSync(checkedInBaselinePath, 'utf8'); + return parseJson(checkedInText, checkedInBaselinePath); +} + +function validateOmcContextMigration(current, baseline, checkedInBaseline) { + assert.notEqual( + checkedInBaseline, + null, + 'cannot ratchet baseline: changed OMC context requires the reviewed checked-in migration', + ); + assert.equal( + typeof checkedInBaseline, + 'object', + 'cannot ratchet baseline: checked-in OMC migration baseline must be an object', + ); + ensurePromotableSnapshot(checkedInBaseline, 'checked-in migration baseline'); + const currentOmc = nonEmptyStringAt(current, ['omc_version'], 'current snapshot'); + const baselineOmc = nonEmptyStringAt(baseline, ['omc_version'], 'baseline snapshot'); + assert.equal( + nonEmptyStringAt(checkedInBaseline, ['omc_version'], 'checked-in migration baseline'), + currentOmc, + 'cannot ratchet baseline: checked-in OMC context does not match current snapshot', + ); + const migration = valueAt(checkedInBaseline, ['omc_context_migration']); + assert.equal( + typeof migration, + 'object', + 'cannot ratchet baseline: checked-in omc_context_migration must be an object', + ); + assert.equal( + nonEmptyStringAt(migration, ['from_omc_version'], 'OMC context migration'), + baselineOmc, + 'cannot ratchet baseline: OMC migration source does not match promoted baseline', + ); + assert.equal( + nonEmptyStringAt(migration, ['to_omc_version'], 'OMC context migration'), + currentOmc, + 'cannot ratchet baseline: OMC migration target does not match current snapshot', + ); + const currentTargetCount = integerAt(current, ['sim_target_models'], 'current snapshot'); + assert.equal( + integerAt(migration, ['sim_target_models'], 'OMC context migration'), + currentTargetCount, + 'cannot ratchet baseline: OMC migration target count does not match current snapshot', + ); + assert.equal( + integerAt(checkedInBaseline, ['sim_target_models'], 'checked-in migration baseline'), + currentTargetCount, + 'cannot ratchet baseline: checked-in OMC target count does not match current snapshot', + ); +} + +function validatedSchemaMigration(current, baseline, contractMigration = null) { + const currentVersion = integerAt(current, ['quality_gate_version'], 'current snapshot'); + const baselineVersion = integerAt(baseline, ['quality_gate_version'], 'baseline snapshot'); + if (currentVersion === baselineVersion) { + return null; + } + assert.equal( + currentVersion, + EXPECTED_QUALITY_GATE_VERSION, + 'cannot ratchet baseline: current quality schema is unsupported', + ); + const migration = valueAt(current, ['metric_schema_migration']); + assert.equal( + typeof migration, + 'object', + 'cannot ratchet baseline: metric_schema_migration must be an object', + ); + assert.equal( + integerAt(migration, ['from_quality_gate_version'], 'metric schema migration'), + baselineVersion, + 'cannot ratchet baseline: schema migration source does not match baseline', + ); + assert.equal( + integerAt(migration, ['to_quality_gate_version'], 'metric schema migration'), + currentVersion, + 'cannot ratchet baseline: schema migration target does not match current snapshot', + ); + const before = integerAt( + migration, + ['flatten_models_before'], + 'metric schema migration', + ); + const after = integerAt( + migration, + ['flatten_models_after'], + 'metric schema migration', + ); + assert.equal( + before, + V2_FLATTEN_MODELS_BEFORE, + 'cannot ratchet baseline: migration before-count differs from the reviewed correction', + ); + assert.equal( + after, + V2_FLATTEN_MODELS_AFTER, + 'cannot ratchet baseline: migration after-count differs from the reviewed correction', + ); + assert.equal( + integerAt(baseline, ['flatten_models'], 'baseline snapshot'), + before, + 'cannot ratchet baseline: migration before-count does not match baseline', + ); + const schemaTargetFlatten = contractMigration === null + ? integerAt(current, ['flatten_models'], 'current snapshot') + : integerAt( + contractMigration, + ['stage_counts_before', 'flatten_models'], + 'compiler contract migration', + ); + assert.equal( + schemaTargetFlatten, + after, + 'cannot ratchet baseline: migration after-count does not match its schema target', + ); + const models = valueAt(migration, ['reattributed_models']); + assert.equal(Array.isArray(models), true, 'metric schema migration model set must be an array'); + assert.equal( + models.every((model) => typeof model === 'string' && model.length > 0), + true, + 'metric schema migration model names must be non-empty strings', + ); + assert.equal( + models.length, + before - after, + 'metric schema migration model count must explain the count delta', + ); + assert.equal( + new Set(models).size, + models.length, + 'metric schema migration model set must be unique', + ); + assert.deepEqual( + [...models].sort(), + [...V2_REATTRIBUTED_MODELS].sort(), + 'metric schema migration model set differs from the reviewed correction', + ); + assert.equal( + stringAt(migration, ['reattributed_error_code'], 'metric schema migration'), + V2_REATTRIBUTED_ERROR_CODE, + 'metric schema migration diagnostic cohort differs from the reviewed correction', + ); + return migration; +} + +function validatedCompilerContractMigration( + current, + baseline, + checkedInBaseline, + declaration, +) { + if (declaration === null) { + return null; + } + assert.notEqual( + checkedInBaseline, + null, + 'cannot ratchet baseline: compiler contract cutover requires the reviewed checked-in baseline', + ); + const checkedDeclaration = valueAt(checkedInBaseline, ['compiler_contract_migration']); + assert.deepEqual( + declaration, + checkedDeclaration, + 'cannot ratchet baseline: current compiler contract declaration differs from checked-in review', + ); + assert.equal( + stringAt(declaration, ['from_contract'], 'compiler contract migration'), + CHECKED_DAE_CONTRACT_FROM, + 'cannot ratchet baseline: compiler contract source differs from the reviewed cutover', + ); + assert.equal( + stringAt(declaration, ['to_contract'], 'compiler contract migration'), + CHECKED_DAE_CONTRACT_TO, + 'cannot ratchet baseline: compiler contract target differs from the reviewed cutover', + ); + const evidenceCommit = stringAt( + declaration, + ['evidence_git_commit'], + 'compiler contract migration', + ); + assert.equal( + evidenceCommit, + CHECKED_DAE_EVIDENCE_COMMIT, + 'cannot ratchet baseline: compiler contract evidence commit differs from review', + ); + assert.equal( + stringAt(checkedInBaseline, ['git_commit'], 'checked-in migration baseline'), + evidenceCommit, + 'cannot ratchet baseline: checked-in baseline is not the reviewed evidence run', + ); + assert.equal( + integerAt(declaration, ['sim_target_models'], 'compiler contract migration'), + integerAt(current, ['sim_target_models'], 'current snapshot'), + 'cannot ratchet baseline: compiler contract target set differs', + ); + assert.deepEqual( + valueAt(declaration, ['stage_counts_before']), + CHECKED_DAE_STAGE_COUNTS_BEFORE, + 'cannot ratchet baseline: compiler contract source counts differ from review', + ); + assert.deepEqual( + valueAt(declaration, ['stage_counts_after']), + CHECKED_DAE_STAGE_COUNTS_AFTER, + 'cannot ratchet baseline: compiler contract target counts differ from review', + ); + assert.deepEqual( + valueAt(declaration, ['phase_failure_counts_after']), + CHECKED_DAE_PHASE_FAILURE_COUNTS, + 'cannot ratchet baseline: compiler contract failure census differs from review', + ); + assert.deepEqual( + valueAt(declaration, ['error_code_counts_after']), + CHECKED_DAE_ERROR_CODE_COUNTS, + 'cannot ratchet baseline: compiler contract diagnostic census differs from review', + ); + assertContractTargetMatchesCheckedBaseline(checkedInBaseline); + assertContractSourceDominatesPromoted(baseline); + return declaration; +} + +function assertContractTargetMatchesCheckedBaseline(checkedInBaseline) { + for (const [metric, count] of Object.entries(CHECKED_DAE_STAGE_COUNTS_AFTER)) { + assert.equal( + integerAt(checkedInBaseline, [metric], 'checked-in migration baseline'), + count, + `cannot ratchet baseline: checked-in ${metric} differs from contract evidence`, + ); + } + const failedModels = Object.values(CHECKED_DAE_PHASE_FAILURE_COUNTS) + .reduce((sum, count) => sum + count, 0); + assert.equal( + failedModels + CHECKED_DAE_STAGE_COUNTS_AFTER.compiled_models, + integerAt(checkedInBaseline, ['sim_target_models'], 'checked-in migration baseline'), + 'cannot ratchet baseline: compiler contract failure census does not cover the target set', + ); +} + +function assertContractSourceDominatesPromoted(baseline) { + for (const [, path] of CONTEXT_INDEPENDENT_HIGHER_IS_BETTER) { + const metric = path[0]; + if (metric === 'flatten_models') { + continue; + } + assert.ok( + integerAt(baseline, path, 'baseline snapshot') <= CHECKED_DAE_STAGE_COUNTS_BEFORE[metric], + `cannot ratchet baseline: compiler contract source regresses ${metric}`, + ); + } + for (const [, path] of CONTEXT_INDEPENDENT_LOWER_IS_BETTER) { + const metric = path[0]; + assert.ok( + integerAt(baseline, path, 'baseline snapshot') >= CHECKED_DAE_STAGE_COUNTS_BEFORE[metric], + `cannot ratchet baseline: compiler contract source regresses ${metric}`, + ); + } +} + function parseJson(text, path) { try { return JSON.parse(text); @@ -161,8 +622,12 @@ function compareIntegerMetrics( higherIsBetter, improvements, regressions, + skippedPaths = new Set(), ) { for (const [label, path] of metrics) { + if (skippedPaths.has(path.join('.'))) { + continue; + } compareMetric( label, integerAt(current, path, 'current snapshot'), @@ -174,6 +639,31 @@ function compareIntegerMetrics( } } +function compareRuntimeSpeedups(current, baseline, improvements, regressions) { + for (const [label, path] of [ + [ + 'runtime system speedup median', + ['runtime_ratio_stats', 'system_ratio_both_success', 'median'], + ], + [ + 'runtime wall speedup median', + ['runtime_ratio_stats', 'wall_ratio_both_success', 'median'], + ], + ]) { + const currentRatio = numberAt(current, path, 'current snapshot'); + const baselineRatio = numberAt(baseline, path, 'baseline snapshot'); + if (currentRatio < baselineRatio * 0.65) { + regressions.push( + `${label}: ${baselineRatio.toExponential(6)} -> ${currentRatio.toExponential(6)}`, + ); + } else if (currentRatio > baselineRatio) { + improvements.push( + `${label}: ${baselineRatio.toExponential(6)} -> ${currentRatio.toExponential(6)}`, + ); + } + } +} + function compareFloatMetrics(metrics, current, baseline, improvements, regressions) { for (const [label, path] of metrics) { compareFloatMetric( @@ -260,6 +750,12 @@ function stringAt(snapshot, path, name) { return value; } +function nonEmptyStringAt(snapshot, path, name) { + const value = stringAt(snapshot, path, name).trim(); + assert.notEqual(value, '', `${name}: ${path.join('.')} must be non-empty`); + return value; +} + function valueAt(snapshot, path) { let value = snapshot; for (const key of path) { diff --git a/.github/scripts/msl-baseline-ratchet.test.mjs b/.github/scripts/msl-baseline-ratchet.test.mjs index 8f7a026c7..2c1a1322c 100644 --- a/.github/scripts/msl-baseline-ratchet.test.mjs +++ b/.github/scripts/msl-baseline-ratchet.test.mjs @@ -12,22 +12,31 @@ import { function fullSnapshot() { return { - quality_gate_version: 1, + quality_gate_version: 2, run_scope: 'full', omc_version: 'OpenModelica 1.27.0', simulatable_attempted: 10, sim_target_models: 10, parse_models: 10, flatten_models: 9, + dae_models: 8, compiled_models: 8, solve_models: 7, balanced_models: 8, + balance_denominator: 8, initial_balanced_models: 8, + initial_unbalanced_models: 0, + sim_attempted: 7, + ic_attempted: 6, ic_ok: 6, sim_ok: 5, partial_models: 1, unbalanced_models: 0, ic_solver_fail: 2, + runtime_ratio_stats: { + system_ratio_both_success: { median: 2.0 }, + wall_ratio_both_success: { median: 10.0 }, + }, trace_accuracy_stats: { models_compared: 5, agreement_high: 3, @@ -52,6 +61,123 @@ function fullSnapshot() { }; } +function exactV2Migration() { + return { + from_quality_gate_version: 1, + to_quality_gate_version: 2, + flatten_models_before: 565, + flatten_models_after: 555, + reattributed_error_code: 'ER002', + reattributed_models: [ + 'Modelica.Fluid.Examples.AST_BatchPlant.BatchPlant_StandardWater', + 'Modelica.Fluid.Examples.AST_BatchPlant.Test.OneTank', + 'Modelica.Fluid.Examples.AST_BatchPlant.Test.TankWithEmptyingPipe1', + 'Modelica.Fluid.Examples.AST_BatchPlant.Test.TankWithEmptyingPipe2', + 'Modelica.Fluid.Examples.AST_BatchPlant.Test.TanksWithEmptyingPipe1', + 'Modelica.Fluid.Examples.AST_BatchPlant.Test.TanksWithEmptyingPipe2', + 'Modelica.Fluid.Examples.AST_BatchPlant.Test.TwoTanks', + 'Modelica.Fluid.Examples.Explanatory.MeasuringTemperature', + 'Modelica.Fluid.Examples.Explanatory.MomentumBalanceFittings', + 'Modelica.Fluid.Examples.InverseParameterization', + ], + }; +} + +function approvedOmcMigration(from, to) { + const checkedIn = fullSnapshot(); + checkedIn.omc_version = to; + checkedIn.omc_context_migration = { + from_omc_version: from, + to_omc_version: to, + sim_target_models: checkedIn.sim_target_models, + }; + return checkedIn; +} + +function exactCheckedDaeContractMigration() { + return { + from_contract: 'permissive-dae-v1', + to_contract: 'checked-dae-v1', + evidence_git_commit: '3fc9a6cb9c60e1137eb6151f29cb87e9ad35064b', + sim_target_models: 566, + stage_counts_before: { + parse_models: 566, + flatten_models: 555, + dae_models: 545, + compiled_models: 545, + solve_models: 446, + balanced_models: 532, + unbalanced_models: 0, + partial_models: 13, + balance_denominator: 532, + initial_balanced_models: 532, + initial_unbalanced_models: 0, + sim_attempted: 496, + ic_attempted: 267, + ic_ok: 252, + ic_solver_fail: 15, + sim_ok: 207, + }, + stage_counts_after: { + parse_models: 566, + flatten_models: 444, + dae_models: 228, + compiled_models: 228, + solve_models: 202, + balanced_models: 217, + unbalanced_models: 0, + partial_models: 11, + balance_denominator: 217, + initial_balanced_models: 217, + initial_unbalanced_models: 0, + sim_attempted: 210, + ic_attempted: 150, + ic_ok: 146, + ic_solver_fail: 4, + sim_ok: 122, + }, + phase_failure_counts_after: { + Flatten: 82, + Instantiate: 9, + Resolve: 25, + ToDae: 216, + Typecheck: 6, + }, + error_code_counts_after: { + ED001: 24, + ED008: 7, + ED009: 3, + ED010: 14, + ED013: 22, + ED018: 29, + ED019: 111, + ED020: 1, + ED021: 5, + EF004: 24, + EF005: 11, + EF016: 16, + EF020: 1, + EF024: 16, + EF025: 12, + EI007: 2, + EI012: 6, + EI027: 1, + EL005: 60, + EMSL_TIMEOUT_MODEL_ATTEMPT: 11, + ER066: 23, + ER130: 2, + ET000: 1, + ET004: 4, + EX001: 6, + EX002: 13, + }, + }; +} + +function applyStageCounts(snapshot, counts) { + Object.assign(snapshot, counts); +} + test('promotable snapshot accepts full non-partial artifacts', () => { const snapshot = fullSnapshot(); assert.doesNotThrow(() => ensurePromotableSnapshot(snapshot)); @@ -109,6 +235,152 @@ test('ratchet rejects changed fixed target context', () => { assert.throws(() => ratchetDecision(current, baseline), /sim_target_models changed/); }); +test('ratchet accepts an exact versioned metric-attribution migration', () => { + const baseline = fullSnapshot(); + baseline.quality_gate_version = 1; + baseline.flatten_models = 565; + const current = fullSnapshot(); + current.flatten_models = 555; + current.metric_schema_migration = exactV2Migration(); + + const decision = ratchetDecision(current, baseline); + assert.equal(decision.promote, true); + assert.match(decision.improvements.join('\n'), /quality schema/); +}); + +test('ratchet rejects an unproven metric-attribution migration', () => { + const baseline = fullSnapshot(); + baseline.quality_gate_version = 1; + baseline.flatten_models = 565; + const current = fullSnapshot(); + current.flatten_models = 555; + current.metric_schema_migration = exactV2Migration(); + current.metric_schema_migration.reattributed_models[9] = 'Modelica.HandLowered.Substitute'; + assert.throws(() => ratchetDecision(current, baseline), /reviewed correction/); +}); + +test('schema migration rejects an unrelated cumulative metric regression', () => { + const baseline = fullSnapshot(); + baseline.quality_gate_version = 1; + baseline.flatten_models = 565; + const current = fullSnapshot(); + current.flatten_models = 555; + current.compiled_models -= 1; + current.metric_schema_migration = exactV2Migration(); + + const decision = ratchetDecision(current, baseline); + assert.equal(decision.promote, false); + assert.match(decision.reason, /compiled models/); +}); + +test('schema migration rejects an unrelated headline regression', () => { + const baseline = fullSnapshot(); + baseline.quality_gate_version = 1; + baseline.flatten_models = 565; + const current = fullSnapshot(); + current.flatten_models = 555; + current.trace_accuracy_stats.agreement_high -= 1; + current.metric_schema_migration = exactV2Migration(); + + const decision = ratchetDecision(current, baseline); + assert.equal(decision.promote, false); + assert.match(decision.reason, /high trace agreement/); +}); + +test('ratchet accepts only the reviewed checked-DAE contract cutover', () => { + const contract = exactCheckedDaeContractMigration(); + const baseline = fullSnapshot(); + baseline.quality_gate_version = 1; + baseline.omc_version = 'OpenModelica old'; + baseline.simulatable_attempted = 566; + baseline.sim_target_models = 566; + applyStageCounts(baseline, { + ...contract.stage_counts_before, + flatten_models: 565, + solve_models: 381, + sim_attempted: 413, + ic_attempted: 259, + ic_ok: 239, + ic_solver_fail: 20, + sim_ok: 170, + }); + + const checkedIn = fullSnapshot(); + checkedIn.git_commit = contract.evidence_git_commit; + checkedIn.omc_version = 'OpenModelica new'; + checkedIn.simulatable_attempted = 566; + checkedIn.sim_target_models = 566; + applyStageCounts(checkedIn, contract.stage_counts_after); + checkedIn.metric_schema_migration = exactV2Migration(); + checkedIn.compiler_contract_migration = structuredClone(contract); + checkedIn.omc_context_migration = { + from_omc_version: baseline.omc_version, + to_omc_version: checkedIn.omc_version, + sim_target_models: 566, + }; + + const current = structuredClone(checkedIn); + const decision = ratchetDecision(current, baseline, checkedIn); + assert.equal(decision.promote, true); + assert.match(decision.improvements.join('\n'), /compiler contract/); + + current.compiler_contract_migration.stage_counts_after.compiled_models += 1; + assert.throws( + () => ratchetDecision(current, baseline, checkedIn), + /differs from checked-in review/, + ); +}); + +test('OMC migration compares independent metrics without cross-context trace rejection', () => { + const baseline = fullSnapshot(); + baseline.quality_gate_version = 1; + baseline.flatten_models = 565; + baseline.omc_version = 'OpenModelica old'; + const current = fullSnapshot(); + current.flatten_models = 555; + current.omc_version = 'OpenModelica new'; + current.metric_schema_migration = exactV2Migration(); + current.trace_accuracy_stats.agreement_high = 0; + current.runtime_ratio_stats.system_ratio_both_success.median = 0.01; + const checkedIn = approvedOmcMigration(baseline.omc_version, current.omc_version); + + const decision = ratchetDecision(current, baseline, checkedIn); + assert.equal(decision.promote, true); + assert.match(decision.improvements.join('\n'), /OMC context/); + + current.compiled_models -= 1; + const regressed = ratchetDecision(current, baseline, checkedIn); + assert.equal(regressed.promote, false); + assert.match(regressed.reason, /compiled models/); +}); + +test('OMC migration rejects missing or mismatched checked-in approval', () => { + const baseline = fullSnapshot(); + baseline.omc_version = 'OpenModelica old'; + const current = fullSnapshot(); + current.omc_version = 'OpenModelica new'; + + assert.throws( + () => ratchetDecision(current, baseline), + /reviewed checked-in migration/, + ); + const reversed = approvedOmcMigration(current.omc_version, baseline.omc_version); + assert.throws( + () => ratchetDecision(current, baseline, reversed), + /does not match current snapshot/, + ); +}); + +test('ratchet rejects a runtime speedup drop beyond 35 percent', () => { + const baseline = fullSnapshot(); + const current = fullSnapshot(); + current.sim_ok = 6; + current.runtime_ratio_stats.system_ratio_both_success.median = 1.29; + const decision = ratchetDecision(current, baseline); + assert.equal(decision.promote, false); + assert.match(decision.reason, /runtime system speedup median/); +}); + test('promoteBaselineIfImproved writes only when improved', () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'msl-ratchet-')); const baselinePath = path.join(dir, 'baseline.json'); @@ -128,6 +400,34 @@ test('promoteBaselineIfImproved writes only when improved', () => { assert.equal(JSON.parse(fs.readFileSync(baselinePath, 'utf8')).sim_ok, 6); }); +test('promotion loads the reviewed checked-in OMC migration', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'msl-ratchet-')); + const baselinePath = path.join(dir, 'baseline.json'); + const sourcePath = path.join(dir, 'source.json'); + const checkedInBaselinePath = path.join(dir, 'checked-in.json'); + const baseline = fullSnapshot(); + baseline.quality_gate_version = 1; + baseline.flatten_models = 565; + baseline.omc_version = 'OpenModelica old'; + const source = fullSnapshot(); + source.flatten_models = 555; + source.omc_version = 'OpenModelica new'; + source.metric_schema_migration = exactV2Migration(); + const checkedIn = approvedOmcMigration(baseline.omc_version, source.omc_version); + fs.writeFileSync(baselinePath, JSON.stringify(baseline, null, 2)); + fs.writeFileSync(sourcePath, JSON.stringify(source, null, 2)); + fs.writeFileSync(checkedInBaselinePath, JSON.stringify(checkedIn, null, 2)); + + const decision = promoteBaselineIfImproved({ + sourcePath, + baselinePath, + checkedInBaselinePath, + log: () => {}, + }); + assert.equal(decision.promote, true); + assert.equal(JSON.parse(fs.readFileSync(baselinePath, 'utf8')).omc_version, source.omc_version); +}); + test('promoteBaselineIfImproved leaves equivalent baseline unchanged', () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'msl-ratchet-')); const baselinePath = path.join(dir, 'baseline.json'); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7049e7200..a8aeb2dbd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,8 +87,8 @@ jobs: # Architectural invariant, checked without compiling anything: `xtask` must # carry NO rumoca-* workspace crate. It parses args, moves files, and shells - # out; heavy compiler-linked work runs on demand via `cargo run -p ` - # (rumoca-test-msl, rumoca-tool-docs). If a rumoca-* dep creeps back in, + # out; compiler-linked work runs on demand through a command in its owning + # package. If a rumoca-* dep creeps back in, # building any xtask bin again drags in the whole compiler/diffsol stack. - name: Architecture — xtask stays dependency-light run: | @@ -112,9 +112,9 @@ jobs: fetch-depth: 0 - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y libudev-dev + uses: ./.github/actions/apt-install + with: + packages: libudev-dev - name: Install Rust run: | @@ -147,11 +147,13 @@ jobs: path: target/review/review-scan.json if-no-files-found: ignore - - name: Check generated Modelica parser is current + - name: Check generated parsers are current shell: bash run: | set -euo pipefail - git diff --exit-code -- crates/rumoca-phase-parse/src/generated + git diff --exit-code -- \ + crates/rumoca-phase-parse/src/generated \ + crates/rumoca-phase-parse-galec/src/parse/generated - name: Generate crate DAG release artifact run: cargo xtask repo graph crates --format dot @@ -163,12 +165,9 @@ jobs: path: target/crate-dag/workspace-crate-dag.dot if-no-files-found: error - # Reproducible build + clippy + fmt via the root crane+fenix flake, backed by - # the Cachix binary cache (cognipilot.cachix.org). First run is cold and pushes - # the built closure; subsequent runs (and local `cachix use rumoca`) restore - # the prebuilt dependency closure + rumoca binary in seconds. Replaces the - # deprecated magic-nix-cache, which rate-limited the GitHub Actions cache API - # on a closure this size (roadmap M4 slice 2). + # Reproducible package build via the root crane+fenix flake. Nix store paths + # are cached privately in GitHub Actions, so pull requests need no external + # cache credentials. nix-checks: name: Nix flake checks (Linux) runs-on: ubuntu-24.04 @@ -183,19 +182,17 @@ jobs: with: determinate: false - - name: Cachix (CogniPilot binary cache) - uses: cachix/cachix-action@v17 + - name: GitHub Actions Nix cache + uses: DeterminateSystems/magic-nix-cache-action@v14 with: - name: cognipilot - # Public cache: pulls need no auth; the token lets CI push new paths. - authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} - - - name: Nix build the flake package check - # Build ONLY the reproducible package check. Format and clippy are owned - # by the standalone Format/Lint jobs, and the heavy release MSL artifact - # bundle is built once by the dedicated `nix-build-msl` producer and - # consumed via Cachix; building any of that here too would duplicate - # release+LTO work. + use-flakehub: false + diagnostic-endpoint: '' + + - name: Nix build the package and required MLIR CPU checks + # Build only the reproducible package and focused MLIR CPU checks. + # Format and clippy are owned by the standalone Format/Lint jobs, and + # the heavy release MSL artifact bundle is built once by the dedicated + # `nix-build-msl` producer. # # We deliberately do NOT run `nix flake check`: it eval-builds every output # (re-building those packages), and its `--no-build` eval trips over the @@ -203,7 +200,52 @@ jobs: # (`path '…rust-nightly.toml.drv' is not valid`). `nix build` realizes the # toolchain, so building the checks directly is the reliable gate. run: | - nix build --print-build-logs .#checks.x86_64-linux.rumoca + # Nix evaluates the finite Crane + LLVM derivation graph recursively. + # The default hosted-runner stack is too small for this workspace; + # keep the evaluator budget explicit and bounded rather than relying + # on an unlimited process stack. + ulimit -s 65536 + nix build --print-build-logs \ + .#checks.x86_64-linux.rumoca \ + .#checks.x86_64-linux.mlir-cpu + + kani: + name: Kani symbolic event-history contract + runs-on: ubuntu-24.04 + timeout-minutes: 45 + env: + CARGO_BUILD_JOBS: 4 + RUST_TEST_THREADS: 4 + RAYON_NUM_THREADS: 4 + steps: + - uses: actions/checkout@v5 + with: + token: ${{ github.token }} + persist-credentials: true + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@v22 + with: + determinate: false + + - name: GitHub Actions Nix cache + uses: DeterminateSystems/magic-nix-cache-action@v14 + with: + use-flakehub: false + diagnostic-endpoint: '' + + - name: Prove bounded semantic and runtime contracts with pinned Kani + run: | + nix develop .#kani --command cargo kani --version + nix develop .#kani --command cargo xtask verify kani + + - name: Upload Kani proof summary + if: always() + uses: actions/upload-artifact@v6 + with: + name: kani-proof-summary + path: target/verification/kani-summary.json + if-no-files-found: warn test: name: Test (${{ matrix.os }}) @@ -220,13 +262,14 @@ jobs: with: token: ${{ github.token }} persist-credentials: true + fetch-depth: 0 - name: Install system dependencies (Linux) if: runner.os == 'Linux' - run: | - sudo apt-get update + uses: ./.github/actions/apt-install + with: # libxml2-utils provides xmllint for rumoca-efmi XSD validation tests - sudo apt-get install -y libudev-dev libxml2-utils + packages: libudev-dev libxml2-utils # macOS ships /usr/bin/xmllint; Windows runner images have no xmllint # contract, so install libxml2's tools explicitly (the rumoca-efmi XSD @@ -267,6 +310,11 @@ jobs: echo "$HOME/.cargo/bin" >> $GITHUB_PATH shell: bash + - name: Install cargo-nextest + uses: taiki-e/install-action@v2 + with: + tool: nextest + - name: Rust cache (native) uses: Swatinem/rust-cache@v2 with: @@ -313,6 +361,8 @@ jobs: shell: bash run: | set -euo pipefail + mkdir -p target/msl + touch target/msl/formatter-stability-required msl_dir=target/msl/ModelicaStandardLibrary-4.1.0 if [[ -f "$msl_dir/Complex.mo" && -f "$msl_dir/Modelica 4.1.0/package.mo" ]]; then echo "MSL cache is valid at $msl_dir" @@ -356,8 +406,6 @@ jobs: # fails on the smoke step). - name: Run workspace tests (Linux monitored) if: runner.os == 'Linux' && !cancelled() - env: - REQUIRE_MSL_FMT_DRIFT: "1" shell: bash run: | set -euo pipefail @@ -381,13 +429,30 @@ jobs: - name: Run workspace tests if: runner.os != 'Linux' && !cancelled() - env: - REQUIRE_MSL_FMT_DRIFT: "1" run: cargo xtask verify workspace - name: Build all binaries run: cargo xtask verify binaries + - name: Run tensor compile-scaling ratchet + if: runner.os == 'Linux' + run: | + cargo build \ + --package rumoca-test-msl \ + --bin rumoca-tensor-scaling \ + --features rumoca-test-msl/tensor-scaling-bin + target/debug/rumoca-tensor-scaling \ + --enforce \ + --output target/tensor-scaling/report.json + + - name: Upload tensor compile-scaling report + if: runner.os == 'Linux' && always() + uses: actions/upload-artifact@v6 + with: + name: tensor-compile-scaling-report + path: target/tensor-scaling/report.json + if-no-files-found: warn + template-runtime-tests: name: Template Runtime (${{ matrix.backend }}) runs-on: ubuntu-24.04 @@ -397,30 +462,25 @@ jobs: include: - backend: render nix_shell: ci-template-core - - backend: native - nix_shell: ci-template-core - - backend: embedded-c - nix_shell: ci-template-core - - backend: fmi + - backend: c nix_shell: ci-template-core - backend: casadi nix_shell: ci-template-python - - backend: sympy - nix_shell: ci-template-python - - backend: symforce - nix_shell: ci-template-python - - backend: onnx - nix_shell: ci-template-python + - backend: cuda + nix_shell: ci-template-cuda + - backend: fmi + nix_shell: ci-template-fmi - backend: jax nix_shell: ci-template-python - - backend: julia - nix_shell: ci-template-julia + - backend: modelica + nix_shell: ci-template-modelica + - backend: rust + nix_shell: ci-template-core + - backend: wasm + nix_shell: ci-template-wasm env: CARGO_PROFILE_DEV_DEBUG: 0 CARGO_PROFILE_TEST_DEBUG: 0 - JULIA_DEPOT_PATH: "${{ github.workspace }}/target/julia-depot:" - JULIA_PKG_PRECOMPILE_AUTO: '0' - JULIA_PROJECT: ${{ github.workspace }}/infra/julia steps: - uses: actions/checkout@v5 with: @@ -432,10 +492,11 @@ jobs: with: determinate: false - - name: Cachix (CogniPilot binary cache) - uses: cachix/cachix-action@v17 + - name: GitHub Actions Nix cache + uses: DeterminateSystems/magic-nix-cache-action@v14 with: - name: cognipilot + use-flakehub: false + diagnostic-endpoint: '' - name: Rust cache (template-runtime) uses: Swatinem/rust-cache@v2 @@ -465,17 +526,8 @@ jobs: df -h . / /tmp /home/runner/actions-runner/cached 2>/dev/null || true du -sh target target/debug ~/.cargo ~/.rustup /home/runner/actions-runner/cached/_diag 2>/dev/null || true - - name: Julia template runtime cache - if: matrix.backend == 'julia' - uses: actions/cache@v4 - with: - path: target/julia-depot - key: ${{ runner.os }}-julia-template-${{ hashFiles('infra/julia/Manifest.toml') }} - restore-keys: | - ${{ runner.os }}-julia-template- - - name: Prepare Python template runtime dependencies - if: matrix.nix_shell == 'ci-template-python' + if: matrix.nix_shell == 'ci-template-python' || matrix.nix_shell == 'ci-template-fmi' env: TEMPLATE_BACKEND: ${{ matrix.backend }} run: | @@ -490,22 +542,14 @@ jobs: python -m pip install casadi==3.7.2 numpy==2.4.3 python -c 'import casadi, numpy; print("casadi template deps ok")' ;; - sympy) - python -m pip install sympy==1.14.0 - python -c 'import sympy; print("sympy template deps ok")' - ;; - symforce) - python -m pip install numpy==2.4.3 scipy==1.17.1 symforce==0.11.0 sympy==1.14.0 - python -c 'import symforce; print("symforce template deps ok")' - ;; - onnx) - python -m pip install numpy==2.4.3 onnx==1.20.1 onnxruntime==1.24.3 - python -c 'import numpy, onnx, onnxruntime; print("onnx template deps ok")' - ;; jax) python -m pip install diffrax==0.7.2 jax==0.9.2 numpy==2.4.3 scipy==1.17.1 python -c 'import diffrax, jax, numpy; print("jax template deps ok")' ;; + fmi) + python -m pip install fmpy==0.3.30 + python -c 'import fmpy; assert fmpy.__version__ == "0.3.30"; print("FMPy conformance dependency ok")' + ;; *) echo "::error::unexpected Python template backend: $TEMPLATE_BACKEND" exit 1 @@ -513,17 +557,47 @@ jobs: esac EOF - - name: Prepare Julia template runtime dependencies - if: matrix.backend == 'julia' + - name: Fetch pinned official FMI standards + if: matrix.backend == 'fmi' run: | nix develop .#${{ matrix.nix_shell }} --command bash <<'EOF' set -euo pipefail - julia --project=infra/julia <<'JL' - using Pkg - Pkg.instantiate() - using ModelingToolkit, OrdinaryDiffEqTsit5, IfElse, SciMLBase, Sundials - println("julia template runtime deps ok") - JL + cache="$PWD/target/fmi-standards" + mkdir -p "$cache/2.0.5" "$cache/3.0.2" + mkdir -p "$cache/vdm2" "$cache/vdm3" + curl --proto '=https' --tlsv1.2 --fail --location --silent --show-error \ + https://github.com/modelica/fmi-standard/releases/download/v2.0.5/FMI-Standard-2.0.5.zip \ + --output "$cache/fmi-2.0.5.zip" + curl --proto '=https' --tlsv1.2 --fail --location --silent --show-error \ + https://github.com/modelica/fmi-standard/releases/download/v3.0.2/FMI-Standard-3.0.2.zip \ + --output "$cache/fmi-3.0.2.zip" + curl --proto '=https' --tlsv1.2 --fail --location --silent --show-error \ + https://github.com/INTO-CPS-Association/FMI-VDM-Model/releases/download/Release/1.1.3/vdmcheck2-1.1.3-distribution-rules.zip \ + --output "$cache/vdmcheck2-1.1.3.zip" + curl --proto '=https' --tlsv1.2 --fail --location --silent --show-error \ + https://github.com/INTO-CPS-Association/FMI-VDM-Model/releases/download/Release/1.1.3/vdmcheck3-1.1.3-distribution-rules.zip \ + --output "$cache/vdmcheck3-1.1.3.zip" + printf '%s %s\n' \ + 2695efc7f3aac3443957b0784880e16eec84013558995547f5d706cbf2104909 \ + "$cache/fmi-2.0.5.zip" \ + 454a12f40069db7efc38faee1c8899fabe74086c0b052f88133b16f256c444bd \ + "$cache/fmi-3.0.2.zip" \ + 53916d61e7f46ff12e010b05646085e27f808acde6069d47f4ee9e45ae98e7d5 \ + "$cache/vdmcheck2-1.1.3.zip" \ + 9f429acedff53350106efa66bbaed077ddbb107c81cca96f585451da3499afd7 \ + "$cache/vdmcheck3-1.1.3.zip" | sha256sum --check + unzip -q "$cache/fmi-2.0.5.zip" -d "$cache/2.0.5" + unzip -q "$cache/fmi-3.0.2.zip" -d "$cache/3.0.2" + unzip -q "$cache/vdmcheck2-1.1.3.zip" -d "$cache/vdm2" + unzip -q "$cache/vdmcheck3-1.1.3.zip" -d "$cache/vdm3" + mkdir -p target/fmi-conformance + printf '%s\n' \ + '{' \ + " \"fmi2_standard_dir\": \"$cache/2.0.5\"," \ + " \"fmi3_standard_dir\": \"$cache/3.0.2\"," \ + " \"fmi2_vdm_check\": \"$cache/vdm2/vdmcheck-1.1.3/VDMCheck2.sh\"," \ + " \"fmi3_vdm_check\": \"$cache/vdm3/vdmcheck-1.1.3/VDMCheck3.sh\"" \ + '}' > target/fmi-conformance/config.json EOF - name: Run template runtime checks @@ -532,13 +606,17 @@ jobs: run: | nix develop .#${{ matrix.nix_shell }} --command bash <<'EOF' set -euo pipefail - mkdir -p target/template-runtimes - touch target/template-runtimes/strict venv="target/template-runtime-venv-${TEMPLATE_BACKEND}" + system_cmake=$(command -v cmake || true) if [ -d "$venv" ]; then . "$venv/bin/activate" fi - cargo xtask verify template-runtimes --backend "$TEMPLATE_BACKEND" + if [ "$TEMPLATE_BACKEND" = fmi ]; then + export PATH="$(dirname "$system_cmake"):$PATH" + fi + cargo xtask verify template-runtimes \ + --backend "$TEMPLATE_BACKEND" \ + --require-external-tools EOF editor-msl-smoke: @@ -580,9 +658,9 @@ jobs: cache-dependency-path: '**/package-lock.json' - name: Install Xvfb and system dependencies - run: | - sudo apt-get update - sudo apt-get install -y xvfb xauth libudev-dev + uses: ./.github/actions/apt-install + with: + packages: xvfb xauth libudev-dev - name: Select headless browser shell: bash @@ -691,12 +769,13 @@ jobs: with: token: ${{ github.token }} persist-credentials: true + fetch-depth: 0 - name: Install system dependencies - run: | - sudo apt-get update + uses: ./.github/actions/apt-install + with: # libxml2-utils provides xmllint for rumoca-efmi XSD validation tests - sudo apt-get install -y libudev-dev libxml2-utils + packages: libudev-dev libxml2-utils - name: Install Rust run: | @@ -769,12 +848,11 @@ jobs: run: | ./target/debug/xtask coverage gate --allowed-workspace-line-coverage-drop 3.0 - # Build the heavy MSL artifacts (release + LTO) ONCE via Nix/crane and push to - # Cachix, so the shard / merge / ModelicaTest consumers restore them instead - # of each recompiling + re-LTO'ing the workspace. LTO is a link-time cost no - # per-crate cache can avoid — only build-once does. On an unchanged dep closure - # the crane `cargoArtifacts` layer is a Cachix hit; only the workspace crates - # rebuild here, once, for all consumers. + # Build the heavy MSL artifacts (release + LTO) ONCE via Nix/crane. The + # producer keeps the pinned OpenModelica closure in its own stable GitHub + # Actions cache and the source-dependent MSL closure in a commit-keyed cache, + # then exports both as workflow artifacts. Consumers never rebuild either + # closure independently. nix-build-msl: name: Build MSL artifacts (Nix, once) runs-on: ubuntu-24.04 @@ -789,18 +867,116 @@ jobs: with: determinate: false - - name: Cachix (CogniPilot binary cache) + # The workflow archive is the primary cross-run cache, but Actions caches + # can be evicted. Keep the public cache as a read-only fallback so an + # eviction does not rebuild the pinned OpenModelica revision from source. + - name: Public CogniPilot Nix cache fallback uses: cachix/cachix-action@v17 with: name: cognipilot - # Public cache: consumer pulls need no auth; the token lets this - # producer push the freshly built closure. Absent on fork PRs, in which - # case consumers Cachix-miss and fall back to compiling — still correct. - authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} - - name: Build + cache MSL artifacts + - name: Install closure archive tooling + uses: ./.github/actions/apt-install + with: + packages: zstd + + - name: Derive Nix closure cache keys + id: nix-cache-keys + shell: bash + run: | + set -euo pipefail + python3 - <<'PY' + import hashlib + import json + import os + + with open("flake.lock", encoding="utf-8") as lock_file: + locked = json.load(lock_file)["nodes"]["openmodelica"]["locked"] + identity = f'{locked["rev"]}\0{locked["narHash"]}'.encode() + digest = hashlib.sha256(identity).hexdigest()[:16] + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + output.write(f'openmodelica={locked["rev"]}-{digest}\n') + PY + + - name: Restore OpenModelica closure cache + uses: actions/cache@v5 + with: + path: target/nix-cache/openmodelica-cli-closure.nar.zst + key: openmodelica-cli-closure-v1-${{ runner.os }}-${{ steps.nix-cache-keys.outputs.openmodelica }} + restore-keys: | + openmodelica-cli-closure-v1-${{ runner.os }}- + + - name: Restore MSL artifacts closure cache + uses: actions/cache@v5 + with: + path: target/nix-cache/msl-artifacts-closure.nar.zst + key: msl-artifacts-closure-v1-${{ runner.os }}-${{ github.sha }} + restore-keys: | + msl-artifacts-closure-v1-${{ runner.os }}- + + - name: Import cached Nix closures + shell: bash run: | - nix build --print-build-logs .#msl-artifacts + set -euo pipefail + for archive in \ + target/nix-cache/openmodelica-cli-closure.nar.zst \ + target/nix-cache/msl-artifacts-closure.nar.zst + do + if [[ -s "$archive" ]]; then + zstd --decompress --stdout "$archive" | nix-store --import + else + echo "No prior closure cache at $archive" + fi + done + + - name: Build shared MSL and OpenModelica artifacts + shell: bash + run: | + set -euo pipefail + nix build --print-build-logs .#msl-artifacts \ + --out-link result-msl-artifacts + nix build --print-build-logs .#openmodelica-cli \ + --out-link result-openmodelica + + - name: Export shared Nix closures + shell: bash + run: | + set -euo pipefail + mkdir -p target/nix-cache + export_closure() { + local output_link=$1 + local archive=$2 + local -a closure_paths=() + mapfile -t closure_paths < <( + nix-store -qR "$output_link" | sort -u + ) + nix-store --export "${closure_paths[@]}" | + zstd -T0 -3 -f -o "$archive" + } + export_closure \ + result-openmodelica \ + target/nix-cache/openmodelica-cli-closure.nar.zst + export_closure \ + result-msl-artifacts \ + target/nix-cache/msl-artifacts-closure.nar.zst + + - name: Upload OpenModelica closure + uses: actions/upload-artifact@v6 + with: + name: openmodelica-cli-nix-closure + path: target/nix-cache/openmodelica-cli-closure.nar.zst + if-no-files-found: error + compression-level: 0 + retention-days: 1 + + - name: Upload MSL artifacts closure + uses: actions/upload-artifact@v6 + with: + name: msl-artifacts-nix-closure + path: target/nix-cache/msl-artifacts-closure.nar.zst + if-no-files-found: error + compression-level: 0 + retention-days: 1 # ============================================================================ # MSL parity gate, sharded. The ~54min root-example parity run (575 models + @@ -808,8 +984,8 @@ jobs: # (so the timeout tail spreads evenly); the msl-merge job then runs the # baseline ratchet ONCE on the merged results. ModelicaTest is its own job. # - # Each consumer restores the prebuilt MSL artifact bundle from Cachix (needs: - # nix-build-msl) and runs it via `--prebuilt-test-binary` / + # Each consumer imports the producer's GitHub workflow artifacts (needs: + # nix-build-msl) and runs them via `--prebuilt-test-binary` / # `--prebuilt-sim-worker`, so no workspace compile/LTO happens here — only the # light `cargo xtask` wrapper builds. # ============================================================================ @@ -829,9 +1005,9 @@ jobs: persist-credentials: true - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y libudev-dev + uses: ./.github/actions/apt-install + with: + packages: libudev-dev zstd - name: Install Rust run: | @@ -880,25 +1056,32 @@ jobs: with: determinate: false - - name: Cachix (CogniPilot binary cache) - uses: cachix/cachix-action@v17 + - name: Download shared Nix closures + uses: actions/download-artifact@v8 with: - name: cognipilot + pattern: '*-nix-closure' + path: target/nix-cache + merge-multiple: true - - name: Restore flake-pinned OpenModelica CLI (Cachix) + - name: Import shared Nix closures shell: bash run: | set -euo pipefail + for archive in \ + target/nix-cache/openmodelica-cli-closure.nar.zst \ + target/nix-cache/msl-artifacts-closure.nar.zst + do + zstd --decompress --stdout "$archive" | nix-store --import + done nix build .#openmodelica-cli --out-link result-openmodelica + nix build .#msl-artifacts --out-link result-msl-artifacts echo "$PWD/result-openmodelica/bin" >> "$GITHUB_PATH" result-openmodelica/bin/omc --version - - name: Restore prebuilt MSL binaries (Cachix) - run: | - nix build .#msl-artifacts --out-link result-msl-artifacts - - name: Run MSL parity shard ${{ matrix.shard }}/4 run: | + # One bounded attempt; 20 s covers the certified strict-high tail on + # hosted runners without retries, exclusions, or looser trace checks. cargo xtask verify msl-parity \ --shard ${{ matrix.shard }}/4 \ --prebuilt-test-binary "$PWD/result-msl-artifacts/bin/msl_tests" \ @@ -908,6 +1091,8 @@ jobs: --sim-parallelism 3 \ --sim-worker-memory-mb 2048 \ --sim-total-memory-mb 6144 \ + --sim-timeout-secs 20 \ + --model-attempt-timeout-secs 20 \ --monitor-interval-secs 10 - name: Upload shard results @@ -918,6 +1103,7 @@ jobs: target/msl/results/msl_results.json target/msl/results/omc_simulation_reference.json target/msl/results/sim_trace_comparison.json + target/msl/results/msl_band_table.json if-no-files-found: error msl-merge: @@ -935,9 +1121,9 @@ jobs: persist-credentials: true - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y libudev-dev + uses: ./.github/actions/apt-install + with: + packages: libudev-dev zstd - name: Install Rust run: | @@ -963,13 +1149,19 @@ jobs: with: determinate: false - - name: Cachix (CogniPilot binary cache) - uses: cachix/cachix-action@v17 + - name: Download shared Nix closure + uses: actions/download-artifact@v8 with: - name: cognipilot + name: msl-artifacts-nix-closure + path: target/nix-cache - - name: Restore prebuilt MSL binaries (Cachix) + - name: Import shared Nix closure + shell: bash run: | + set -euo pipefail + zstd --decompress --stdout \ + target/nix-cache/msl-artifacts-closure.nar.zst | + nix-store --import nix build .#msl-artifacts --out-link result-msl-artifacts - name: Merge shards + run quality gate @@ -978,6 +1170,10 @@ jobs: --merge-shards target/msl/shards \ --prebuilt-test-binary "$PWD/result-msl-artifacts/bin/msl_tests" + # The band table is part of the merged evidence, not an optional extra: + # without it the merged run cannot say which models entered or left the + # compared set, and `parity_measured: false` in the snapshot is exactly the + # gate's own verdict that it had no comparable table to read. - name: Validate merged MSL quality outputs shell: bash run: | @@ -986,6 +1182,13 @@ jobs: test -s target/msl/results/omc_simulation_reference.json test -s target/msl/results/sim_trace_comparison.json test -s target/msl/results/msl_quality_current.json + test -s target/msl/results/msl_band_table.json + measured="$(jq -r '.parity_measured // false' target/msl/results/msl_quality_current.json)" + if [[ "$measured" != "true" ]]; then + reason="$(jq -r '.parity_unmeasured_reason // "no reason recorded"' target/msl/results/msl_quality_current.json)" + echo "::error title=parity unmeasured: comparator did not run::the merged run published no parity number ($reason); sim_ok is completion, never parity" + exit 1 + fi - name: Generate MSL compatibility report if: always() @@ -1020,44 +1223,13 @@ jobs: core.info(`Skipping MSL PR summary comment; ${path} was not produced.`); return; } - - const marker = ''; const body = fs.readFileSync(path, 'utf8'); - if (!body.includes(marker)) { - core.setFailed(`Refusing to publish ${path}: missing ${marker}`); - return; - } - - const { owner, repo } = context.repo; - const issue_number = context.payload.pull_request.number; - const comments = await github.paginate(github.rest.issues.listComments, { - owner, - repo, - issue_number, - per_page: 100, - }); - const previous = comments.find(comment => - comment.user?.type === 'Bot' && comment.body?.includes(marker) - ); - if (previous) { - await github.rest.issues.updateComment({ - owner, - repo, - comment_id: previous.id, - body, - }); - } else { - await github.rest.issues.createComment({ - owner, - repo, - issue_number, - body, - }); - } + const upsertMslPrComment = require('./.github/scripts/upsert-msl-pr-comment.cjs'); + await upsertMslPrComment({ github, context, body }); - name: Locate MSL quality snapshot id: msl-quality-snapshot - if: ${{ always() && github.event_name == 'push' && github.ref == 'refs/heads/main' }} + if: ${{ success() && github.event_name == 'push' && github.ref == 'refs/heads/main' }} shell: bash run: | set -euo pipefail @@ -1229,6 +1401,7 @@ jobs: target/msl/results/omc_simulation_reference.json target/msl/results/sim_trace_comparison.json target/msl/results/msl_quality_current.json + target/msl/results/msl_band_table.json target/msl/results/msl_package_pass_rates.md target/msl/results/msl_package_trace_accuracy.md target/msl/results/mls_contract_coverage.md @@ -1237,11 +1410,48 @@ jobs: target/msl/results/msl_compatibility_report.json if-no-files-found: error + msl-pr-status-on-failure: + name: MSL PR status (upstream failure) + runs-on: ubuntu-24.04 + needs: [nix-build-msl, msl-shards, msl-merge] + if: >- + ${{ always() && github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository && + needs.msl-merge.result != 'success' }} + steps: + - uses: actions/checkout@v5 + with: + token: ${{ github.token }} + persist-credentials: false + + - name: Replace stale MSL statistics with the current failure status + uses: actions/github-script@v8 + with: + script: | + const marker = ''; + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const body = [ + marker, + '## MSL quality summary', + '', + `Current CI run [did not produce a complete MSL quality snapshot](${runUrl}).`, + '', + '| Stage | Result |', + '|---|---|', + `| Build MSL artifacts | \`${{ needs.nix-build-msl.result }}\` |`, + `| MSL parity shards | \`${{ needs.msl-shards.result }}\` |`, + `| Merge and quality gate | \`${{ needs.msl-merge.result }}\` |`, + '', + 'The statistics from an earlier commit are not current for this PR head.', + ].join('\n'); + const upsertMslPrComment = require('./.github/scripts/upsert-msl-pr-comment.cjs'); + await upsertMslPrComment({ github, context, body }); + modelicatest-gate: name: ModelicaTest Semantic Gate runs-on: ubuntu-24.04 needs: [nix-build-msl] - timeout-minutes: 60 + timeout-minutes: 120 steps: - uses: actions/checkout@v5 with: @@ -1249,9 +1459,9 @@ jobs: persist-credentials: true - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y libudev-dev + uses: ./.github/actions/apt-install + with: + packages: libudev-dev zstd - name: Install Rust run: | @@ -1295,8 +1505,14 @@ jobs: shell: bash run: | set -euo pipefail + modelicatest_revision=8ae3d35c24e519cb2996cab20f3b13daf2b0c50a rm -rf target/msl/modelicatest-source - git clone --depth 1 --branch v4.1.0 https://github.com/modelica/ModelicaStandardLibrary.git target/msl/modelicatest-source + git init target/msl/modelicatest-source + git -C target/msl/modelicatest-source remote add origin \ + https://github.com/modelica/ModelicaStandardLibrary.git + git -C target/msl/modelicatest-source fetch --depth 1 origin "$modelicatest_revision" + git -C target/msl/modelicatest-source checkout --detach FETCH_HEAD + test "$(git -C target/msl/modelicatest-source rev-parse HEAD)" = "$modelicatest_revision" rm -rf target/msl/ModelicaStandardLibrary-4.1.0/ModelicaTest cp -a target/msl/modelicatest-source/ModelicaTest target/msl/ModelicaStandardLibrary-4.1.0/ModelicaTest @@ -1305,23 +1521,28 @@ jobs: with: determinate: false - - name: Cachix (CogniPilot binary cache) - uses: cachix/cachix-action@v17 + - name: Download shared Nix closures + uses: actions/download-artifact@v8 with: - name: cognipilot + pattern: '*-nix-closure' + path: target/nix-cache + merge-multiple: true - - name: Restore flake-pinned OpenModelica CLI (Cachix) + - name: Import shared Nix closures shell: bash run: | set -euo pipefail + for archive in \ + target/nix-cache/openmodelica-cli-closure.nar.zst \ + target/nix-cache/msl-artifacts-closure.nar.zst + do + zstd --decompress --stdout "$archive" | nix-store --import + done nix build .#openmodelica-cli --out-link result-openmodelica + nix build .#msl-artifacts --out-link result-msl-artifacts echo "$PWD/result-openmodelica/bin" >> "$GITHUB_PATH" result-openmodelica/bin/omc --version - - name: Restore prebuilt MSL binaries (Cachix) - run: | - nix build .#msl-artifacts --out-link result-msl-artifacts - - name: Run ModelicaTest semantic gate shell: bash run: | @@ -1346,6 +1567,39 @@ jobs: --out target/msl/modelicatest-results/modelica_test_catalog.md \ --json-out target/msl/modelicatest-results/modelica_test_catalog.json + # Report-only survey of the *whole* ModelicaTest package. The blocking + # gate above stays bounded to the committed target list; this pass exists + # to discover which additional ModelicaTest models now reach `sim_ok` so + # the committed list can be ratcheted upward. It never fails the job + # (`--require-selected-targets-success` is deliberately absent) and only + # runs on main, where wall time is not on the PR critical path. + - name: Survey ModelicaTest coverage (report only) + if: github.ref == 'refs/heads/main' + continue-on-error: true + shell: bash + run: | + set -euo pipefail + rm -rf target/msl/modelicatest-survey + cargo xtask verify msl-parity \ + --results-dir target/msl/modelicatest-survey \ + --include-modelica-test \ + --sim-match ModelicaTest. \ + --sim-set full \ + --prebuilt-test-binary "$PWD/result-msl-artifacts/bin/msl_tests" \ + --prebuilt-model-worker "$PWD/result-msl-artifacts/bin/rumoca-worker" \ + --prebuilt-sim-worker "$PWD/result-msl-artifacts/bin/rumoca-sim-worker" \ + --stage-parallelism 3 \ + --sim-parallelism 3 \ + --sim-worker-memory-mb 2048 \ + --sim-total-memory-mb 6144 + ./result-msl-artifacts/bin/rumoca-msl-tools modelica-test-catalog \ + --results target/msl/modelicatest-survey/msl_results.json \ + --out target/msl/modelicatest-survey/modelica_test_catalog.md \ + --json-out target/msl/modelicatest-survey/modelica_test_catalog.json \ + --base-targets crates/rumoca-test-msl/tests/msl_tests/modelica_test_targets_ci.json \ + --promote-targets-out target/msl/modelicatest-survey/modelica_test_targets_ci.promoted.json \ + --per-category 6 + - name: Upload ModelicaTest artifacts if: always() uses: actions/upload-artifact@v6 @@ -1354,14 +1608,16 @@ jobs: path: | target/msl/modelicatest-results/msl_results.json target/msl/modelicatest-results/modelica_test_catalog.md + target/msl/modelicatest-survey/modelica_test_catalog.md + target/msl/modelicatest-survey/modelica_test_targets_ci.promoted.json if-no-files-found: warn modelica-models-gate: name: modelica_models Compatibility Gate runs-on: ubuntu-24.04 needs: [nix-build-msl] - # Cachix is optional for PRs, so a cache miss must leave enough time to - # rebuild the shared MSL artifact bundle before compiling the corpus. + # Importing the producer's closure keeps this gate focused on the external + # compatibility corpus rather than rebuilding the workspace. timeout-minutes: 60 steps: - uses: actions/checkout@v5 @@ -1376,22 +1632,33 @@ jobs: uses: actions/checkout@v5 with: repository: CogniPilot/modelica_models - ref: 973cc7f4d5ff39eee23303f83e6f10473a254f51 + ref: a41f7c0c00b55c1bf54f03c9b66b901ba8e43c6f path: target/modelica-models persist-credentials: false + - name: Install closure archive tooling + uses: ./.github/actions/apt-install + with: + packages: zstd + - name: Install Nix uses: DeterminateSystems/nix-installer-action@v22 with: determinate: false - - name: Cachix (CogniPilot binary cache) - uses: cachix/cachix-action@v17 + - name: Download shared Nix closure + uses: actions/download-artifact@v8 with: - name: cognipilot + name: msl-artifacts-nix-closure + path: target/nix-cache - - name: Restore prebuilt compatibility runner (Cachix) + - name: Import shared Nix closure + shell: bash run: | + set -euo pipefail + zstd --decompress --stdout \ + target/nix-cache/msl-artifacts-closure.nar.zst | + nix-store --import nix build .#msl-artifacts --out-link result-msl-artifacts - name: Compile aggregate corpus with Rumoca @@ -1428,9 +1695,9 @@ jobs: persist-credentials: true - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y libudev-dev + uses: ./.github/actions/apt-install + with: + packages: libudev-dev - name: Install Rust run: | @@ -1469,9 +1736,9 @@ jobs: persist-credentials: true - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y libudev-dev + uses: ./.github/actions/apt-install + with: + packages: libudev-dev - name: Install Rust run: | @@ -1513,10 +1780,11 @@ jobs: with: determinate: false - - name: Cachix (CogniPilot binary cache) - uses: cachix/cachix-action@v17 + - name: GitHub Actions Nix cache + uses: DeterminateSystems/magic-nix-cache-action@v14 with: - name: cognipilot + use-flakehub: false + diagnostic-endpoint: '' - name: Rust cache (wasm) uses: Swatinem/rust-cache@v2 @@ -1534,7 +1802,7 @@ jobs: # non-browser-safe relaxed-SIMD graph) # core -> @cognipilot/rumoca-core (no bundled solver — bring your own) run: | - nix develop --command bash <<'EOF' + nix develop .#ci-wasm --command bash <<'EOF' set -euo pipefail node packages/rumoca/build.mjs --profile release --variant full-web --optimize node packages/rumoca/build.mjs --profile release --variant core --optimize @@ -1543,7 +1811,7 @@ jobs: - name: Prepare GitHub Pages content if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') run: | - nix develop --command bash <<'EOF' + nix develop .#wasm --command bash <<'EOF' set -euo pipefail if [ -d packages/rumoca/dist/release-full-web ]; then PKG_SUBDIR="release-full-web" @@ -1642,9 +1910,9 @@ jobs: persist-credentials: true - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y libudev-dev + uses: ./.github/actions/apt-install + with: + packages: libudev-dev - name: Install Rust nightly with rust-src run: | @@ -1721,9 +1989,9 @@ jobs: - name: Install musl tools (Linux musl) if: contains(matrix.target, 'musl') - run: | - sudo apt-get update - sudo apt-get install -y musl-tools + uses: ./.github/actions/apt-install + with: + packages: musl-tools - name: Configure musl build (Linux musl) if: contains(matrix.target, 'musl') @@ -1827,15 +2095,19 @@ jobs: include: - os: ubuntu-24.04 target: x86_64-unknown-linux-gnu + nix_system: x86_64-linux name: linux-x86_64-gnu - os: ubuntu-24.04-arm target: aarch64-unknown-linux-gnu + nix_system: aarch64-linux name: linux-aarch64-gnu - os: macos-15-intel target: x86_64-apple-darwin + nix_system: x86_64-darwin name: macos-x86_64 - os: macos-15 target: aarch64-apple-darwin + nix_system: aarch64-darwin name: macos-aarch64 - os: windows-2025 target: x86_64-pc-windows-msvc @@ -1852,11 +2124,12 @@ jobs: with: determinate: false - - name: Cachix (CogniPilot binary cache) + - name: GitHub Actions Nix cache if: runner.os != 'Windows' - uses: cachix/cachix-action@v17 + uses: DeterminateSystems/magic-nix-cache-action@v14 with: - name: cognipilot + use-flakehub: false + diagnostic-endpoint: '' - uses: actions/setup-python@v6 if: runner.os == 'Windows' @@ -1880,7 +2153,7 @@ jobs: - name: Build wheels (Nix) if: runner.os != 'Windows' run: | - nix develop --command bash -lc ' + nix develop ".#devShells.${{ matrix.nix_system }}.ci-python-wheel" --command bash -lc ' set -euo pipefail maturin build \ --release \ @@ -1903,7 +2176,7 @@ jobs: if: runner.os != 'Windows' shell: bash run: | - nix develop --command bash -lc ' + nix develop ".#devShells.${{ matrix.nix_system }}.ci-python-wheel" --command bash -lc ' set -euo pipefail python -m venv target/python-wheel-smoke-venv . target/python-wheel-smoke-venv/bin/activate @@ -1953,14 +2226,15 @@ jobs: with: determinate: false - - name: Cachix (CogniPilot binary cache) - uses: cachix/cachix-action@v17 + - name: GitHub Actions Nix cache + uses: DeterminateSystems/magic-nix-cache-action@v14 with: - name: cognipilot + use-flakehub: false + diagnostic-endpoint: '' - name: Build sdist run: | - nix develop --command bash -lc ' + nix develop .#ci-python-wheel --command bash -c ' set -euo pipefail cd crates/rumoca-bind-python maturin sdist --out dist diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 000000000..9630ac064 --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,893 @@ +name: Nightly + +# Long-budget lanes that must not sit on the PR critical path: +# * the full MSL/OMC root-example sweep (566 models), built once and fanned +# out over N shards before one merged quality reading (`msl-sweep-*`); +# * the event/friction cohort, which needs minutes-per-model budgets rather +# than the 10s PR budget (these models time out, they do not fail); +# * the cross-backend MSL suites (`msl-external-tests`), which exercise +# generated Solve targets outside the pull-request critical path; +# * a real fuzzing budget for the parser. +# +# Every job here is diagnostic. None of them gates a merge, and none of them +# writes to the committed quality baseline. +on: + schedule: + - cron: '0 7 * * *' + workflow_dispatch: + inputs: + msl_shard_count: + description: 'MSL sweep fan-out width (integer, 1..16)' + required: false + default: '4' + type: string + +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: 0 + RUST_BACKTRACE: 1 + +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + # ========================================================================== + # Full MSL/OMC root-example sweep (566 models), sharded. + # + # `msl-sweep-build` builds the harness ONCE (Nix `.#msl-artifacts`: the + # `msl_tests` libtest binary plus `rumoca-worker`, `rumoca-sim-worker` and + # `rumoca-msl-tools`) and republishes it as a Nix closure artifact. + # `msl-sweep-shards` fans that single build out over N stripes of the + # slowest-first model order (`verify msl-parity --shard m/n`), so no shard + # recompiles the workspace and the timeout tail spreads evenly. + # `msl-sweep-merge` fans back in (`verify msl-parity --merge-shards`), which + # concatenates the stripes and runs the quality ratchet exactly ONCE on the + # merged full set. + # + # The ratchet is RECORDED, NOT ENFORCED here: the checked-in baseline is + # pre-cutover, so a ratchet miss is the expected, informative state and the + # run still uploads every artifact. The *fan-in* is enforced, so "the sweep + # was incomplete" can never be misread as "the sweep completed and got worse". + # + # Acceptance contract for the two rejections this lane adds (SPEC_0008, + # "Acceptance Contract Before Rejection"): + # * `Plan shard fan-out` rejects an `msl_shard_count` dispatch input that is + # not an integer in 1..16. It accepts every integer in that range, and an + # absent input (every scheduled run), which means 4. + # Owner: this workflow. + # * `Verify every shard reported` / `Verify the fan-in is complete` reject a + # merge whose `shard-*/` directory count differs from the fanned-out shard + # count, whose shard payloads are missing/empty, or that wrote no merged + # `msl_results.json`, OMC reference, trace comparison, or quality + # snapshot. They accept a complete fan-in whose quality ratchet FAILED — + # that outcome is annotated in the run summary and every artifact is still + # uploaded. Owner: this workflow; the `shard-*/msl_results.json` layout it + # guards is `list_shard_dirs` in + # crates/rumoca-test-msl/tests/balance_pipeline/balance_pipeline_merge.rs. + # * `Verify the merged sweep measured parity` rejects a merged OMC reference + # whose `trace_comparison.models_compared` is zero — files that exist but + # contain no comparison. It accepts any nonzero count, whatever the bands + # say: a bad parity number is a finding, a missing one is a broken run. + # Owner: this workflow; the reading it guards is `MslParityMeasurement` in + # crates/rumoca-test-msl/tests/balance_pipeline/balance_pipeline_quality_gate/parity_measurement.rs. + # + # OMC references are cached across nights (`Restore OMC parity reference + # cache`), keyed on the OpenModelica flake revision, the MSL release, and the + # shard index. The harness re-keys every individual entry on the model set, + # MSL version, OMC version, batch timeout, and experiment stop-time policy, + # so a coarse cache hit never yields a reference for the wrong experiment — + # it only avoids regenerating one that already matches. + # + # Deliberately NOT on the PR path: `.github/workflows/ci.yml` owns the + # PR-facing sharded lane (baseline promotion, PR comment); this one is the + # diagnostic full-sweep recorder and never vetoes a merge. + # ========================================================================== + msl-sweep-build: + name: MSL Sweep (build the harness once) + runs-on: ubuntu-24.04 + timeout-minutes: 120 + outputs: + shards: ${{ steps.plan.outputs.shards }} + shard-count: ${{ steps.plan.outputs.count }} + steps: + - uses: actions/checkout@v5 + with: + token: ${{ github.token }} + + # Fail before the (long) Nix build rather than after it: a typo in the + # dispatch input must not cost an hour of runner time. + - name: Plan shard fan-out + id: plan + shell: bash + env: + SHARD_COUNT: ${{ github.event.inputs.msl_shard_count || '4' }} + run: | + set -euo pipefail + count="$SHARD_COUNT" + if [[ ! "$count" =~ ^[1-9][0-9]*$ ]] || (( count > 16 )); then + echo "::error title=Bad MSL shard fan-out::msl_shard_count must be an integer in 1..16, got '$count'" + exit 1 + fi + { + echo "shards=[$(seq 1 "$count" | paste -sd, -)]" + echo "count=$count" + } >> "$GITHUB_OUTPUT" + echo "Fanning the MSL sweep out over $count shards." + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@v22 + with: + determinate: false + + - name: Install closure archive tooling + uses: ./.github/actions/apt-install + with: + packages: zstd + + # Same closure cache keys as ci.yml's `nix-build-msl`, so the nightly + # sweep warm-starts from whatever the last main-branch CI run built + # instead of rebuilding OpenModelica from source. + - name: Derive Nix closure cache keys + id: nix-cache-keys + shell: bash + run: | + set -euo pipefail + python3 - <<'PY' + import hashlib + import json + import os + + with open("flake.lock", encoding="utf-8") as lock_file: + locked = json.load(lock_file)["nodes"]["openmodelica"]["locked"] + identity = f'{locked["rev"]}\0{locked["narHash"]}'.encode() + digest = hashlib.sha256(identity).hexdigest()[:16] + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + output.write(f'openmodelica={locked["rev"]}-{digest}\n') + PY + + - name: Restore OpenModelica closure cache + uses: actions/cache@v5 + with: + path: target/nix-cache/openmodelica-cli-closure.nar.zst + key: openmodelica-cli-closure-v1-${{ runner.os }}-${{ steps.nix-cache-keys.outputs.openmodelica }} + restore-keys: | + openmodelica-cli-closure-v1-${{ runner.os }}- + + - name: Restore MSL artifacts closure cache + uses: actions/cache@v5 + with: + path: target/nix-cache/msl-artifacts-closure.nar.zst + key: msl-artifacts-closure-v1-${{ runner.os }}-${{ github.sha }} + restore-keys: | + msl-artifacts-closure-v1-${{ runner.os }}- + + - name: Import cached Nix closures + shell: bash + run: | + set -euo pipefail + for archive in \ + target/nix-cache/openmodelica-cli-closure.nar.zst \ + target/nix-cache/msl-artifacts-closure.nar.zst + do + if [[ -s "$archive" ]]; then + zstd --decompress --stdout "$archive" | nix-store --import + else + echo "No prior closure cache at $archive" + fi + done + + - name: Build shared MSL and OpenModelica artifacts + shell: bash + run: | + set -euo pipefail + nix build --print-build-logs .#msl-artifacts \ + --out-link result-msl-artifacts + nix build --print-build-logs .#openmodelica-cli \ + --out-link result-openmodelica + + - name: Export shared Nix closures + shell: bash + run: | + set -euo pipefail + mkdir -p target/nix-cache + export_closure() { + local output_link=$1 + local archive=$2 + local -a closure_paths=() + mapfile -t closure_paths < <( + nix-store -qR "$output_link" | sort -u + ) + nix-store --export "${closure_paths[@]}" | + zstd -T0 -3 -f -o "$archive" + } + export_closure \ + result-openmodelica \ + target/nix-cache/openmodelica-cli-closure.nar.zst + export_closure \ + result-msl-artifacts \ + target/nix-cache/msl-artifacts-closure.nar.zst + + - name: Upload OpenModelica closure + uses: actions/upload-artifact@v6 + with: + name: openmodelica-cli-nix-closure + path: target/nix-cache/openmodelica-cli-closure.nar.zst + if-no-files-found: error + compression-level: 0 + retention-days: 1 + + - name: Upload MSL artifacts closure + uses: actions/upload-artifact@v6 + with: + name: msl-artifacts-nix-closure + path: target/nix-cache/msl-artifacts-closure.nar.zst + if-no-files-found: error + compression-level: 0 + retention-days: 1 + + msl-sweep-shards: + name: MSL Sweep (shard ${{ matrix.shard }}/${{ needs.msl-sweep-build.outputs.shard-count }}) + runs-on: ubuntu-24.04 + timeout-minutes: 180 + needs: msl-sweep-build + strategy: + # One slow stripe must not cancel the others: a partial sweep is still + # forensically useful, and the merge job refuses to report it as a full + # one anyway. + fail-fast: false + matrix: + shard: ${{ fromJSON(needs.msl-sweep-build.outputs.shards) }} + steps: + - uses: actions/checkout@v5 + with: + token: ${{ github.token }} + + - name: Install system dependencies + uses: ./.github/actions/apt-install + with: + packages: libudev-dev zstd + + - name: Install Rust + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain "$(awk -F'"' '/^channel/ { print $2 }' rust-toolchain.toml)" + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + + # Read-only: the nightly lane never writes the shared `msl-gate` cache + # that the PR lane depends on. + - name: Rust cache (msl-gate) + uses: Swatinem/rust-cache@v2 + with: + shared-key: msl-gate + cache-targets: false + save-if: false + + - name: Cache MSL + uses: actions/cache@v5 + continue-on-error: true + with: + path: target/msl/ModelicaStandardLibrary-4.1.0 + key: msl-v4.1.0-release-zip-layout-v2 + + - name: Ensure MSL + shell: bash + run: | + set -euo pipefail + msl_dir=target/msl/ModelicaStandardLibrary-4.1.0 + if [[ -f "$msl_dir/Complex.mo" && -f "$msl_dir/Modelica 4.1.0/package.mo" ]]; then + echo "MSL cache is valid at $msl_dir" + exit 0 + fi + rm -rf "$msl_dir" + mkdir -p "$msl_dir" + curl -L -o target/msl/msl-v4.1.0.zip \ + https://github.com/modelica/ModelicaStandardLibrary/releases/download/v4.1.0/ModelicaStandardLibrary_v4.1.0.zip + unzip -q target/msl/msl-v4.1.0.zip -d "$msl_dir" + rm target/msl/msl-v4.1.0.zip + test -f "$msl_dir/Complex.mo" + test -f "$msl_dir/Modelica 4.1.0/package.mo" + + # NOT `rm -rf target/msl/results`: that also deletes `omc_parity_cache/`, + # the keyed OMC reference cache restored below. Everything else goes. + - name: Clean stale MSL results + shell: bash + run: | + set -euo pipefail + if [[ -d target/msl/results ]]; then + find target/msl/results -mindepth 1 -maxdepth 1 \ + ! -name omc_parity_cache -exec rm -rf {} + + fi + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@v22 + with: + determinate: false + + # OMC references are the expensive half of this lane: `omc simulate()` + # generates C and invokes gcc per model. The harness already keys each + # cached reference on (model set, MSL version, OMC version, batch timeout, + # experiment stop-time policy) and regenerates on any mismatch, so this + # coarse key only has to change when the OMC build or the MSL release + # does — a stale entry can never be reused as a fresh one, it just misses. + - name: Derive OMC parity cache key + id: omc-parity-cache-key + shell: bash + run: | + set -euo pipefail + python3 - <<'PY' + import hashlib + import json + import os + + with open("flake.lock", encoding="utf-8") as lock_file: + locked = json.load(lock_file)["nodes"]["openmodelica"]["locked"] + identity = f'{locked["rev"]}\0{locked["narHash"]}'.encode() + digest = hashlib.sha256(identity).hexdigest()[:16] + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + output.write(f"omc={digest}\n") + PY + + - name: Restore OMC parity reference cache + uses: actions/cache@v5 + with: + path: target/msl/results/omc_parity_cache + key: >- + omc-parity-cache-v1-${{ runner.os }}-msl4.1.0-${{ + steps.omc-parity-cache-key.outputs.omc }}-shard${{ matrix.shard }}-${{ github.run_id }} + restore-keys: | + omc-parity-cache-v1-${{ runner.os }}-msl4.1.0-${{ steps.omc-parity-cache-key.outputs.omc }}-shard${{ matrix.shard }}- + omc-parity-cache-v1-${{ runner.os }}-msl4.1.0-${{ steps.omc-parity-cache-key.outputs.omc }}- + + - name: Download shared Nix closures + uses: actions/download-artifact@v8 + with: + pattern: '*-nix-closure' + path: target/nix-cache + merge-multiple: true + + - name: Import shared Nix closures + shell: bash + run: | + set -euo pipefail + for archive in \ + target/nix-cache/openmodelica-cli-closure.nar.zst \ + target/nix-cache/msl-artifacts-closure.nar.zst + do + zstd --decompress --stdout "$archive" | nix-store --import + done + nix build .#openmodelica-cli --out-link result-openmodelica + nix build .#msl-artifacts --out-link result-msl-artifacts + echo "$PWD/result-openmodelica/bin" >> "$GITHUB_PATH" + result-openmodelica/bin/omc --version + + # `--no-remote-quality-baseline` pins every job in this lane to the + # checked-in baseline, so a nightly reading never silently changes meaning + # because the promoted release asset moved under it. + - name: Run MSL sweep shard ${{ matrix.shard }} + shell: bash + env: + SHARD_INDEX: ${{ matrix.shard }} + SHARD_COUNT: ${{ needs.msl-sweep-build.outputs.shard-count }} + run: | + set -euo pipefail + cargo xtask verify msl-parity \ + --shard "${SHARD_INDEX}/${SHARD_COUNT}" \ + --no-remote-quality-baseline \ + --prebuilt-test-binary "$PWD/result-msl-artifacts/bin/msl_tests" \ + --prebuilt-model-worker "$PWD/result-msl-artifacts/bin/rumoca-worker" \ + --prebuilt-sim-worker "$PWD/result-msl-artifacts/bin/rumoca-sim-worker" \ + --stage-parallelism 3 \ + --sim-parallelism 3 \ + --sim-worker-memory-mb 2048 \ + --sim-total-memory-mb 6144 \ + --monitor-interval-secs 30 + + # The `shard-` artifact name is load-bearing: `download-artifact` + # materialises each artifact under a directory of its own name, and the + # fan-in enumerates `/shard-*/msl_results.json`. `warn` here is + # safe because the merge job hard-fails on any shard directory that is + # missing or empty. + - name: Upload shard ${{ matrix.shard }} results + if: always() + uses: actions/upload-artifact@v6 + with: + name: shard-${{ matrix.shard }} + path: | + target/msl/results/msl_results.json + target/msl/results/omc_simulation_reference.json + target/msl/results/sim_trace_comparison.json + target/msl/results/msl_band_table.json + if-no-files-found: warn + + msl-sweep-merge: + name: MSL Sweep (merge + record the ratchet) + runs-on: ubuntu-24.04 + timeout-minutes: 60 + needs: [msl-sweep-build, msl-sweep-shards] + steps: + - uses: actions/checkout@v5 + with: + token: ${{ github.token }} + + - name: Install system dependencies + uses: ./.github/actions/apt-install + with: + packages: libudev-dev zstd + + - name: Install Rust + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain "$(awk -F'"' '/^channel/ { print $2 }' rust-toolchain.toml)" + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + + - name: Rust cache (msl-gate) + uses: Swatinem/rust-cache@v2 + with: + shared-key: msl-gate + cache-targets: false + save-if: false + + - name: Download shard results + uses: actions/download-artifact@v8 + with: + pattern: shard-* + path: target/msl/shards + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@v22 + with: + determinate: false + + - name: Download shared Nix closure + uses: actions/download-artifact@v8 + with: + name: msl-artifacts-nix-closure + path: target/nix-cache + + - name: Import shared Nix closure + shell: bash + run: | + set -euo pipefail + zstd --decompress --stdout \ + target/nix-cache/msl-artifacts-closure.nar.zst | + nix-store --import + nix build .#msl-artifacts --out-link result-msl-artifacts + + # Rejection 1 of the acceptance contract above. `list_shard_dirs` merges + # whatever `shard-*/` directories it finds, so a lost artifact would + # otherwise silently produce a smaller, better-looking "full" sweep. + - name: Verify every shard reported + shell: bash + env: + EXPECTED_SHARDS: ${{ needs.msl-sweep-build.outputs.shard-count }} + run: | + set -euo pipefail + found="$(find target/msl/shards -mindepth 1 -maxdepth 1 -type d -name 'shard-*' | wc -l)" + if [[ "$found" -ne "$EXPECTED_SHARDS" ]]; then + echo "::error title=Incomplete MSL fan-in::expected $EXPECTED_SHARDS shard-*/ directories under target/msl/shards, found $found" + exit 1 + fi + for dir in target/msl/shards/shard-*; do + for file in msl_results.json omc_simulation_reference.json sim_trace_comparison.json msl_band_table.json; do + if [[ ! -s "$dir/$file" ]]; then + echo "::error title=Incomplete MSL fan-in::$dir/$file is missing or empty" + exit 1 + fi + done + done + echo "All $EXPECTED_SHARDS shards reported." + + # `continue-on-error` covers exactly one accepted outcome: the merged set + # did not clear the pre-cutover ratchet. Everything else the command can + # get wrong (a stripe that will not merge, a summary it refuses to write) + # is caught by the next step, which is not forgiving. + - name: Merge shards + run the quality ratchet + id: merge-gate + continue-on-error: true + shell: bash + run: | + set -euo pipefail + cargo xtask verify msl-parity \ + --merge-shards target/msl/shards \ + --no-remote-quality-baseline \ + --prebuilt-test-binary "$PWD/result-msl-artifacts/bin/msl_tests" + + # Rejection 2 of the acceptance contract above. The ratchet runs last in + # the merge test, after every artifact is written, so a ratchet miss still + # leaves all four files behind; a missing file means the merge itself + # broke and the run has nothing honest to report. + - name: Verify the fan-in is complete + id: fanin + shell: bash + run: | + set -euo pipefail + missing=0 + for file in \ + msl_results.json \ + omc_simulation_reference.json \ + sim_trace_comparison.json \ + msl_quality_current.json \ + msl_band_table.json + do + if [[ ! -s "target/msl/results/$file" ]]; then + echo "::error title=MSL merge produced no $file::target/msl/results/$file is missing or empty" + missing=1 + fi + done + exit "$missing" + + # Rejection 3 of the acceptance contract above. Present-but-empty bands + # are the shape that let `sim_ok` masquerade as parity: the files existed, + # nothing had been compared. A merged reference that compared zero models + # is "parity unmeasured", and this lane says so instead of reporting the + # sweep's `sim_ok` as if it were a parity number. Accepts any merged + # reference with `models_compared > 0`, whatever the bands say. + - name: Verify the merged sweep measured parity + id: parity-measured + shell: bash + run: | + set -euo pipefail + reference="target/msl/results/omc_simulation_reference.json" + if [[ ! -s "$reference" ]]; then + echo "::error title=parity unmeasured: comparator did not run::$reference is missing or empty; this sweep has no parity number, only sim_ok (which is completion, never parity)" + exit 1 + fi + compared="$(jq -r '.trace_comparison.models_compared // 0' "$reference")" + if [[ "$compared" -le 0 ]]; then + echo "::error title=parity unmeasured: comparator did not run::$reference reports models_compared=$compared; this sweep has no parity number, only sim_ok (which is completion, never parity)" + exit 1 + fi + echo "The merged sweep compared $compared model(s) against OMC." + + # Rejection 4. `continue-on-error` on the merge step accepts exactly one + # outcome: the ratchet was missed. A run whose gate reported "parity + # unmeasured" — no comparable per-model band table, or a table that + # disagrees with the reference — is not a ratchet miss, and must not pass + # through that forgiveness. The gate writes its own verdict into the + # snapshot, so this reads the verdict rather than re-deriving it. + - name: Verify the merged sweep pinned its cohort + id: cohort-pinned + shell: bash + run: | + set -euo pipefail + snapshot="target/msl/results/msl_quality_current.json" + measured="$(jq -r '.parity_measured // false' "$snapshot")" + if [[ "$measured" != "true" ]]; then + reason="$(jq -r '.parity_unmeasured_reason // "no reason recorded"' "$snapshot")" + echo "::error title=parity unmeasured: comparator did not run::$reason; sim_ok is completion, never parity" + exit 1 + fi + table="target/msl/results/msl_band_table.json" + cohort="$(jq -r '.counts.cohort_models // 0' "$table")" + table_compared="$(jq -r '.counts.compared_models // 0' "$table")" + reference_compared="$(jq -r '.trace_comparison.models_compared // 0' "target/msl/results/omc_simulation_reference.json")" + if [[ "$table_compared" -ne "$reference_compared" ]]; then + echo "::error title=Band table disagrees with the reference::the per-model table compares $table_compared model(s), the reference reports $reference_compared; one artifact is stale and neither may be quoted" + exit 1 + fi + echo "Cohort pinned: $cohort model rows, $table_compared compared." + + - name: Typed-bucket triage report + if: ${{ always() && steps.fanin.outcome == 'success' }} + shell: bash + run: | + set -euo pipefail + ./result-msl-artifacts/bin/rumoca-msl-tools triage \ + --results-dir target/msl/results \ + --output-json target/msl/results/msl_triage.json \ + --output-md target/msl/results/msl_triage.md \ + --top 40 + + - name: Record the sweep in the run summary + if: always() + shell: bash + env: + GATE_OUTCOME: ${{ steps.merge-gate.outcome }} + SHARD_COUNT: ${{ needs.msl-sweep-build.outputs.shard-count }} + run: | + set -euo pipefail + results="target/msl/results/msl_results.json" + reference="target/msl/results/omc_simulation_reference.json" + triage="target/msl/results/msl_triage.json" + { + echo "## MSL/OMC sweep — ${SHARD_COUNT} shards, merged once" + echo + # The parity reading comes FIRST and comes from the comparator. + # `sim_ok` is listed below it as completion, explicitly labelled so + # it cannot be lifted out of this summary as a parity number. + echo "### Parity (OMC trace comparator)" + echo + targets=0 + if [[ -s "$results" ]]; then + targets="$(jq -r '(.sim_target_models // []) | length' "$results")" + fi + if [[ -s "$reference" ]] && \ + [[ "$(jq -r '.trace_comparison.models_compared // 0' "$reference")" -gt 0 ]] && \ + [[ "$targets" -gt 0 ]]; then + jq -r --argjson targets "$targets" ' + .trace_comparison as $t + | def pct($n): (($n * 1000 / $targets) | floor) / 10; + "| band | models | % of \($targets) cohort targets |", + "|---|---|---|", + "| strict-high (the parity number) | \($t.agreement_high) | \(pct($t.agreement_high)) |", + "| minor | \($t.agreement_minor) | \(pct($t.agreement_minor)) |", + "| deviation | \($t.agreement_deviation) | \(pct($t.agreement_deviation)) |", + "| models compared | \($t.models_compared) | |" + ' "$reference" + else + echo "**parity unmeasured: comparator did not run.** This sweep has no" + echo "parity number. \`sim_ok\` below is completion, never parity." + fi + echo + echo "### Completion (reported, never a parity claim)" + echo + if [[ -s "$results" ]]; then + jq -r ' + "| metric | value |", + "|---|---|", + "| models discovered | \(.total_models) |", + "| compiled | \(.compiled_models) |", + "| balanced | \(.balanced_models) |", + "| sim attempted | \(.sim_attempted) |", + "| sim_ok (completion, not parity) | \(.sim_ok) |" + ' "$results" + else + echo "The fan-in did not complete: no merged msl_results.json was produced." + fi + echo + echo "Quality ratchet outcome: **${GATE_OUTCOME}** (recorded, not enforced)." + echo + echo "This lane reads the sweep against the checked-in, pre-cutover baseline" + echo "and reports the honest number instead of vetoing. ci.yml owns the" + echo "enforcing lane." + if [[ -s "$triage" ]]; then + echo + jq -r ' + (.failure_bucket_counts // {}) as $typed + | (if ($typed | length) > 0 then $typed else (.reason_counts // {}) end) as $counts + | (if ($typed | length) > 0 + then "typed failure buckets" + else "taxonomy reasons (this report carries no typed buckets)" + end) as $label + | "### Top \($label)", + "", + "| bucket | models |", + "|---|---|", + ($counts | to_entries | sort_by(-.value) | .[0:10] | .[] | "| \(.key) | \(.value) |"), + "", + "Taxonomy coverage: \(.taxonomy_coverage.classified_percent)% classified, typed-source \(.taxonomy_coverage.typed_percent // "n/a")%." + ' "$triage" + fi + } >> "$GITHUB_STEP_SUMMARY" + case "$GATE_OUTCOME" in + success) ;; + failure) + echo "::warning title=MSL quality ratchet not met::the merged sweep did not clear the checked-in pre-cutover baseline; see the run summary for the recorded numbers" + ;; + *) + echo "::warning title=MSL quality ratchet not read::the merge step never ran (outcome: ${GATE_OUTCOME}), so this run has no ratchet reading" + ;; + esac + + - name: Upload merged MSL sweep results + if: ${{ always() && steps.fanin.outcome == 'success' }} + uses: actions/upload-artifact@v6 + with: + name: msl-sweep-results + path: | + target/msl/results/msl_results.json + target/msl/results/msl_triage.json + target/msl/results/msl_triage.md + if-no-files-found: error + + - name: Upload MSL sweep supporting reports + if: always() + uses: actions/upload-artifact@v6 + with: + name: msl-sweep-reports + path: | + target/msl/results/msl_quality_current.json + target/msl/results/omc_simulation_reference.json + target/msl/results/sim_trace_comparison.json + target/msl/results/msl_band_table.json + target/msl/results/msl_package_pass_rates.md + target/msl/results/msl_package_trace_accuracy.md + target/msl/results/mls_contract_coverage.md + target/msl/results/msl_cargo_setup_timing.md + if-no-files-found: warn + + event-cohort-parity: + name: Event Cohort Parity (long budget) + runs-on: ubuntu-24.04 + timeout-minutes: 300 + steps: + - uses: actions/checkout@v5 + with: + token: ${{ github.token }} + + - name: Install system dependencies + uses: ./.github/actions/apt-install + with: + packages: libudev-dev zstd + + - name: Install Rust + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain "$(awk -F'"' '/^channel/ { print $2 }' rust-toolchain.toml)" + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + + - name: Rust cache (nightly-event-cohort) + uses: Swatinem/rust-cache@v2 + with: + shared-key: msl-gate + cache-on-failure: true + cache-targets: false + save-if: false + + - name: Cache MSL + uses: actions/cache@v5 + continue-on-error: true + with: + path: target/msl/ModelicaStandardLibrary-4.1.0 + key: msl-v4.1.0-release-zip-layout-v2 + + - name: Ensure MSL + shell: bash + run: | + set -euo pipefail + msl_dir=target/msl/ModelicaStandardLibrary-4.1.0 + if [[ -f "$msl_dir/Complex.mo" && -f "$msl_dir/Modelica 4.1.0/package.mo" ]]; then + echo "MSL cache is valid at $msl_dir" + exit 0 + fi + rm -rf "$msl_dir" + mkdir -p "$msl_dir" + curl -L -o target/msl/msl-v4.1.0.zip \ + https://github.com/modelica/ModelicaStandardLibrary/releases/download/v4.1.0/ModelicaStandardLibrary_v4.1.0.zip + unzip -q target/msl/msl-v4.1.0.zip -d "$msl_dir" + rm target/msl/msl-v4.1.0.zip + test -f "$msl_dir/Complex.mo" + test -f "$msl_dir/Modelica 4.1.0/package.mo" + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@v22 + with: + determinate: false + + - name: Build OpenModelica + shell: bash + run: | + set -euo pipefail + nix build .#openmodelica-cli --out-link result-openmodelica + echo "$PWD/result-openmodelica/bin" >> "$GITHUB_PATH" + result-openmodelica/bin/omc --version + + # No `--require-selected-targets-success`: the whole point of this lane is + # that the cohort does not pass yet. Passing an explicit target file also + # skips the baseline-relative quality gate, so the run is purely + # diagnostic. `--all-omc-targets` is required because these models are not + # `sim_ok`, and the default OMC selection would produce no reference to + # compare against. + - name: Run event-cohort parity with long budgets + shell: bash + run: | + set -euo pipefail + rm -rf target/msl/event-cohort + cargo xtask verify msl-parity \ + --results-dir target/msl/event-cohort \ + --sim-targets-file crates/rumoca-test-msl/tests/msl_tests/event_cohort_targets_nightly.json \ + --sim-set full \ + --sim-timeout-secs 300 \ + --ir-solve-timeout-secs 60 \ + --model-attempt-timeout-secs 420 \ + --all-omc-targets + + # `triage` takes a results *directory* plus explicit output paths; the + # former `--results`/`--out` spelling has never been a valid flag pair, so + # this step could only ever fail its argument parse — silently, because it + # was also `continue-on-error`. Both are fixed: real flags, and a failure + # that shows up. + - name: Triage event-cohort results + if: always() + shell: bash + run: | + set -euo pipefail + cargo run --release -p rumoca-test-msl --bin rumoca-msl-tools -- triage \ + --results-dir target/msl/event-cohort \ + --output-json target/msl/event-cohort/msl_triage.json \ + --output-md target/msl/event-cohort/msl_triage.md + + - name: Upload event-cohort artifacts + if: always() + uses: actions/upload-artifact@v6 + with: + name: nightly-event-cohort + path: | + target/msl/event-cohort/msl_results.json + target/msl/event-cohort/msl_triage.json + target/msl/event-cohort/msl_triage.md + target/msl/event-cohort/sim_traces + if-no-files-found: warn + + cross-backend-msl: + name: Cross-backend MSL suites + runs-on: ubuntu-24.04 + timeout-minutes: 180 + steps: + - uses: actions/checkout@v5 + with: + token: ${{ github.token }} + + - name: Install system dependencies + uses: ./.github/actions/apt-install + with: + packages: libudev-dev + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@v22 + with: + determinate: false + + - name: Cache MSL + uses: actions/cache@v5 + continue-on-error: true + with: + path: target/msl/ModelicaStandardLibrary-4.1.0 + key: msl-v4.1.0-release-zip-layout-v2 + + - name: Ensure MSL + shell: bash + run: | + set -euo pipefail + msl_dir=target/msl/ModelicaStandardLibrary-4.1.0 + if [[ -f "$msl_dir/Complex.mo" && -f "$msl_dir/Modelica 4.1.0/package.mo" ]]; then + exit 0 + fi + rm -rf "$msl_dir" + mkdir -p "$msl_dir" + curl -L -o target/msl/msl-v4.1.0.zip \ + https://github.com/modelica/ModelicaStandardLibrary/releases/download/v4.1.0/ModelicaStandardLibrary_v4.1.0.zip + unzip -q target/msl/msl-v4.1.0.zip -d "$msl_dir" + rm target/msl/msl-v4.1.0.zip + + # These are diagnostic MSL corpus cross-checks, not the required template + # runtime gates. The flake's dev shell supplies the C toolchain and the + # CasADi Python package. Target-list discovery remains a manual maintenance + # command because it writes proposed fixture files rather than asserting a + # fixed correctness threshold. + - name: C Solve MSL suite + continue-on-error: true + run: nix develop .#default --command cargo test --release -p rumoca-test-msl --features msl-external-tests --test c_ode_msl_test -- --nocapture + + - name: CasADi MSL suite + continue-on-error: true + run: nix develop .#python --command cargo test --release -p rumoca-test-msl --features msl-external-tests --test casadi_msl_test -- --nocapture + + parser-fuzz: + name: Parser fuzzing + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - uses: actions/checkout@v5 + with: + token: ${{ github.token }} + + - name: Install Rust nightly (cargo-fuzz needs the sanitizer flags) + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain nightly + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + + - name: Install cargo-fuzz + run: cargo install cargo-fuzz --locked + + - name: Fuzz the Modelica parser + run: cargo xtask verify fuzz --max-total-secs 900 + + - name: Upload fuzz reproducers + if: failure() + uses: actions/upload-artifact@v6 + with: + name: nightly-fuzz-artifacts + path: infra/fuzz/artifacts + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 60b36206d..925c6480e 100644 --- a/.gitignore +++ b/.gitignore @@ -15,8 +15,9 @@ dist/ _native.cpython* *.so .claude/ +.codex/ .vscode/ -dev/ +/dev/ gh-pages/ docs/*/book/ *.fmu @@ -62,3 +63,8 @@ examples/SportCub_embedded_c/ # Generated by packages/rumoca/build.mjs from the single-source web package packages/playground/vendor/ + +# proptest writes a regression file next to a failing test so the shrunk case +# replays on the next run. Useful locally, but committing one silently pins a +# seed into the suite; reproduce a failure by quoting the case in a real test. +crates/*/proptest-regressions/ diff --git a/AGENTS.md b/AGENTS.md index a8a564045..b118e4d0f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ spec, it's not a rule — propose a spec change first. how specs themselves work; what the statuses mean; how to propose a new spec. - [CONTRIBUTING.md](CONTRIBUTING.md) — local setup and `cargo xtask` CLI usage. -- [spec/SPEC_0032_DEVELOPMENT_PROCESS.md](spec/SPEC_0032_DEVELOPMENT_PROCESS.md) — +- [spec/SPEC_0033_DEVELOPMENT_PROCESS.md](spec/SPEC_0033_DEVELOPMENT_PROCESS.md) — operational workflow, triage proof requirements, upstream-first fix policy, and MSL-backed validation expectations. @@ -21,21 +21,27 @@ spec, it's not a rule — propose a spec change first. | If you are touching... | Read these specs | |---|---| -| Compiler pipeline / any IR / any phase | [SPEC_0007](spec/SPEC_0007_IR_PIPELINE.md) — IR stage contracts, structural-transformation scope | +| Compiler pipeline / any IR / any phase | [SPEC_0007](spec/SPEC_0007_IR_PIPELINE.md) — IR stage contracts, structural-lowering scope; row catalog in [SPEC_0040](spec/SPEC_0040_IR_STAGE_CONTRACT_CATALOG.md) | +| Valid-by-construction IR aggregates and proofs | [SPEC_0036](spec/SPEC_0036_VALID_BY_CONSTRUCTION_IR.md) — construction rules; row catalog in [SPEC_0043](spec/SPEC_0043_CONSTRUCTION_CATALOG.md) | | Range-preserving array/tensor/stencil IR | [SPEC_0032](spec/SPEC_0032_RANGE_PRESERVING_TENSORS.md) — compact domains, scalar views, Map/AffineStencil ownership | -| Crate dependencies, foundation types, re-exports, single-source helpers | [SPEC_0029](spec/SPEC_0029_CRATE_BOUNDARIES.md) | +| Crate dependencies, foundation types, re-exports, single-source helpers | [SPEC_0029](spec/SPEC_0029_CRATE_BOUNDARIES.md) — boundary rules; ownership catalog in [SPEC_0041](spec/SPEC_0041_CRATE_OWNERSHIP_CATALOG.md) | +| eFMI/GALEC export targets | [SPEC_0034](spec/SPEC_0034_GALEC_EFMI_EXPORT.md) — GAL-NNN rules; language traps and decisions in [SPEC_0042](spec/SPEC_0042_GALEC_LANGUAGE_CATALOG.md) | | Modelica semantics (any MLS-affecting change) | [SPEC_0022](spec/SPEC_0022_MLS_COMPILER_COMPLIANCE.md) (use its section index) | | Name lookup, scopes, `DefId` | [SPEC_0001](spec/SPEC_0001_DEFID.md), [SPEC_0002](spec/SPEC_0002_SCOPE_TREE.md) | | Diagnostics, spans, error codes, tracing | [SPEC_0008](spec/SPEC_0008_PHASE_ERRORS.md) | | Tool config (`rumoca-tool-*`) | [SPEC_0018](spec/SPEC_0018_TOOL_CONFIG.md) | | Function length, nesting, file size, deterministic collections, code-size policy | [SPEC_0021](spec/SPEC_0021_CODE_COMPLEXITY.md) | -| Development workflow, bug triage, root-cause proof, upstream-first fixes | [SPEC_0032](spec/SPEC_0032_DEVELOPMENT_PROCESS.md) | +| Development workflow, bug triage, root-cause proof, upstream-first fixes | [SPEC_0033](spec/SPEC_0033_DEVELOPMENT_PROCESS.md); trace-evidence rows in [SPEC_0050](spec/SPEC_0050_TRACE_EVIDENCE_CATALOG.md) | | Opening a PR (branch naming, workflow, metrics, verification commands, MSL gates, done criteria) | [SPEC_0025](spec/SPEC_0025_PR_REVIEW_PROCESS.md) | ## Rules of thumb - Active specs (`ACCEPTED` / `REFERENCE`) are mandatory. Archived specs are historical context only. +- A `REFERENCE` annex (`SPEC_0040`–`SPEC_0044`, `SPEC_0047`, `SPEC_0049`, or + `SPEC_0050`) holds the lookup catalog for its + parent spec. Its rows are normative by reference from the parent section that + links them; read the parent first, then the catalog row it cites. - If you cannot find the spec for what you're about to change, stop and ask before coding — the rule either exists somewhere you haven't looked or it needs to be written. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ddc6566c0..c368c4f6b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,6 +49,25 @@ cargo test cargo xtask --help ``` +Nix is only a convenience wrapper: all canonical build and verification logic +remains in Cargo/xtask and works with equivalent Rust, native, and task-specific +packages installed through the host system. If you choose Nix, the default +`nix develop` shell contains only the pinned Rust and native build toolchain; it +does not build Rumoca. Select optional tools with a named shell: + +```bash +nix develop .#wasm # Node, Binaryen, wasm-pack +nix develop .#python # Python, JAX/CasADi, maturin +nix develop .#julia # Julia (Linux) +nix develop .#modelica # OpenModelica (Linux) +nix develop .#fmi # FMI validation/template tools +nix develop .#docs # mdBook and docs WASM tools +nix develop .#full # all optional development tools +``` + +Use `cargo run -p rumoca -- ...` while developing the compiler. Building the +store-native package remains an explicit `nix build .#rumoca` operation. + Package, playground, VS Code, and browser-asset workflows do require Node/npm. CI uses Node 20, so local package validation should use Node 20 as well: @@ -70,6 +89,37 @@ Cargo builds must remain Rust-only. If a selected package/web command reports a missing `node` or `npm`, install Node 20 using your platform package manager, Volta, nvm, or the official Node installer, then retry that command. +### Kani bounded verification + +The SPEC_0037 verification track carries bounded-verification harnesses in +`rumoca-ir-dae` and `rumoca-solver`. Each property is written once as a plain +function with two drivers: `#[cfg(kani)]` proof harnesses and, under +`#[cfg(not(kani))]`, a `proptest` fallback stating the identical property. + +The official Linux flake pins Kani 0.67.0 and its matching Rust nightly in a +dedicated shell, leaving the ordinary development toolchain unchanged. Run the +required proof set with: + +```bash +nix develop .#kani --command cargo xtask verify kani +``` + +The command rejects any other Kani version and drives the solver harnesses from +the checked-in `infra/verification/kani-proofs.json` manifest. Add a harness to that +manifest in the same change that makes it required; every entry MUST declare its +production kernel, symbolic inputs, enumeration barrier, counterexample meaning, +bounds, and a non-empty `assumptions` list (SPEC_0037) — the gate rejects +entries without one. GitHub CI runs this exact +gate with a bounded Linux job and uploads the versioned, per-harness result at +`target/verification/kani-summary.json`. + +The gate verifies one Kani harness at a time as required by SPEC_0037. Cargo's +build jobs still use the repository's normal host-aware resource budget. + +Ordinary `cargo test -p rumoca-solver` still runs the `proptest` fallbacks. A +green fallback is validation evidence, never proof evidence; only a successful +`cargo xtask verify kani` run under the pinned verifier is Kani proof evidence. + ## Common Commands Typical local verification: @@ -89,7 +139,7 @@ expect the same local prerequisites that CI installs: `cargo-llvm-cov`, Node 20/npm for package/web tasks, and the wasm Rust target/tooling. `cargo xtask verify template-runtimes` wraps Cargo-native opt-in example-template execution checks such as -`cargo test -p rumoca --features template-runtime-tests --test backend_template_runtime_regression -- --nocapture`. +`cargo test -p rumoca --features template-runtime-tests --test suite_template_runtime backend_template_runtime_regression:: -- --nocapture`. Editor validation: @@ -114,6 +164,28 @@ cargo xtask repo msl flamegraph --model Modelica.Electrical.Digital.Examples.DFF cargo xtask repo msl promote-quality-baseline ``` +Verification-surface classification: + +- `cargo xtask verify workspace` includes the two required + `rumoca/msl-sim-tests` MSL simulation regressions. It needs the pinned MSL + tree at `target/msl/ModelicaStandardLibrary-4.1.0`, which the CI workspace + job stages before running. +- `backend-stress-tests` is an opt-in 30-model diagnostic survey, not a + correctness gate: it reports per-model failures and only requires one + end-to-end comparison for each selected backend. +- `msl-external-tests` contains opt-in MSL corpus cross-checks for generated + backends. Nightly CI surveys the checked C Solve and CasADi targets under the + Nix development shell. FMI 2/3 packaging is intentionally absent until it is + rebuilt against the checked kernel. `fmu_target_discovery` is a manual + target-list maintenance workflow, not a pass/fail verification gate. + +```bash +nix develop .#full --command cargo test --release -p rumoca-test-msl \ + --features backend-stress-tests --test backend_stress_test -- --nocapture +nix develop .#default --command cargo test --release -p rumoca-test-msl \ + --features msl-external-tests --test c_ode_msl_test -- --nocapture +``` + Command discovery: ```bash @@ -126,9 +198,10 @@ cargo xtask help repo cli install ## Parser Grammar Regeneration The Modelica parser is generated from -`crates/rumoca-phase-parse/src/modelica.par` by the crate build script. The -generated Rust files are checked in under -`crates/rumoca-phase-parse/src/generated/` so parser changes are reviewable. +`crates/rumoca-phase-parse/src/modelica.par`, and the GALEC parser is generated +from `crates/rumoca-phase-parse-galec/src/parse/galec.par`, by their phase-crate +build scripts. Generated Rust files are checked in beside each grammar so +parser changes are reviewable. When changing the grammar or parser generator settings, regenerate and test with: @@ -136,7 +209,10 @@ with: ```bash cargo check -p rumoca-phase-parse cargo test -p rumoca-phase-parse --test recovery_corpus --quiet +cargo check -p rumoca-phase-parse-galec +cargo test -p rumoca-phase-parse-galec --quiet git diff -- crates/rumoca-phase-parse/src/generated +git diff -- crates/rumoca-phase-parse-galec/src/parse/generated ``` The workspace pins `parol` and `parol_runtime` to exact patch versions in diff --git a/Cargo.lock b/Cargo.lock index 89ce0c762..c1a3b92f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -309,6 +309,21 @@ dependencies = [ "serde", ] +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bit-vec" version = "0.9.1" @@ -558,6 +573,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "codespan-reporting" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" +dependencies = [ + "unicode-width 0.2.2", +] + [[package]] name = "codespan-reporting" version = "0.13.1" @@ -740,7 +764,7 @@ dependencies = [ "hashbrown 0.15.5", "log", "regalloc2", - "rustc-hash", + "rustc-hash 2.1.3", "serde", "smallvec", "target-lexicon 0.13.5", @@ -1224,34 +1248,13 @@ dependencies = [ "walkdir", ] -[[package]] -name = "dirs" -version = "5.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" -dependencies = [ - "dirs-sys 0.4.1", -] - [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys 0.5.0", -] - -[[package]] -name = "dirs-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" -dependencies = [ - "libc", - "option-ext", - "redox_users 0.4.6", - "windows-sys 0.48.0", + "dirs-sys", ] [[package]] @@ -1262,7 +1265,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users 0.5.2", + "redox_users", "windows-sys 0.61.2", ] @@ -2101,6 +2104,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + [[package]] name = "hmac" version = "0.12.1" @@ -2842,6 +2851,31 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "naga" +version = "27.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.13.0", + "cfg-if", + "cfg_aliases", + "codespan-reporting 0.12.0", + "half", + "hashbrown 0.16.1", + "hexf-parse", + "indexmap 2.14.0", + "libm", + "log", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "thiserror 2.0.18", + "unicode-ident", +] + [[package]] name = "nalgebra" version = "0.34.2" @@ -3038,39 +3072,6 @@ dependencies = [ "py_literal", ] -[[package]] -name = "ntest" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54d1aa56874c2152c24681ed0df95ee155cc06c5c61b78e2d1e8c0cae8bc5326" -dependencies = [ - "ntest_test_cases", - "ntest_timeout", -] - -[[package]] -name = "ntest_test_cases" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6913433c6319ef9b2df316bb8e3db864a41724c2bb8f12555e07dc4ec69d3db1" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "ntest_timeout" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9224be3459a0c1d6e9b0f42ab0e76e98b29aef5aba33c0487dfcf47ea08b5150" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -3296,7 +3297,7 @@ dependencies = [ "rayon", "regex", "regex-syntax", - "rustc-hash", + "rustc-hash 2.1.3", "scnr2", "serde", "serde_json", @@ -3322,7 +3323,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "730ad4d9a5de274892b65349e8c05fb651aa4d5d762ec475dcc0668e850c14a9" dependencies = [ "anyhow", - "codespan-reporting", + "codespan-reporting 0.13.1", "derive_builder", "function_name", "log", @@ -3659,6 +3660,25 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec 0.8.0", + "bitflags 2.13.0", + "num-traits", + "rand 0.9.4", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + [[package]] name = "pulp" version = "0.22.3" @@ -3770,6 +3790,12 @@ dependencies = [ "pulp", ] +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quick-xml" version = "0.37.5" @@ -3790,7 +3816,7 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash", + "rustc-hash 2.1.3", "rustls", "socket2 0.6.4", "thiserror 2.0.18", @@ -3812,7 +3838,7 @@ dependencies = [ "rand 0.10.2", "rand_pcg", "ring", - "rustc-hash", + "rustc-hash 2.1.3", "rustls", "rustls-pki-types", "rustls-platform-verifier", @@ -3963,6 +3989,15 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "raw-cpuid" version = "11.6.0" @@ -4029,17 +4064,6 @@ dependencies = [ "bitflags 2.13.0", ] -[[package]] -name = "redox_users" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 1.0.69", -] - [[package]] name = "redox_users" version = "0.5.2" @@ -4081,7 +4105,7 @@ dependencies = [ "bumpalo", "hashbrown 0.15.5", "log", - "rustc-hash", + "rustc-hash 2.1.3", "smallvec", ] @@ -4186,58 +4210,57 @@ dependencies = [ [[package]] name = "rumoca" -version = "0.9.20" +version = "0.10.0" dependencies = [ "anyhow", "clap", "clap_complete", "crossterm", - "dirs 5.0.1", - "flate2", - "indexmap 2.14.0", "miette", "mimalloc", "minijinja", - "ntest", "quick-xml", - "rayon", "rumoca-compile", "rumoca-core", + "rumoca-eval-galec", "rumoca-eval-solve", - "rumoca-galec-codegen", "rumoca-ir-ast", "rumoca-ir-dae", "rumoca-ir-flat", + "rumoca-ir-galec", "rumoca-ir-solve", "rumoca-phase-codegen", "rumoca-phase-dae", "rumoca-phase-flatten", + "rumoca-phase-galec", "rumoca-phase-instantiate", "rumoca-phase-parse", "rumoca-phase-resolve", "rumoca-phase-solve", "rumoca-phase-typecheck", "rumoca-sim", + "rumoca-solver", "rumoca-tool-fmt", "rumoca-tool-lint", "serde", "serde_json", + "sha1", "sigpipe", - "tar", + "syn 2.0.118", "tempfile", "thiserror 2.0.18", + "time", "toml", - "tracing", "tracing-subscriber", "tungstenite 0.26.2", - "ureq", + "uuid", "walkdir", "zip", ] [[package]] name = "rumoca-bind-python" -version = "0.9.20" +version = "0.10.0" dependencies = [ "pyo3", "pyo3-build-config", @@ -4253,8 +4276,9 @@ dependencies = [ [[package]] name = "rumoca-bind-wasm" -version = "0.9.20" +version = "0.10.0" dependencies = [ + "anyhow", "bincode", "console_error_panic_hook", "getrandom 0.3.4", @@ -4264,7 +4288,7 @@ dependencies = [ "rumoca-compile", "rumoca-core", "rumoca-eval-ast", - "rumoca-ir-solve", + "rumoca-phase-galec", "rumoca-sim", "rumoca-tool-lint", "rumoca-tool-lsp", @@ -4278,13 +4302,14 @@ dependencies = [ [[package]] name = "rumoca-bind-wasm-diffsol" -version = "0.9.20" +version = "0.10.0" dependencies = [ "console_error_panic_hook", "getrandom 0.3.4", "rumoca-ir-solve", + "rumoca-phase-solve", + "rumoca-sim", "rumoca-solver", - "rumoca-solver-diffsol", "serde", "serde_json", "wasm-bindgen", @@ -4292,15 +4317,15 @@ dependencies = [ [[package]] name = "rumoca-bind-wasm-galec" -version = "0.9.20" +version = "0.10.0" dependencies = [ "console_error_panic_hook", - "getrandom 0.3.4", "lsp-types", "rumoca-compile", - "rumoca-galec-codegen", - "rumoca-ir-galec", - "rumoca-tool-galec-lsp", + "rumoca-phase-codegen", + "rumoca-phase-galec", + "rumoca-phase-parse-galec", + "rumoca-tool-lsp-galec", "serde", "serde_json", "wasm-bindgen", @@ -4308,7 +4333,7 @@ dependencies = [ [[package]] name = "rumoca-codec" -version = "0.9.20" +version = "0.10.0" dependencies = [ "anyhow", "rumoca-codec-flatbuffers", @@ -4317,7 +4342,7 @@ dependencies = [ [[package]] name = "rumoca-codec-flatbuffers" -version = "0.9.20" +version = "0.10.0" dependencies = [ "anyhow", "rumoca-signal-frame", @@ -4326,21 +4351,19 @@ dependencies = [ [[package]] name = "rumoca-compile" -version = "0.9.20" +version = "0.10.0" dependencies = [ "anyhow", "bincode", "blake3", + "flate2", "indexmap 2.14.0", - "miette", - "minijinja", "rayon", "rumoca-core", - "rumoca-galec-codegen", "rumoca-ir-ast", "rumoca-ir-dae", "rumoca-ir-flat", - "rumoca-ir-galec", + "rumoca-ir-solve", "rumoca-phase-codegen", "rumoca-phase-dae", "rumoca-phase-flatten", @@ -4358,11 +4381,10 @@ dependencies = [ [[package]] name = "rumoca-contracts" -version = "0.9.20" +version = "0.10.0" dependencies = [ "indexmap 2.14.0", "rumoca-compile", - "rumoca-phase-dae", "rumoca-sim", "serde", "serde_json", @@ -4372,64 +4394,70 @@ dependencies = [ [[package]] name = "rumoca-core" -version = "0.9.20" +version = "0.10.0" dependencies = [ + "bincode", "indexmap 2.14.0", "miette", "serde", + "serde_json", ] [[package]] name = "rumoca-eval-ast" -version = "0.9.20" +version = "0.10.0" dependencies = [ "indexmap 2.14.0", "rumoca-core", "rumoca-ir-ast", - "rustc-hash", + "rustc-hash 2.1.3", ] [[package]] name = "rumoca-eval-dae" -version = "0.9.20" +version = "0.10.0" dependencies = [ - "blake3", - "indexmap 2.14.0", "rumoca-core", "rumoca-ir-dae", - "rustc-hash", - "tracing", + "rustc-hash 2.1.3", + "thiserror 2.0.18", ] [[package]] name = "rumoca-eval-flat" -version = "0.9.20" +version = "0.10.0" dependencies = [ "indexmap 2.14.0", "rumoca-core", "rumoca-ir-flat", - "rustc-hash", + "rustc-hash 2.1.3", "thiserror 2.0.18", "tracing", ] +[[package]] +name = "rumoca-eval-galec" +version = "0.10.0" +dependencies = [ + "rumoca-core", + "rumoca-ir-galec", + "thiserror 2.0.18", +] + [[package]] name = "rumoca-eval-solve" -version = "0.9.20" +version = "0.10.0" dependencies = [ "indexmap 2.14.0", "rumoca-core", - "rumoca-eval-dae", - "rumoca-ir-dae", "rumoca-ir-solve", - "rumoca-solver", - "thiserror 2.0.18", + "serde_json", "tracing", ] [[package]] name = "rumoca-exec-cranelift" -version = "0.9.20" +version = "0.10.0" dependencies = [ "cranelift-codegen", "cranelift-frontend", @@ -4442,13 +4470,12 @@ dependencies = [ [[package]] name = "rumoca-exec-mlir" -version = "0.9.20" +version = "0.10.0" dependencies = [ "indexmap 2.14.0", "libloading", "rumoca-core", "rumoca-eval-solve", - "rumoca-ir-dae", "rumoca-ir-solve", "rumoca-phase-codegen", "rumoca-phase-solve", @@ -4458,7 +4485,7 @@ dependencies = [ [[package]] name = "rumoca-exec-wasm" -version = "0.9.20" +version = "0.10.0" dependencies = [ "js-sys", "rumoca-ir-solve", @@ -4467,27 +4494,11 @@ dependencies = [ "wasmparser", ] -[[package]] -name = "rumoca-galec-codegen" -version = "0.9.20" -dependencies = [ - "rumoca-core", - "rumoca-ir-dae", - "rumoca-ir-galec", - "serde", - "serde_json", - "sha1", - "thiserror 2.0.18", - "time", - "uuid", -] - [[package]] name = "rumoca-input" -version = "0.9.20" +version = "0.10.0" dependencies = [ "anyhow", - "indexmap 2.14.0", "rumoca-signal-frame", "serde", "serde_json", @@ -4496,7 +4507,7 @@ dependencies = [ [[package]] name = "rumoca-input-gamepad" -version = "0.9.20" +version = "0.10.0" dependencies = [ "anyhow", "gilrs", @@ -4505,79 +4516,71 @@ dependencies = [ [[package]] name = "rumoca-input-keyboard" -version = "0.9.20" +version = "0.10.0" dependencies = [ - "anyhow", "crossterm", "rumoca-input", ] [[package]] name = "rumoca-ir-ast" -version = "0.9.20" +version = "0.10.0" dependencies = [ "indexmap 2.14.0", "rumoca-core", - "rustc-hash", + "rustc-hash 2.1.3", "serde", ] [[package]] name = "rumoca-ir-dae" -version = "0.9.20" +version = "0.10.0" dependencies = [ "bincode", - "indexmap 2.14.0", + "proptest", "rumoca-core", - "rustc-hash", + "rustc-hash 2.1.3", "serde", "serde_json", + "thiserror 2.0.18", ] [[package]] name = "rumoca-ir-flat" -version = "0.9.20" +version = "0.10.0" dependencies = [ "indexmap 2.14.0", "rumoca-core", - "rumoca-ir-ast", - "rustc-hash", + "rustc-hash 2.1.3", "serde", + "serde_json", ] [[package]] name = "rumoca-ir-galec" -version = "0.9.20" +version = "0.10.0" dependencies = [ - "anyhow", - "parol", - "parol_runtime", "rumoca-core", - "scnr2", + "rustc-hash 2.1.3", + "serde", "thiserror 2.0.18", ] [[package]] name = "rumoca-ir-solve" -version = "0.9.20" +version = "0.10.0" dependencies = [ "bincode", "indexmap 2.14.0", "rumoca-core", "serde", "serde_json", -] - -[[package]] -name = "rumoca-lsp-position" -version = "0.9.20" -dependencies = [ - "lsp-types", + "thiserror 2.0.18", ] [[package]] name = "rumoca-opt" -version = "0.9.20" +version = "0.10.0" dependencies = [ "indexmap 2.14.0", "rumoca", @@ -4591,41 +4594,42 @@ dependencies = [ [[package]] name = "rumoca-phase-codegen" -version = "0.9.20" +version = "0.10.0" dependencies = [ "indexmap 2.14.0", "miette", "minijinja", + "naga", "rumoca-core", + "rumoca-eval-dae", "rumoca-eval-solve", - "rumoca-galec-codegen", "rumoca-ir-ast", "rumoca-ir-dae", "rumoca-ir-flat", + "rumoca-ir-galec", "rumoca-ir-solve", + "serde", "serde_json", + "tempfile", "thiserror 2.0.18", ] [[package]] name = "rumoca-phase-dae" -version = "0.9.20" +version = "0.10.0" dependencies = [ - "indexmap 2.14.0", "miette", "rumoca-core", - "rumoca-eval-dae", + "rumoca-eval-flat", "rumoca-ir-dae", "rumoca-ir-flat", - "rustc-hash", "serde", "thiserror 2.0.18", - "tracing", ] [[package]] name = "rumoca-phase-flatten" -version = "0.9.20" +version = "0.10.0" dependencies = [ "indexmap 2.14.0", "miette", @@ -4634,44 +4638,79 @@ dependencies = [ "rumoca-eval-flat", "rumoca-ir-ast", "rumoca-ir-flat", - "rustc-hash", + "rumoca-phase-dae", + "rumoca-phase-instantiate", + "rumoca-phase-parse", + "rumoca-phase-resolve", + "rumoca-phase-typecheck", + "rustc-hash 2.1.3", "thiserror 2.0.18", "tracing", ] +[[package]] +name = "rumoca-phase-galec" +version = "0.10.0" +dependencies = [ + "rumoca-core", + "rumoca-eval-dae", + "rumoca-ir-dae", + "rumoca-ir-galec", + "rumoca-phase-structural", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "rumoca-phase-instantiate" -version = "0.9.20" +version = "0.10.0" dependencies = [ "indexmap 2.14.0", "miette", "rumoca-core", "rumoca-eval-ast", "rumoca-ir-ast", - "rustc-hash", + "rumoca-phase-parse", + "rumoca-phase-resolve", + "rustc-hash 2.1.3", "thiserror 2.0.18", "tracing", ] [[package]] name = "rumoca-phase-parse" -version = "0.9.20" +version = "0.10.0" dependencies = [ "anyhow", "indexmap 2.14.0", "miette", "parol", "parol_runtime", + "proptest", "rumoca-core", "rumoca-ir-ast", "scnr2", + "tempfile", ] [[package]] -name = "rumoca-phase-resolve" -version = "0.9.20" +name = "rumoca-phase-parse-galec" +version = "0.10.0" dependencies = [ "anyhow", + "parol", + "parol_runtime", + "rumoca-core", + "rumoca-ir-galec", + "rumoca-phase-codegen", + "scnr2", + "thiserror 2.0.18", +] + +[[package]] +name = "rumoca-phase-resolve" +version = "0.10.0" +dependencies = [ "indexmap 2.14.0", "miette", "rumoca-core", @@ -4683,7 +4722,7 @@ dependencies = [ [[package]] name = "rumoca-phase-solve" -version = "0.9.20" +version = "0.10.0" dependencies = [ "indexmap 2.14.0", "rumoca-core", @@ -4692,58 +4731,72 @@ dependencies = [ "rumoca-ir-dae", "rumoca-ir-solve", "rumoca-phase-structural", - "tracing", + "rustc-hash 2.1.3", + "serde", + "thiserror 2.0.18", ] [[package]] name = "rumoca-phase-structural" -version = "0.9.20" +version = "0.10.0" dependencies = [ "indexmap 2.14.0", "rumoca-core", + "rumoca-eval-dae", "rumoca-ir-dae", + "serde_json", "thiserror 2.0.18", "tracing", ] [[package]] name = "rumoca-phase-typecheck" -version = "0.9.20" +version = "0.10.0" dependencies = [ - "miette", "rumoca-core", "rumoca-eval-ast", "rumoca-ir-ast", + "rumoca-phase-instantiate", "rumoca-phase-parse", "rumoca-phase-resolve", - "rustc-hash", + "rustc-hash 2.1.3", "thiserror 2.0.18", ] +[[package]] +name = "rumoca-reference" +version = "0.10.0" +dependencies = [ + "proptest", + "rumoca-compile", + "rumoca-sim", +] + [[package]] name = "rumoca-signal-frame" -version = "0.9.20" +version = "0.10.0" dependencies = [ "indexmap 2.14.0", ] [[package]] name = "rumoca-sim" -version = "0.9.20" +version = "0.10.0" dependencies = [ "anyhow", "indexmap 2.14.0", "libc", + "proptest", "rumoca-codec", "rumoca-compile", "rumoca-core", "rumoca-eval-solve", + "rumoca-exec-cranelift", "rumoca-input", "rumoca-input-gamepad", "rumoca-input-keyboard", "rumoca-ir-dae", "rumoca-ir-solve", - "rumoca-phase-dae", "rumoca-phase-solve", "rumoca-phase-structural", "rumoca-solver", @@ -4763,12 +4816,17 @@ dependencies = [ [[package]] name = "rumoca-solver" -version = "0.9.20" +version = "0.10.0" dependencies = [ + "faer", + "indexmap 2.14.0", "instant", "nalgebra", + "proptest", "rumoca-core", + "rumoca-eval-solve", "rumoca-ir-solve", + "rustc-hash 2.1.3", "serde", "serde_json", "thiserror 2.0.18", @@ -4777,59 +4835,44 @@ dependencies = [ [[package]] name = "rumoca-solver-diffsol" -version = "0.9.20" +version = "0.10.0" dependencies = [ "diffsol", - "indexmap 2.14.0", - "rumoca-core", - "rumoca-eval-solve", - "rumoca-ir-solve", "rumoca-solver", - "thiserror 2.0.18", - "tracing", + "self_cell", ] [[package]] name = "rumoca-solver-rk45" -version = "0.9.20" +version = "0.10.0" dependencies = [ - "indexmap 2.14.0", - "rumoca-core", - "rumoca-eval-solve", - "rumoca-ir-solve", "rumoca-solver", - "thiserror 2.0.18", - "tracing", ] [[package]] name = "rumoca-test-msl" -version = "0.9.20" +version = "0.10.0" dependencies = [ "anyhow", "blake3", "clap", - "dirs 5.0.1", "flate2", "indexmap 2.14.0", "mimalloc", "nix 0.29.0", - "ntest", "rayon", "regex", "rumoca-compile", + "rumoca-eval-dae", "rumoca-ir-ast", "rumoca-ir-dae", "rumoca-ir-flat", "rumoca-ir-solve", "rumoca-phase-dae", "rumoca-phase-flatten", - "rumoca-phase-instantiate", "rumoca-phase-parse", - "rumoca-phase-resolve", - "rumoca-phase-structural", - "rumoca-phase-typecheck", "rumoca-sim", + "rumoca-solver", "rumoca-worker", "serde", "serde_json", @@ -4841,22 +4884,9 @@ dependencies = [ "zmq", ] -[[package]] -name = "rumoca-tool-docs" -version = "0.9.20" -dependencies = [ - "anyhow", - "bincode", - "clap", - "flate2", - "mimalloc", - "rayon", - "rumoca-compile", -] - [[package]] name = "rumoca-tool-fmt" -version = "0.9.20" +version = "0.10.0" dependencies = [ "rumoca-compile", "serde", @@ -4865,22 +4895,9 @@ dependencies = [ "toml", ] -[[package]] -name = "rumoca-tool-galec-lsp" -version = "0.9.20" -dependencies = [ - "clap", - "futures-util", - "lsp-types", - "rumoca-ir-galec", - "rumoca-lsp-position", - "tokio", - "tower-lsp", -] - [[package]] name = "rumoca-tool-lint" -version = "0.9.20" +version = "0.10.0" dependencies = [ "rumoca-compile", "serde", @@ -4891,7 +4908,7 @@ dependencies = [ [[package]] name = "rumoca-tool-lsp" -version = "0.9.20" +version = "0.10.0" dependencies = [ "anyhow", "blake3", @@ -4899,8 +4916,8 @@ dependencies = [ "futures-util", "lsp-types", "rumoca-compile", - "rumoca-lsp-position", - "rumoca-phase-dae", + "rumoca-phase-codegen", + "rumoca-phase-galec", "rumoca-sim", "rumoca-tool-fmt", "rumoca-tool-lint", @@ -4912,9 +4929,22 @@ dependencies = [ "tracing", ] +[[package]] +name = "rumoca-tool-lsp-galec" +version = "0.10.0" +dependencies = [ + "clap", + "futures-util", + "lsp-types", + "rumoca-core", + "rumoca-phase-parse-galec", + "tokio", + "tower-lsp", +] + [[package]] name = "rumoca-transport-udp" -version = "0.9.20" +version = "0.10.0" dependencies = [ "anyhow", "serde", @@ -4922,7 +4952,7 @@ dependencies = [ [[package]] name = "rumoca-transport-websocket" -version = "0.9.20" +version = "0.10.0" dependencies = [ "serde", "serde_json", @@ -4932,7 +4962,7 @@ dependencies = [ [[package]] name = "rumoca-transport-zenoh" -version = "0.9.20" +version = "0.10.0" dependencies = [ "anyhow", "serde", @@ -4942,13 +4972,15 @@ dependencies = [ [[package]] name = "rumoca-worker" -version = "0.9.20" +version = "0.10.0" dependencies = [ "clap", "core_affinity", "mimalloc", "rumoca-compile", "rumoca-core", + "rumoca-phase-solve", + "rumoca-phase-structural", "rumoca-sim", "serde", "serde_json", @@ -4960,6 +4992,12 @@ version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.3" @@ -5100,6 +5138,18 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.23" @@ -5191,7 +5241,7 @@ dependencies = [ "proc-macro2", "quote", "regex-syntax", - "rustc-hash", + "rustc-hash 2.1.3", "syn 2.0.118", ] @@ -5249,6 +5299,12 @@ dependencies = [ "libc", ] +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + [[package]] name = "semver" version = "1.0.28" @@ -5445,7 +5501,7 @@ version = "3.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" dependencies = [ - "dirs 6.0.0", + "dirs", ] [[package]] @@ -6358,6 +6414,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11c524dc90cd71769e23e877ffdcb128f867970f1febdb8eb11a776f5f49e7fc" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -6883,15 +6945,6 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -6928,21 +6981,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - [[package]] name = "windows-targets" version = "0.52.6" @@ -6991,12 +7029,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -7015,12 +7047,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -7039,12 +7065,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -7075,12 +7095,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -7099,12 +7113,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -7123,12 +7131,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -7147,12 +7149,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -7231,7 +7227,7 @@ checksum = "3a74a847d8392999f89e9668c4dd46283b91fd6fc1f34aa5ecf4ceaf8fa3258e" [[package]] name = "xtask" -version = "0.9.20" +version = "0.10.0" dependencies = [ "anyhow", "blake3", @@ -7239,6 +7235,7 @@ dependencies = [ "clap_complete", "lsp-types", "mimalloc", + "num_cpus", "serde", "serde_json", "sha2", @@ -7265,7 +7262,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" dependencies = [ - "bit-vec", + "bit-vec 0.9.1", "time", ] diff --git a/Cargo.toml b/Cargo.toml index 405c8bf3a..67150100d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,23 +1,32 @@ [workspace] resolver = "2" +# `infra/fuzz/` is a standalone cargo-fuzz crate with its own workspace: it +# needs the nightly-only `-Z sanitizer` machinery and must never be built by +# the normal workspace commands. +exclude = ["infra/fuzz"] members = [ "crates/rumoca", "crates/rumoca-worker", "crates/rumoca-test-msl", "crates/rumoca-core", "crates/rumoca-contracts", + # Executable reference semantics (SPEC_0037 verification track) + "crates/rumoca-reference", "crates/rumoca-ir-dae", "crates/rumoca-ir-solve", "crates/rumoca-eval-ast", "crates/rumoca-eval-flat", "crates/rumoca-eval-dae", + "crates/rumoca-eval-galec", "crates/rumoca-eval-solve", "crates/rumoca-ir-ast", "crates/rumoca-ir-flat", "crates/rumoca-phase-codegen", + "crates/rumoca-phase-galec", "crates/rumoca-phase-flatten", "crates/rumoca-phase-instantiate", "crates/rumoca-phase-parse", + "crates/rumoca-phase-parse-galec", "crates/rumoca-phase-resolve", "crates/rumoca-phase-dae", "crates/rumoca-phase-structural", @@ -39,15 +48,12 @@ members = [ "crates/rumoca-phase-typecheck", # GALEC / eFMI export "crates/rumoca-ir-galec", - "crates/rumoca-galec-codegen", # Compile pipeline and tools "crates/rumoca-compile", "crates/rumoca-tool-lint", "crates/rumoca-tool-fmt", "crates/rumoca-tool-lsp", - "crates/rumoca-tool-galec-lsp", - "crates/rumoca-tool-docs", - "crates/rumoca-lsp-position", + "crates/rumoca-tool-lsp-galec", "crates/xtask", # Bindings "crates/rumoca-bind-wasm", @@ -61,7 +67,7 @@ members = [ ] [workspace.package] -version = "0.9.20" +version = "0.10.0" edition = "2024" license = "Apache-2.0" repository = "https://github.com/CogniPilot/rumoca" @@ -82,22 +88,26 @@ rumoca = { path = "crates/rumoca", default-features = false } rumoca-worker = { path = "crates/rumoca-worker" } rumoca-core = { path = "crates/rumoca-core" } rumoca-contracts = { path = "crates/rumoca-contracts" } +rumoca-reference = { path = "crates/rumoca-reference" } rumoca-ir-dae = { path = "crates/rumoca-ir-dae" } rumoca-ir-solve = { path = "crates/rumoca-ir-solve" } rumoca-eval-ast = { path = "crates/rumoca-eval-ast" } rumoca-eval-flat = { path = "crates/rumoca-eval-flat" } rumoca-eval-dae = { path = "crates/rumoca-eval-dae" } +rumoca-eval-galec = { path = "crates/rumoca-eval-galec" } rumoca-eval-solve = { path = "crates/rumoca-eval-solve" } rumoca-ir-ast = { path = "crates/rumoca-ir-ast" } rumoca-ir-flat = { path = "crates/rumoca-ir-flat" } rumoca-phase-flatten = { path = "crates/rumoca-phase-flatten" } rumoca-phase-instantiate = { path = "crates/rumoca-phase-instantiate" } rumoca-phase-parse = { path = "crates/rumoca-phase-parse" } +rumoca-phase-parse-galec = { path = "crates/rumoca-phase-parse-galec" } rumoca-phase-resolve = { path = "crates/rumoca-phase-resolve" } rumoca-phase-dae = { path = "crates/rumoca-phase-dae" } rumoca-phase-structural = { path = "crates/rumoca-phase-structural" } rumoca-phase-solve = { path = "crates/rumoca-phase-solve", default-features = false } rumoca-phase-codegen = { path = "crates/rumoca-phase-codegen" } +rumoca-phase-galec = { path = "crates/rumoca-phase-galec" } rumoca-signal-frame = { path = "crates/rumoca-signal-frame" } rumoca-codec = { path = "crates/rumoca-codec" } rumoca-codec-flatbuffers = { path = "crates/rumoca-codec-flatbuffers" } @@ -115,15 +125,13 @@ rumoca-solver-rk45 = { path = "crates/rumoca-solver-rk45" } rumoca-phase-typecheck = { path = "crates/rumoca-phase-typecheck" } rumoca-compile = { path = "crates/rumoca-compile" } rumoca-ir-galec = { path = "crates/rumoca-ir-galec" } -rumoca-galec-codegen = { path = "crates/rumoca-galec-codegen" } rumoca-exec-cranelift = { path = "crates/rumoca-exec-cranelift" } rumoca-exec-mlir = { path = "crates/rumoca-exec-mlir" } rumoca-exec-wasm = { path = "crates/rumoca-exec-wasm" } rumoca-tool-lint = { path = "crates/rumoca-tool-lint" } rumoca-tool-fmt = { path = "crates/rumoca-tool-fmt" } rumoca-tool-lsp = { path = "crates/rumoca-tool-lsp", default-features = false } -rumoca-tool-galec-lsp = { path = "crates/rumoca-tool-galec-lsp", default-features = false } -rumoca-lsp-position = { path = "crates/rumoca-lsp-position" } +rumoca-tool-lsp-galec = { path = "crates/rumoca-tool-lsp-galec", default-features = false } rumoca-bind-wasm = { path = "crates/rumoca-bind-wasm" } rumoca-bind-python = { path = "crates/rumoca-bind-python" } rumoca-test-msl = { path = "crates/rumoca-test-msl" } @@ -131,17 +139,21 @@ rumoca-test-msl = { path = "crates/rumoca-test-msl" } # External dependencies anyhow = "1.0" mimalloc = "0.1" +num_cpus = "1.17" blake3 = "1.5" clap = { version = "4.5", features = ["derive"] } clap_complete = "4.5" core_affinity = "0.8" dirs = "6.0" flate2 = "1.0" +faer = { version = "0.24.4", default-features = false, features = ["std", "sparse-linalg"] } indexmap = { version = "2.7", features = ["serde"] } miette = { version = "7.4", features = ["fancy"] } minijinja = { version = "2.5", features = ["debug", "preserve_order"] } parol = "=4.2.2" parol_runtime = "=4.2.0" +# Property-based testing (dev-dependency only) +proptest = "1" quick-xml = "0.37" rayon = "1.10" rustc-hash = "2.0" @@ -151,7 +163,9 @@ sigpipe = "0.1" serde = { version = "1.0", features = ["derive", "rc"] } serde_json = { version = "1.0", features = ["preserve_order"] } serde_yaml = "0.9" +self_cell = "1.2.2" bincode = "1.3" +syn = { version = "2.0", features = ["full", "visit"] } futures-util = "0.3" tar = "0.4" thiserror = "2.0" @@ -179,6 +193,11 @@ console_error_panic_hook = "0.1" pyo3 = { version = "0.22", features = ["extension-module"] } [workspace.lints.rust] +# `cfg(kani)` is set only by the Kani verifier in the dedicated `.#kani` shell +# (see CONTRIBUTING.md). Declaring it here keeps the bounded-verification +# harnesses warning-free without a blanket `allow`, so a *typo* in any other +# cfg name still warns. +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(kani)'] } unsafe_code = "deny" unreachable_pub = "deny" unnameable_types = "deny" @@ -210,6 +229,20 @@ missing_enforced_import_renames = "deny" # For faster builds [profile.dev] opt-level = 1 +# Full DWARF (`debug = 2`, Cargo's default for dev) is what makes the ~60 +# integration-test binaries ~800 MB each and drives rust-lld to multi-GB peak +# RSS while linking them in parallel. Line tables alone keep panic backtraces, +# `perf`/flamegraph symbolication and `file:line` attribution intact; only +# variable/type inspection in a debugger is lost. The `test` profile inherits +# this, so it applies to every unit- and integration-test binary. +debug = "line-tables-only" +# Leave the remaining line tables in the per-CU `.dwo` files instead of copying +# them into every linked artifact. Each test binary then shares one pool of +# debuginfo rather than carrying its own, which is what halves link-time peak +# RSS again. The tradeoff is that a dev/test binary is no longer self-contained: +# it resolves `file:line` only while its `target/debug/deps/*.dwo` files are +# still in place, so never ship or archive a dev binary on its own. +split-debuginfo = "unpacked" # `build-override` applies to build scripts and proc-macro crates only. # It speeds up codegen-heavy macro/build tooling without forcing all dev crates @@ -225,6 +258,16 @@ opt-level = 3 [profile.release] lto = "thin" +# Fast edit/test profile for the local full-MSL campaign. Keep release-level +# runtime optimization, but avoid ThinLTO and retain incremental artifacts so +# compiler/runtime changes do not make every parity sweep pay a release link. +# Official release builds continue to use `[profile.release]` above. +[profile.msl-fast] +inherits = "release" +lto = false +incremental = true +codegen-units = 16 + # Speed up parser-heavy runtime in normal debug builds. [profile.dev.package.rumoca-phase-parse] opt-level = 3 diff --git a/README.md b/README.md index c59b51fcb..1b2730a1d 100644 --- a/README.md +++ b/README.md @@ -131,10 +131,26 @@ The goal is to make model package trees belong to the **models themselves**, not cargo build --workspace ``` -Alternatively, a reproducible [Nix](https://nixos.org) flake lives at the repo -root (`flake.nix`): `nix develop` drops you into a shell with the exact pinned -toolchain plus Node/Python, `nix build` produces the `rumoca` CLI, and -`nix flake check` runs the same build + clippy + rustfmt gate CI uses. +Nix is only a convenience wrapper. Every Cargo/xtask workflow works with the +pinned Rust toolchain and required host packages installed normally. As an +alternative, a reproducible [Nix](https://nixos.org) flake lives at the repo +root (`flake.nix`). `nix develop` provides the exact pinned Rust and native +build toolchain without building Rumoca or realizing optional runtimes. +Task-specific shells add those tools only when needed: + +| Command | Additional tools | +|---|---| +| `nix develop .#wasm` | Node, Binaryen, and wasm-pack | +| `nix develop .#python` | Python with JAX/CasADi and maturin | +| `nix develop .#julia` | Julia (Linux) | +| `nix develop .#modelica` | Pinned OpenModelica (Linux) | +| `nix develop .#fmi` | FMI template and validation tools | +| `nix develop .#docs` | mdBook and documentation WASM tools | +| `nix develop .#full` | All optional development tools | + +Run Rumoca from source with `cargo run -p rumoca -- ...`; `nix build .#rumoca` +produces the explicit reproducible package, and `nix flake check` runs the +build, clippy, and rustfmt gates used by CI. ### Common commands @@ -223,8 +239,8 @@ Render a codegen scenario: cargo run -p rumoca -- \ compile examples/models/SympyDecay.mo \ --model SympyDecay \ - --target examples/codegen/standalone_web \ - --output examples/codegen/gen/sympy_decay_standalone_web + --target examples/codegen/checked_dae_report \ + --output examples/codegen/gen/sympy_decay_checked_dae_report ``` Codegen scenarios write generated files under `examples/codegen/gen/`, which is @@ -363,7 +379,7 @@ the local coverage/editor prerequisites are installed (`cargo-llvm-cov`, Node 20/npm for package/web tasks, and wasm Rust tooling). `cargo xtask verify template-runtimes` wraps the equivalent Cargo command for opt-in example-template runtime checks: -`cargo test -p rumoca --features template-runtime-tests --test backend_template_runtime_regression -- --nocapture`. +`cargo test -p rumoca --features template-runtime-tests --test suite_template_runtime backend_template_runtime_regression:: -- --nocapture`. ## Compiler Pipeline @@ -382,8 +398,9 @@ equivalent Cargo command for opt-in example-template runtime checks: ## Code Generation Targets Use explicit template files you own and version with your project. -The raw template example in `examples/codegen/custom_casadi.jinja` is a -starting point, not a stable production artifact. +The raw template example in +`examples/codegen/custom_checked_variables.jinja` is a starting point, not a +stable production artifact. ## VS Code Extension diff --git a/TODO.md b/TODO.md deleted file mode 100644 index d3cf13457..000000000 --- a/TODO.md +++ /dev/null @@ -1,11 +0,0 @@ -# TODO - -- CUBS2 protected access: `Cubs2AltitudeHold` reads - `outerLoop.guidance.pathAltitude`, but `pathAltitude` is protected inside - `FixedWingOuterLoop.RouteGuidance`. Expose a public telemetry value on - `FixedWingOuterLoop` or otherwise consume a public signal instead of reading - the protected component. -- Python binding PathLike cleanup: file/path arguments now accept - `os.PathLike`, but source-root lists are still documented as `Sequence[str]`. - If roots should accept `PathLike`, implement that through `Session` argument - parsing so caches remain owned by the session. diff --git a/crates/rumoca-contracts/data/contract_cases.toml b/crates/rumoca-contracts/data/contract_cases.toml index c787c22a5..30ff2a88d 100644 --- a/crates/rumoca-contracts/data/contract_cases.toml +++ b/crates/rumoca-contracts/data/contract_cases.toml @@ -330,6 +330,20 @@ test_file = 'tests/conn_contracts.rs' kind = 'Balance' outcome = 'Accept' +[[cases]] +contract_id = 'CONN-030' +case_id = 'conn_030_stream_member_matched_with_non_stream_member_rejected' +test_file = 'tests/conn_contracts.rs' +kind = 'Compile' +outcome = 'Reject' + +[[cases]] +contract_id = 'CONN-030' +case_id = 'conn_030_stream_member_matched_with_stream_member_accepted' +test_file = 'tests/conn_contracts.rs' +kind = 'Compile' +outcome = 'Accept' + [[cases]] contract_id = 'CONN-029' case_id = 'conn_029_connect_requires_connectors' @@ -1002,13 +1016,6 @@ test_file = 'tests/expr_contracts.rs' kind = 'Compile' outcome = 'Accept' -[[cases]] -contract_id = 'EXPR-040' -case_id = 'expr_040_integer_event_trigger' -test_file = 'tests/expr_contracts.rs' -kind = 'Compile' -outcome = 'Accept' - [[cases]] contract_id = 'FUNC-001' case_id = 'func_001_input_output_ok' @@ -1823,7 +1830,7 @@ outcome = 'Accept' [[cases]] contract_id = 'SIM-009' -case_id = 'sim_009_sample_in_fx_lowers_to_internal_runtime_operator' +case_id = 'sim_009_sample_in_fx_lowers_to_ordinary_dae_and_schedule_metadata' test_file = 'tests/sim_contracts.rs' kind = 'Compile' outcome = 'Accept' @@ -1898,6 +1905,48 @@ test_file = 'tests/strm_contracts.rs' kind = 'Compile' outcome = 'Accept' +[[cases]] +contract_id = 'STRM-003' +case_id = 'strm_003_replaceable_medium_interface_real_alias_ok' +test_file = 'tests/strm_contracts.rs' +kind = 'Compile' +outcome = 'Accept' + +[[cases]] +contract_id = 'STRM-003' +case_id = 'strm_003_replaceable_interface_member_constraint_real_ok' +test_file = 'tests/strm_contracts.rs' +kind = 'Compile' +outcome = 'Accept' + +[[cases]] +contract_id = 'STRM-003' +case_id = 'strm_003_replaceable_medium_interface_integer_rejected' +test_file = 'tests/strm_contracts.rs' +kind = 'Compile' +outcome = 'Reject' + +[[cases]] +contract_id = 'STRM-003' +case_id = 'strm_003_replaceable_medium_missing_interface_member_rejected' +test_file = 'tests/strm_contracts.rs' +kind = 'Compile' +outcome = 'Reject' + +[[cases]] +contract_id = 'STRM-003' +case_id = 'strm_003_replaceable_medium_ambiguous_interface_member_rejected' +test_file = 'tests/strm_contracts.rs' +kind = 'Compile' +outcome = 'Reject' + +[[cases]] +contract_id = 'STRM-003' +case_id = 'strm_003_explicit_medium_constraint_controls_interface_rejected' +test_file = 'tests/strm_contracts.rs' +kind = 'Compile' +outcome = 'Reject' + [[cases]] contract_id = 'STRM-003' case_id = 'strm_003_stream_connector_flow_must_be_real_rejected' @@ -2150,6 +2199,13 @@ test_file = 'tests/inst_contracts.rs' kind = 'Compile' outcome = 'Reject' +[[cases]] +contract_id = 'INST-013' +case_id = 'inst_013_enclosing_parameter_rejected' +test_file = 'tests/inst_contracts.rs' +kind = 'Compile' +outcome = 'Reject' + [[cases]] contract_id = 'INST-018' case_id = 'inst_018_outer_type_mismatch_rejected' @@ -2199,6 +2255,20 @@ test_file = 'tests/conn_contracts.rs' kind = 'Compile' outcome = 'Reject' +[[cases]] +contract_id = 'CONN-028' +case_id = 'conn_028_parameter_member_connected_to_variable_member_rejected' +test_file = 'tests/conn_contracts.rs' +kind = 'Compile' +outcome = 'Reject' + +[[cases]] +contract_id = 'CONN-028' +case_id = 'conn_028_parameter_member_connected_to_parameter_member_accepted' +test_file = 'tests/conn_contracts.rs' +kind = 'Compile' +outcome = 'Accept' + [[cases]] contract_id = 'CLK-014' case_id = 'clk_014_nested_clocked_when_rejected' @@ -2409,6 +2479,34 @@ test_file = 'tests/arr_contracts.rs' kind = 'Compile' outcome = 'Reject' +[[cases]] +contract_id = 'ARR-041' +case_id = 'arr_041_diagonal_of_vector_accepted' +test_file = 'tests/arr_contracts.rs' +kind = 'Compile' +outcome = 'Accept' + +[[cases]] +contract_id = 'ARR-041' +case_id = 'arr_041_diagonal_of_matrix_rejected' +test_file = 'tests/arr_contracts.rs' +kind = 'Compile' +outcome = 'Reject' + +[[cases]] +contract_id = 'ARR-042' +case_id = 'arr_042_outer_product_of_vectors_accepted' +test_file = 'tests/arr_contracts.rs' +kind = 'Compile' +outcome = 'Accept' + +[[cases]] +contract_id = 'ARR-042' +case_id = 'arr_042_outer_product_of_matrix_rejected' +test_file = 'tests/arr_contracts.rs' +kind = 'Compile' +outcome = 'Reject' + [[cases]] contract_id = 'ALG-011' case_id = 'alg_011_non_boolean_when_condition_rejected' @@ -2535,62 +2633,6 @@ test_file = 'tests/decl_contracts.rs' kind = 'Compile' outcome = 'Reject' -[[cases]] -contract_id = 'SM-002' -case_id = 'sm_002_duplicate_outgoing_transition_priority_rejected' -test_file = 'tests/sm_contracts.rs' -kind = 'Compile' -outcome = 'Reject' - -[[cases]] -contract_id = 'SM-003' -case_id = 'sm_003_missing_initial_state_rejected' -test_file = 'tests/sm_contracts.rs' -kind = 'Compile' -outcome = 'Reject' - -[[cases]] -contract_id = 'SM-003' -case_id = 'sm_003_multiple_initial_states_rejected' -test_file = 'tests/sm_contracts.rs' -kind = 'Compile' -outcome = 'Reject' - -[[cases]] -contract_id = 'SM-004' -case_id = 'sm_004_transition_priority_below_one_rejected' -test_file = 'tests/sm_contracts.rs' -kind = 'Compile' -outcome = 'Reject' - -[[cases]] -contract_id = 'SM-005' -case_id = 'sm_005_state_machine_operator_forbidden_in_function' -test_file = 'tests/sm_contracts.rs' -kind = 'Compile' -outcome = 'Reject' - -[[cases]] -contract_id = 'SM-006' -case_id = 'sm_006_transition_inside_when_equation_rejected' -test_file = 'tests/sm_contracts.rs' -kind = 'Compile' -outcome = 'Reject' - -[[cases]] -contract_id = 'SM-006' -case_id = 'sm_006_initial_state_inside_nonparameter_if_rejected' -test_file = 'tests/sm_contracts.rs' -kind = 'Compile' -outcome = 'Reject' - -[[cases]] -contract_id = 'SM-007' -case_id = 'sm_007_active_state_on_non_state_rejected' -test_file = 'tests/sm_contracts.rs' -kind = 'Compile' -outcome = 'Reject' - [[cases]] contract_id = 'ANN-003' case_id = 'ann_003_invalid_unit_expression_rejected' @@ -2815,6 +2857,27 @@ test_file = 'tests/func_contracts.rs' kind = 'Compile' outcome = 'Reject' +[[cases]] +contract_id = 'FUNC-036' +case_id = 'func_036_external_object_without_destructor_rejected' +test_file = 'tests/func_contracts.rs' +kind = 'Compile' +outcome = 'Reject' + +[[cases]] +contract_id = 'FUNC-036' +case_id = 'func_036_external_object_replaceable_constructor_rejected' +test_file = 'tests/func_contracts.rs' +kind = 'Compile' +outcome = 'Reject' + +[[cases]] +contract_id = 'FUNC-037' +case_id = 'func_037_external_object_destructor_with_output_rejected' +test_file = 'tests/func_contracts.rs' +kind = 'Compile' +outcome = 'Reject' + [[cases]] contract_id = 'INST-009' case_id = 'inst_009_member_of_final_component_modification_rejected' @@ -2850,13 +2913,6 @@ test_file = 'tests/inst_contracts.rs' kind = 'Compile' outcome = 'Reject' -[[cases]] -contract_id = 'TYPE-002' -case_id = 'type_002_redeclared_class_missing_member_rejected' -test_file = 'tests/type_contracts.rs' -kind = 'Compile' -outcome = 'Reject' - [[cases]] contract_id = 'TYPE-010' case_id = 'type_010_redeclared_class_flipped_causality_rejected' @@ -3046,13 +3102,6 @@ test_file = 'tests/func_contracts.rs' kind = 'Compile' outcome = 'Reject' -[[cases]] -contract_id = 'FUNC-029' -case_id = 'func_029_constructor_for_conditional_record_rejected' -test_file = 'tests/func_contracts.rs' -kind = 'Compile' -outcome = 'Reject' - [[cases]] contract_id = 'INST-040' case_id = 'inst_040_each_on_scalar_component_rejected' @@ -3174,7 +3223,14 @@ outcome = 'Reject' [[cases]] contract_id = 'TYPE-019' -case_id = 'type_019_sibling_function_extra_output_rejected' +case_id = 'type_019_sibling_function_trailing_output_accepted' +test_file = 'tests/type_contracts.rs' +kind = 'Compile' +outcome = 'Accept' + +[[cases]] +contract_id = 'TYPE-019' +case_id = 'type_019_sibling_function_interleaved_output_rejected' test_file = 'tests/type_contracts.rs' kind = 'Compile' outcome = 'Reject' @@ -3193,13 +3249,6 @@ test_file = 'tests/type_contracts.rs' kind = 'Compile' outcome = 'Accept' -[[cases]] -contract_id = 'TYPE-022' -case_id = 'type_022_replacement_with_replaceable_member_rejected' -test_file = 'tests/type_contracts.rs' -kind = 'Compile' -outcome = 'Reject' - [[cases]] contract_id = 'INST-025' case_id = 'inst_025_diamond_inherited_equations_deduplicated' @@ -3403,20 +3452,6 @@ test_file = 'tests/arr_contracts.rs' kind = 'Compile' outcome = 'Reject' -[[cases]] -contract_id = 'CONN-012' -case_id = 'conn_012_expandable_duplicate_sources_rejected' -test_file = 'tests/conn_contracts.rs' -kind = 'Compile' -outcome = 'Reject' - -[[cases]] -contract_id = 'CONN-021' -case_id = 'conn_021_expandable_input_without_source_rejected' -test_file = 'tests/conn_contracts.rs' -kind = 'Compile' -outcome = 'Reject' - [[cases]] contract_id = 'FUNC-033' case_id = 'func_033_function_alias_of_record_rejected' @@ -3620,27 +3655,6 @@ test_file = 'tests/inst_contracts.rs' kind = 'Compile' outcome = 'Accept' -[[cases]] -contract_id = 'SM-001' -case_id = 'sm_001_mixed_clock_state_machine_rejected_as_unsupported' -test_file = 'tests/sm_contracts.rs' -kind = 'Compile' -outcome = 'Reject' - -[[cases]] -contract_id = 'SM-008' -case_id = 'sm_008_parallel_machines_shared_assignment_rejected_as_unsupported' -test_file = 'tests/sm_contracts.rs' -kind = 'Compile' -outcome = 'Reject' - -[[cases]] -contract_id = 'TYPE-003' -case_id = 'type_003_extra_input_without_default_rejected' -test_file = 'tests/type_contracts.rs' -kind = 'Compile' -outcome = 'Reject' - [[cases]] contract_id = 'TYPE-007' case_id = 'type_007_external_object_mismatch_rejected' @@ -3717,3 +3731,10 @@ case_id = 'oprec_011_zero_inner_dimension_product_rejected' test_file = 'tests/oprec_contracts.rs' kind = 'Compile' outcome = 'Reject' + +[[cases]] +contract_id = 'OPREC-011' +case_id = 'oprec_011_unused_zero_sized_array_is_legal' +test_file = 'tests/oprec_contracts.rs' +kind = 'Compile' +outcome = 'Accept' diff --git a/crates/rumoca-contracts/data/contracts.toml b/crates/rumoca-contracts/data/contracts.toml index d03b2268a..21a2dd6c8 100644 --- a/crates/rumoca-contracts/data/contracts.toml +++ b/crates/rumoca-contracts/data/contracts.toml @@ -649,6 +649,24 @@ requirement = 'min/max require scalar enumeration, Boolean, Integer, or Real typ status = 'Implemented' tier = 1 +[[contracts]] +id = 'ARR-041' +category = 'Array' +name = 'diagonal vector shape' +mls_ref = '§10.3.5' +requirement = 'diagonal(v) requires a vector and returns a square matrix with both extents equal to size(v, 1)' +status = 'Implemented' +tier = 1 + +[[contracts]] +id = 'ARR-042' +category = 'Array' +name = 'outer product shape' +mls_ref = '§10.3.5' +requirement = 'outerProduct(v1, v2) requires two vectors and returns a matrix with extents size(v1, 1) and size(v2, 1)' +status = 'Implemented' +tier = 1 + [[contracts]] id = 'CLK-001' category = 'Clock' @@ -934,7 +952,7 @@ category = 'Connection' name = 'Expandable input deduction' mls_ref = '§9.1.3' requirement = 'Multiple inputs in expandable connectors deduced as input is error' -status = 'Implemented' +status = 'Deferred' tier = 1 [[contracts]] @@ -1015,7 +1033,7 @@ category = 'Connection' name = 'Expandable input source' mls_ref = '§9.1.3' requirement = 'If variable appears as input in expandable, should appear as non-input in at least one other' -status = 'Implemented' +status = 'Deferred' tier = 1 [[contracts]] @@ -1090,6 +1108,15 @@ requirement = 'Both arguments of connect must be connector references' status = 'Implemented' tier = 1 +[[contracts]] +id = 'CONN-030' +category = 'Connection' +name = 'Stream-to-stream' +mls_ref = '§9.3' +requirement = 'Stream variables may only connect to other stream variables' +status = 'Implemented' +tier = 1 + [[contracts]] id = 'DECL-001' category = 'Declaration' @@ -2113,7 +2140,8 @@ category = 'Expression' name = 'Event triggering operators' mls_ref = '§3.7.2' requirement = 'div, ceil, floor, integer can only change values at events and will trigger events as needed' -status = 'Implemented' +status = 'Partial' +notes = 'div/ceil/floor/integer are constructed and lowered as pure builtins (Solve UnaryOp::Floor/Ceil, no relation-memory owner), so no event root is generated at their step points; the contract therefore holds only for arguments that are already discrete between events.' tier = 1 [[contracts]] @@ -2373,8 +2401,8 @@ id = 'FUNC-029' category = 'Function' name = 'Record cast conditional error' mls_ref = '§12.6.1' -requirement = 'Conditional components in target record: it is an error' -status = 'Implemented' +requirement = 'A record cast is erroneous if a corresponding source model/block/connector component is conditional' +status = 'Deferred' tier = 1 [[contracts]] @@ -2431,6 +2459,34 @@ requirement = 'Most restrictive derivative annotations should be written first' status = 'NotApplicable' tier = 1 +[[contracts]] +id = 'FUNC-036' +category = 'Function' +name = 'ExternalObject lifecycle shape' +mls_ref = '§12.9.7' +requirement = 'ExternalObject owner uses the specialized class `class`, directly extends ExternalObject, owns exactly non-replaceable constructor and destructor functions, and owns no other elements' +status = 'Implemented' +tier = 1 + +[[contracts]] +id = 'FUNC-037' +category = 'Function' +name = 'ExternalObject lifecycle signatures' +mls_ref = '§12.9.7' +requirement = 'Constructor has exactly one output of the owning ExternalObject type; destructor has exactly one input of that type and no outputs' +status = 'Implemented' +tier = 1 + +[[contracts]] +id = 'FUNC-038' +category = 'Function' +name = 'ExternalObject lifecycle calls' +mls_ref = '§12.9.7' +requirement = 'Constructor and destructor cannot be called explicitly; each constructed object is constructed and destroyed exactly once' +status = 'Partial' +notes = 'Only the first clause is enforced: Resolve rejects explicit constructor/destructor calls with ER134. The exactly-once construction/destruction clause is unimplemented - no phase tracks ExternalObject lifetime or emits a destructor call, and ExternalObject values cannot reach the DAE at all (construction rejects them with UnsupportedFlatSemantics).' +tier = 1 + [[contracts]] id = 'INST-001' category = 'Instantiation' @@ -3313,13 +3369,24 @@ requirement = 'System shall consist of differential equations, discrete equation status = 'Implemented' tier = 1 +[[contracts]] +id = 'SIM-010' +category = 'Simulation' +name = 'Clocked event-iteration participation' +mls_ref = 'App B' +requirement = 'Clocked variables use previous values, and a clock partition is solved once per tick, in the first event iteration of that tick; that restriction governs fixed-point re-iteration of the partition, not value exchange between producers inside that single solution, and clocked lanes do not participate in ordinary z == pre(z), m == pre(m) convergence' +status = 'Partial' +notes = 'Partial as of 2026-08-12. Implemented and covered: the first-event-iteration restriction on clock solving and the exclusion of clocked lanes from the ordinary z == pre(z), m == pre(m) fixed point — bounded-proof covered by crates/rumoca-solver/src/verification/event_iteration.rs, whose atomic pre-advance property leaves clocked lanes unchanged, and exercised by tests/sim_contracts.rs::sim_010_clocked_counter_advances_once_per_tick. Same-tick value exchange between producers inside that single partition solution is implemented by the SPEC_0040 SOLVE-C57 owner (dev/2026-08-11-clock-partition-transaction-design.md): construction issues DiscreteSolveSystem::clock_partition_order and the runtime replays it over private work state, exercised by the sim_010 exchange tests in tests/sim_contracts.rs. Credit is NOT claimed: SDO-227 fails a green SIM-010 claim without the SPEC_0046 ResidualSccOwner, and a coupled simultaneous discrete residual SCC is still a typed rejection with that owner only preregistered (SPEC_0046 SDO-021/SDO-023). Per the crate Partial convention the tests are therefore absent from data/contract_cases.toml and SIM-010 is absent from IMPLEMENTED_CONTRACT_IDS.' +tier = 1 + [[contracts]] id = 'SM-001' category = 'StateMachine' name = 'Same clock' mls_ref = '§17.1' requirement = 'All state machine components must have the same clock' -status = 'Implemented' +status = 'NotImplemented' +notes = 'All MLS §17 operators are rejected with ER073; no state-machine clock partition is elaborated yet.' tier = 1 [[contracts]] @@ -3328,7 +3395,8 @@ category = 'StateMachine' name = 'Different priorities' mls_ref = '§17.1' requirement = 'All transitions leaving one state must have different priorities' -status = 'Implemented' +status = 'Partial' +notes = 'Resolve validates literal transition priorities, but executable state-machine elaboration is not implemented and ER073 rejects the model.' tier = 1 [[contracts]] @@ -3337,7 +3405,8 @@ category = 'StateMachine' name = 'Single initial state' mls_ref = '§17.1' requirement = 'One and only one instance in each state machine must be marked as initial' -status = 'Implemented' +status = 'Partial' +notes = 'Resolve validates the syntactic transition graph, but executable state-machine elaboration is not implemented and ER073 rejects the model.' tier = 1 [[contracts]] @@ -3346,7 +3415,8 @@ category = 'StateMachine' name = 'Priority minimum' mls_ref = '§17.1' requirement = 'Priority ≥ 1 required' -status = 'Implemented' +status = 'Partial' +notes = 'Resolve validates literal priorities, but executable state-machine elaboration is not implemented and ER073 rejects the model.' tier = 1 [[contracts]] @@ -3355,7 +3425,8 @@ category = 'StateMachine' name = 'Not in functions' mls_ref = '§17.1' requirement = 'None of these operators allowed inside function classes (transition, initialState, activeState, etc.)' -status = 'Implemented' +status = 'Partial' +notes = 'The function-context restriction is diagnosed, but all otherwise valid MLS §17 operator uses are also rejected with ER073.' tier = 1 [[contracts]] @@ -3364,7 +3435,8 @@ category = 'StateMachine' name = 'Equation context only' mls_ref = '§17.1' requirement = 'transition/initialState can only be used in equations, not in non-parameter if-equations or when-equations' -status = 'Implemented' +status = 'Partial' +notes = 'Resolve validates forbidden equation contexts, but executable state-machine elaboration is not implemented and ER073 rejects the model.' tier = 1 [[contracts]] @@ -3373,7 +3445,8 @@ category = 'StateMachine' name = 'activeState instance check' mls_ref = '§17.3.1' requirement = 'Error if the instance is not a state of a state machine' -status = 'Implemented' +status = 'Partial' +notes = 'Resolve validates activeState targets against the syntactic transition graph, but executable state-machine elaboration is not implemented.' tier = 1 [[contracts]] @@ -3382,7 +3455,8 @@ category = 'StateMachine' name = 'Parallel assignment error' mls_ref = '§17' requirement = 'Error if parallel state machines assign to same variable at same clock tick' -status = 'Implemented' +status = 'NotImplemented' +notes = 'All MLS §17 operators are rejected with ER073; parallel state machines and their assignments are not elaborated yet.' tier = 1 [[contracts]] @@ -3499,7 +3573,7 @@ category = 'Type' name = 'Plug-compatible modifier' mls_ref = '§6.5' requirement = 'Redeclarations must be plug-compatible with constraining interface' -status = 'Implemented' +status = 'Partial' tier = 1 [[contracts]] @@ -3508,7 +3582,7 @@ category = 'Type' name = 'Default-connectable' mls_ref = '§6.5' requirement = 'Additional public components must be default-connectable' -status = 'Implemented' +status = 'Partial' tier = 1 [[contracts]] @@ -3679,7 +3753,7 @@ category = 'Type' name = 'Transitively non-replaceable' mls_ref = '§6.4' requirement = 'If B is transitively non-replaceable then A must be transitively non-replaceable' -status = 'Implemented' +status = 'Partial' tier = 1 [[contracts]] @@ -3879,4 +3953,3 @@ mls_ref = '§19.1' requirement = "Multiplication uses dot notation: 'N.m' for newton-meter, not 'Nm'" status = 'Implemented' tier = 1 - diff --git a/crates/rumoca-contracts/data/formal_statements.toml b/crates/rumoca-contracts/data/formal_statements.toml new file mode 100644 index 000000000..801ed6b9f --- /dev/null +++ b/crates/rumoca-contracts/data/formal_statements.toml @@ -0,0 +1,1299 @@ +# Formal-statement registry — which formal statement justifies each pinned +# behavior, and whether it is spec-normative or oracle-implied. +# +# Hand written. Nothing generates this table: the classification each row makes +# is a judgment, and a generator would launder it. See +# `src/registry/formal.rs` for the schema, the tiers, and the rules +# `tests/formal_statement_invariants.rs` enforces over every row. +# +# Field summary: +# id FS--, category from SPEC_0022 +# statement the formal statement, one precise sentence +# tier SpecSourced | OracleImplied +# edition/section SpecSourced: the MLS edition and section +# quote_kind/quote SpecSourced: Verbatim MLS text, or the tree's Paraphrase +# quote_source SpecSourced: the file where `quote` already appears +# oracle/evidence OracleImplied: the oracle, and where its evidence is +# latitude_note OracleImplied: what the specification leaves open +# pin_kind Test | Site | Record +# pin_file/pin_anchor the file, and the exact text that must occur in it +# status Enforced | RecordedDivergence | Unimplemented + +# --------------------------------------------------------------------------- +# EQN — when-equation activation (MLS §8.3.5, §8.3.5.1, §8.5, §8.6) +# --------------------------------------------------------------------------- + +[[statements]] +id = 'FS-EQN-001' +statement = 'A when-clause activates on the rising edge of a Boolean activation buffer whose start value is the activation condition evaluated at the initialization instant, so a condition already true there presents no edge.' +tier = 'SpecSourced' +edition = '3.6' +section = '§8.3.5.1' +quote_kind = 'Verbatim' +quote = 'Boolean b(start = x.start > 2); b = x > 2; v1 = if edge(b) then expr1 else pre(v1);' +quote_source = 'crates/rumoca/tests/suite_core/time_event_when_activation.rs' +pin_kind = 'Test' +pin_file = 'crates/rumoca/tests/suite_core/time_event_when_activation.rs' +pin_anchor = 'a_state_condition_true_at_the_start_never_activates_on_either_session' +pin_polarity = 'Asserts' +status = 'Enforced' +note = 'rumoca left the buffer at false, which manufactured an edge at the initial event for every already-true activation on both solver paths.' + +[[statements]] +id = 'FS-EQN-002' +statement = 'Before the start of integration every variable satisfies v = pre(v), so an activation buffer cannot present a rising edge at the initial event unless the condition itself rises there.' +tier = 'SpecSourced' +edition = '3.6' +section = '§8.6' +quote_kind = 'Verbatim' +quote = 'Before the start of the integration, it must be guaranteed that for all variables `v`, `v = pre(v)`' +quote_source = 'crates/rumoca/tests/suite_core/time_event_when_activation.rs' +pin_kind = 'Test' +pin_file = 'crates/rumoca/tests/suite_core/time_event_when_activation.rs' +pin_anchor = 'a_falling_time_condition_never_activates_on_either_session' +pin_polarity = 'Asserts' +status = 'Enforced' +related_sections = ['§8.3.5.1'] + +[[statements]] +id = 'FS-EQN-003' +statement = 'A when-clause is active during initialization if and only if it is explicitly enabled with initial().' +tier = 'SpecSourced' +edition = '3.6' +section = '§8.6' +quote_kind = 'Verbatim' +quote = 'The equations of a when-clause are active during initialization, if and only if they are explicitly enabled with `initial()`' +quote_source = 'crates/rumoca/tests/suite_core/time_event_when_activation.rs' +pin_kind = 'Test' +pin_file = 'crates/rumoca/tests/suite_core/time_event_when_activation.rs' +pin_anchor = 'an_initial_activation_still_runs_at_the_initial_event_on_either_session' +pin_polarity = 'Asserts' +status = 'Enforced' +contracts = ['EQN-023'] + +[[statements]] +id = 'FS-EQN-004' +statement = 'An initial()-enabled when-clause does not enable the when-clauses beside it; every other activation still needs its own rising edge at the initial event.' +tier = 'SpecSourced' +edition = '3.6' +section = '§8.6' +quote_kind = 'Verbatim' +quote = 'The equations of a when-clause are active during initialization, if and only if they are explicitly enabled with `initial()`' +quote_source = 'crates/rumoca/tests/suite_core/time_event_when_activation.rs' +pin_kind = 'Test' +pin_file = 'crates/rumoca/tests/suite_core/time_event_when_activation.rs' +pin_anchor = 'an_initial_event_does_not_activate_the_conditions_beside_it' +pin_polarity = 'Asserts' +status = 'Enforced' +note = 'The seeded buffer removes spurious initial activations without removing the §8.6 one; this row is what keeps FS-EQN-001 and FS-EQN-002 readable as one fix rather than a suppression.' + +[[statements]] +id = 'FS-EQN-005' +statement = 'A when-clause activates only at the instant when its scalar condition, or any element of its vector condition, becomes true.' +tier = 'SpecSourced' +edition = '3.6' +section = '§8.3.5' +quote_kind = 'Verbatim' +quote = 'only at the instant when the scalar expression or any of the elements of the vector expression becomes true' +quote_source = 'crates/rumoca-phase-dae/src/construction/model_events.rs' +pin_kind = 'Site' +pin_file = 'crates/rumoca-phase-dae/src/construction/model_events.rs' +pin_anchor = 'lower_chain_guards' +pin_polarity = 'Asserts' +status = 'Enforced' + +[[statements]] +id = 'FS-EQN-006' +statement = 'A vector activation is realised as one Boolean buffer per element with the activation edge(b1) or ... or edge(bn), which is not the edge of the disjunction of the elements.' +tier = 'SpecSourced' +edition = '3.6' +section = '§8.3.5.1' +quote_kind = 'Paraphrase' +quote = '§8.3.5.1 realises that as one `Boolean bi` per element with the activation `edge(b1) or … or edge(bn)`' +quote_source = 'crates/rumoca-phase-dae/src/construction/conditions.rs' +pin_kind = 'Test' +pin_file = 'crates/rumoca/tests/suite_core/time_event_when_activation.rs' +pin_anchor = 'a_vector_activation_rises_where_its_disjunction_cannot' +pin_polarity = 'Asserts' +status = 'Enforced' +related_sections = ['§8.3.5'] +note = 'Folding a vector into one Or gives the whole vector one buffer, which deletes outright every activation whose disjunction is a tautology — the shape Modelica.Blocks.Logical.TriggeredTrapezoid and LogicalDelay are written in.' + +[[statements]] +id = 'FS-EQN-007' +statement = 'A source-written when-clause over a literal or parameter-constant condition still owns an activation buffer seeded from that condition, so it never has a rising edge and its body never runs.' +tier = 'SpecSourced' +edition = '3.6' +section = '§8.3.5.1' +quote_kind = 'Verbatim' +quote = 'Boolean b(start = x.start > 2); b = x > 2; v1 = if edge(b) then expr1 else pre(v1);' +quote_source = 'crates/rumoca/tests/suite_core/time_event_when_activation.rs' +pin_kind = 'Test' +pin_file = 'crates/rumoca/tests/suite_core/time_event_when_activation.rs' +pin_anchor = 'a_source_when_over_a_literal_never_runs' +pin_polarity = 'Asserts' +status = 'Enforced' +note = 'The synthesised activation of an unguarded algorithm section and of a section-level assert is a distinct DAE node (ConditionOperation::Always) that carries no buffer, so the two shapes can no longer be confused.' + +[[statements]] +id = 'FS-EQN-008' +statement = 'An assert is violated because its condition is false, not because it became false, so a section-level assertion is a level check that carries no activation buffer.' +tier = 'SpecSourced' +edition = '3.6' +section = '§8.3.7' +quote_kind = 'Verbatim' +quote = 'assert(condition, message) ... the assertion is violated if the condition is false' +quote_source = 'crates/rumoca-phase-dae/src/construction.rs' +pin_kind = 'Site' +pin_file = 'crates/rumoca-phase-dae/src/construction.rs' +pin_anchor = 'lower_assertions' +pin_polarity = 'Asserts' +status = 'Enforced' +related_sections = ['§8.3.5', '§8.5'] +note = 'An assertion written inside a when body keeps its edge, because there the activation belongs to the when.' + +[[statements]] +id = 'FS-EQN-009' +statement = 'The branches of a when/elsewhen chain are ordered by priority, so where two branch edges coincide the earlier branch resolves the assignment conflict.' +tier = 'SpecSourced' +edition = '3.6' +section = '§8.3.5.4' +quote_kind = 'Verbatim' +quote = 'can be used to resolve assignment conflicts since the first of the when/elsewhen parts are given higher priority than later ones' +quote_source = 'crates/rumoca-phase-dae/src/construction/model_events.rs' +pin_kind = 'Test' +pin_file = 'crates/rumoca-sim/src/solve_lowering/tests.rs' +pin_anchor = 'checked_when_elsewhen_priority_selects_first_on_simultaneous_rise' +pin_polarity = 'Asserts' +status = 'Enforced' +related_sections = ['§8.3.5', '§8.3.5.1'] +note = 'The priority rule lives in §8.3.5.4, the Single Assignment Rule applied to when-equations, not in §8.3.5 itself. FS-EQN-016 records the shape where rumoca does not reach this resolution, because its two branches land in different event iterations.' + +[[statements]] +id = 'FS-EQN-010' +statement = 'An elsewhen branch carries no condition of its own beyond its own rising edge, so a first branch that stays true does not suppress the branches after it.' +tier = 'SpecSourced' +edition = '3.6' +section = '§8.3.5' +quote_kind = 'Verbatim' +quote = 'only at the instant when the scalar expression or any of the elements of the vector expression becomes true' +quote_source = 'crates/rumoca-phase-dae/src/construction/model_events.rs' +pin_kind = 'Test' +pin_file = 'crates/rumoca-sim/src/solve_lowering/tests.rs' +pin_anchor = 'checked_when_elsewhen_runs_its_later_branch_while_the_first_is_still_true' +pin_polarity = 'Asserts' +status = 'Enforced' +note = 'The PersistentFirstPriority fixture. Guarding branch i with cond_i and not (cond_1 or ...) held y = 1 for the whole run where OpenModelica reaches y = 2 at t = 0.7.' + +[[statements]] +id = 'FS-EQN-011' +statement = 'A when body expands to simultaneous equations, one v = if edge(b) then expr else pre(v) per assigned variable, so a body that reads a variable it also assigns must observe the newly assigned value.' +tier = 'SpecSourced' +edition = '3.6' +section = '§8.3.5.1' +quote_kind = 'Verbatim' +quote = 'Boolean b(start = x.start > 2); b = x > 2; v1 = if edge(b) then expr1 else pre(v1);' +quote_source = 'crates/rumoca/tests/suite_core/time_event_when_activation.rs' +pin_kind = 'Record' +pin_file = 'crates/rumoca/tests/suite_core/time_event_when_activation.rs' +pin_anchor = 'A `when` body reading a variable the same body assigns — SPEC VIOLATION' +pin_polarity = 'PinsDivergence' +status = 'RecordedDivergence' +note = 'rumoca evaluates the rows of an event pass sequentially against the event-entry snapshot, so it sees the old value: omc gives b = 100 and rumoca gives b = -100 on both solver sessions. This is the whole of what separates rumoca from omc on the *shape* of Modelica.Blocks.Math.ContinuousSignalExtrema, whose t_min/t_max read y_min after the same body assigns it — the model itself does not compile in rumoca today (initial() inside an expression and terminal(), ED018; pre of a continuous variable, ED019), so no claim is made about its measured behavior. The claim is measured on reductions of the shape.' + +[[statements]] +id = 'FS-EQN-012' +statement = 'Whether the special relations time >= discrete expression and time < discrete expression trigger a time event at time = discrete expression is left to the implementation.' +tier = 'SpecSourced' +edition = '3.6' +section = '§8.5' +quote_kind = 'Verbatim' +quote = 'It is a quality of implementation issue that the following special relations `time >= discrete expression`, `time < discrete expression` trigger a time event at `time = discrete expression`' +quote_source = 'crates/rumoca/tests/suite_core/time_event_when_activation.rs' +pin_kind = 'Site' +pin_file = 'crates/rumoca-phase-dae/src/construction/analysis/expression_events.rs' +pin_anchor = 'time_event_instant' +pin_polarity = 'Asserts' +status = 'Enforced' +note = 'This row states the freedom only. FS-EQN-013 states which side of it rumoca takes and why.' + +[[statements]] +id = 'FS-EQN-013' +statement = 'A when activation over time and parameter-evaluable operands owns an exactly scheduled instant rather than a located zero crossing, because a crossing located on the instant leaves a strict relation still reading false and is consumed with the activation never true.' +tier = 'OracleImplied' +oracle = 'OpenModelica (omc, dassl, stopTime = 1.0, numberOfIntervals = 20)' +evidence = 'crates/rumoca/tests/suite_core/time_event_when_activation.rs — the module-header comparison table, eight models measured at t = 0.45 / 0.5 / 0.55' +latitude_note = 'MLS §8.5 grants the choice (FS-EQN-012), so a located crossing conforms as well as a schedule. Only the schedule reproduces the omc column, and only for a when activation: conditions of assertions and of algorithm if-statements keep owning their events through their own semantic owners.' +pin_kind = 'Test' +pin_file = 'crates/rumoca/tests/suite_core/time_event_when_activation.rs' +pin_anchor = 'strict_time_activation_fires_at_its_instant' +pin_polarity = 'Asserts' +status = 'Enforced' + +[[statements]] +id = 'FS-EQN-014' +statement = 'An event-generating expression has an internal buffer whose value can change only at event instants; during continuous integration it holds the value from the last event instant.' +tier = 'SpecSourced' +edition = '3.6' +section = '§8.5' +quote_kind = 'Verbatim' +quote = 'An event generating expression has an internal buffer, and the value of the expression can only be changed at event instants' +quote_source = 'crates/rumoca/tests/suite_core/time_event_when_activation.rs' +pin_kind = 'Site' +pin_file = 'crates/rumoca-phase-dae/src/construction/analysis/expression_events.rs' +pin_anchor = 'collect_activation_time_events' +pin_polarity = 'Asserts' +status = 'Enforced' + +[[statements]] +id = 'FS-EQN-015' +statement = 'A scheduled time-event instant and a state crossing that land on the same instant belong to one event iteration, so an activation that reads pre(a) at that instant reads the value a held before the iteration.' +tier = 'SpecSourced' +edition = '3.6' +section = '§8.5' +quote_kind = 'Verbatim' +quote = 'An event generating expression has an internal buffer, and the value of the expression can only be changed at event instants' +quote_source = 'crates/rumoca/tests/suite_core/time_event_when_activation.rs' +pin_kind = 'Record' +pin_file = 'crates/rumoca/tests/suite_core/time_event_when_activation.rs' +pin_anchor = 'REGRESSION INTRODUCED EARLIER' +pin_polarity = 'PinsDivergence' +status = 'RecordedDivergence' +note = 'Giving the time relation a scheduled instant splits the two activations into two iterations on the rk-like session, and b regressed from 10 to 11 relative to the pre-change tree. Measured and accepted: no MSL when gains a scheduled instant, and five other probes moved from wrong to omc-matching. The fix belongs to the rk-like session, which applies a scheduled right limit without carrying the continuous state across it.' + +[[statements]] +id = 'FS-EQN-016' +statement = 'Two branches of one when/elsewhen chain whose instants coincide are resolved in one event iteration, so branch priority decides and the first branch assignment stands for the rest of the run.' +tier = 'SpecSourced' +edition = '3.6' +section = '§8.3.5.1' +quote_kind = 'Paraphrase' +quote = '§8.3.5.1 spells the chain out as one if-expression per assigned variable whose arms are `edge(b1)`, `edge(b2)`, … over one `Boolean bi` per branch condition' +quote_source = 'crates/rumoca-phase-dae/src/construction/model_events.rs' +pin_kind = 'Record' +pin_file = 'crates/rumoca/tests/suite_core/time_event_when_activation.rs' +pin_anchor = 'REGRESSION INTRODUCED HERE' +pin_polarity = 'PinsDivergence' +status = 'RecordedDivergence' +related_sections = ['§8.3.5', '§8.3.5.4'] +note = 'A violation rather than a latitude: §8.3.5.1 gives each assigned variable ONE if-elseif expression whose arms are the branch edges, and one equation cannot be evaluated across two event iterations, so splitting the chain is not a scheduling freedom. §8.3.5.4 then supplies the resolution FS-EQN-009 carries. rumoca gives the first branch a scheduled instant and the second a located crossing, which land in different iterations, so the second branch finds its own edge where the first no longer has one: y regressed from 1 to 2 relative to the pre-change tree on this shape. Before the branch guards were corrected the level subtraction masked it. The diffsol session leaves y = 0 here, before and after.' + +[[statements]] +id = 'FS-EQN-017' +statement = 'A scalar initial() occurring inside a larger activation condition does not enable the when-clause during initialization; §8.6 enables exactly the spellings when initial() then and when {..., initial(), ...} then.' +tier = 'OracleImplied' +oracle = 'OpenModelica (omc)' +evidence = 'crates/rumoca/tests/suite_core/time_event_when_activation.rs — the divergence list in the module header' +latitude_note = 'MLS §8.6 names two enabling spellings; that a condition merely containing initial() is therefore not enabled is read off omc, which enables neither `when initial() or time > 0.5` nor `when not initial()`. rumoca treats any condition containing initial() as enabling. The vector spelling is correct; the scalar one is a separate §8.6 admissibility question and no test asserts it.' +pin_kind = 'Record' +pin_file = 'crates/rumoca/tests/suite_core/time_event_when_activation.rs' +pin_anchor = 'A scalar `initial()` inside a larger condition enables the whole' +pin_polarity = 'PinsDivergence' +status = 'RecordedDivergence' + +[[statements]] +id = 'FS-EQN-018' +statement = 'A vector activation carrying initial() beside a time threshold that the event itself moves owns a dynamic time-event deadline that is re-evaluated after every event boundary.' +tier = 'SpecSourced' +edition = '3.6' +section = '§8.3.5' +quote_kind = 'Paraphrase' +quote = 'A vector activation whose `time` threshold reschedules itself owns a checked dynamic deadline.' +quote_source = 'crates/rumoca/tests/suite_core/time_event_when_activation.rs' +pin_kind = 'Test' +pin_file = 'crates/rumoca/tests/suite_core/time_event_when_activation.rs' +pin_anchor = 'a_rescheduling_vector_activation_owns_a_dynamic_time_event' +pin_polarity = 'Asserts' +status = 'Enforced' +note = 'This is the shape used by Modelica.Blocks.Sources.TimeTable and CombiTimeTable. The checked DAE distinguishes the dynamic deadline from both a static scheduled instant and a continuously searched root.' + +# --------------------------------------------------------------------------- +# SIM — the §8.6 initialization instant, and solver-session divergences +# --------------------------------------------------------------------------- + +[[statements]] +id = 'FS-SIM-001' +statement = 'For every Real variable with fixed = true, the equation variable = startExpression is added to the initialization equations.' +tier = 'SpecSourced' +edition = '3.6' +section = '§8.6' +quote_kind = 'Paraphrase' +quote = 'For every Real variable vc with fixed = true, the equation vc = startExpression is added to the initialization equations.' +quote_source = 'crates/rumoca-phase-structural/src/dae_transform/tests/initial_values.rs' +pin_kind = 'Site' +pin_file = 'crates/rumoca-phase-solve/src/lower/initial_pins.rs' +pin_anchor = 'lower_transferred_initial_values' +pin_polarity = 'Asserts' +status = 'Enforced' +contracts = ['SIM-002', 'SIM-004'] +note = 'A demotion turns a state into an algebraic, which owns no initialization equation, so the structural phase proves which state a pin fixes and carries the value over.' + +[[statements]] +id = 'FS-SIM-002' +statement = 'Every parameter declared fixed = false is an unknown of the initialization system, and its start value may be used as a guess.' +tier = 'SpecSourced' +edition = '3.6' +section = '§8.6' +quote_kind = 'Paraphrase' +quote = 'All variables declared as parameter having `fixed = false` are treated as unknowns during the initialization phase, i.e., there must be additional equations for them — and the start-value can be used as a guess-value during initialization.' +quote_source = 'crates/rumoca-phase-solve/src/lower/initial_parameters.rs' +pin_kind = 'Test' +pin_file = 'crates/rumoca-phase-solve/src/tests/initialization.rs' +pin_anchor = 'fixed_false_parameter_becomes_an_initialization_projection_unknown' +pin_polarity = 'Asserts' +status = 'Enforced' +contracts = ['SIM-003'] + +[[statements]] +id = 'FS-SIM-003' +statement = 'A parameter that has both a binding equation and fixed = false is solved from its binding equation, not from the initialization projection.' +tier = 'SpecSourced' +edition = '3.6' +section = '§8.6' +quote_kind = 'Verbatim' +quote = 'In the case a parameter has both a binding equation and `fixed = false` a diagnostic is recommended, but the parameter should be solved from the binding equation.' +quote_source = 'crates/rumoca-phase-solve/src/lower/initial_parameters.rs' +pin_kind = 'Test' +pin_file = 'crates/rumoca-phase-solve/src/tests/initialization.rs' +pin_anchor = 'a_parameter_reading_an_initialization_unknown_is_re_applied_after_the_solve' +pin_polarity = 'Asserts' +status = 'Enforced' +note = 'A bound parameter reading a projection unknown is both re-applied as an update row and substituted into every residual reading it, so the projection solves the guessed unknowns simultaneously with the binding rather than iterating against a stale number.' + +[[statements]] +id = 'FS-SIM-004' +statement = 'A parameter carrying both a binding equation and fixed = false draws no diagnostic.' +tier = 'OracleImplied' +oracle = 'OpenModelica (omc)' +evidence = 'crates/rumoca-phase-solve/src/lower/initial_parameters.rs — the module header' +latitude_note = 'MLS §8.6 recommends a diagnostic rather than requiring one, and the declaration has one unambiguous reading, which is the one implemented. omc accepts the same models with a probably-redundant warning; rumoca emits nothing, so the two tools differ on diagnostics and agree on values.' +pin_kind = 'Record' +pin_file = 'crates/rumoca-phase-solve/src/lower/initial_parameters.rs' +pin_anchor = 'probably redundant' +pin_polarity = 'PinsDivergence' +status = 'RecordedDivergence' + +[[statements]] +id = 'FS-SIM-005' +statement = 'The declared start value of an initialization unknown is the guess the projection starts from, so a nonlinear initialization block with several roots is decided by the guess and not by the equations alone.' +tier = 'SpecSourced' +edition = '3.6' +section = '§8.6' +quote_kind = 'Verbatim' +quote = 'the start-value can be used as a guess-value during initialization' +quote_source = 'crates/rumoca-phase-solve/src/lower/initial_parameters.rs' +pin_kind = 'Site' +pin_file = 'crates/rumoca-phase-solve/src/lower/initial_projection.rs' +pin_anchor = 'initialization_unknown_space' +pin_polarity = 'Asserts' +status = 'Enforced' +note = 'q*q = 4 from start = 3 gives 2 and from start = -3 gives -2; both are legal §8.6 answers, so a cross-tool comparison of such a model compares guesses as much as equations.' + +[[statements]] +id = 'FS-SIM-006' +statement = 'Where one initialization equation cannot fix two guessed states, which state keeps its guess is not determined by the model.' +tier = 'OracleImplied' +oracle = 'OpenModelica (omc)' +evidence = 'crates/rumoca-phase-solve/src/lower/initial_projection.rs — the module header, under the two choices this phase makes that the model does not' +latitude_note = 'Nothing in §8.6 picks between them, because the model states nothing about it. omc warns that the initial conditions are not fully specified and keeps x, solving y = 5; this planner takes the first augmenting assignment in solver-slot order and keeps y, solving x = 2. Neither answer is more correct; the row exists so the choice cannot drift silently.' +pin_kind = 'Test' +pin_file = 'crates/rumoca-sim/src/solve_lowering/tests.rs' +pin_anchor = 'an_under_determined_state_component_keeps_the_remaining_start_guesses' +pin_polarity = 'PinsDivergence' +status = 'RecordedDivergence' + +[[statements]] +id = 'FS-SIM-007' +statement = 'The initialization system is simultaneous over every equation of the model at once, so an initialization row that reads an algebraic variable determines that variable together with the states: `a = 2*time + 5; der(x) = a - x;` with `initial equation der(x) = 0` initializes x to 5.' +tier = 'SpecSourced' +edition = '3.6' +section = '§8.6' +quote_kind = 'Verbatim' +quote = 'The initialization uses all equations and algorithms that are utilized in the intended operation.' +quote_source = 'crates/rumoca-phase-solve/src/lower/initial_projection.rs' +pin_kind = 'Test' +pin_file = 'crates/rumoca-sim/src/solve_lowering/tests.rs' +pin_anchor = 'an_algebraic_reading_initialization_row_cannot_certify_against_a_stale_seed' +pin_polarity = 'PinsDivergence' +status = 'RecordedDivergence' +related_sections = ['§8.6'] +note = 'This is a §8.6 requirement, not a latitude: der(...) and the pre-variables are interpreted as unknown algebraic variables and the whole equation set is solved at once, so x(0) = 5 is required rather than chosen. Initialization residual evaluation now reconstructs algebraic/output coordinates before certifying the complete residual, preventing declaration seeds from silently certifying x(0) = 0. Rows requiring the not-yet-proved coupled algebraic/initial projection fail closed with a typed unowned-coordinate diagnostic. That is sound for a small verified profile but is still a Modelica completeness gap; enforcing this statement requires a checked simultaneous reduction with total derivatives.' + +[[statements]] +id = 'FS-SIM-008' +statement = 'An initialization system whose rows read a discrete coordinate may still be solvable, so `x + q = 5; x = d + 2;` with `d(start = 0, fixed = true)` is determined at x = 2, q = 3.' +tier = 'OracleImplied' +oracle = 'OpenModelica (omc)' +evidence = 'crates/rumoca-phase-solve/src/lower/initial_projection.rs — the module header, under discrete reads' +latitude_note = 'The discrete coordinate is at its §8.6 value when the residual runs, so the row checks a real number; rumoca simply has no way to solve for the state it also reads and reports EX001. That is an over-refusal against omc, not merely an unplanned row.' +pin_kind = 'Record' +pin_file = 'crates/rumoca-phase-solve/src/lower/initial_projection.rs' +pin_anchor = 'That is an over-refusal against OMC' +pin_polarity = 'PinsDivergence' +status = 'RecordedDivergence' + +[[statements]] +id = 'FS-SIM-009' +statement = 'A self-rescheduling when guard whose threshold is already met at the start instant has no rising edge there, so it never fires, never re-arms, and its accumulator holds its start value for the whole run.' +tier = 'OracleImplied' +oracle = 'OpenModelica (omc)' +evidence = 'crates/rumoca/tests/suite_core/periodic_source_counter_regression.rs — module-header item 8 and the TSTART_THRESHOLD_STALLS table' +latitude_note = 'The behavior follows from §8.3.5.1 start clause and §8.6 pre(v) = v (FS-EQN-001, FS-EQN-002), but that derivation is rumoca reasoning; what fixes the answer as count = 0 for the whole run is the omc run. The companion model with startTime = -0.035, whose first threshold is not met at the start, counts normally and is what keeps this row from reading as self-rescheduling thresholds do not work.' +pin_kind = 'Test' +pin_file = 'crates/rumoca/tests/suite_core/periodic_source_counter_regression.rs' +pin_anchor = 'initial_self_rescheduling_counter_never_starts_when_its_threshold_is_already_met' +pin_polarity = 'Asserts' +status = 'Enforced' +note = 'CAVEAT: the pinned case is the narrow one — a single self-rescheduling guard already met at the start. The wider shape, where a discrete accumulator stalls because its initial value leaves every guard in a chain already satisfied, is not pinned by any test in this tree, so this row must not be read as covering it.' + +[[statements]] +id = 'FS-SIM-010' +statement = 'A when-clause that legitimately runs at the initial event runs exactly once there.' +tier = 'OracleImplied' +oracle = 'OpenModelica (omc)' +evidence = 'crates/rumoca/tests/suite_core/time_event_when_activation.rs — the initial()-enabled activation is observed at the start and remains at one through the run on both sessions' +latitude_note = 'MLS §8.6 requires an initial()-enabled when-clause to be active during initialization but does not prescribe how many internal event-iteration passes a tool uses. OpenModelica selects the observable result of one application; both numerical sessions now produce that same result through the common FMI ME host.' +pin_kind = 'Test' +pin_file = 'crates/rumoca/tests/suite_core/time_event_when_activation.rs' +pin_anchor = 'an_initial_activation_still_runs_at_the_initial_event_on_either_session' +pin_polarity = 'Asserts' +status = 'Enforced' + +[[statements]] +id = 'FS-SIM-011' +statement = 'A zero crossing over a continuous state is located at the instant the state reaches the threshold.' +tier = 'OracleImplied' +oracle = 'OpenModelica (omc)' +evidence = 'crates/rumoca/tests/suite_core/time_event_when_activation.rs — the divergence list in the module header' +latitude_note = 'MLS §8.5 requires the crossing to be located but fixes no accuracy for the search. On SimSolverMode::Bdf `when x > 0.3 then y = 1` with der(x) = 1 first reads y = 1 at t = 0.7 rather than 0.3. Measured identical before and after the activation work, so it is that session root location and not the activation semantics.' +pin_kind = 'Record' +pin_file = 'crates/rumoca/tests/suite_core/time_event_when_activation.rs' +pin_anchor = 'The diffsol session and a located state crossing' +pin_polarity = 'PinsDivergence' +status = 'RecordedDivergence' + +[[statements]] +id = 'FS-SIM-012' +statement = 'The activation of a when-clause is applied at the event right limit, and the output row is stamped with that limit rather than with the instant.' +tier = 'OracleImplied' +oracle = 'OpenModelica (omc, dassl, stopTime = 1.0, numberOfIntervals = 20)' +evidence = 'crates/rumoca/tests/suite_core/time_event_when_activation.rs — the module header, on the one presentational difference asserted around' +latitude_note = 'Both tools apply the activation at the right limit, where the §8.5 buffered relation first reads true, and both stamp the row with that limit; they differ only in how wide it is. omc uses about 4e-10 relative; rumoca uses 2 * atol, i.e. 0.500002 for an instant at 0.5 at the default atol. The values agree and the stamps differ by 2e-6 out of a 0.05 output interval.' +pin_kind = 'Test' +pin_file = 'crates/rumoca/tests/suite_core/time_event_when_activation.rs' +pin_anchor = 'the_diffsol_session_agrees_on_a_scheduled_instant' +pin_polarity = 'Asserts' +status = 'Enforced' + +[[statements]] +id = 'FS-SIM-013' +statement = 'A when-clause over a relation that is already true at the start instant does not fire there, while one that becomes true just after the start fires once at the start right limit.' +tier = 'OracleImplied' +oracle = 'OpenModelica (omc)' +evidence = 'crates/rumoca-phase-dae/src/construction/analysis/expression_events.rs — the doc comment on the time-event instant bound; the omc right-limit row is stamped t = 1e-10 for `when time > 0`' +latitude_note = 'MLS §8.5 defines an event as the instant an event-generating expression changes value, and nothing changes at the start; whether a tool nevertheless schedules a stop there is not fixed. rumoca leaves both spellings to the zero crossing, which locates them at the start and applies them at the right limit — where omc applies them too. Both halves now agree with omc on the rk-like session.' +pin_kind = 'Test' +pin_file = 'crates/rumoca/tests/suite_core/time_event_when_activation.rs' +pin_anchor = 'an_activation_at_the_start_instant_fires_exactly_once' +pin_polarity = 'Asserts' +status = 'Enforced' +note = 'This row previously recorded `when time >= 0` firing at t = 0 against omc. That divergence is gone: seeding the activation buffer from the settled initialization values (FS-EQN-001) removed the manufactured edge, and a probe of both sessions at this tree confirms `when time >= 0` no longer fires. The stale claim survived in the expression_events.rs doc comment and was corrected with this row. FS-SIM-014 carries the divergence the same probe did find.' + +[[statements]] +id = 'FS-SIM-014' +statement = 'A when-clause whose relation becomes true just after the start instant fires once, on every solver session.' +tier = 'OracleImplied' +oracle = 'OpenModelica (omc), against a probe of both rumoca solver sessions at this tree' +evidence = 'crates/rumoca-phase-dae/src/construction/analysis/expression_events.rs — the doc comment on the time-event instant bound, which names the Bdf gap' +latitude_note = 'MLS §8.5 requires the crossing to be located but fixes neither the accuracy of the search nor how a session applies a crossing that lands on the start instant. On SimSolverMode::RkLike `when time > 0` fires once at the right limit as omc does; on SimSolverMode::Bdf it does not fire at all, because the crossing located at the start instant is never applied there. That is a diffsol event-boundary defect rather than an owner decision.' +pin_kind = 'Record' +pin_file = 'crates/rumoca-phase-dae/src/construction/analysis/expression_events.rs' +pin_anchor = 'diffsol event-boundary defect' +pin_polarity = 'PinsDivergence' +status = 'RecordedDivergence' +note = 'NEEDS A TEST. No test pins either half of this row: the rk-like agreement is covered incidentally by an_activation_at_the_start_instant_fires_exactly_once (FS-SIM-013), but the Bdf gap is recorded only in prose. A wave that touches the diffsol event boundary should convert this Record pin into a Test pin and flip its polarity.' + +[[statements]] +id = 'FS-SIM-015' +statement = 'A state-carrying linked FMI Model Exchange component is integrated only as a reduced state-only ODE, and one whose state derivatives read a solver coordinate that no algebraic projection block produces is rejected by name rather than re-expressed as a general implicit DAE.' +tier = 'SpecSilent' +rationale = 'MLS §8.6 scopes what a tool must establish before and during a simulation run — the initialization system, the event iteration, and the values every variable holds at each instant — and it states those as properties of the solution, never of the system form or integrator used to reach them. The silence is therefore established by what §8.6 does state: it fixes the answer and leaves the machinery free, so a tool carrying a general implicit DAE beside the reduced ODE conforms exactly as well as one carrying only the reduced form. Nothing in §8.6, or in the §8.5 event semantics it leans on, distinguishes them.' +alternatives_considered = 'Keeping both systems was the status quo and was refused on evidence rather than on conformance: a census of the 566-model MSL cohort found zero models constructing the general one, so it was carried, untested by any cohort model, purely as a fallback — and as a fallback it could only ever have absorbed a rumoca-phase-solve regression by switching integrators with no diagnostic. Keeping it behind a flag was refused: pre-1.0 the tree deletes a superseded path rather than gating it. Rejecting unprojectable models *silently* (returning the old boolean false) was refused because an unprojectable coordinate is a lowering defect, and answering a defect with a different integrator hides it. FMI 3.0 Model Exchange was considered as an oracle and rejected as one: ME constrains what a component exports, not what a tool integrates internally, and a mass-matrix system M·x@ = f with invertible M is presentable as x@ = M**-1 f anyway — this crate already does exactly that in OdeModel::solve_state_mass — so ME implies nothing about the general path.' +oracle_consulted = 'None. OpenModelica settles trajectory values, not which internal system form a tool reduces to, so it cannot separate the two candidates: both produce the same trajectories, which is why the 566-model freeze comparison was byte-identical on both sides.' +pin_kind = 'Test' +pin_file = 'crates/rumoca-solver/src/fmi_me/validation.rs' +pin_anchor = 'linked_component_rejects_an_unprojectable_derivative_dependency_by_name' +pin_polarity = 'Asserts' +status = 'Enforced' +note = 'The common linked FMI ME component now owns this validation before any numerical plugin is selected, so BDF and RK-like sessions admit and reject the same model set. The retired general path cannot remain reachable as a solver-specific fallback.' + +[[statements]] +id = 'FS-SIM-018' +statement = 'At a scheduled time-event instant every numerical solver plugin records one canonical observation carrying the settled right-limit value.' +tier = 'SpecSilent' +rationale = 'MLS §8.5 fixes what happens *to the solution* at an event instant — the integration halts, the discrete equations are re-evaluated until the event iteration settles, and every variable has a left and a right limit there — and MLS §8.6 fixes what must hold before integration starts. Neither states which of those two limits a tool must deposit in its result at the instant, nor whether it must deposit both. That is a result-file convention, not a solution property: a tool that reports only the right limit and one that reports both agree on every value MLS constrains. The silence is established by what §8.5 does state, exactly as for FS-SIM-015: it fixes the answer and leaves the reporting free.' +alternatives_considered = 'Keeping solver-specific observation conventions was refused because it makes a model trace depend on the numerical plugin and shifts every later row in a row-indexed comparison. Recording both limits was also considered, but the ME runtime already owns the settled superdense state and all plugins can expose that state through one common convention.' +oracle_consulted = 'OpenModelica (omc a96aa1a-cmake). On the pinned ScheduledStep probe with a 0.01 output grid, its CSV records the ordinary aligned sample at t = 0.5 with Vs = 0, then an event pair at t = 0.5000000000000306 with Vs = 0 and Vs = 24. The few-ULP displacement and total row count are tool artifacts, but the same-time left/right pair decisively selects two-sided boundary observability over the kernel host current right-only convention.' +pin_kind = 'Test' +pin_file = 'crates/rumoca/tests/suite_core/fmi_me_host_divergence.rs' +pin_anchor = 'the_two_hosts_agree_on_the_observation_at_a_scheduled_event_instant' +pin_polarity = 'Asserts' +status = 'Enforced' +note = 'The common ME runtime owns event iteration and observation projection. The test pins plugin-independent trace shape and value at the scheduled boundary; OpenModelica remains the external trace oracle even though its result file chooses a two-sided reporting convention.' + +# --------------------------------------------------------------------------- +# EXPR — event-generating operators (MLS §3.7.5) and semiLinear (§3.7.4.5) +# --------------------------------------------------------------------------- + +[[statements]] +id = 'FS-EXPR-001' +statement = 'noEvent mandates that no zero crossing function monitors any normally event-generating subexpression inside its argument.' +tier = 'SpecSourced' +edition = '3.6' +section = '§3.7.5' +quote_kind = 'Verbatim' +quote = 'no zero crossing functions shall be used to monitor any of the normally event-generating subexpressions inside expr' +quote_source = 'crates/rumoca-phase-dae/src/construction/analysis/expression_events.rs' +pin_kind = 'Site' +pin_file = 'crates/rumoca-phase-dae/src/construction/analysis/expression_events.rs' +pin_anchor = 'collect_event_owners' +pin_polarity = 'Asserts' +status = 'Enforced' +contracts = ['EXPR-039'] + +[[statements]] +id = 'FS-EXPR-002' +statement = 'smooth grants a freedom rather than imposing a rule: a tool may decline to generate events for expressions inside it, and smooth does not guarantee that no events are generated.' +tier = 'SpecSourced' +edition = '3.6' +section = '§3.7.5' +quote_kind = 'Verbatim' +quote = 'a tool is free to not generate events for expressions inside `smooth`. However, `smooth` does not guarantee that no events will be generated' +quote_source = 'crates/rumoca-phase-dae/src/construction/analysis/expression_events.rs' +pin_kind = 'Site' +pin_file = 'crates/rumoca-phase-dae/src/construction/analysis/expression_events.rs' +pin_anchor = 'collect_event_owners' +pin_polarity = 'Asserts' +status = 'Enforced' +contracts = ['EXPR-038'] +note = 'This row states the freedom only. FS-EXPR-003 states which side of it rumoca takes.' + +[[statements]] +id = 'FS-EXPR-003' +statement = 'No relation inside a smooth owns a zero crossing, including the relation the semiLinear definition places inside its own smooth.' +tier = 'OracleImplied' +oracle = 'OpenModelica (omc), read from its generated C for Modelica.Thermal.FluidHeatFlow.Examples.PumpDropOut' +evidence = 'crates/rumoca-phase-dae/src/construction/analysis/expression_semi_linear.rs — the events section of the module header' +latitude_note = 'MLS §3.7.5 grants the freedom (FS-EXPR-002) rather than mandating suppression; only noEvent says shall. rumoca takes the freedom uniformly. omc resolves it the same way: the Rule 1 selectors appear as ordinary equations while the zero-crossing table lists only the ramp time events and the friction relations.' +pin_kind = 'Test' +pin_file = 'crates/rumoca-phase-dae/src/construction/tests/semi_linear.rs' +pin_anchor = 'semi_linear_owns_no_state_event_because_its_definition_is_smooth' +pin_polarity = 'Asserts' +status = 'Enforced' + +[[statements]] +id = 'FS-EXPR-004' +statement = 'semiLinear(x, positiveSlope, negativeSlope) is defined as smooth(0, if x >= 0 then positiveSlope*x else negativeSlope*x).' +tier = 'SpecSourced' +edition = '3.6' +section = '§3.7.4.5' +quote_kind = 'Paraphrase' +quote = 'smooth(0, if x >= 0 then positiveSlope*x else negativeSlope*x)' +quote_source = 'crates/rumoca-phase-dae/src/construction/analysis/expression_semi_linear.rs' +pin_kind = 'Test' +pin_file = 'crates/rumoca-phase-dae/src/construction/tests/semi_linear.rs' +pin_anchor = 'semi_linear_lowers_to_the_checked_conditional_of_its_two_segments' +pin_polarity = 'Asserts' +status = 'Enforced' + +[[statements]] +id = 'FS-EXPR-005' +statement = 'Equations with semiLinear become underdetermined when the first argument reaches zero, and it is recommended that Rule 1 and Rule 2 transform such equation sets during translation to select one meaningful solution.' +tier = 'SpecSourced' +edition = '3.6' +section = '§3.7.4.5' +quote_kind = 'Paraphrase' +quote = 'In some situations, equations with the `semiLinear` function become underdetermined if the first argument (`x`) becomes zero, i.e., there is an infinite number of solutions. It is recommended that the following rules are used to transform the equations during the translation phase in order to select one meaningful solution in such cases.' +quote_source = 'crates/rumoca-phase-dae/src/construction/analysis/expression_semi_linear.rs' +pin_kind = 'Test' +pin_file = 'crates/rumoca-phase-dae/src/construction/tests/semi_linear.rs' +pin_anchor = 'rule_one_rewrites_the_underdetermined_pair_into_a_selector_and_a_collapsed_operator' +pin_polarity = 'Asserts' +status = 'Enforced' +note = 'The transformation is recommended, not required, so the untransformed equation set is conformant and this owner never rejects: it transforms only what it can prove.' + +[[statements]] +id = 'FS-EXPR-006' +statement = 'semiLinear(m_flow, port_h, h) is identical to -semiLinear(-m_flow, h, port_h), which is what lets an equation whose x and y are both the negation of the group be read as the same equation with its slopes exchanged.' +tier = 'SpecSourced' +edition = '3.6' +section = '§3.7.4.5' +quote_kind = 'Verbatim' +quote = '`semiLinear(m_flow, port_h, h)` is identical to `-semiLinear(-m_flow, h, port_h)`' +quote_source = 'crates/rumoca-phase-dae/src/construction/analysis/expression_semi_linear.rs' +pin_kind = 'Test' +pin_file = 'crates/rumoca/tests/suite_core/semi_linear_zero_flow.rs' +pin_anchor = 'zero_flow_node_enthalpy_is_determined_by_the_rule_one_selector_not_held_by_the_solver' +pin_polarity = 'Asserts' +status = 'Enforced' +note = 'No MSL model writes the Rule 1 chain literally; FluidHeatFlow writes one port equation each and the connection equations are what make two of them share one x and one y.' + +[[statements]] +id = 'FS-EXPR-007' +statement = 'A junction of more than two semiLinear ports, whose port enthalpy flows are three different variables constrained only by one sum, has no selected solution at exactly zero flow.' +tier = 'SpecSilent' +rationale = 'MLS §3.7.4.5 states Rule 1 over a *chain*: a sequence of equations that all share one y and one x, whose slopes link end to end. A three-way junction shares neither, so the rule does not reach it — the silence is scoped by the rule the section does state, not merely by the section failing to mention junctions. §3.7.4.5 only *recommends* transforming the underdetermined case away where its rules apply, so leaving the junction rows as written is conformant.' +alternatives_considered = 'Rejecting the shape was considered and refused: a structurally identical model whose x never reaches zero simulates correctly without any transformation, so rejection would take those models too. Inventing a junction rule was refused because any choice of one meaningful solution would be this compiler picking a value the model never states.' +pin_kind = 'Record' +pin_file = 'crates/rumoca-phase-dae/src/construction/analysis/expression_semi_linear.rs' +pin_anchor = 'the MLS supplies no rule' +pin_polarity = 'PinsDivergence' +status = 'Unimplemented' +note = 'A per-node property, not a per-model one: ParallelPumpDropOut and TwoMass each join three FlowPorts at a node and also carry ordinary two-port nodes whose rows this owner does chain and rewrite.' + +[[statements]] +id = 'FS-EXPR-008' +statement = 'Which end of a semiLinear chain the surviving positive slope comes from is not determined; the two orientations differ only in the value at exactly x == 0 and both satisfy Rule 1.' +tier = 'SpecSilent' +rationale = 'MLS §3.7.4.5 states Rule 1 as a rewrite over a chain without naming which endpoint sa is, and the section supplies the identity that makes the two orientations interchangeable — semiLinear(m_flow, port_h, h) is identical to -semiLinear(-m_flow, h, port_h) — so reversing the orientation maps the chain onto itself with sa and sb exchanged. The silence is therefore established by the rule and its own identity, not assumed from the section being quiet: the specification says the two are the same equation, and says nothing that separates them at x == 0.' +alternatives_considered = 'rumoca fixes the orientation from the model itself: sa is the endpoint whose slope class roots on the earlier-declared variable, which on the FluidHeatFlow models is the upstream-in-forward-flow end. The alternative of calibrating against another tool was tried and abandoned. What stays unpinned is a chain endpoint whose slope class holds two variables proven equal: that class roots on whichever the union merged first, and the tie-break can name the opposite end. No FluidHeatFlow node proves two slopes equal.' +oracle_consulted = 'OpenModelica (omc) — rejected as an anchor: its selection at x == 0 is itself a function of the operand order its connection balances were emitted in, so it does not answer the question and cannot be calibrated against.' +pin_kind = 'Site' +pin_file = 'crates/rumoca-phase-dae/src/construction/analysis/expression_semi_linear.rs' +pin_anchor = 'fn emit_chain(' +pin_polarity = 'Asserts' +status = 'Enforced' +note = 'rumoca fixes the orientation from the model itself: sa is the endpoint whose slope class roots on the earlier-declared variable. What that does not pin is a chain endpoint whose slope class holds two variables proven equal; no FluidHeatFlow node proves two slopes equal.' + +# --------------------------------------------------------------------------- +# CLK — sample and clocked sampling (MLS §3.7.5, §16.5.1) +# --------------------------------------------------------------------------- + +[[statements]] +id = 'FS-CLK-001' +statement = 'The Boolean sample(start, interval) takes two scalar parameter expressions, so both operands must be parameter-evaluable at translation time.' +tier = 'SpecSourced' +edition = '3.6' +section = '§3.7.5' +quote_kind = 'Paraphrase' +quote = 'sample(start, interval) requires exactly two scalar parameter arguments' +quote_source = 'crates/rumoca-phase-dae/src/construction/analysis/event_conditions.rs' +pin_kind = 'Site' +pin_file = 'crates/rumoca-phase-dae/src/construction/analysis/event_conditions.rs' +pin_anchor = 'evaluate_sample_schedule' +pin_polarity = 'Asserts' +status = 'Enforced' +note = 'This row carries the arity and scalarity rule only. Which *forms* of parameter expression rumoca can evaluate at construction is a narrower question, and FS-CLK-005 records where that narrowness is not the specification.' + +[[statements]] +id = 'FS-CLK-005' +statement = 'A parameter declared fixed = false with no binding, whose value an initial equation supplies, is a legal parameter expression for the start operand of sample.' +tier = 'SpecSourced' +edition = '3.6' +section = '§3.7.5' +quote_kind = 'Paraphrase' +quote = 'sample start' +quote_source = 'crates/rumoca-phase-dae/src/construction/analysis/event_conditions.rs' +pin_kind = 'Site' +pin_file = 'crates/rumoca-phase-dae/src/construction/analysis/event_conditions.rs' +pin_anchor = 'evaluate_clock_seconds' +pin_polarity = 'PinsDivergence' +status = 'Unimplemented' +related_sections = ['§8.6'] +note = 'rumoca refuses the operand as not parameter-evaluable, because the parameter set holds no number for it at construction: MLS §8.6 gives the initial section its value. That is an over-refusal, not a §3.7.5 rule — `parameter Real t0(fixed = false)` with `initial equation t0 = time` is a legal parameter expression and OMC compiles it. 52 MSL models sit behind this refusal (the Blocks.Math.Mean and SignalExtrema instances). The reduced value IS the simulation start instant, a runtime option, so folding it at construction would be wrong whenever t_start mod interval is nonzero — OMC shifts its whole sample grid with the start time — which is why the fix is start-relative periodic scheduling rather than a constant fold.' + +[[statements]] +id = 'FS-CLK-002' +statement = 'The schedule of a Boolean sample(start, interval) is metadata rather than a zero crossing, so noEvent cannot suppress it.' +tier = 'SpecSourced' +edition = '3.6' +section = '§3.7.5' +quote_kind = 'Paraphrase' +quote = '`sample(start, interval)` is a periodic Boolean operator whose schedule is metadata, not a zero crossing, so `noEvent` cannot suppress it' +quote_source = 'crates/rumoca-phase-dae/src/construction/analysis/expression_events.rs' +pin_kind = 'Test' +pin_file = 'crates/rumoca/tests/suite_core/periodic_source_counter_regression.rs' +pin_anchor = 'sample_when_accumulator_advances_once_per_tick' +pin_polarity = 'Asserts' +status = 'Enforced' +related_sections = ['§16.5.1'] + +[[statements]] +id = 'FS-CLK-003' +statement = 'A zero-phase Boolean sample ticks at the initialization instant and re-arms, so its accumulator advances once per interval afterwards.' +tier = 'SpecSourced' +edition = '3.6' +section = '§3.7.5' +quote_kind = 'Paraphrase' +quote = '`sample(start, interval)` is a periodic Boolean operator whose schedule is metadata, not a zero crossing, so `noEvent` cannot suppress it' +quote_source = 'crates/rumoca-phase-dae/src/construction/analysis/expression_events.rs' +pin_kind = 'Test' +pin_file = 'crates/rumoca/tests/suite_core/periodic_source_counter_regression.rs' +pin_anchor = 'rk_like_zero_phase_sample_when_accumulator_advances_after_initial_tick' +pin_polarity = 'Asserts' +status = 'Enforced' + +[[statements]] +id = 'FS-CLK-004' +statement = 'The clocked sample(u) samples the value of u on an inferred clock; it is not a structural Boolean event indicator and must not be folded to true.' +tier = 'SpecSourced' +edition = '3.6' +section = '§16.5.1' +quote_kind = 'Paraphrase' +quote = '`sample(u)` samples the value of `u` on an inferred clock. It is not a structural Boolean event indicator and must not be folded to `true`.' +quote_source = 'crates/rumoca/tests/suite_core/clocked_sample_regression.rs' +pin_kind = 'Test' +pin_file = 'crates/rumoca/tests/suite_core/clocked_sample_regression.rs' +pin_anchor = 'real_sample_time_equation_stays_runtime_sample_after_flatten' +pin_polarity = 'Asserts' +status = 'Enforced' +related_sections = ['§16.3', '§3.7.5'] +note = 'The two-operand §16.3 value sample names its clock and only a whole Clock coordinate can be that operand, so the §3.7.5 form keeps its Real second operand and is deliberately not matched as a clocked value sample.' + +# --------------------------------------------------------------------------- +# FUNC — purity (§12.3) and call semantics (§11.2.1.1, §12.4.2.1, §12.4.3) +# --------------------------------------------------------------------------- + +[[statements]] +id = 'FS-FUNC-001' +statement = 'An external function declared with neither pure nor impure shall be treated as impure, and writing no prefix is deprecated — reported, never rejected.' +tier = 'SpecSourced' +edition = '3.7' +section = '§12.3' +quote_kind = 'Verbatim' +quote = 'shall be treated as impure' +quote_source = 'crates/rumoca-contracts/tests/func_contracts.rs' +pin_kind = 'Test' +pin_file = 'crates/rumoca-contracts/tests/func_contracts.rs' +pin_anchor = 'func_032_external_function_without_purity_is_impure_but_unrestricted' +pin_polarity = 'Asserts' +status = 'Enforced' +contracts = ['FUNC-032', 'FUNC-004'] +related_sections = ['§12.9'] +note = 'MLS 3.6 §12.3 put both halves in one sentence — assumed to be impure, but without any restriction on calling them — and stated the report as a requirement, so rumoca emits WR001 for every such declaration rather than only at simulation-model call sites.' + +[[statements]] +id = 'FS-FUNC-002' +statement = 'pure(f(...)) only bypasses purity checking of the callee; the argument expressions of the call are unaffected.' +tier = 'SpecSourced' +edition = '3.7' +section = '§12.3' +quote_kind = 'Verbatim' +quote = 'only by-passes the purity checking of the callee impureFunction; the argument expressions of the function call are not affected' +quote_source = 'crates/rumoca-core/src/lib.rs' +pin_kind = 'Site' +pin_file = 'crates/rumoca-core/src/lib.rs' +pin_anchor = 'PURITY_WRAPPER' +pin_polarity = 'Asserts' +status = 'Enforced' +contracts = ['FUNC-005'] +note = 'pure is a keyword, so the name can never be shadowed by a declaration; the wrapper carries no value of its own and lowering erases it.' + +[[statements]] +id = 'FS-FUNC-003' +statement = 'A function with n results needs at most n receiving variables in a multi-result call statement.' +tier = 'SpecSourced' +edition = '3.6' +section = '§11.2.1.1' +quote_kind = 'Verbatim' +quote = 'A function with n results needs m≤n receiving variables' +quote_source = 'crates/rumoca-phase-dae/src/construction/tests/multi_output_calls.rs' +pin_kind = 'Test' +pin_file = 'crates/rumoca-phase-dae/src/construction/tests/multi_output_calls.rs' +pin_anchor = 'fewer_receivers_than_results_reads_the_leading_results' +pin_polarity = 'Asserts' +status = 'Enforced' + +[[statements]] +id = 'FS-FUNC-004' +statement = 'Receiving variables may be omitted from a multi-result call statement, and an omitted slot reads no result and defines nothing.' +tier = 'SpecSourced' +edition = '3.6' +section = '§11.2.1.1' +quote_kind = 'Verbatim' +quote = 'It is possible to omit receiving variables from this list' +quote_source = 'crates/rumoca-phase-dae/src/construction/tests/multi_output_calls.rs' +pin_kind = 'Test' +pin_file = 'crates/rumoca-phase-dae/src/construction/tests/multi_output_calls.rs' +pin_anchor = 'omitted_receiving_variable_defines_nothing' +pin_polarity = 'Asserts' +status = 'Enforced' + +[[statements]] +id = 'FS-FUNC-005' +statement = 'The type of each component reference in a multi-result receiving list must agree with the type of the corresponding output component.' +tier = 'SpecSourced' +edition = '3.6' +section = '§12.4.3' +quote_kind = 'Verbatim' +quote = 'The type of each component reference in the list must agree with the type of the corresponding output component.' +quote_source = 'crates/rumoca-phase-dae/src/construction/analysis/function_bodies.rs' +pin_kind = 'Test' +pin_file = 'crates/rumoca-phase-dae/src/construction/tests/multi_output_calls.rs' +pin_anchor = 'receiving_variable_shape_disagreement_is_rejected_by_name' +pin_polarity = 'Asserts' +status = 'Enforced' +contracts = ['FUNC-025'] + +[[statements]] +id = 'FS-FUNC-006' +statement = 'A multi-result call is evaluated once and its results are then assigned, so reading each result as its own invocation is observationally equal to that single evaluation only for a callee whose results depend on nothing but its arguments.' +tier = 'SpecSourced' +edition = '3.6' +section = '§12.4.3' +quote_kind = 'Paraphrase' +quote = 'MLS §12.4.3 evaluates a multi-result call once' +quote_source = 'crates/rumoca-phase-dae/src/construction/analysis/function_bodies.rs' +pin_kind = 'Test' +pin_file = 'crates/rumoca-phase-dae/src/construction/tests/multi_output_calls.rs' +pin_anchor = 'multi_result_call_to_an_impure_external_callee_is_rejected_by_name' +pin_polarity = 'Asserts' +status = 'Enforced' +related_sections = ['§12.3', '§11.2.1.1'] +note = 'The canonical DAE owns no multi-result node: it owns one call(function, ordinal, ..) per result read, so a statement reading k results denotes k invocations. Rather than diverge silently, an impure external callee is refused by name. What makes body_is_pure the exact predicate is the §12.3 rule of FS-FUNC-001 — a bare external is treated as impure — but the statement being protected here is the §12.4.3 single evaluation, which is why this row cites §12.4.3 and lists §12.3 beside it. A shared multi-result node would remove both the refusal and the cost.' + +[[statements]] +id = 'FS-FUNC-007' +statement = 'A function partial application denotes a partially evaluated function, which is itself a function value and not an array of scalars.' +tier = 'SpecSourced' +edition = '3.6' +section = '§12.4.2.1' +quote_kind = 'Verbatim' +quote = 'returns a partially evaluated function that is also a function, with the remaining not bound formal parameters still present in the same order as in the original function declaration' +quote_source = 'crates/rumoca-phase-dae/src/construction/function_shapes/mod.rs' +pin_kind = 'Test' +pin_file = 'crates/rumoca-phase-dae/src/construction/tests/multi_output_calls.rs' +pin_anchor = 'function_partial_application_is_rejected_by_name' +pin_polarity = 'PinsDivergence' +status = 'Unimplemented' +contracts = ['FUNC-033'] +note = 'Flat carries no marker distinguishing the form from an under-applied call, so the shape prover would otherwise report an arity mismatch and point a reader at the callee declaration instead of at the unimplemented feature. The signature Flat does preserve is exact: every argument a retained named-argument wrapper, and fewer arguments than the callee declares.' + +# --------------------------------------------------------------------------- +# ARR — array construction and subscripts (MLS §10.4.2.1, §10.5) +# --------------------------------------------------------------------------- + +[[statements]] +id = 'FS-ARR-001' +statement = 'The concatenation operator needs at least one argument; the empty matrix construction is undefined.' +tier = 'SpecSourced' +edition = '3.6' +section = '§10.4.2.1' +quote_kind = 'Verbatim' +quote = 'There must be at least one argument (i.e., [] is not defined)' +quote_source = 'crates/rumoca-phase-dae/src/construction/function_shapes/expression_rules.rs' +pin_kind = 'Site' +pin_file = 'crates/rumoca-phase-dae/src/construction/function_shapes/expression_rules.rs' +pin_anchor = 'matrix_expression_shape' +pin_polarity = 'Asserts' +status = 'Enforced' +contracts = ['ARR-023'] + +[[statements]] +id = 'FS-ARR-002' +statement = 'The bracket operator builds [A, B, ...] as cat(2, promote(A, n), ...), so it always denotes a matrix and a row of scalar operands is a 1 x n matrix.' +tier = 'SpecSourced' +edition = '3.6' +section = '§10.4.2.1' +quote_kind = 'Paraphrase' +quote = 'Concatenation along second dimension' +quote_source = 'crates/rumoca-phase-dae/src/construction/function_shapes/expression_rules.rs' +pin_kind = 'Test' +pin_file = 'crates/rumoca-phase-dae/src/construction/tests/multi_output_calls.rs' +pin_anchor = 'matrix_row_of_scalars_proves_rank_two' +pin_polarity = 'Asserts' +status = 'Enforced' +contracts = ['ARR-007'] +note = 'Before this rule the comma spelling arrived as one flat operand list and was built as an n-vector: [0, 1, 1, 0, 0] was a 5-vector rather than the 1 x 5 matrix MLS gives it.' + +[[statements]] +id = 'FS-ARR-003' +statement = 'A non-scalar operand of the bracket operator is promoted before concatenation, so a vector operand becomes a column and a row of vectors transposes into the result.' +tier = 'SpecSourced' +edition = '3.6' +section = '§10.4.2.1' +quote_kind = 'Paraphrase' +quote = 'Concatenation along second dimension' +quote_source = 'crates/rumoca-phase-dae/src/construction/function_shapes/expression_rules.rs' +pin_kind = 'Test' +pin_file = 'crates/rumoca-phase-dae/src/construction/tests/multi_output_calls.rs' +pin_anchor = 'model_scope_matrix_row_of_vectors_uses_checked_promotion' +pin_polarity = 'Asserts' +status = 'Enforced' +note = 'The checked construction promotes each length-3 vector to 3 x 1 and concatenates the result to 3 x 2. This replaces the former 2 x 3 interpretation that answered y = 2 where omc gives 3.' + +[[statements]] +id = 'FS-ARR-004' +statement = 'A subscript belongs to the part of a component reference it is written on, so in a.b[e].c the sliced array is b and the expression denotes member c of element e.' +tier = 'SpecSourced' +edition = '3.6' +section = '§10.5' +quote_kind = 'Paraphrase' +quote = 'MLS §10.5: a subscript belongs to the part it is written on' +quote_source = 'crates/rumoca-phase-dae/src/construction/analysis/record_array_fields/tests.rs' +pin_kind = 'Test' +pin_file = 'crates/rumoca-phase-dae/src/construction/analysis/record_array_fields/tests.rs' +pin_anchor = 'nested_component_array_slice_projects_the_subscripted_part' +pin_polarity = 'Asserts' +status = 'Enforced' +note = 'Anchoring the proof on the head of the path would only ever admit b[e].c and never a.b[e].c.' + +[[statements]] +id = 'FS-ARR-005' +statement = 'A member projection whose path carries two array occurrences denotes a higher-rank array, which the projection certificate cannot describe.' +tier = 'SpecSourced' +edition = '3.6' +section = '§10.5' +quote_kind = 'Paraphrase' +quote = 'A second array part would make the expression denote a higher-rank array (MLS §10.5) that this certificate cannot describe' +quote_source = 'crates/rumoca-phase-dae/src/construction/analysis/record_array_fields.rs' +pin_kind = 'Test' +pin_file = 'crates/rumoca-phase-dae/src/construction/analysis/record_array_fields/tests.rs' +pin_anchor = 'a_second_array_part_on_the_slice_path_is_rejected_by_name' +pin_polarity = 'PinsDivergence' +status = 'Unimplemented' +note = 'A defensive guard rather than a reachable source rejection: the legal spelling leaf[1].ac.pin[:].v never becomes a ProjectionPattern today. Silently projecting one of the two arrays would fabricate a shape the model never wrote.' + +[[statements]] +id = 'FS-ARR-006' +statement = 'A member slice subscripting more than one dimension denotes an array §10.5 gives a meaning to.' +tier = 'SpecSourced' +edition = '3.6' +section = '§10.5' +quote_kind = 'Paraphrase' +quote = 'MLS §10.5 gives the construct a meaning; this compiler abstains from it by name' +quote_source = 'crates/rumoca-phase-dae/src/construction/analysis/record_array_fields/tests.rs' +pin_kind = 'Test' +pin_file = 'crates/rumoca-phase-dae/src/construction/analysis/record_array_fields/tests.rs' +pin_anchor = 'a_multi_dimensional_member_slice_abstains_by_name' +pin_polarity = 'PinsDivergence' +status = 'Unimplemented' +note = 'The abstention is by name rather than by minting a shape the compiler cannot realize; the test pins the abstention, not the meaning.' + +[[statements]] +id = 'FS-ARR-007' +statement = 'The subscripts a component reference may carry are budgeted against the dimensions the declaration gives the component, so the reference is measured against the declared array and never against the scalar element a subscript selects.' +tier = 'SpecSourced' +edition = '3.6' +section = '§10.6.9' +quote_kind = 'Paraphrase' +quote = 'the array dimensions belong to the declaration, so `c[1]` in an equation is a subscript on the declared array, not on the element instance that the subscript selects' +quote_source = 'crates/rumoca-phase-typecheck/src/semantic_scope.rs' +pin_kind = 'Test' +pin_file = 'crates/rumoca-contracts/tests/arr_contracts.rs' +pin_anchor = 'arr_026_element_expanded_array_reports_its_declared_rank_when_over_subscripted' +pin_polarity = 'Asserts' +status = 'Enforced' +contracts = ['ARR-026'] +related_sections = ['§10.1', '§10.4.1', '§10.5'] +note = 'The declared extents are read from the record instantiation writes for every array it expands, not from the descriptor it writes only for the ones it compacted: SPEC_0032 §1 makes compaction an optimization, and a diagnostic that changed with it was reporting the compiler rather than the model. 56 MSL models (57 by diagnostic text; one carries it under the ET000 summary) were rejected as `has 0 dimension(s)` on legal subscripts because of it. The section follows the contract it cites: SPEC_0022 stamps ARR-026 = §10.6.9 for the subscript-count rule, and §10.5.1 for ARR-025, which is about index type.' + +[[statements]] +id = 'FS-ARR-008' +statement = 'A component reference is measured against the dimensions of every part of its path, so a part whose declared extents the compiler cannot determine leaves the whole reference unmeasurable and the subscript budget unenforced, rather than measurable against zero dimensions.' +tier = 'SpecSourced' +edition = '3.6' +section = '§10.6.9' +quote_kind = 'Paraphrase' +quote = '§10.4.1 composes the reference shape from every part, so a part with unknown extents makes the composition unknown' +quote_source = 'crates/rumoca-phase-typecheck/src/typechecker/equation_compat.rs' +pin_kind = 'Test' +pin_file = 'crates/rumoca-contracts/tests/arr_contracts.rs' +pin_anchor = 'arr_026_record_array_member_of_a_scalar_owner_is_subscriptable' +pin_polarity = 'Asserts' +status = 'Enforced' +contracts = ['ARR-026'] +related_sections = ['§10.4.1', '§10.5.1'] +note = 'The subscript walk accumulates the extents of each part and spends that part of the subscripts against them. An absent part used to contribute nothing, which left the already-spent vector of the enclosing prefix standing and reported its leftover length as the rank of the absent declaration — `has 0 dimension(s)` for a member the model gave three. Absence is unknown, not scalar; a known scalar is recorded as an explicit empty extent list and still rejects. This rule governs absence only, and does NOT close the `has 0 dimension(s)` class: the same text still reaches a model when the shape lookup answers a positive wrong shape rather than absence, which `arr_026_redeclared_record_member_array_still_reports_zero_dimensions` pins on a redeclared record member array (omc accepts it). That is wrong-instance resolution, a separate defect tracked on its own. Counting convention for the MSL cohort, which differs by one between the two ways of counting: 22 models carried the ET009 *code* before this change and 2 carry it after, while 23 carried the diagnostic *text* and 3 carry it after — the extra model, `CCCV_StackRC`, reports the same text under an ET000 summary and is unchanged either way. Both conventions agree that 20 models were fixed. Of the 3 that remain, 2 are the `kDegraded[:,2]` unspecified-extent defect and 1 is the redeclared shape above; none is an absence. The section follows the contract it cites: SPEC_0022 stamps ARR-026 = §10.6.9 for the subscript-count rule.' + +[[statements]] +id = 'FS-ARR-009' +statement = 'A component reference may carry no more subscripts than the declaration it names has dimensions, and exceeding that count is an error of the model.' +tier = 'SpecSourced' +edition = '3.6' +section = '§10.6.9' +quote_kind = 'Paraphrase' +quote = 'a literal subscript that selects no expanded element' +quote_source = 'crates/rumoca-phase-typecheck/src/semantic_scope.rs' +pin_kind = 'Test' +pin_file = 'crates/rumoca-contracts/tests/arr_contracts.rs' +pin_anchor = 'arr_026_over_subscripted_record_member_loses_its_equation_instead_of_being_named' +pin_polarity = 'PinsDivergence' +status = 'Unimplemented' +contracts = ['ARR-026'] +related_sections = ['§10.5.1'] +note = 'The count rule is enforced wherever the shape lookup answers, which `arr_026_a_member_array_is_still_rejected_when_over_subscripted` holds it to. It is not enforced when the reference reaches the member through a prefix the instance-shape index cannot resolve, because the identity walk filters candidate instances by the very subscripts being validated: an over-long subscript list selects no expanded element, the walk reports absence, and FS-ARR-008 then abstains. The pin records what happens instead, and it is worse than an unnamed rejection: the binding equation is dropped from the DAE entirely, so `Cx yy = c.i[1, 2]` surfaces as `unbalanced model: 3 equations, 4 unknowns` spanned on the enclosing model rather than on the subscript. omc names it (`Wrong number of subscripts in c.i[1, 2] (2 subscripts for 1 dimensions)`). The model is still rejected, so this is a diagnostic and blame-target gap rather than an admissibility one. The fix is to stop filtering the final prefix part by its own subscripts in the shape query; that is a change to identity resolution, not to the abstention, and is deliberately not folded into it. Index-bounds checking (§10.5.1) is a separate statement and is not covered by this row or its pin.' + +# --------------------------------------------------------------------------- +# INST / PKG — scoping and class occurrences (MLS §5.3.1, §7.1, §13.2) +# --------------------------------------------------------------------------- + +[[statements]] +id = 'FS-INST-001' +statement = 'Name lookup starts in the current scope and proceeds to enclosing scopes until the name is found or the global scope is reached.' +tier = 'SpecSourced' +edition = '3.6' +section = '§5.3.1' +quote_kind = 'Paraphrase' +quote = 'Name lookup starts in the current scope and proceeds to enclosing scopes until the name is found or global scope is reached.' +quote_source = 'crates/rumoca-ir-ast/src/scope.rs' +pin_kind = 'Site' +pin_file = 'crates/rumoca-ir-ast/src/scope.rs' +pin_anchor = 'pub fn lookup(' +pin_polarity = 'Asserts' +status = 'Enforced' + +[[statements]] +id = 'FS-INST-002' +statement = 'An encapsulated boundary exposes the predefined names and neither the enclosing scopes nor arbitrary top-level classes.' +tier = 'SpecSourced' +edition = '3.6' +section = '§5.3.1' +quote_kind = 'Paraphrase' +quote = 'an encapsulated boundary exposes predefined names, but neither enclosing scopes nor arbitrary top-level classes' +quote_source = 'crates/rumoca-ir-ast/src/scope.rs' +pin_kind = 'Site' +pin_file = 'crates/rumoca-ir-ast/src/scope.rs' +pin_anchor = 'predefined_member' +pin_polarity = 'Asserts' +status = 'Enforced' +note = 'A predefined name resolves through its own registered declaration identity, so a source declaration that shadows the same spelling cannot change the query.' + +[[statements]] +id = 'FS-INST-003' +statement = 'A reference to an enclosing-scope constant from inside an encapsulated class is not resolvable.' +tier = 'OracleImplied' +oracle = 'OpenModelica (omc checkModel), run in-process when omc is on PATH' +evidence = 'crates/rumoca/tests/suite_core/omc_differential_semantics.rs — the test asserts omc reports that the variable is not found in scope, then asserts rumoca rejects the same source' +latitude_note = 'The test cites no MLS sentence: it is the only true differential-oracle test in the tree, and it fixes the rejection by agreement rather than by citation. FS-INST-002 carries the §5.3.1 reading the compiler actually implements.' +pin_kind = 'Test' +pin_file = 'crates/rumoca/tests/suite_core/omc_differential_semantics.rs' +pin_anchor = 'encapsulated_scope_rejection_matches_omc' +pin_polarity = 'Asserts' +status = 'Enforced' +note = 'The test skips when omc is unavailable, so it is evidence when it runs and silent when it does not.' + +[[statements]] +id = 'FS-INST-004' +statement = 'Only components name a part of a component reference; an extends clause adds class occurrences that no part spells, so those occurrences are stepped over when a path is matched against a declaration chain.' +tier = 'SpecSourced' +edition = '3.6' +section = '§7.1' +quote_kind = 'Paraphrase' +quote = 'an `extends` adds class occurrences that no part spells (MLS §7.1)' +quote_source = 'crates/rumoca-phase-dae/src/construction/analysis/record_array_fields.rs' +pin_kind = 'Site' +pin_file = 'crates/rumoca-phase-dae/src/construction/analysis/record_array_fields.rs' +pin_anchor = 'component_ancestry' +pin_polarity = 'Asserts' +status = 'Enforced' +note = 'Stepping over class occurrences is also how a same-spelled coordinate belonging to a sibling instance is excluded without consulting a rendered name.' + +[[statements]] +id = 'FS-PKG-001' +statement = 'A callable is identified by its lookup-qualified spelling, not by its use-site spelling, so two exposures of one declaration stay distinct.' +tier = 'SpecSourced' +edition = '3.6' +section = '§13.2' +quote_kind = 'Paraphrase' +quote = "Resolve records a callable's use-site spelling in the structured parts and its lookup-qualified spelling (MLS §5.3, §13.2) as the rendered name" +quote_source = 'crates/rumoca-phase-flatten/src/functions/callable_scope_identity.rs' +pin_kind = 'Site' +pin_file = 'crates/rumoca-phase-flatten/src/functions/callable_scope_identity.rs' +pin_anchor = 'scope_qualified_reference' +pin_polarity = 'Asserts' +status = 'Enforced' +related_sections = ['§5.3', '§13.2.1'] +note = 'A renaming import such as import generator = Modelica.Math.Random.Generators.Xorshift128plus leaves the root segment resolved to the imported declaration while spelling the local alias; the alias is replaced by that root declaration own declared name, so the segment keeps its exact resolved identity, span and subscripts.' + +# --------------------------------------------------------------------------- +# CONN / STRM — connection member pairing (MLS §9.3, §15.1) +# --------------------------------------------------------------------------- + +[[statements]] +id = 'FS-CONN-001' +statement = 'A connect may pair a stream variable only with another stream variable.' +tier = 'SpecSourced' +edition = '3.6' +section = '§9.3' +quote_kind = 'Verbatim' +quote = 'stream variables only to other stream variables' +quote_source = 'crates/rumoca-phase-flatten/src/errors.rs' +pin_kind = 'Test' +pin_file = 'crates/rumoca-phase-flatten/src/connections/tests/member_pairing_tests.rs' +pin_anchor = 'stream_paired_with_non_stream_is_rejected_with_both_member_spans' +pin_polarity = 'Asserts' +status = 'Enforced' +contracts = ['CONN-030'] +related_sections = ['§15.1'] + +[[statements]] +id = 'FS-CONN-002' +statement = 'A connect may pair a parameter primitive only with a parameter and a constant primitive only with a constant.' +tier = 'SpecSourced' +edition = '3.6' +section = '§9.3' +quote_kind = 'Verbatim' +quote = 'the primitive components may only connect parameter variables to parameter variables and constant variables to constant variables' +quote_source = 'crates/rumoca-phase-flatten/src/errors.rs' +pin_kind = 'Test' +pin_file = 'crates/rumoca-phase-flatten/src/connections/tests/member_pairing_tests.rs' +pin_anchor = 'parameter_paired_with_variable_is_rejected_with_both_member_spans' +pin_polarity = 'Asserts' +status = 'Enforced' +contracts = ['CONN-028'] + +[[statements]] +id = 'FS-CONN-003' +statement = 'Connecting two parameter, or two constant, primitives yields an equality assertion rather than a connection equation.' +tier = 'SpecSourced' +edition = '3.6' +section = '§9.3' +quote_kind = 'Verbatim' +quote = 'Constants or parameters in connected components yield the appropriate assert-statements [...]; connections are not generated.' +quote_source = 'crates/rumoca-phase-flatten/src/connections/tests/member_pairing_tests.rs' +pin_kind = 'Test' +pin_file = 'crates/rumoca-phase-flatten/src/connections/tests/member_pairing_tests.rs' +pin_anchor = 'parameter_paired_with_parameter_generates_no_equation' +pin_polarity = 'PinsDivergence' +status = 'RecordedDivergence' +note = 'rumoca generates no equation and no assertion for such a pair, so two connected settings that disagree pass silently. The pinned test asserts the current behavior, which is why this row is a divergence record and not an enforcement.' + +[[statements]] +id = 'FS-STRM-001' +statement = 'A stream variable carries mixing semantics instead of a connection equation, so a stream and non-stream pair has no defined equation at all.' +tier = 'SpecSourced' +edition = '3.6' +section = '§15.1' +quote_kind = 'Paraphrase' +quote = 'MLS §15.1 gives a stream variable mixing semantics instead of a connection equation, so a stream/non-stream pair has no defined equation at all' +quote_source = 'crates/rumoca-phase-flatten/src/errors.rs' +pin_kind = 'Test' +pin_file = 'crates/rumoca-phase-flatten/src/connections/tests/member_pairing_tests.rs' +pin_anchor = 'non_stream_paired_with_stream_is_rejected_with_both_member_spans' +pin_polarity = 'Asserts' +status = 'Enforced' +contracts = ['STRM-001'] + +[[statements]] +id = 'FS-STRM-002' +statement = 'Every connected outside stream connector owns one connection equation, and inside stream connectors generate none.' +tier = 'SpecSourced' +edition = '3.6' +section = '§15.1' +quote_kind = 'Paraphrase' +quote = 'Inside stream connectors generate no equation' +quote_source = 'crates/rumoca-phase-flatten/src/connections/equation_generation.rs' +pin_kind = 'Site' +pin_file = 'crates/rumoca-phase-flatten/src/connections/equation_generation.rs' +pin_anchor = 'generate_outside_stream_equations' +pin_polarity = 'Asserts' +status = 'Enforced' +contracts = ['STRM-004', 'STRM-005'] +related_sections = ['§15.2'] +note = 'Per §15.2 the right-hand side is the mixing enthalpy of the connection set declared inside the connector own model with the connector excluded, not inStream() of the connector, which by definition looks in the opposite direction.' + +# --------------------------------------------------------------------------- +# Rows added by the executable-reference track (crates/rumoca-reference). +# +# Appended at the end rather than filed into the category blocks above so the +# addition cannot collide textually with concurrent edits to those blocks. Note +# that appending does not protect the *identifiers*: these were allocated +# against the branch head, after a concurrently landed row had already taken +# FS-SIM-015. Check the highest id in each category before adding a row, not +# just the end of this file. +# +# Two of the three rows (FS-EQN-019, FS-SIM-016) pin the differential harness, +# which runs one model through the compiler and through an independently +# written reference semantics and requires them to agree. That makes those pins +# two-sided: they fail when the compiler changes AND when the reference does. +# Two-sided is what a differential pin is, and it is weaker than a one-sided pin +# on exactly one axis — a reader who sees one red must check which side moved +# before concluding the compiler regressed. +# +# FS-SIM-017 is the exception and says so in its own note: it pins a +# reference-only test, because its statement is about how a §8.6 sentence should +# be read rather than about what this compiler does. Its compiler-facing +# consequences are FS-EQN-001 through FS-EQN-004, which carry their own pins in +# the compiler's own tests. No other row here may follow that pattern without +# the same justification: a reference-only pin is evidence about the reference, +# and on its own it says nothing about this tree. +# --------------------------------------------------------------------------- + +[[statements]] +id = 'FS-EQN-019' +statement = 'An activation condition is re-evaluated live inside the event iteration while its pre value is seeded once from the instant left limit, so an activation whose operand another activation writes in the same instant still rises, and a guard whose body advances its own threshold still settles.' +tier = 'SpecSilent' +rationale = 'MLS Appendix B fixes the answer and leaves the machinery free. Its event iteration says "solve equations for unknowns, with pre(z) and pre(m) fixed", and a condition variable c := f(relation(v)) is among the unknowns it solves — so conditions are live within the instant, and §8.5 freezing relations *between* events says nothing about *within* one. The silence is in the word "solve": for a self-rescheduling guard `when time >= nextTime` whose body advances nextTime, the inner system has no solution at all with pre held fixed, because the condition is true exactly when the body has not run and false once it has. A tool must therefore choose what "solve" means for an inner system that has no solution, and Appendix B does not choose for it. This tree performs one ordered pass per outer iteration and lets the outer loop, which does advance pre, carry the propagation. That reaches the state Appendix B is defined to stop at without searching for a fixed point that does not exist.' +alternatives_considered = 'An inner fixed point was the first implementation and spins forever on the self-rescheduling shape. Latching every condition at the instant two limits and freezing it for the whole iteration was the second, and it terminates, but it is broader than any section supports and it breaks a cascade: `when time >= 0.5 then x = 3` beside `when x > 2 then y = 1` samples `x > 2` before `x` is written, so the second clause never fires, and never fires afterwards either because by then the condition holds on both limits. Both sessions of this compiler give y = 1, which is what Appendix B gives. Narrowing the latch to only those conditions whose own body writes their operands was considered and refused as unprincipled: it is a syntactic test standing in for a semantic one, and it would have no answer for a condition two activations downstream.' +oracle_consulted = 'None. OpenModelica settles the values a model takes, and on the cascade above it would agree with the answer this row already reaches, so it cannot separate the candidates on the case that matters. What distinguishes them is the self-rescheduling shape, where the disagreement is not about a value but about whether an inner solve terminates — which is a property of a tool, not of a trajectory, and therefore not something an oracle run can settle.' +pin_kind = 'Test' +pin_file = 'crates/rumoca-reference/tests/differential.rs' +pin_anchor = 'hand_written_models_agree_between_reference_and_pipeline' +pin_polarity = 'Asserts' +status = 'Enforced' +note = 'Found by the reference failing twice, not by reasoning, which is why this row is SpecSilent rather than SpecSourced: the first version of the statement asserted the latching rule as though §8.5 required it, and the cascade counterexample above refuted it. The StateConditionCascade and SelfReschedulingCounter cases in the pinned file are the two shapes that pull in opposite directions; a change satisfying only one of them fails the other.' + +[[statements]] +id = 'FS-SIM-016' +statement = 'A self-rescheduling time-event guard whose first threshold is not met at the start instant fires on its own grid for the whole run.' +tier = 'SpecSourced' +edition = '3.6' +section = '§8.3.5.1' +quote_kind = 'Paraphrase' +quote = 'A guard whose instant its own body advances re-arms at every crossing.' +quote_source = 'crates/rumoca-reference/tests/semantics.rs' +pin_kind = 'Test' +pin_file = 'crates/rumoca-reference/tests/differential.rs' +pin_anchor = 'the_diffsol_session_fires_a_self_rescheduling_time_event' +pin_polarity = 'Asserts' +status = 'Enforced' +related_sections = ['§8.5'] +note = 'The rk-like plugin, the diffsol BDF plugin, and the independent reference all count 1, 2, 3, 4 on a 0.2 s grid. BDF restarts its multistep history at each event because settled discrete values may change continuous derivatives even when FMI reports unchanged continuous state values. This is not FS-SIM-009, whose subject is a guard already met at the start, and it is not FS-SIM-011, which concerns located state-crossing accuracy.' + +[[statements]] +id = 'FS-SIM-017' +statement = 'The §8.6 requirement that every variable satisfies v = pre(v) before integration constrains the values pre is seeded from and not the meaning of the operator, so an activation buffer enters the initial instant holding its condition on the start values while initial() is false in that seed.' +tier = 'SpecSilent' +rationale = 'MLS §8.6 says "before the start of the integration, it must be guaranteed that for all variables v, v = pre(v)", and it also says the equations of a when-clause are active during initialization if and only if they are explicitly enabled with initial(). Read as an instruction to make pre the identity operator during initialization, the first sentence contradicts the second: edge(b) becomes b and not b, which is false for every buffer, and `when initial() then ...` can never run. §8.6 does not say which reading it means, and the two are not distinguishable from its text — that is the silence. This tree reads the requirement as a constraint on the values pre starts from: committed is seeded from the start environment, so v = pre(v) holds on entry for every declared variable, while a buffer gated on initial() still sees a rising edge because its own seed is evaluated with initial() false.' +alternatives_considered = 'Making pre the identity during initialization was implemented first and refused when it silently disabled every initial()-gated when-clause — a failure that no start-value test detects, because it changes nothing about start values. Seeding buffers to false unconditionally was refused because it manufactures a rising edge at the initial instant for every already-true condition, which is precisely the defect FS-EQN-001 records having fixed. Evaluating the seed with initial() true was refused because it removes the only edge an initial()-gated clause can have, arriving back at the first alternative by a different route.' +oracle_consulted = 'None was run for this row. The observable consequences are already settled by omc through FS-EQN-001 to FS-EQN-004, which this reading reproduces; what is unsettled is the interpretation those rows leave implicit, and an oracle returns trajectories rather than interpretations. The row exists so the choice is written down where the next reader of §8.6 will look, rather than being rediscovered from a failing test.' +pin_kind = 'Test' +pin_file = 'crates/rumoca-reference/tests/semantics.rs' +pin_anchor = 'an_initial_gated_activation_runs_at_initialization' +pin_polarity = 'Asserts' +status = 'Enforced' +note = 'Unlike the two rows above, this pin exercises the reference alone. It is admissible here because the statement is about an interpretation of §8.6 rather than about compiler behavior — the compiler-facing consequences are FS-EQN-001 through FS-EQN-004, which carry their own pins in crates/rumoca/tests/suite_core/time_event_when_activation.rs. A reader wanting evidence about this tree should follow those.' + +[[statements]] +id = 'FS-EQN-020' +statement = 'The pre values used by event iteration at the initial instant are seeded from the values the initialization system settled, not from the declared start values that seeded that solve.' +tier = 'SpecSourced' +edition = '3.6' +section = '§8.6' +quote_kind = 'Verbatim' +quote = 'Before the start of the integration, it must be guaranteed that for all variables `v`, `v = pre(v)`. If this is not the case for some variables `vi`, `pre(vi) := vi` must be set and an event iteration at the initial time must follow, so the model is re-evaluated, until this condition is fulfilled.' +quote_source = 'crates/rumoca/tests/suite_core/initialization_ordering.rs' +pin_kind = 'Test' +pin_file = 'crates/rumoca/tests/suite_core/initialization_ordering.rs' +pin_anchor = 'both_hosts_seed_initial_event_pre_from_the_settled_state' +pin_polarity = 'Asserts' +status = 'Enforced' +contracts = ['EQN-035'] +note = 'The BDF path already took this snapshot after initialization. The FMI 3 ME kernel took it before settle_initialization_system, so a state moved from start = 0 to x = 5 still exposed pre(x) = 0 to when initial(); this row pins both hosts to the same section 8.6 ordering.' + +[[statements]] +id = 'FS-SIM-019' +statement = 'A structured initial-equation family determines every scalar coordinate selected by its compact domain before integration starts.' +tier = 'SpecSourced' +edition = '3.6' +section = '§8.6' +quote_kind = 'Paraphrase' +quote = 'The initialization problem contains all initial equations and solves the variables they determine before integration starts.' +quote_source = 'crates/rumoca-bind-wasm/src/tests/simulation_runtime_tests.rs' +pin_kind = 'Test' +pin_file = 'crates/rumoca-bind-wasm/src/tests/simulation_runtime_tests.rs' +pin_anchor = 'test_prepare_gpu_simulation_refuses_unowned_structured_initial_equations' +pin_polarity = 'PinsDivergence' +status = 'Unimplemented' +note = 'The checked DAE preserves the compact two-dimensional initial-equation family, but the initialization projection plan does not yet assign each family point to its state coordinate. Runtime preparation now refuses that incomplete ownership proof instead of publishing declared zero starts as though the initial equations had settled.' diff --git a/crates/rumoca-contracts/src/lib.rs b/crates/rumoca-contracts/src/lib.rs index 1a349b8ee..583ba2201 100644 --- a/crates/rumoca-contracts/src/lib.rs +++ b/crates/rumoca-contracts/src/lib.rs @@ -5,7 +5,7 @@ //! //! # Overview //! -//! The MLS defines 431 contracts across 18 categories. This framework: +//! The MLS defines 438 contracts across 18 categories. This framework: //! - Registers all contracts with metadata //! - Provides test infrastructure and macros //! - Tracks compliance status @@ -30,6 +30,10 @@ pub mod test_support; use std::sync::OnceLock; // Re-export main types +pub use registry::formal::{ + EnforcementStatus, FormalStatement, FormalStatementError, MlsEdition, PinPolarity, QuoteKind, + StatementPin, StatementTier, load_all_formal_statements, parse_formal_statements, +}; pub use registry::{Contract, ContractCategory, ContractId, ContractRegistry, ContractStatus}; pub use report::ComplianceReport; pub use runner::{ContractResult, TestRunner}; @@ -99,6 +103,8 @@ pub const IMPLEMENTED_CONTRACT_IDS: &[&str] = &[ "ARR-038", "ARR-039", "ARR-040", + "ARR-041", + "ARR-042", "CLK-001", "CLK-002", "CLK-003", @@ -130,7 +136,6 @@ pub const IMPLEMENTED_CONTRACT_IDS: &[&str] = &[ "CONN-009", "CONN-010", "CONN-011", - "CONN-012", "CONN-013", "CONN-014", "CONN-015", @@ -139,7 +144,6 @@ pub const IMPLEMENTED_CONTRACT_IDS: &[&str] = &[ "CONN-018", "CONN-019", "CONN-020", - "CONN-021", "CONN-022", "CONN-023", "CONN-024", @@ -148,6 +152,7 @@ pub const IMPLEMENTED_CONTRACT_IDS: &[&str] = &[ "CONN-027", "CONN-028", "CONN-029", + "CONN-030", "DECL-001", "DECL-002", "DECL-003", @@ -258,7 +263,6 @@ pub const IMPLEMENTED_CONTRACT_IDS: &[&str] = &[ "EXPR-037", "EXPR-038", "EXPR-039", - "EXPR-040", "FUNC-001", "FUNC-002", "FUNC-003", @@ -281,12 +285,13 @@ pub const IMPLEMENTED_CONTRACT_IDS: &[&str] = &[ "FUNC-022", "FUNC-023", "FUNC-027", - "FUNC-029", "FUNC-030", "FUNC-031", "FUNC-032", "FUNC-033", "FUNC-034", + "FUNC-036", + "FUNC-037", "INST-001", "INST-002", "INST-003", @@ -378,14 +383,6 @@ pub const IMPLEMENTED_CONTRACT_IDS: &[&str] = &[ "SIM-007", "SIM-008", "SIM-009", - "SM-001", - "SM-002", - "SM-003", - "SM-004", - "SM-005", - "SM-006", - "SM-007", - "SM-008", "STRM-001", "STRM-002", "STRM-003", @@ -398,8 +395,6 @@ pub const IMPLEMENTED_CONTRACT_IDS: &[&str] = &[ "STRM-010", "STRM-011", "TYPE-001", - "TYPE-002", - "TYPE-003", "TYPE-004", "TYPE-005", "TYPE-006", @@ -417,7 +412,6 @@ pub const IMPLEMENTED_CONTRACT_IDS: &[&str] = &[ "TYPE-019", "TYPE-020", "TYPE-021", - "TYPE-022", "TYPE-024", "TYPE-025", "TYPE-026", @@ -441,6 +435,18 @@ pub const IMPLEMENTED_CONTRACT_IDS: &[&str] = &[ "UNIT-009", ]; +static FORMAL_STATEMENTS: OnceLock> = OnceLock::new(); + +/// The formal-statement registry: which formal statement justifies each pinned +/// behavior, and whether it is spec-sourced or oracle-implied. +/// +/// See [`registry::formal`] for the tiers and how rows are added. The table is +/// parsed once per process; a malformed row panics on first access, because it +/// ships inside the binary. +pub fn formal_statements() -> &'static [FormalStatement] { + FORMAL_STATEMENTS.get_or_init(load_all_formal_statements) +} + static REGISTRY_TEMPLATE: OnceLock = OnceLock::new(); fn build_registry() -> ContractRegistry { @@ -466,11 +472,11 @@ mod tests { #[test] fn test_registry_has_all_contracts() { let registry = create_registry(); - // SPEC_0022 defines 431 contracts + // SPEC_0022 defines 438 contracts assert_eq!( registry.len(), - 431, - "Expected 431 contracts, got {}", + 438, + "Expected 438 contracts, got {}", registry.len() ); } @@ -492,16 +498,16 @@ mod tests { assert_eq!(registry.count_by_category(ContractCategory::Expression), 40); assert_eq!(registry.count_by_category(ContractCategory::Equation), 38); assert_eq!(registry.count_by_category(ContractCategory::Algorithm), 17); - assert_eq!(registry.count_by_category(ContractCategory::Connection), 29); - assert_eq!(registry.count_by_category(ContractCategory::Function), 35); + assert_eq!(registry.count_by_category(ContractCategory::Connection), 30); + assert_eq!(registry.count_by_category(ContractCategory::Function), 38); assert_eq!(registry.count_by_category(ContractCategory::Type), 35); - assert_eq!(registry.count_by_category(ContractCategory::Array), 40); + assert_eq!(registry.count_by_category(ContractCategory::Array), 42); assert_eq!(registry.count_by_category(ContractCategory::Package), 12); assert_eq!( registry.count_by_category(ContractCategory::OperatorRecord), 11 ); - assert_eq!(registry.count_by_category(ContractCategory::Simulation), 9); + assert_eq!(registry.count_by_category(ContractCategory::Simulation), 10); assert_eq!(registry.count_by_category(ContractCategory::Clock), 20); assert_eq!(registry.count_by_category(ContractCategory::Stream), 11); assert_eq!( diff --git a/crates/rumoca-contracts/src/registry/formal.rs b/crates/rumoca-contracts/src/registry/formal.rs new file mode 100644 index 000000000..0475c1fc2 --- /dev/null +++ b/crates/rumoca-contracts/src/registry/formal.rs @@ -0,0 +1,678 @@ +//! Formal-statement registry: which formal statement justifies a pinned behavior. +//! +//! [`super`]'s contract registry and SPEC_0022 answer *what* the compiler +//! implements: one row per MLS requirement, with a status. That is not enough to +//! re-litigate a semantics decision, because it never says where the decision's +//! authority came from. A reader who finds `when true then` deleted, or a +//! counter frozen at its start value, cannot tell from the contract row whether +//! the behavior is what the Modelica Language Specification requires or merely +//! what OpenModelica happens to do. +//! +//! This registry answers that second question. Every row states one formal +//! statement in one sentence, names the tier the statement comes from, and +//! points at the place in the tree that holds the compiler to it. +//! +//! # The three tiers +//! +//! * [`StatementTier::SpecSourced`] — the statement is normative. It carries the +//! MLS edition, the section, and the fragment of MLS text it rests on. A +//! reader who disagrees with the row has to argue with the specification. +//! * [`StatementTier::OracleImplied`] — the specification does not decide the +//! question, or decides it only up to an explicit freedom, and the behavior is +//! pinned to what an oracle does instead. The row names the oracle, an +//! evidence pointer, and a latitude note saying what the specification left +//! open. A reader who disagrees with the row is free to argue for a different +//! choice, and only has to re-measure the oracle to do it. +//! * [`StatementTier::SpecSilent`] — the specification does not decide the +//! question and *no oracle can settle it either*, so the compiler chooses and +//! says so. The row carries the rationale establishing the silence, the +//! alternatives that were considered, and — when one was tried — which oracle +//! was consulted and why it did not answer. +//! +//! The tiers are not a quality ranking. An `OracleImplied` row is often the more +//! load-bearing of the two named before it, and a `SpecSilent` row is the most +//! fragile of the three: nothing outside this registry records that the choice +//! was ever made. +//! +//! `SpecSilent` exists because the alternative is worse. A choice with no +//! authority behind it was previously filed as `OracleImplied` with an oracle +//! field reading "none", which is a contradiction in the schema and reads to a +//! later maintainer as though an oracle had agreed. +//! +//! # Quotes are grounded, never composed +//! +//! A fabricated specification quote is worse than no quote, so a `SpecSourced` +//! row may not introduce one. Its `quote` must already appear, verbatim, in the +//! compiler source or tests named by `quote_source` — the place where the +//! quotation was written and reviewed alongside the code it governs. +//! `tests/formal_statement_invariants.rs` checks this mechanically, comparing +//! the two with comment markers stripped and whitespace collapsed so a doc +//! comment may wrap the quotation across lines. +//! +//! Not every rule the compiler leans on is quoted verbatim somewhere in the +//! tree; plenty reach the source as the compiler's own reading of a section. +//! [`QuoteKind`] says which of the two a row carries, so a reader can tell an +//! MLS sentence from an interpretation of one without opening the file. +//! +//! # Pins, and which way a pin points +//! +//! Every row points at exactly one place, through [`StatementPin`]: +//! +//! * `Test` — a test function that a change to the pinned behavior makes fail. +//! * `Site` — the construction or enforcement site in production source. +//! * `Record` — a written record (a module header, a diagnostic) for a statement +//! the compiler does not currently satisfy. +//! +//! A `Test` pin does not say by itself *which way* it points, and the difference +//! matters enough to be a field. A test under an `Enforced` row asserts the +//! statement, so it fails when the statement stops holding. A test under a +//! [`EnforcementStatus::RecordedDivergence`] row asserts what the compiler does +//! *instead*, so it fails when the statement starts holding — that is, when the +//! divergence is fixed. Reading the second as the first is how a divergence +//! record silently becomes a claim of compliance, so [`PinPolarity`] states it +//! and the invariants check it against the status. +//! +//! An [`EnforcementStatus::Enforced`] row must pin a `Test` or a `Site`: a +//! statement whose only evidence is prose is not enforced, whatever the prose +//! says. +//! +//! # Adding rows +//! +//! Rows live in `data/formal_statements.toml` and are hand written. Nothing +//! generates them, because the classification each row makes — which tier, and +//! which way its pin points — is a judgment, and a generator would launder it. +//! +//! Nothing here checks a section *number*. The invariants prove that a quote is +//! grounded in the tree and that a section is well formed; they cannot prove the +//! section is the right one, and a wrong number is invisible to every guard in +//! this crate. Attribution is a reviewer's job. + +use serde::{Deserialize, Serialize}; + +use super::{ContractCategory, ContractId}; + +/// The Modelica Language Specification edition a statement is quoted from. +/// +/// The compiler cites both editions on purpose: it targets 3.7, and 3.6 still +/// states some rules more precisely (and, for purity, differently). A row that +/// did not say which edition it quoted would be unre-checkable. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum MlsEdition { + /// MLS 3.6. + #[serde(rename = "3.6")] + V3_6, + /// MLS 3.7 — the edition the compiler declares as its target. + #[serde(rename = "3.7")] + V3_7, +} + +impl std::fmt::Display for MlsEdition { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + MlsEdition::V3_6 => "3.6", + MlsEdition::V3_7 => "3.7", + }) + } +} + +/// Whether a `SpecSourced` row quotes the specification or reads it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum QuoteKind { + /// The `quote` is MLS text, word for word. + Verbatim, + /// The `quote` is the compiler's own statement of what the section means. + /// + /// A paraphrase is still grounded — it is quoted from the tree, where it was + /// written beside the code it governs — but it is an interpretation, and a + /// reader re-litigating the row should read the section itself. + Paraphrase, +} + +/// Where a formal statement's authority comes from. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StatementTier { + /// The Modelica Language Specification states it. + SpecSourced { + /// Which edition the quotation is taken from. + edition: MlsEdition, + /// The MLS section, spelled with the section sign (`§8.3.5.1`). + section: String, + /// Whether `quote` is MLS text or the compiler's reading of it. + quote_kind: QuoteKind, + /// The fragment the statement rests on. + quote: String, + /// Where that fragment already appears in the tree. + quote_source: String, + }, + /// The specification leaves it open; an oracle decides it. + OracleImplied { + /// The oracle and the run configuration that produced the evidence. + oracle: String, + /// Where the evidence is recorded in the tree. + evidence: String, + /// What the specification leaves open, and why this is a choice. + latitude_note: String, + }, + /// The specification is silent and no oracle settles it; the compiler chose. + SpecSilent { + /// Why the silence is *established* rather than assumed. + /// + /// A rationale that only says the specification does not mention the + /// case is not enough: it has to say where the reader looked and what + /// the section it looked at does scope, so a later maintainer can tell a + /// searched-for silence from an unread one. + rationale: String, + /// The other choices that were available, and what distinguishes them. + alternatives_considered: String, + /// Which oracle was consulted, when one was, and why it did not answer. + oracle_consulted: Option, + }, +} + +impl StatementTier { + /// The short tier label used in reports. + pub fn label(&self) -> &'static str { + match self { + StatementTier::SpecSourced { .. } => "SpecSourced", + StatementTier::OracleImplied { .. } => "OracleImplied", + StatementTier::SpecSilent { .. } => "SpecSilent", + } + } +} + +/// Which way a [`StatementPin`] points. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum PinPolarity { + /// The pin asserts the statement: it fails when the statement stops holding. + Asserts, + /// The pin asserts what the compiler does *instead* of the statement. + /// + /// Such a pin fails when the divergence is *fixed*, which is the point: the + /// change that fixes it is forced through this row rather than past it. + PinsDivergence, +} + +/// The place that holds the compiler to a statement. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StatementPin { + /// A `#[test]` function that fails when the statement stops holding. + Test { + /// Repository-relative path of the file holding the test. + file: String, + /// The test function's name, without `fn` or parentheses. + function: String, + }, + /// The construction or enforcement site in production source. + Site { + /// Repository-relative path of the file holding the site. + file: String, + /// An item name or other exact spelling that occurs in that file. + symbol: String, + }, + /// A written record of a statement the compiler does not satisfy. + Record { + /// Repository-relative path of the file holding the record. + file: String, + /// A phrase from the record, verbatim, so rewording revisits this row. + anchor: String, + }, +} + +impl StatementPin { + /// The repository-relative file this pin names. + pub fn file(&self) -> &str { + match self { + StatementPin::Test { file, .. } + | StatementPin::Site { file, .. } + | StatementPin::Record { file, .. } => file, + } + } + + /// The exact text that must occur in [`StatementPin::file`]. + pub fn needle(&self) -> String { + match self { + StatementPin::Test { function, .. } => format!("fn {function}("), + StatementPin::Site { symbol, .. } => symbol.clone(), + StatementPin::Record { anchor, .. } => anchor.clone(), + } + } + + /// Whether this pin is executable evidence rather than prose. + pub fn is_executable(&self) -> bool { + matches!(self, StatementPin::Test { .. } | StatementPin::Site { .. }) + } +} + +/// What the compiler currently does about a statement. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum EnforcementStatus { + /// A test or construction site holds the compiler to it. + Enforced, + /// The compiler does not satisfy it, and the divergence is written down. + RecordedDivergence, + /// The statement is admitted but nothing in the compiler implements it yet. + Unimplemented, +} + +/// One formal statement, its tier, its pin, and its status. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FormalStatement { + /// Stable identifier, `FS--`. + pub id: String, + /// The SPEC_0022 category the identifier's middle segment names. + pub category: ContractCategory, + /// The formal statement itself, in one precise sentence. + pub statement: String, + /// Where the statement's authority comes from. + pub tier: StatementTier, + /// The place that holds the compiler to it. + pub pin: StatementPin, + /// Which way [`FormalStatement::pin`] points. + pub pin_polarity: PinPolarity, + /// What the compiler currently does about it. + pub status: EnforcementStatus, + /// SPEC_0022 contracts this statement refines, if any. + pub contracts: Vec, + /// Further MLS sections the statement touches without quoting. + pub related_sections: Vec, + /// Anything a reader needs that the statement itself cannot carry. + pub note: Option, +} + +impl FormalStatement { + /// The identifier's category segment, e.g. `EQN` for `FS-EQN-001`. + pub fn category_prefix(&self) -> &'static str { + self.category.prefix() + } +} + +/// Why a row in `data/formal_statements.toml` is not a [`FormalStatement`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FormalStatementError { + /// The file is not valid TOML, or a row is missing a mandatory field. + Toml(String), + /// A row's identifier does not read `FS--`. + MalformedId(String), + /// A row's identifier names a category no SPEC_0022 prefix spells. + UnknownCategory { + /// The offending identifier. + id: String, + /// The category segment that matched no prefix. + prefix: String, + }, + /// A row's `tier` names neither tier. + UnknownTier { + /// The offending identifier. + id: String, + /// The `tier` value that matched neither variant. + tier: String, + }, + /// A row omits a field the tier it declares requires. + MissingTierField { + /// The offending identifier. + id: String, + /// The declared tier. + tier: String, + /// The field that tier requires. + field: &'static str, + }, + /// A row carries a field belonging to the tier it did not declare. + ForeignTierField { + /// The offending identifier. + id: String, + /// The declared tier. + tier: String, + /// The field that belongs to the other tier. + field: &'static str, + }, + /// A row omits a field every row requires, or leaves it blank. + MissingField { + /// The offending identifier. + id: String, + /// The blank or absent field. + field: &'static str, + }, + /// A row's pin points the wrong way for the status it declares. + PinPolarityMismatch { + /// The offending identifier. + id: String, + /// The declared status. + status: &'static str, + /// The polarity that status requires. + required: &'static str, + }, +} + +impl std::fmt::Display for FormalStatementError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FormalStatementError::Toml(message) => { + write!(f, "formal_statements.toml is not loadable: {message}") + } + FormalStatementError::MalformedId(id) => { + write!(f, "`{id}` is not spelled FS--") + } + FormalStatementError::UnknownCategory { id, prefix } => { + write!( + f, + "`{id}` names category `{prefix}`, which SPEC_0022 has no prefix for" + ) + } + FormalStatementError::UnknownTier { id, tier } => { + write!( + f, + "`{id}` declares tier `{tier}`, which is none of SpecSourced, OracleImplied, \ + SpecSilent" + ) + } + FormalStatementError::MissingTierField { id, tier, field } => { + write!(f, "`{id}` is {tier} but carries no `{field}`") + } + FormalStatementError::ForeignTierField { id, tier, field } => { + write!( + f, + "`{id}` is {tier} but carries `{field}`, which belongs to another tier" + ) + } + FormalStatementError::MissingField { id, field } => { + write!(f, "`{id}` carries no `{field}`") + } + FormalStatementError::PinPolarityMismatch { + id, + status, + required, + } => { + write!( + f, + "`{id}` is {status}, so its pin must be `{required}`; reading a pin the wrong \ + way turns a divergence record into a claim of compliance" + ) + } + } + } +} + +impl std::error::Error for FormalStatementError {} + +/// One row of `data/formal_statements.toml`, before it is checked. +/// +/// The TOML surface is deliberately flat, the way `contracts.toml` and +/// `contract_cases.toml` are: a row is readable and diffable without knowing the +/// Rust types. [`FormalStatement`]'s enums are recovered by [`check_row`], so a +/// row that names a tier without its fields never becomes a statement at all. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawStatement { + id: String, + statement: String, + tier: String, + #[serde(default)] + edition: Option, + #[serde(default)] + section: Option, + #[serde(default)] + quote_kind: Option, + #[serde(default)] + quote: Option, + #[serde(default)] + quote_source: Option, + #[serde(default)] + oracle: Option, + #[serde(default)] + evidence: Option, + #[serde(default)] + latitude_note: Option, + #[serde(default)] + rationale: Option, + #[serde(default)] + alternatives_considered: Option, + #[serde(default)] + oracle_consulted: Option, + pin_kind: String, + pin_file: String, + pin_anchor: String, + pin_polarity: PinPolarity, + status: EnforcementStatus, + #[serde(default)] + contracts: Vec, + #[serde(default)] + related_sections: Vec, + #[serde(default)] + note: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct StatementsToml { + statements: Vec, +} + +/// The category a `FS--` identifier names, and its ordinal. +fn split_id(id: &str) -> Option<(&str, &str)> { + let rest = id.strip_prefix("FS-")?; + let (prefix, digits) = rest.rsplit_once('-')?; + let well_formed = !prefix.is_empty() + && prefix.chars().all(|c| c.is_ascii_uppercase()) + && digits.len() == 3 + && digits.chars().all(|c| c.is_ascii_digit()); + well_formed.then_some((prefix, digits)) +} + +/// Which optional fields each tier owns. +/// +/// A field present on a row whose tier does not own it is a +/// [`FormalStatementError::ForeignTierField`], which is what stops a row from +/// carrying an oracle *and* a quotation and leaving a reader to guess which one +/// the classification rests on. +const TIER_FIELDS: [(&str, &[&str]); 3] = [ + ( + "SpecSourced", + &["edition", "section", "quote_kind", "quote", "quote_source"], + ), + ("OracleImplied", &["oracle", "evidence", "latitude_note"]), + ( + "SpecSilent", + &["rationale", "alternatives_considered", "oracle_consulted"], + ), +]; + +/// Reject any field present on `row` that the declared tier does not own. +fn reject_foreign_tier_fields(row: &RawStatement) -> Result<(), FormalStatementError> { + let present: [(&str, bool); 11] = [ + ("edition", row.edition.is_some()), + ("section", row.section.is_some()), + ("quote_kind", row.quote_kind.is_some()), + ("quote", row.quote.is_some()), + ("quote_source", row.quote_source.is_some()), + ("oracle", row.oracle.is_some()), + ("evidence", row.evidence.is_some()), + ("latitude_note", row.latitude_note.is_some()), + ("rationale", row.rationale.is_some()), + ( + "alternatives_considered", + row.alternatives_considered.is_some(), + ), + ("oracle_consulted", row.oracle_consulted.is_some()), + ]; + let owned = TIER_FIELDS + .iter() + .find(|(tier, _)| *tier == row.tier) + .map(|(_, fields)| *fields) + .ok_or_else(|| FormalStatementError::UnknownTier { + id: row.id.clone(), + tier: row.tier.clone(), + })?; + for (field, is_present) in present { + if is_present && !owned.contains(&field) { + return Err(FormalStatementError::ForeignTierField { + id: row.id.clone(), + tier: row.tier.clone(), + field: TIER_FIELDS + .iter() + .flat_map(|(_, fields)| fields.iter()) + .find(|owned_field| **owned_field == field) + .copied() + .unwrap_or("unknown"), + }); + } + } + Ok(()) +} + +/// Recover the tier a row declares, or say exactly which field is wrong. +fn check_tier(row: &RawStatement) -> Result { + reject_foreign_tier_fields(row)?; + let id = row.id.clone(); + let tier = row.tier.clone(); + let missing = move |field| FormalStatementError::MissingTierField { + id: id.clone(), + tier: tier.clone(), + field, + }; + match row.tier.as_str() { + "SpecSourced" => Ok(StatementTier::SpecSourced { + edition: row.edition.ok_or_else(|| missing("edition"))?, + section: non_blank(row.section.as_deref(), || missing("section"))?, + quote_kind: row.quote_kind.ok_or_else(|| missing("quote_kind"))?, + quote: non_blank(row.quote.as_deref(), || missing("quote"))?, + quote_source: non_blank(row.quote_source.as_deref(), || missing("quote_source"))?, + }), + "OracleImplied" => Ok(StatementTier::OracleImplied { + oracle: non_blank(row.oracle.as_deref(), || missing("oracle"))?, + evidence: non_blank(row.evidence.as_deref(), || missing("evidence"))?, + latitude_note: non_blank(row.latitude_note.as_deref(), || missing("latitude_note"))?, + }), + "SpecSilent" => Ok(StatementTier::SpecSilent { + rationale: non_blank(row.rationale.as_deref(), || missing("rationale"))?, + alternatives_considered: non_blank(row.alternatives_considered.as_deref(), || { + missing("alternatives_considered") + })?, + oracle_consulted: row.oracle_consulted.clone(), + }), + other => Err(FormalStatementError::UnknownTier { + id: row.id.clone(), + tier: other.to_string(), + }), + } +} + +fn non_blank( + value: Option<&str>, + error: impl Fn() -> FormalStatementError, +) -> Result { + match value { + Some(text) if !text.trim().is_empty() => Ok(text.to_string()), + _ => Err(error()), + } +} + +/// Recover the pin a row declares. +fn check_pin(row: &RawStatement) -> Result { + let file = row.pin_file.clone(); + let anchor = row.pin_anchor.clone(); + for (value, field) in [(&file, "pin_file"), (&anchor, "pin_anchor")] { + if value.trim().is_empty() { + return Err(FormalStatementError::MissingField { + id: row.id.clone(), + field, + }); + } + } + match row.pin_kind.as_str() { + "Test" => Ok(StatementPin::Test { + file, + function: anchor, + }), + "Site" => Ok(StatementPin::Site { + file, + symbol: anchor, + }), + "Record" => Ok(StatementPin::Record { file, anchor }), + _ => Err(FormalStatementError::MissingField { + id: row.id.clone(), + field: "pin_kind", + }), + } +} + +/// Reject a pin that points the wrong way for the status it sits under. +/// +/// `Unimplemented` is deliberately unconstrained: such a row may pin the +/// abstention the compiler makes instead, or a record of what is missing, and +/// neither reading is wrong enough to legislate here. +fn check_pin_polarity(row: &RawStatement) -> Result<(), FormalStatementError> { + let required = match row.status { + EnforcementStatus::Enforced => Some((PinPolarity::Asserts, "Enforced", "Asserts")), + EnforcementStatus::RecordedDivergence => Some(( + PinPolarity::PinsDivergence, + "RecordedDivergence", + "PinsDivergence", + )), + EnforcementStatus::Unimplemented => None, + }; + match required { + Some((polarity, status, name)) if row.pin_polarity != polarity => { + Err(FormalStatementError::PinPolarityMismatch { + id: row.id.clone(), + status, + required: name, + }) + } + _ => Ok(()), + } +} + +/// Turn one checked row into a statement, or say why it is not one. +fn check_row(row: RawStatement) -> Result { + let (prefix, _) = + split_id(&row.id).ok_or_else(|| FormalStatementError::MalformedId(row.id.clone()))?; + let category = ContractCategory::from_prefix(prefix).ok_or_else(|| { + FormalStatementError::UnknownCategory { + id: row.id.clone(), + prefix: prefix.to_string(), + } + })?; + if row.statement.trim().is_empty() { + return Err(FormalStatementError::MissingField { + id: row.id.clone(), + field: "statement", + }); + } + let tier = check_tier(&row)?; + let pin = check_pin(&row)?; + check_pin_polarity(&row)?; + Ok(FormalStatement { + category, + statement: row.statement, + tier, + pin, + pin_polarity: row.pin_polarity, + status: row.status, + contracts: row.contracts.iter().map(ContractId::new).collect(), + related_sections: row.related_sections, + note: row.note, + id: row.id, + }) +} + +/// Parse and check a formal-statement table. +/// +/// Exposed so a test can assert that a malformed row is *refused*: the check is +/// the registry's guarantee, and a guarantee nothing exercises is a comment. +pub fn parse_formal_statements(raw: &str) -> Result, FormalStatementError> { + let parsed: StatementsToml = + toml::from_str(raw).map_err(|error| FormalStatementError::Toml(error.to_string()))?; + parsed.statements.into_iter().map(check_row).collect() +} + +/// Load every formal statement from the embedded TOML data file. +/// +/// # Panics +/// +/// Panics when the embedded table is malformed. The table ships inside the +/// binary and the invariant tests parse it on every run, so a failure here is +/// a build-time defect, not a runtime condition. +pub fn load_all_formal_statements() -> Vec { + parse_formal_statements(include_str!("../../data/formal_statements.toml")) + .expect("formal_statements.toml ships in the binary and is gated by formal_statement_invariants; a malformed table is a build-time defect") +} diff --git a/crates/rumoca-contracts/src/registry/mod.rs b/crates/rumoca-contracts/src/registry/mod.rs index 0bf5714ec..28a1c13d4 100644 --- a/crates/rumoca-contracts/src/registry/mod.rs +++ b/crates/rumoca-contracts/src/registry/mod.rs @@ -1,21 +1,35 @@ //! Contract Registry backed by a compile-time static contract table. +pub mod formal; + use indexmap::IndexMap; use serde::{Deserialize, Serialize}; -/// Unique identifier for a contract. +/// Unique identifier for a contract, e.g. `DECL-001` (SPEC_0022). +/// +/// The spelling is the identity here — a contract id is a spec-assigned label, +/// not a compiler-assigned `DefId` — so it is interned: the registry and the +/// runner both key `IndexMap`s on it, and the interner makes every lookup hash +/// a `u32` while collapsing the repeated ids to one allocation each. The +/// representation is private so the id cannot be treated as raw text; the +/// serialized form is unchanged, since `VarName` serializes as its spelling. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct ContractId(pub String); +pub struct ContractId(rumoca_compile::compile::VarName); impl ContractId { pub fn new(id: impl Into) -> Self { - Self(id.into()) + Self(rumoca_compile::compile::VarName::new(id)) + } + + /// The contract's spec label. + pub fn as_str(&self) -> &str { + self.0.as_str() } } impl std::fmt::Display for ContractId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) + f.write_str(self.as_str()) } } @@ -85,6 +99,43 @@ impl ContractCategory { } } + /// Every category, in SPEC_0022 order. + /// + /// [`ContractCategory::from_prefix`] reads this, so a new category enters + /// both the forward and the reverse mapping in one edit. + pub const ALL: [ContractCategory; 18] = [ + ContractCategory::Lexical, + ContractCategory::Declaration, + ContractCategory::Instantiation, + ContractCategory::Expression, + ContractCategory::Equation, + ContractCategory::Algorithm, + ContractCategory::Connection, + ContractCategory::Function, + ContractCategory::Type, + ContractCategory::Array, + ContractCategory::Package, + ContractCategory::OperatorRecord, + ContractCategory::Simulation, + ContractCategory::Clock, + ContractCategory::Stream, + ContractCategory::StateMachine, + ContractCategory::Annotation, + ContractCategory::Unit, + ]; + + /// The category a contract-id prefix names, e.g. `EQN` for + /// [`ContractCategory::Equation`]. + /// + /// This is the inverse of [`ContractCategory::prefix`], and it is what lets + /// an identifier carry its own category instead of restating it in a field + /// that could disagree. + pub fn from_prefix(prefix: &str) -> Option { + Self::ALL + .into_iter() + .find(|category| category.prefix() == prefix) + } + /// Get the MLS section reference. pub fn mls_section(&self) -> &'static str { match self { diff --git a/crates/rumoca-contracts/src/test_support.rs b/crates/rumoca-contracts/src/test_support.rs index d536fbba2..781abb889 100644 --- a/crates/rumoca-contracts/src/test_support.rs +++ b/crates/rumoca-contracts/src/test_support.rs @@ -3,12 +3,11 @@ //! Provides convenience functions for compiling Modelica models //! and asserting success/failure/balance conditions. -use rumoca_compile::compile::{CompilationResult, FailedPhase, PhaseResult}; +use rumoca_compile::compile::{CompilationResult, FailedPhase, PhaseResult, VariableRole}; use rumoca_compile::parsing::{ ParseError, parse_source_to_ast as parse_to_ast, parse_source_to_ast_with_errors, }; use rumoca_compile::{Session, SessionConfig}; -use rumoca_phase_dae::balance as dae_balance; /// Compile a model from source, expecting success. /// Returns the CompilationResult for further assertions. @@ -100,6 +99,97 @@ pub fn expect_failure_in_phase_with_code( ); } +/// Compile a model from source, expecting failure in a specific compile phase +/// that *reports* a specific diagnostic code. +/// +/// Use this instead of [`expect_failure_in_phase_with_code`] when the phase +/// legitimately reports several distinct diagnostics for one source construct. +/// `PhaseResult::error_code` is a *summary*: `summarize_typecheck_error_code` +/// (`rumoca-compile`) collapses a set of differing codes to the `ET000` +/// sentinel, so the summary is not the code of any individual violation. This +/// helper therefore asserts on the phase's diagnostic list, which is where the +/// contract violation is actually recorded. +/// +/// # Panics +/// Panics if parsing fails, compilation succeeds, needs synthesized inner bindings, +/// fails in a different phase, or no reported diagnostic carries the code. +pub fn expect_failure_in_phase_reporting_code( + source: &str, + model: &str, + expected_phase: FailedPhase, + expected_code: &str, +) { + let phase_result = compile_model_phases_or_panic(source, model); + let (actual_phase, codes) = extract_failed_phase_and_diagnostic_codes(phase_result, model); + assert_eq!( + actual_phase, expected_phase, + "Expected failure in phase {expected_phase} for model {model}, got {actual_phase}" + ); + assert!( + codes + .iter() + .any(|code| error_code_matches(code, expected_code)), + "Expected phase {actual_phase} to report error code {expected_code} for model {model}, got {codes:?}" + ); +} + +/// Compile a model from source, expecting failure in a specific compile phase +/// with a diagnostic that carries a code *and* states a specific detail. +/// +/// Use this when the code alone does not distinguish the rejection the contract +/// is about. `ET009` is reported both for a subscript the declaration has no +/// dimension for and for a subscript outside a dimension it does have, and a +/// wrong declared rank is reported with the same code as a right one — so a +/// test that only reads the code cannot tell a correct rejection from a +/// rejection that named the wrong shape. +/// +/// # Panics +/// Panics if parsing fails, compilation succeeds, needs synthesized inner +/// bindings, fails in a different phase, reports no diagnostic with the code, or +/// no such diagnostic states `expected_detail`. +pub fn expect_failure_in_phase_with_detail( + source: &str, + model: &str, + expected_phase: FailedPhase, + expected_code: &str, + expected_detail: &str, +) { + let phase_result = compile_model_phases_or_panic(source, model); + let (actual_phase, messages) = match phase_result { + PhaseResult::Success(_) => { + panic!("Expected compilation failure for model {model}, but it succeeded") + } + PhaseResult::NeedsInner { .. } => panic!( + "Expected compile-phase failure for model {model}, got NeedsInner (missing inner declarations)" + ), + PhaseResult::Failed { + phase, diagnostics, .. + } => { + let messages: Vec = diagnostics + .iter() + .filter(|diagnostic| { + diagnostic + .code + .as_deref() + .is_some_and(|code| error_code_matches(code, expected_code)) + }) + .map(|diagnostic| diagnostic.message.clone()) + .collect(); + (phase, messages) + } + }; + assert_eq!( + actual_phase, expected_phase, + "Expected failure in phase {expected_phase} for model {model}, got {actual_phase}" + ); + assert!( + messages + .iter() + .any(|message| message.contains(expected_detail)), + "Expected a {expected_code} diagnostic stating {expected_detail:?} for model {model}, got {messages:?}" + ); +} + fn compile_model_phases_or_panic(source: &str, model: &str) -> PhaseResult { let mut session = Session::new(SessionConfig::default()); session @@ -143,6 +233,32 @@ fn extract_failed_phase_and_code( (phase, code) } +fn extract_failed_phase_and_diagnostic_codes( + phase_result: PhaseResult, + model: &str, +) -> (FailedPhase, Vec) { + match phase_result { + PhaseResult::Success(_) => { + panic!("Expected compilation failure for model {model}, but it succeeded") + } + PhaseResult::NeedsInner { .. } => { + panic!( + "Expected compile-phase failure for model {model}, got NeedsInner (missing inner declarations)" + ) + } + PhaseResult::Failed { + phase, diagnostics, .. + } => { + let codes: Vec = diagnostics.iter().filter_map(|d| d.code.clone()).collect(); + assert!( + !codes.is_empty(), + "Expected coded diagnostics for model {model} in phase {phase}, got none" + ); + (phase, codes) + } + } +} + fn error_code_matches(actual: &str, expected: &str) -> bool { actual == expected || actual.ends_with(expected) } @@ -154,8 +270,7 @@ fn error_code_matches(actual: &str, expected: &str) -> bool { /// Panics if compilation fails or the system is not balanced. pub fn expect_balanced(source: &str, model: &str) -> CompilationResult { let result = expect_success(source, model); - let balance = - dae_balance(&result.dae).expect("balanced test support requires valid DAE metadata"); + let balance = result.balance_detail.balance(); assert_eq!( balance, 0, "Expected balanced system for {model}, got balance={balance}" @@ -170,8 +285,12 @@ pub fn expect_balanced(source: &str, model: &str) -> CompilationResult { /// - no top-level unbound input variables /// - no unbound fixed parameters (fixed=true by default for parameters) pub fn is_standalone_simulatable(result: &CompilationResult) -> bool { - !result.dae.metadata.is_partial - && result.dae.variables.inputs.is_empty() + !result.flat.is_partial + && !result.dae.inspect(|view| { + view.variables().any(|(_, variable)| { + variable.role() == VariableRole::Input && variable.binding().is_none() + }) + }) && !result.flat.has_unbound_fixed_parameters() } diff --git a/crates/rumoca-contracts/tests/alg_contracts.rs b/crates/rumoca-contracts/tests/alg_contracts.rs index bc6c0791d..21a6d3afc 100644 --- a/crates/rumoca-contracts/tests/alg_contracts.rs +++ b/crates/rumoca-contracts/tests/alg_contracts.rs @@ -149,7 +149,7 @@ fn alg_007_when_in_model_algorithm() { expect_success( r#" model Test - Real x; + input Real x; Real y; algorithm y := 0; diff --git a/crates/rumoca-contracts/tests/arr_contracts.rs b/crates/rumoca-contracts/tests/arr_contracts.rs index fecd40f27..a13e1dcbe 100644 --- a/crates/rumoca-contracts/tests/arr_contracts.rs +++ b/crates/rumoca-contracts/tests/arr_contracts.rs @@ -1,11 +1,11 @@ //! ARR (Array) contract tests - MLS §10 //! -//! Tests for the 40 array contracts defined in SPEC_0022. +//! Tests for the 42 array contracts defined in SPEC_0022. use rumoca_compile::compile::FailedPhase; use rumoca_contracts::test_support::{ - expect_balanced, expect_failure_in_phase_with_code, expect_resolve_failure_with_code, - expect_success, + expect_balanced, expect_failure_in_phase_reporting_code, expect_failure_in_phase_with_code, + expect_failure_in_phase_with_detail, expect_resolve_failure_with_code, expect_success, }; // ============================================================================= @@ -661,6 +661,29 @@ fn arr_037_cross_of_matrix_column_slice_accepted() { ); } +#[test] +fn arr_vector_constructor_values_reach_simulation() { + let trace = rumoca_contracts::test_support::simulate_model( + r#" + model M + Real line[3] = linspace(0.0, 1.0, 3); + Real axis[3] = cross({1.0, 0.0, 0.0}, {0.0, 1.0, 0.0}); + Real t(start = 0, fixed = true); + equation + der(t) = 1; + end M; + "#, + "M", + 0.1, + ); + assert_eq!(trace.final_value("line[1]"), 0.0); + assert_eq!(trace.final_value("line[2]"), 0.5); + assert_eq!(trace.final_value("line[3]"), 1.0); + assert_eq!(trace.final_value("axis[1]"), 0.0); + assert_eq!(trace.final_value("axis[2]"), 0.0); + assert_eq!(trace.final_value("axis[3]"), 1.0); +} + // ============================================================================= // ARR-038: transpose needs at least two dimensions (MLS §10.3.2) // ============================================================================= @@ -751,9 +774,14 @@ fn arr_022_cat_real_dimension_rejected() { // ARR-031: a .^ b requires Real or Integer operands // ============================================================================= +// Type-checking reports two distinct codes for this one construct: the generic +// operand check (`ET002`, MLS 3.7 §6.7 -- no implicit Boolean-to-Real +// conversion) and the array-operator check (`ET009`, MLS 3.7 §10.6.7). The +// summary `PhaseResult::error_code` is therefore the `ET000` multi-code +// sentinel, so this case asserts on the reported diagnostics. #[test] fn arr_031_elementwise_power_on_boolean_rejected() { - expect_failure_in_phase_with_code( + expect_failure_in_phase_reporting_code( r#" model M Real x; @@ -793,9 +821,17 @@ fn arr_033_matrix_power_non_square_rejected() { // must not use colon // ============================================================================= +// `b.sig` is neither declared in `Bus` nor added by any `connect`, so per MLS +// 3.7 §9.1.3 the expandable connector has no such member and the reference is +// an error; `size` then has no array to measure (MLS 3.7 §10.3.1). Type +// instantiation rejects the reference directly (`EI007` unknown member), so +// the model never reaches Typecheck or ToDae -- the earlier, named diagnostic supersedes the +// downstream `ED008` "unresolved reference" this case used to expect. The phase +// also emits a cascading `ET009`, making the summary code the `ET000` +// multi-code sentinel, so this case asserts on the reported diagnostics. #[test] fn arr_013_size_of_undeclared_expandable_member_rejected() { - expect_failure_in_phase_with_code( + expect_failure_in_phase_reporting_code( r#" model M expandable connector Bus @@ -805,8 +841,8 @@ fn arr_013_size_of_undeclared_expandable_member_rejected() { end M; "#, "M", - FailedPhase::ToDae, - "ED008", + FailedPhase::Instantiate, + "EI007", ); } @@ -950,3 +986,556 @@ fn arr_025_enum_dimension_with_enum_index_accepted() { "M", ); } + +// ============================================================================= +// ARR-026: the subscript budget of a component reference is the *declared* +// array dimension (MLS §10.5.1). +// +// A subscripted component array is not made of scalars as far as its +// declaration is concerned: `c[1]` subscripts `c`, which the model declared +// with one dimension, whatever the compiler chose to do about the elements +// underneath. Instantiation expands such an array element by element whenever a +// per-element rewrite fires — an array-valued modifier, an array binding, an +// indexed `start` — and compacts it otherwise (SPEC_0032 §1). That choice is an +// instantiation optimization and must not reach the diagnostic: every model +// below is legal Modelica (`omc checkModel` accepts each one), and each was +// rejected as `ET009 ... has 0 dimension(s)` while the declared extents were +// read off the compaction descriptor instead of off the expansion record. +// ============================================================================= + +#[test] +fn arr_026_element_expanded_array_is_subscriptable() { + // `p = kk` is array-valued, so instantiation expands element by element. + expect_success( + r#" + package P + model Sub + parameter Real p = 1; + Real x; + equation + x = p*time; + end Sub; + model M + parameter Real kk[3] = {1, 2, 3}; + Sub c[3](p = kk); + Real y; + equation + y = c[1].x; + end M; + end P; + "#, + "P.M", + ); +} + +#[test] +fn arr_026_element_expanded_array_with_parameter_extent_is_subscriptable() { + expect_success( + r#" + package P + model Sub + parameter Real p = 1; + Real x; + equation + x = p*time; + end Sub; + model M + parameter Integer m = 3; + parameter Real kk[m] = {1, 2, 3}; + Sub c[m](p = kk); + Real y; + equation + y = c[m].x; + end M; + end P; + "#, + "P.M", + ); +} + +#[test] +fn arr_026_element_expanded_array_member_carries_the_owner_domain() { + // MLS §10.4.1: the shape of `c.x` is size(c) ++ size(x). An unsubscripted + // reference must see the owner's declared extent, not a scalar element. + expect_success( + r#" + package P + model Sub + parameter Real p = 1; + Real x; + equation + x = p*time; + end Sub; + model M + parameter Real kk[3] = {1, 2, 3}; + Sub c[3](p = kk); + Real y[3]; + equation + y = c.x; + end M; + end P; + "#, + "P.M", + ); +} + +#[test] +fn arr_026_nested_member_array_under_an_element_expanded_owner_is_subscriptable() { + // MLS §10.5: each part carries its own subscripts, so `c[1].x[2]` spends + // one against the owner's declared extent and one against the member's. + // The owner here is element-expanded; the member is not. + expect_success( + r#" + package P + model Inner + parameter Real p = 1; + parameter Integer n = 3; + Real x[n]; + equation + x = {p*time for k in 1:n}; + end Inner; + model M + parameter Integer m = 2; + parameter Real kk[m] = {1, 2}; + Inner c[m](p = kk); + Real y; + equation + y = c[1].x[2]; + end M; + end P; + "#, + "P.M", + ); +} + +#[test] +fn arr_026_element_expanded_array_reports_its_declared_rank_when_over_subscripted() { + // The rejection is kept, and it names the rank the declaration has. Reading + // the element's own (scalar) shape reported `0 dimension(s)` here, which is + // a statement about the expansion rather than about the model. + expect_failure_in_phase_with_detail( + r#" + package P + model Sub + parameter Real p = 1; + Real x; + equation + x = p*time; + end Sub; + model M + parameter Real kk[3] = {1, 2, 3}; + Sub c[3](p = kk); + Real y; + equation + y = c[1, 2].x; + end M; + end P; + "#, + "P.M", + FailedPhase::Typecheck, + "ET009", + "`c` has 1 dimension(s) but is subscripted with 2 subscript(s)", + ); +} + +#[test] +fn arr_026_element_expanded_array_bounds_are_checked_against_the_declared_extent() { + expect_failure_in_phase_with_detail( + r#" + package P + model Sub + parameter Real p = 1; + Real x; + equation + x = p*time; + end Sub; + model M + parameter Real kk[3] = {1, 2, 3}; + Sub c[3](p = kk); + Real y; + equation + y = c[4].x; + end M; + end P; + "#, + "P.M", + FailedPhase::Typecheck, + "ET009", + "subscript 4 for `c` is out of bounds for dimension of size 3", + ); +} + +#[test] +fn arr_026_record_array_member_of_a_scalar_owner_is_subscriptable() { + // MLS §10.6.9 budgets `c.i[1]` against the dimensions `i` is declared with. + // Record expansion re-checks this binding inside `yy`'s own class instance, + // where the instance-shape index has no row for `c` at all, so the prefix + // shape is unknown. Unknown is an abstention: measuring the subscript + // against the scalar owner's already-consumed extents reported + // `c.i has 0 dimension(s)`, a statement about the lookup rather than about + // the model. omc accepts this model. + expect_success( + r#" + package P + record Cx + Real re; + end Cx; + model Sub + Cx i[3]; + equation + for k in 1:3 loop + i[k].re = time; + end for; + end Sub; + model M + Sub c; + Cx yy = c.i[1]; + end M; + end P; + "#, + "P.M", + ); +} + +#[test] +fn arr_026_record_array_member_under_an_element_expanded_owner_is_subscriptable() { + // The same abstention with a subscript on the owner as well: `c[1]` is + // spent against the owner's declared extent before `i[1]` is measured, so + // the accumulated extents are empty by the time the unknown member arrives. + expect_success( + r#" + package P + record Cx + Real re; + end Cx; + model Sub + Cx i[3]; + equation + for k in 1:3 loop + i[k].re = time; + end for; + end Sub; + model M + Sub c[2]; + Cx yy = c[1].i[1]; + end M; + end P; + "#, + "P.M", + ); +} + +#[test] +fn arr_026_record_array_member_with_a_parameter_extent_is_subscriptable() { + // The MSL shape this rule was found on + // (`Modelica.Magnetic.QuasiStatic.FundamentalWave.Examples.Components.PolyphaseInductance`, + // `output SI.ComplexCurrent Ie = resistor_e.i[1]`): the member's extent is + // a modified parameter, and the owner is a scalar submodel. omc accepts it. + expect_success( + r#" + package P + record Cx + Real re; + Real im; + end Cx; + model Resistor + parameter Integer m = 3; + Cx i[m]; + equation + for j in 1:m loop + i[j].re = time; + i[j].im = 0; + end for; + end Resistor; + model M + parameter Integer m = 5; + Resistor resistor_e(m = m); + output Cx Ie = resistor_e.i[1]; + end M; + end P; + "#, + "P.M", + ); +} + +#[test] +fn arr_026_a_scalar_member_of_a_scalar_owner_is_still_rejected_when_subscripted() { + // The ablation for the three acceptances above. The abstention is confined + // to prefixes the instance-shape index has no row for; a member whose shape + // *is* known to be scalar keeps reporting zero dimensions, so the fix + // cannot have been "stop checking member paths". + expect_failure_in_phase_with_detail( + r#" + package P + model Sub + parameter Real p = 1; + Real x; + equation + x = p*time; + end Sub; + model M + Sub c(p = 2); + Real y; + equation + y = c.x[1]; + end M; + end P; + "#, + "P.M", + FailedPhase::Typecheck, + "ET009", + "`c.x` has 0 dimension(s) but is subscripted with 1 subscript(s)", + ); +} + +#[test] +fn arr_026_a_member_array_is_still_rejected_when_over_subscripted() { + // Second ablation: a member whose declared extents *are* known is still + // measured against them, and the diagnostic names the member, not its owner. + expect_failure_in_phase_with_detail( + r#" + package P + model Inner + parameter Real p = 1; + parameter Integer n = 3; + Real x[n]; + equation + x = {p*time for k in 1:n}; + end Inner; + model M + Inner c(p = 2); + Real y; + equation + y = c.x[1, 2]; + end M; + end P; + "#, + "P.M", + FailedPhase::Typecheck, + "ET009", + "`c.x` has 1 dimension(s) but is subscripted with 2 subscript(s)", + ); +} + +#[test] +fn arr_026_a_member_array_is_still_bounds_checked() { + // Third ablation: literal bounds on a known member extent (MLS §10.5.1). + expect_failure_in_phase_with_detail( + r#" + package P + model Inner + parameter Real p = 1; + parameter Integer n = 3; + Real x[n]; + equation + x = {p*time for k in 1:n}; + end Inner; + model M + Inner c(p = 2); + Real y; + equation + y = c.x[4]; + end M; + end P; + "#, + "P.M", + FailedPhase::Typecheck, + "ET009", + "subscript 4 for `c.x` is out of bounds for dimension of size 3", + ); +} + +#[test] +fn arr_026_redeclared_record_member_array_uses_its_redeclared_extent() { + // The redeclared record instance owns the member-array extent. This is the + // shape of `Modelica.Electrical.Batteries.Examples.BatteryDischargeCharge`: + // `CellRCStack` redeclares `cellData` to a record carrying `rcData[nRC]`, + // and the binding is distributed over `resistor[cellData.nRC]`. + expect_success( + r#" + package P + record RCData + parameter Real R = 1; + parameter Real T_ref = 300; + end RCData; + record BaseData + parameter Real R0 = 1; + end BaseData; + record TransientData + extends BaseData; + parameter Integer nRC = 2; + parameter RCData rcData[nRC]; + end TransientData; + model Res + parameter Real R = 1; + parameter Real T_ref = 300; + Real v; + equation + v = R*time + T_ref; + end Res; + partial model BaseCellStack + replaceable parameter BaseData cellData constrainedby BaseData; + end BaseCellStack; + model M + extends BaseCellStack(redeclare parameter TransientData cellData(nRC = 2)); + Res resistor[cellData.nRC]( + final R = cellData.rcData.R, + final T_ref = cellData.rcData.T_ref); + end M; + end P; + "#, + "P.M", + ); +} + +#[test] +fn arr_026_over_subscripted_record_member_loses_its_equation_instead_of_being_named() { + // PINS A DIVERGENCE, not a rule. This is the witness for FS-ARR-009. + // + // `c.i[1, 2]` spends two subscripts on a member declared with one + // dimension. omc names it: "Wrong number of subscripts in c.i[1, 2] + // (2 subscripts for 1 dimensions)". rumoca abstains at the subscript walk + // (the literal subscripts select no expanded element, so the shape lookup + // reports absence) and the binding equation is then dropped from the DAE + // altogether, surfacing as an unbalanced model blamed on `M` rather than + // on the subscript. The model is still rejected, but the count rule is not + // what rejects it and the span is not where the error is. + expect_failure_in_phase_with_detail( + r#" + package P + record Cx + Real re; + end Cx; + model Sub + Cx i[3]; + equation + for k in 1:3 loop + i[k].re = time; + end for; + end Sub; + model M + Sub c; + Cx yy = c.i[1, 2]; + end M; + end P; + "#, + "P.M", + FailedPhase::ToDae, + "ED001", + "unbalanced model: 3 equations, 4 unknowns", + ); +} + +#[test] +fn arr_026_a_genuine_scalar_component_is_still_rejected_when_subscripted() { + // The ablation for the four acceptances above: a component the model never + // gave a dimension keeps reporting zero of them. + expect_failure_in_phase_with_detail( + r#" + package P + model Sub + parameter Real p = 1; + Real x; + equation + x = p*time; + end Sub; + model M + Sub c(p = 2); + Real y; + equation + y = c[1].x; + end M; + end P; + "#, + "P.M", + FailedPhase::Typecheck, + "ET009", + "`c` has 0 dimension(s) but is subscripted with 1 subscript(s)", + ); +} + +// ============================================================================= +// ARR-041: diagonal(v) requires a vector and returns a square matrix +// (MLS §10.3.5) +// ============================================================================= + +#[test] +fn arr_041_diagonal_of_vector_accepted() { + expect_success( + r#" + model Test + Real v[3] = {1, 2, 3}; + Real m[3, 3]; + Real x(start = 0, fixed = true); + equation + m = diagonal(v); + der(x) = m[2, 2]; + end Test; + "#, + "Test", + ); +} + +#[test] +fn arr_041_diagonal_of_matrix_rejected() { + expect_failure_in_phase_with_detail( + r#" + model Test + Real a[2, 2] = {{1, 0}, {0, 1}}; + Real m[2, 2]; + Real x(start = 0, fixed = true); + equation + m = diagonal(a); + der(x) = 1; + end Test; + "#, + "Test", + FailedPhase::ToDae, + "ED020", + "expression shape mismatch", + ); +} + +// ============================================================================= +// ARR-042: outerProduct(v1, v2) requires two vectors and keeps their extents +// (MLS §10.3.5) +// ============================================================================= + +#[test] +fn arr_042_outer_product_of_vectors_accepted() { + expect_success( + r#" + model Test + Real a[2] = {1, 2}; + Real b[3] = {1, 2, 3}; + Real m[2, 3]; + Real x(start = 0, fixed = true); + equation + m = outerProduct(a, b); + der(x) = m[2, 3]; + end Test; + "#, + "Test", + ); +} + +#[test] +fn arr_042_outer_product_of_matrix_rejected() { + expect_failure_in_phase_with_detail( + r#" + model Test + Real a[2, 2] = {{1, 0}, {0, 1}}; + Real b[3] = {1, 2, 3}; + Real m[2, 3]; + Real x(start = 0, fixed = true); + equation + m = outerProduct(a, b); + der(x) = 1; + end Test; + "#, + "Test", + FailedPhase::ToDae, + "ED020", + "expression shape mismatch", + ); +} diff --git a/crates/rumoca-contracts/tests/conn_contracts.rs b/crates/rumoca-contracts/tests/conn_contracts.rs index 427a28e87..671e3a84f 100644 --- a/crates/rumoca-contracts/tests/conn_contracts.rs +++ b/crates/rumoca-contracts/tests/conn_contracts.rs @@ -3,6 +3,7 @@ //! Tests for the 29 connection contracts defined in SPEC_0022. use rumoca_compile::compile::FailedPhase; +use rumoca_compile::{Session, SessionConfig}; use rumoca_contracts::test_support::{ expect_balanced, expect_failure_in_phase_with_code, expect_resolve_failure_with_code, expect_success, @@ -426,6 +427,166 @@ fn conn_028_parameter_connector_component_rejected() { ); } +/// The MLS §9.3 clause itself, which is the one SPEC_0022 files CONN-028 +/// under: a `connect` may pair a parameter member only with another parameter +/// member. (The sibling `conn_028_parameter_connector_component_rejected` above +/// covers the *other* rule, MLS §9.1's ban on declaring a connector component +/// `parameter`, enforced in resolve as `ER027`.) Two connector classes agreeing +/// on member names but disagreeing on the `parameter` prefix leave the +/// non-parameter side with no connection equation, so the pair is rejected +/// instead of dropped. +/// +/// The repro is doubly invalid and this test does **not** isolate the clause: +/// a member that is `parameter` on one side and a variable on the other also +/// makes one connector violate the §9.3.1 balance rule (`PlugQ` has two +/// potential variables against one flow), which is inherent — MLS §4.7 excludes +/// parameters from the potential count, so the prefix difference always shifts +/// the balance. OMC reports exactly that as a warning while still accepting the +/// model. rumoca's own CONN-017 balance check does not fire here because it +/// skips non-Real members, so the failure observed below is the intended +/// `EF028` and not a balance rejection — but a future CONN-017 that counts +/// Integer members would give this model a second, independent reason to fail. +#[test] +fn conn_028_parameter_member_connected_to_variable_member_rejected() { + expect_failure_in_phase_with_code( + r#" + connector PlugP + parameter Integer m = 3; + Real v; + flow Real i; + end PlugP; + connector PlugQ + Integer m; + Real v; + flow Real i; + end PlugQ; + model Test + PlugP a; + PlugQ b; + equation + connect(a, b); + a.v = 1; + end Test; + "#, + "Test", + FailedPhase::Flatten, + "EF028", + ); +} + +/// The accepting half of the same clause: parameter-to-parameter is legal, and +/// MLS §9.3 generates no connection equation for it. A generated `a.p.m = b.p.m` +/// would add a fifth equation over the same four unknowns, so the balance is +/// what pins "connections are not generated". +#[test] +fn conn_028_parameter_member_connected_to_parameter_member_accepted() { + expect_balanced( + r#" + connector Plug + parameter Integer m = 3; + Real v; + flow Real i; + end Plug; + model Comp + Plug p; + parameter Real r = 1; + equation + p.v = r * p.i; + end Comp; + model Test + Comp a; + Comp b; + equation + connect(a.p, b.p); + end Test; + "#, + "Test", + ); +} + +// ============================================================================= +// CONN-030: Stream-to-stream +// "Stream variables may only connect to other stream variables" (MLS §9.3) +// ============================================================================= + +/// MLS §9.3 admits "stream variables only to other stream variables", and MLS +/// §15.1 (STRM-005) says a stream variable at an inside connector leads to no +/// connection equation at all. Pairing a stream member with a non-stream member +/// therefore selects no equation on either reading: the flat model used to get +/// either nothing (silently under-constraining the non-stream side) or a +/// potential equality that STRM-005 forbids on the stream side. +/// +/// OMC rejects the same model outright: "The connectors in connect(a, b) are +/// not type compatible." +#[test] +fn conn_030_stream_member_matched_with_non_stream_member_rejected() { + expect_failure_in_phase_with_code( + r#" + connector StreamPort + Real p; + flow Real m_flow; + stream Real h_outflow; + end StreamPort; + connector PlainPort + Real h_outflow; + flow Real m_flow; + end PlainPort; + model Test + StreamPort a; + PlainPort b; + equation + connect(a, b); + a.p = 1; + a.h_outflow = 300; + b.h_outflow = 400; + end Test; + "#, + "Test", + FailedPhase::Flatten, + "EF027", + ); +} + +/// The accepting half: matched stream members still form a §15.2 stream set and +/// never receive a §9.2 equality of their own. +#[test] +fn conn_030_stream_member_matched_with_stream_member_accepted() { + let result = expect_success( + r#" + connector StreamPort + Real p; + flow Real m_flow; + stream Real h_outflow; + end StreamPort; + model Vol + StreamPort port; + parameter Real h_out = 2; + Real h_in; + equation + port.h_outflow = h_out; + h_in = inStream(port.h_outflow); + end Vol; + model Test + Vol v1(h_out = 2); + Vol v2(h_out = 4); + equation + connect(v1.port, v2.port); + v1.port.p = 1; + v1.port.m_flow = 1; + end Test; + "#, + "Test", + ); + assert!( + result + .flat + .variables + .iter() + .any(|(name, variable)| name.as_str() == "v1.port.h_outflow" && variable.connected), + "a stream-to-stream connect must still join a stream connection set" + ); +} + // ============================================================================= // CONN-023: Overconstrained not in function // "None of these operators allowed inside function classes" (MLS §9.4) @@ -470,8 +631,43 @@ fn conn_011_expandable_connect_neither_declared_rejected() { end M; "#, "M", - FailedPhase::Typecheck, - "ET001", + FailedPhase::Flatten, + "EF020", + ); +} + +#[test] +fn conn_011_declared_expandable_member_is_not_treated_as_virtual() { + expect_success( + r#" + partial model M + expandable connector Bus + Real sig; + end Bus; + Bus b1; + Bus b2; + equation + connect(b1.sig, b2.sig); + end M; + "#, + "M", + ); +} + +#[test] +fn conn_011_empty_expandable_buses_can_connect_without_member_synthesis() { + expect_success( + r#" + model M + expandable connector Bus + end Bus; + Bus b1; + Bus b2; + equation + connect(b1, b2); + end M; + "#, + "M", ); } @@ -479,6 +675,275 @@ fn conn_011_expandable_connect_neither_declared_rejected() { // CONN-019: Subscripts shall be evaluable expressions or special operator : // ============================================================================= +// The accepted case first (SPEC_0008): a literal subscript on an array of a +// *simple* connector is evaluable, so `connect(a, gate.x[1])` is a connection +// to one element of `gate.x`. A simple connector has no members to expand, so +// the flat model declares the array once, with its dimension intact, and owns +// nothing named `gate.x[1]`. The generated connection equation therefore has to +// reach the DAE as the declared coordinate carrying a subscript; a reference +// whose *name* embedded the index would name no declaration and be rejected as +// an unresolved Flat reference. +#[test] +fn conn_019_connect_to_array_connector_element_accepted() { + expect_success( + r#" + connector RealInput = input Real; + connector RealOutput = output Real; + model Gate + RealInput x[2]; + RealOutput y; + equation + y = x[1] + x[2]; + end Gate; + model M + Gate gate; + RealOutput a; + RealOutput b; + Real probe; + equation + connect(a, gate.x[1]); + connect(b, gate.x[2]); + probe = gate.y; + a = 1.0; + b = 2.0; + end M; + "#, + "M", + ); +} + +// The same element connection one level deeper: the composite that owns the +// element connection is itself a component, which is the shape that reaches +// flattening as a nested rendered path. +#[test] +fn conn_019_nested_connect_to_array_connector_element_accepted() { + expect_success( + r#" + connector RealInput = input Real; + connector RealOutput = output Real; + model Gate + RealInput x[2]; + RealOutput y; + equation + y = x[1] + x[2]; + end Gate; + model Adder + Gate gate; + RealInput u; + RealOutput c; + equation + connect(u, gate.x[2]); + gate.x[1] = 1.0; + c = gate.y; + end Adder; + model M + Adder adder; + Real probe; + equation + adder.u = 2.0; + probe = adder.c; + end M; + "#, + "M", + ); +} + +// An endpoint subscript may leave dimensions behind. MLS §10.5: a subscript +// consumes one leading declared dimension, so `snk.u[1]` of a `Real[2,3]` +// declaration denotes `Real[3]` and connects to another `Real[3]`. MLS §9.2 +// generates one equality per scalar leaf and MLS §4.8 counts those scalars, so +// the connection contributes three equations, not one. Counting a subscripted +// endpoint as a single scalar leaves this legal model short of equations and +// gets it rejected as unbalanced. OMC (devshell build a96aa1a) `checkModel(M)` reports "14 +// equation(s) and 14 variable(s)" and simulates it to snk.s = 7.0. +#[test] +fn conn_019_connect_to_array_connector_slice_is_balanced() { + expect_balanced( + r#" + connector RealInput = input Real; + connector RealOutput = output Real; + model Src + RealOutput y[2,3]; + equation + y = {{1.0,2.0,3.0},{4.0,5.0,6.0}}; + end Src; + model Snk + RealInput u[2,3]; + RealOutput s; + equation + s = u[1,1] + u[2,3]; + end Snk; + model M + Src src; + Snk snk; + Real probe; + equation + connect(snk.u[1], src.y[1]); + connect(snk.u[2], src.y[2]); + probe = snk.s; + end M; + "#, + "M", + ); +} + +// The counterpart rejection (CONN-008, MLS §9.2 "same named elements with the +// same dimensions"): `snk.u[1]` denotes `Real[2]` while `src.y[1]` denotes +// `Real[3]`, so the connection is genuinely unbalanced and keeps the typed +// incompatible-connector error. OMC (devshell build a96aa1a) rejects the same model with "The +// connectors in connect(snk.u[1], src.y[1]) are not type compatible." +#[test] +fn conn_008_connect_array_connector_slice_shape_mismatch_rejected() { + expect_failure_in_phase_with_code( + r#" + connector RealInput = input Real; + connector RealOutput = output Real; + model Src + RealOutput y[2,3]; + equation + y = {{1.0,2.0,3.0},{4.0,5.0,6.0}}; + end Src; + model Snk + RealInput u[2,2]; + RealOutput s; + equation + s = u[1,1] + u[2,2]; + end Snk; + model M + Src src; + Snk snk; + Real probe; + equation + connect(snk.u[1], src.y[1]); + probe = snk.s; + end M; + "#, + "M", + FailedPhase::Flatten, + "EF002", + ); +} + +// MLS §10.5 gives a subscript no dimension to select along when the declaration +// has none, so `connect(a[1], b)` on a scalar connector `a` is an error, not a +// connection of the whole of `a`. OMC (devshell build a96aa1a) rejects it with "Wrong number of +// subscripts in a[1] (1 subscripts for 0 dimensions)". +#[test] +fn conn_019_connect_subscript_on_dimensionless_connector_rejected() { + expect_failure_in_phase_with_code( + r#" + connector C + Real e; + flow Real f; + end C; + model M + C a; + C b; + equation + connect(a[1], b); + a.e = 1.0; + end M; + "#, + "M", + FailedPhase::Flatten, + "EF026", + ); +} + +// MLS §7.3 allows an extends-modification to redeclare a component together +// with array dimensions the base declaration did not have. OMC accepts this +// model. Rumoca's instantiation keeps only the redeclared *type*, so `a` +// reaches flatten carrying the base declaration's rank of zero — a fact about +// this compiler, not about the source. Whatever else this model does +// downstream, the connection phase must not blame the source for it: the +// rank-zero endpoint check is suppressed when the rank is not authoritative. +#[test] +fn conn_019_redeclared_array_dimensions_are_not_reported_as_a_dimensionless_connector() { + let source = r#" + connector C + Real e; + flow Real f; + end C; + model Base + replaceable C a; + end Base; + model Drv + C p[2]; + Real s; + equation + s = p[1].e + p[2].e; + end Drv; + model M + extends Base(redeclare C a[2]); + Drv d; + Real probe; + equation + connect(a[1], d.p[1]); + connect(a[2], d.p[2]); + probe = d.s; + end M; + "#; + let mut session = Session::new(SessionConfig::default()); + session + .add_document("test.mo", source) + .expect("parse redeclared-dimension model"); + let codes: Vec = session + .compile_model_diagnostics("M") + .diagnostics + .iter() + .filter_map(|diagnostic| diagnostic.code.clone()) + .collect(); + // The connection phase abstains, so what is left is the instantiate gap + // itself: the redeclared dimensions never arrive, `a` stays a scalar, and + // the model is short two equations. Asserting that exact outcome keeps this + // from passing vacuously on some third result, and makes it fail loudly if + // the redeclare-dimension gap is ever closed (then this model compiles and + // this expectation should become `expect_balanced`). + assert!( + !codes.iter().any(|code| code.ends_with("EF026")), + "a rank dropped by the redeclare-dimension gap must not be reported as a \ + dimensionless connector, got codes: {codes:?}" + ); + assert!( + codes.iter().any(|code| code.ends_with("ED001")), + "the surviving failure must be the unbalanced-model report caused by the \ + dropped redeclare dimensions, got codes: {codes:?}" + ); +} + +// Acceptance before rejection: the subscript budget comes from the declaration, +// so an element of a parameter-sized connector array stays a legal connect +// argument. OMC (devshell build a96aa1a) `checkModel(M)` reports "6 equation(s) and 6 variable(s)". +#[test] +fn conn_019_connect_to_parameter_sized_connector_array_element_accepted() { + expect_balanced( + r#" + connector RealInput = input Real; + connector RealOutput = output Real; + model Gate + parameter Integer n = 2; + RealInput x[n]; + RealOutput y; + equation + y = x[1] + x[2]; + end Gate; + model M + Gate gate; + RealOutput a; + RealOutput b; + Real probe; + equation + connect(a, gate.x[1]); + connect(b, gate.x[2]); + probe = gate.y; + a = 1.0; + b = 2.0; + end M; + "#, + "M", + ); +} + #[test] fn conn_019_connect_subscript_not_evaluable_rejected() { expect_resolve_failure_with_code( @@ -657,8 +1122,8 @@ fn conn_014_branch_cycle_rejected() { end M; "#, "M", - FailedPhase::ToDae, - "ED017", + FailedPhase::Flatten, + "EF022", ); } @@ -687,8 +1152,8 @@ fn conn_015_two_connected_definite_roots_rejected() { end M; "#, "M", - FailedPhase::ToDae, - "ED017", + FailedPhase::Flatten, + "EF022", ); } @@ -837,12 +1302,12 @@ fn conn_022_overdetermined_type_with_flow_member_rejected() { // ============================================================================= // CONN-012/021: expandable connector causality deduction. Member synthesis -// from component connects is not implemented yet, so these models (and -// therefore every deduction conflict) are rejected during typecheck. +// from component connects is not implemented yet, so these models are rejected +// explicitly at the pre-connection-set augmentation boundary. // ============================================================================= #[test] -fn conn_012_expandable_duplicate_sources_rejected() { +fn unsupported_expandable_duplicate_sources_fail_closed() { expect_failure_in_phase_with_code( r#" model M @@ -863,13 +1328,13 @@ fn conn_012_expandable_duplicate_sources_rejected() { end M; "#, "M", - FailedPhase::Typecheck, - "ET001", + FailedPhase::Flatten, + "EF020", ); } #[test] -fn conn_021_expandable_input_without_source_rejected() { +fn unsupported_expandable_input_without_source_fails_closed() { expect_failure_in_phase_with_code( r#" model M @@ -886,7 +1351,7 @@ fn conn_021_expandable_input_without_source_rejected() { end M; "#, "M", - FailedPhase::Typecheck, - "ET001", + FailedPhase::Flatten, + "EF020", ); } diff --git a/crates/rumoca-contracts/tests/decl_contracts.rs b/crates/rumoca-contracts/tests/decl_contracts.rs index f65dad8b6..afd727f5e 100644 --- a/crates/rumoca-contracts/tests/decl_contracts.rs +++ b/crates/rumoca-contracts/tests/decl_contracts.rs @@ -2,7 +2,7 @@ //! //! Tests for the 36 declaration contracts defined in SPEC_0022. -use rumoca_compile::compile::FailedPhase; +use rumoca_compile::compile::{FailedPhase, VariableRole}; use rumoca_contracts::test_support::{ expect_balanced, expect_compile_failure, expect_failure_in_phase_with_code, expect_parse_err_with_code, expect_parse_ok, expect_resolve_failure_with_code, expect_success, @@ -88,7 +88,7 @@ fn decl_002_block_connector_needs_io_prefix() { #[test] fn decl_002_allows_block_connector_with_member_level_io() { - expect_success( + let result = expect_success( r#" connector C input Real u; @@ -102,6 +102,15 @@ fn decl_002_allows_block_connector_with_member_level_io() { "#, "B", ); + result.dae.inspect(|view| { + let role = |name| { + view.variables() + .find(|(_, variable)| variable.name().as_str() == name) + .map(|(_, variable)| variable.role()) + }; + assert_eq!(role("c.u"), Some(VariableRole::Input)); + assert_eq!(role("c.y"), Some(VariableRole::Output)); + }); } // ============================================================================= diff --git a/crates/rumoca-contracts/tests/eqn_contracts.rs b/crates/rumoca-contracts/tests/eqn_contracts.rs index 223d50b7a..443cc56a8 100644 --- a/crates/rumoca-contracts/tests/eqn_contracts.rs +++ b/crates/rumoca-contracts/tests/eqn_contracts.rs @@ -2,7 +2,8 @@ //! //! Tests for the 38 equation contracts defined in SPEC_0022. -use rumoca_compile::compile::FailedPhase; +use rumoca_compile::compile::{ExpressionOperation, FailedPhase, VariableRole}; +use rumoca_compile::{Session, SessionConfig}; use rumoca_contracts::test_support::{ expect_balanced, expect_failure_in_phase_with_code, expect_parse_err_with_code, expect_resolve_failure_with_code, expect_success, @@ -67,7 +68,7 @@ fn eqn_001_underspecified() { #[test] fn eqn_002_input_with_binding() { - expect_success( + let result = expect_success( r#" model Test input Real u = 1.0; @@ -75,9 +76,26 @@ fn eqn_002_input_with_binding() { equation der(x) = u; end Test; - "#, + "#, "Test", ); + result.dae.inspect(|view| { + let input = view + .variables() + .find(|(_, variable)| variable.name().as_str() == "u") + .map(|(_, variable)| variable) + .expect("checked DAE retains the model input"); + let binding = input + .binding() + .expect("checked DAE retains the input default"); + assert_eq!(input.role(), VariableRole::Input); + assert_eq!( + result + .dae + .source_text(view.expression(binding).unwrap().provenance()), + Some("1.0") + ); + }); } // ============================================================================= @@ -678,7 +696,7 @@ fn eqn_017_single_reinit_accepted() { #[test] fn eqn_017_reinit_in_two_when_equations_rejected() { - expect_resolve_failure_with_code( + expect_failure_in_phase_with_code( r#" model Test Real x(start = 0, fixed = true); @@ -691,12 +709,31 @@ fn eqn_017_reinit_in_two_when_equations_rejected() { reinit(x, 0.1); end when; end Test; - "#, + "#, "Test", - "ER051", + FailedPhase::ToDae, + "ED020", ); } +#[test] +fn eqn_017_references_share_typed_state_owner_and_preserve_second_span() { + let source = r#" + model Test + Real x(start = 0, fixed = true); + equation + der(x) = 1; + when x > 0.5 then + reinit(x, 0); + end when; + when x > 0.7 then + reinit(x, 0.1); + end when; + end Test; + "#; + expect_compile_failure_at_last_source_slice(source, "Test", "ED020", "x"); +} + // ============================================================================= // EQN-018: reinit if branches // "Multiple reinit in same when-clause must appear in different if branches" @@ -704,7 +741,7 @@ fn eqn_017_reinit_in_two_when_equations_rejected() { #[test] fn eqn_018_multiple_reinit_same_branch_rejected() { - expect_resolve_failure_with_code( + expect_failure_in_phase_with_code( r#" model Test Real x(start = 0, fixed = true); @@ -715,9 +752,10 @@ fn eqn_018_multiple_reinit_same_branch_rejected() { reinit(x, 0.1); end when; end Test; - "#, + "#, "Test", - "ER052", + FailedPhase::Flatten, + "EF004", ); } @@ -750,7 +788,7 @@ fn eqn_020_distinct_when_targets_accepted() { #[test] fn eqn_020_same_variable_in_two_when_equations_rejected() { - expect_resolve_failure_with_code( + expect_failure_in_phase_with_code( r#" model Test Real x(start = 0); @@ -764,9 +802,75 @@ fn eqn_020_same_variable_in_two_when_equations_rejected() { d = 2; end when; end Test; - "#, + "#, "Test", - "ER053", + FailedPhase::ToDae, + "ED020", + ); +} + +#[test] +fn eqn_020_equivalent_local_references_fail_at_second_typed_owner() { + let source = r#" + model Test + discrete Real d; + Boolean firstTrigger = time > 0.5; + Boolean secondTrigger = time > 0.7; + equation + when firstTrigger then + d = 1; + end when; + when secondTrigger then + .d = 2; + end when; + end Test; + "#; + expect_compile_failure_at_last_source_slice(source, "Test", "ED020", ".d"); +} + +fn expect_compile_failure_at_last_source_slice( + source: &str, + model: &str, + expected_code: &str, + expected_slice: &str, +) { + let mut session = Session::new(SessionConfig::default()); + session + .add_document("test.mo", source) + .expect("contract source parses"); + let diagnostics = session.compile_model_diagnostics(model); + let diagnostic = diagnostics + .diagnostics + .iter() + .find(|diagnostic| { + diagnostic + .code + .as_deref() + .is_some_and(|code| code.ends_with(expected_code)) + }) + .unwrap_or_else(|| { + panic!( + "expected diagnostic {expected_code}, got {:?}", + diagnostics.diagnostics + ) + }); + let label = diagnostic + .labels + .iter() + .find(|label| label.primary) + .expect("contract failure has a primary source label"); + let expected_start = source + .rfind(expected_slice) + .expect("expected offending source slice is present"); + let (expected_start, expected_label) = expected_slice + .strip_prefix('.') + .map_or((expected_start, expected_slice), |identifier| { + (expected_start + 1, identifier) + }); + assert_eq!(label.span.start.0, expected_start); + assert_eq!( + &source[label.span.start.0..label.span.end.0], + expected_label ); } @@ -842,6 +946,35 @@ fn eqn_007_discrete_equation_not_solved_form_rejected() { ); } +#[test] +fn eqn_007_discrete_conditional_solved_form_accepted() { + let result = expect_success( + r#" + model M + Integer i(start = 0); + equation + if time > 1 then + i = 1; + else + i = 0; + end if; + end M; + "#, + "M", + ); + result.dae.inspect(|view| { + assert_eq!(view.discrete_value_definition_count(), 1); + let owner = view + .discrete_value_owner(view.discrete_value_owner_id(0).unwrap()) + .unwrap(); + let (value, _) = owner.branches().get(0).unwrap().values().get(0).unwrap(); + assert!(matches!( + view.expression(value).unwrap().operation(), + ExpressionOperation::Conditional(_) + )); + }); +} + // ============================================================================= // EQN-014: Any left hand side indices must be evaluable expressions (§8.3.5) // ============================================================================= @@ -898,18 +1031,22 @@ fn eqn_019_connect_in_noneval_if_rejected() { #[test] fn eqn_023_when_initial_accepted() { - expect_success( + let trace = rumoca_contracts::test_support::simulate_model( r#" model M discrete Real x; + Real y(start = 0); equation + der(y) = 0; when initial() then x = 1; end when; end M; "#, "M", + 0.1, ); + assert_eq!(trace.final_value("x"), 1.0); } // ============================================================================= @@ -1092,8 +1229,33 @@ fn eqn_012_branch_variable_sets_differ_rejected() { end M; "#, "M", - FailedPhase::ToDae, - "ED010", + FailedPhase::Flatten, + "EF004", + ); +} + +#[test] +fn eqn_012_branch_variable_sets_match_accepted() { + expect_success( + r#" + model M + parameter Boolean sel = true; + Integer i(start = 0); + Integer j(start = 0); + Boolean c = time > 1; + equation + when c then + if sel then + i = 1; + j = 2; + else + i = 3; + j = 4; + end if; + end when; + end M; + "#, + "M", ); } diff --git a/crates/rumoca-contracts/tests/expr_contracts.rs b/crates/rumoca-contracts/tests/expr_contracts.rs index 8abba7943..7cf6eb84a 100644 --- a/crates/rumoca-contracts/tests/expr_contracts.rs +++ b/crates/rumoca-contracts/tests/expr_contracts.rs @@ -617,8 +617,23 @@ fn expr_039_noevent_usage() { } // ============================================================================= -// EXPR-040: Event triggering operators -// "div, ceil, floor, integer can only change values at events" +// EXPR-040: Event triggering operators (MLS §3.7.2) +// "div, ceil, floor, integer can only change values at events and will trigger +// events as needed" +// +// Registry status is Partial: event generation for div/ceil/floor/integer is +// unimplemented. They are constructed and lowered as pure builtins (Solve +// UnaryOp::Floor/Ceil, no relation-memory owner), so no event root exists at +// their step points and the requirement holds only for arguments that are +// already discrete between events. The test below therefore asserts nothing +// beyond successful compilation, and EXPR-040 is deliberately absent from +// `data/contract_cases.toml` and from IMPLEMENTED_CONTRACT_IDS. +// +// A future event-root implementation must make this test assert the behavior, +// not just the compile: that `integer(x)` owns an event root over the crossing +// of each integer step point, that `n` is held constant between those events +// rather than tracking `x` continuously, and that the state event is reported +// at the crossing time. Only then may EXPR-040 be promoted out of Partial. // ============================================================================= #[test] @@ -800,7 +815,7 @@ fn expr_022_string_of_string_rejected() { // ============================================================================= // EXPR-034: homotopy types // "Scalar expressions actual and simplified are subtypes of Real" -// (MLS §3.7.2.4) +// (MLS 3.6 §3.7.4.3) // ============================================================================= #[test] @@ -921,6 +936,15 @@ fn expr_038_smooth_expression_accepted() { // EXPR-010/011/030/031: spatialDistribution restrictions. The operator is not // supported yet; every use (and therefore every violation) is rejected at the // DAE boundary as an unresolved function call. +// +// The DAE boundary is the first owner (SPEC_0008) that can prove the call has +// no checked owner: `spatialDistribution` is a legal MLS name that Resolve +// registers as a predefined member, and Flatten legitimately forwards any +// non-intrinsic call as a user-function call, so neither earlier phase holds +// the proof. `ED005` (`UnresolvedFunctionCall`) was retired when DAE +// construction became valid-by-construction; SPEC_0008 requires retiring rather +// than reusing a code, so the surviving DAE-boundary code for a call with no +// resolved owner is `ED008` (`UnresolvedReference`). // ============================================================================= #[test] @@ -936,7 +960,7 @@ fn expr_010_spatial_distribution_rejected_as_unsupported() { "#, "M", FailedPhase::ToDae, - "ED005", + "ED008", ); } @@ -953,7 +977,7 @@ fn expr_011_spatial_distribution_unsorted_points_rejected_as_unsupported() { "#, "M", FailedPhase::ToDae, - "ED005", + "ED008", ); } @@ -970,7 +994,7 @@ fn expr_030_spatial_distribution_size_mismatch_rejected_as_unsupported() { "#, "M", FailedPhase::ToDae, - "ED005", + "ED008", ); } @@ -987,6 +1011,6 @@ fn expr_031_spatial_distribution_vectorized_rejected_as_unsupported() { "#, "M", FailedPhase::ToDae, - "ED005", + "ED008", ); } diff --git a/crates/rumoca-contracts/tests/formal_statement_invariants.rs b/crates/rumoca-contracts/tests/formal_statement_invariants.rs new file mode 100644 index 000000000..3db37c8f4 --- /dev/null +++ b/crates/rumoca-contracts/tests/formal_statement_invariants.rs @@ -0,0 +1,605 @@ +//! Well-formedness guards for the formal-statement registry. +//! +//! The registry's value is that a reader can trust a row without opening the +//! files it names. These tests are what make that trust earned: every pin points +//! at a file that exists and text that occurs in it, every specification quote +//! already appears in the tree rather than being introduced here, and a row +//! claiming enforcement points at something executable. +//! +//! What these guards cannot check is *attribution*: whether a section number is +//! the right one, and whether a quotation the tree already carries was correctly +//! transcribed from the specification in the first place. Both are invisible +//! here — a row citing §8.3.5 for a §8.3.5.4 rule passes every test below. That +//! is review work, and the module header of `registry::formal` says so. + +use rumoca_contracts::{ + EnforcementStatus, FormalStatement, PinPolarity, StatementPin, StatementTier, create_registry, + formal_statements, parse_formal_statements, +}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("workspace root is two levels above crates/rumoca-contracts") + .to_path_buf() +} + +/// Read a repository-relative file, failing with the row that named it. +fn read_repo_file(relative: &str, owner: &str) -> String { + let path = workspace_root().join(relative); + assert!( + path.exists(), + "{owner} names a file that does not exist: {}", + path.display() + ); + fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("{owner} names an unreadable file {relative}: {error}")) +} + +/// Line-wrapping artifacts stripped and whitespace collapsed. +/// +/// A quotation reaches the tree wrapped across lines: as a doc comment each line +/// carries a marker, and as a diagnostic string each line carries a Rust +/// continuation backslash. Comparing the normalized forms is what lets a row +/// quote a whole sentence instead of whichever fragment fitted on one line. +fn normalized_prose(text: &str) -> String { + let mut words = Vec::new(); + for line in text.lines() { + let line = line.trim(); + let line = line + .strip_prefix("//!") + .or_else(|| line.strip_prefix("///")) + .or_else(|| line.strip_prefix("//")) + .unwrap_or(line); + let line = line.trim_end().strip_suffix('\\').unwrap_or(line); + words.extend(line.split_whitespace()); + } + words.join(" ") +} + +fn statements() -> &'static [FormalStatement] { + formal_statements() +} + +#[test] +fn registry_is_not_empty_and_carries_every_tier() { + let all = statements(); + assert!( + !all.is_empty(), + "the formal-statement registry must not be empty" + ); + + for label in ["SpecSourced", "OracleImplied", "SpecSilent"] { + let count = all + .iter() + .filter(|entry| entry.tier.label() == label) + .count(); + assert!( + count > 0, + "tier {label} is unpopulated; a tier no row uses is a tier nobody reaches for, and the \ + rows that needed it end up filed under one that misdescribes them" + ); + } +} + +#[test] +fn ids_are_unique_and_name_their_own_category() { + let mut seen = BTreeSet::new(); + for entry in statements() { + assert!( + seen.insert(entry.id.clone()), + "duplicate formal-statement id: {}", + entry.id + ); + let expected = format!("FS-{}-", entry.category_prefix()); + assert!( + entry.id.starts_with(&expected), + "{} does not start with its own category prefix {expected}", + entry.id + ); + } +} + +#[test] +fn statements_read_as_one_sentence() { + for entry in statements() { + let statement = entry.statement.trim(); + assert!( + statement.ends_with('.'), + "{}: the formal statement must be a sentence ending in a period", + entry.id + ); + assert!( + statement.len() >= 40, + "{}: the formal statement is too short to be precise: {statement:?}", + entry.id + ); + } +} + +/// Fail once with every offending row, so adding five rows reports five +/// problems instead of the first one five times. +fn report(kind: &str, offenders: Vec) { + assert!( + offenders.is_empty(), + "{} row(s) fail the {kind} check:\n {}", + offenders.len(), + offenders.join("\n ") + ); +} + +#[test] +fn every_pin_file_exists_and_contains_its_anchor() { + let mut cache: BTreeMap = BTreeMap::new(); + let mut offenders = Vec::new(); + for entry in statements() { + let file = entry.pin.file(); + let content = cache + .entry(file.to_string()) + .or_insert_with(|| read_repo_file(file, &entry.id)); + let needle = entry.pin.needle(); + let found = content.contains(&needle) + || normalized_prose(content).contains(&normalized_prose(&needle)); + if !found { + offenders.push(format!("{}: {file} does not contain {needle:?}", entry.id)); + } + } + report("pin-anchor", offenders); +} + +/// How many lines above a `fn` an attribute may sit and still govern it. +/// +/// Doc comments and `#[allow]` lines routinely separate the two; four lines +/// covers every test in this tree and is short enough that a plain helper +/// function cannot borrow an unrelated attribute from further up. +const ATTRIBUTE_LOOKBACK: usize = 4; + +/// Whether `file` declares `function` as a test rather than as a plain helper. +fn declares_a_test_function(content: &str, function: &str) -> bool { + let signature = format!("fn {function}("); + let lines: Vec<&str> = content.lines().collect(); + lines.iter().enumerate().any(|(index, line)| { + if !line.contains(&signature) { + return false; + } + let start = index.saturating_sub(ATTRIBUTE_LOOKBACK); + lines[start..index] + .iter() + .any(|above| above.trim_start().starts_with("#[test]") || above.contains("#[rstest")) + }) +} + +#[test] +fn test_pins_name_a_real_test_function() { + let mut cache: BTreeMap = BTreeMap::new(); + let mut offenders = Vec::new(); + for entry in statements() { + let StatementPin::Test { file, function } = &entry.pin else { + continue; + }; + let content = cache + .entry(file.clone()) + .or_insert_with(|| read_repo_file(file, &entry.id)); + if !declares_a_test_function(content, function) { + offenders.push(format!( + "{}: {file} declares `fn {function}` but not under #[test]; a helper function is \ + not evidence", + entry.id + )); + } + } + report("test-pin", offenders); +} + +/// Shortest `Site` anchor that can still name one place rather than many. +const MIN_SITE_ANCHOR: usize = 12; + +#[test] +fn site_anchors_are_specific_enough_to_name_one_place() { + let mut offenders = Vec::new(); + for entry in statements() { + let StatementPin::Site { file, symbol } = &entry.pin else { + continue; + }; + if symbol.trim().len() < MIN_SITE_ANCHOR { + offenders.push(format!( + "{}: Site anchor {symbol:?} in {file} is shorter than {MIN_SITE_ANCHOR} \ + characters, so it cannot be trusted to name one place", + entry.id + )); + } + } + report("site-anchor-specificity", offenders); +} + +#[test] +fn pin_polarity_agrees_with_status() { + let mut offenders = Vec::new(); + for entry in statements() { + let required = match entry.status { + EnforcementStatus::Enforced => Some(PinPolarity::Asserts), + EnforcementStatus::RecordedDivergence => Some(PinPolarity::PinsDivergence), + EnforcementStatus::Unimplemented => None, + }; + if let Some(required) = required + && entry.pin_polarity != required + { + offenders.push(format!( + "{}: status {:?} requires pin_polarity {required:?}, found {:?}", + entry.id, entry.status, entry.pin_polarity + )); + } + } + report("pin-polarity", offenders); +} + +#[test] +fn spec_silent_rows_establish_the_silence() { + let mut offenders = Vec::new(); + for entry in statements() { + let StatementTier::SpecSilent { + rationale, + alternatives_considered, + .. + } = &entry.tier + else { + continue; + }; + // A silence is established by naming what the specification *does* + // scope, not by observing that it failed to mention the case. + let cites_a_section = rationale.contains('§'); + if !cites_a_section { + offenders.push(format!( + "{}: the rationale must name the section whose stated scope leaves this open, so a \ + reader can tell a searched-for silence from an unread one", + entry.id + )); + } + if alternatives_considered.len() < 60 { + offenders.push(format!( + "{}: alternatives_considered must say what else was available", + entry.id + )); + } + } + report("spec-silent", offenders); +} + +#[test] +fn spec_sourced_quotes_already_appear_in_their_source() { + let mut cache: BTreeMap = BTreeMap::new(); + let mut offenders = Vec::new(); + for entry in statements() { + let StatementTier::SpecSourced { + quote, + quote_source, + .. + } = &entry.tier + else { + continue; + }; + let content = cache + .entry(quote_source.clone()) + .or_insert_with(|| read_repo_file(quote_source, &entry.id)); + if !normalized_prose(content).contains(&normalized_prose(quote)) { + offenders.push(format!( + "{}: {quote_source} does not contain {quote:?}", + entry.id + )); + } + } + report( + "quote-grounding (a registry row cites a quotation, it never introduces one)", + offenders, + ); +} + +#[test] +fn spec_sourced_sections_are_well_formed() { + for entry in statements() { + let StatementTier::SpecSourced { section, .. } = &entry.tier else { + continue; + }; + for reference in std::iter::once(section).chain(entry.related_sections.iter()) { + let Some(digits) = reference.strip_prefix('§') else { + panic!("{}: section {reference:?} must start with §", entry.id); + }; + assert!( + !digits.is_empty() + && digits + .chars() + .all(|c| c.is_ascii_digit() || c == '.' || c.is_ascii_uppercase()) + && !digits.ends_with('.'), + "{}: section {reference:?} is not an MLS section number", + entry.id + ); + } + } +} + +#[test] +fn oracle_implied_rows_name_an_oracle_and_its_evidence() { + for entry in statements() { + let StatementTier::OracleImplied { + oracle, + evidence, + latitude_note, + } = &entry.tier + else { + continue; + }; + assert!( + evidence.contains('/') || evidence.contains(".rs"), + "{}: the evidence pointer must name a file, not a claim: {evidence:?}", + entry.id + ); + assert!( + latitude_note.len() >= 60, + "{}: the latitude note must say what the specification leaves open", + entry.id + ); + assert!(!oracle.trim().is_empty(), "{}: oracle is blank", entry.id); + } +} + +#[test] +fn enforced_statements_pin_executable_evidence() { + for entry in statements() { + if entry.status != EnforcementStatus::Enforced { + continue; + } + assert!( + entry.pin.is_executable(), + "{}: an Enforced statement must pin a Test or a Site; a written record is not \ + enforcement", + entry.id + ); + } +} + +#[test] +fn recorded_divergences_say_what_diverges() { + for entry in statements() { + if entry.status != EnforcementStatus::RecordedDivergence { + continue; + } + let has_prose = entry.note.is_some() + || matches!(entry.tier, StatementTier::OracleImplied { .. }) + || matches!(entry.pin, StatementPin::Record { .. }); + assert!( + has_prose, + "{}: a recorded divergence must carry a note, an oracle tier, or a Record pin", + entry.id + ); + } +} + +#[test] +fn referenced_contracts_exist_in_the_spec_0022_registry() { + let registry = create_registry(); + for entry in statements() { + for contract in &entry.contracts { + assert!( + registry.get(contract.as_str()).is_some(), + "{}: references contract {contract}, which is not in the SPEC_0022 registry", + entry.id + ); + } + } +} + +#[test] +fn no_two_statements_state_the_same_thing() { + let mut seen: BTreeMap = BTreeMap::new(); + for entry in statements() { + let key = normalized_prose(&entry.statement).to_lowercase(); + if let Some(previous) = seen.insert(key, entry.id.as_str()) { + panic!( + "{} and {} state the same thing; one of them is redundant", + previous, entry.id + ); + } + } +} + +// --------------------------------------------------------------------------- +// The loader's checks are the registry's guarantee, so they are exercised. +// --------------------------------------------------------------------------- + +const WELL_FORMED_ROW: &str = r#" +[[statements]] +id = 'FS-EQN-900' +statement = 'A when-clause activates on a rising edge, which is a sentence long enough to pass.' +tier = 'SpecSourced' +edition = '3.6' +section = '§8.3.5' +quote_kind = 'Verbatim' +quote = 'formal statement' +quote_source = 'crates/rumoca-contracts/src/registry/formal.rs' +pin_kind = 'Test' +pin_file = 'crates/rumoca-contracts/tests/formal_statement_invariants.rs' +pin_anchor = 'ids_are_unique_and_name_their_own_category' +pin_polarity = 'Asserts' +status = 'Enforced' +"#; + +fn row_without(field: &str) -> String { + WELL_FORMED_ROW + .lines() + .filter(|line| !line.starts_with(&format!("{field} ="))) + .collect::>() + .join("\n") +} + +#[test] +fn the_well_formed_fixture_row_parses() { + let parsed = parse_formal_statements(WELL_FORMED_ROW).expect("fixture row must parse"); + assert_eq!(parsed.len(), 1); +} + +#[test] +fn a_spec_sourced_row_without_a_section_is_refused() { + let error = parse_formal_statements(&row_without("section")) + .expect_err("a SpecSourced row without a section is not a statement"); + assert!( + error.to_string().contains("section"), + "the refusal must name the missing field: {error}" + ); +} + +#[test] +fn a_spec_sourced_row_missing_any_quotation_field_is_refused() { + for field in ["quote", "quote_source", "quote_kind", "edition"] { + let error = parse_formal_statements(&row_without(field)) + .unwrap_err_or_else(|| format!("a SpecSourced row without {field} must be refused")); + assert!( + error.to_string().contains(field), + "the refusal must name {field}: {error}" + ); + } +} + +/// `Result::expect_err` with a lazily built message. +trait UnwrapErrOrElse { + fn unwrap_err_or_else(self, message: impl FnOnce() -> String) -> E; +} + +impl UnwrapErrOrElse for Result { + fn unwrap_err_or_else(self, message: impl FnOnce() -> String) -> E { + match self { + Ok(value) => panic!("{}, got {value:?}", message()), + Err(error) => error, + } + } +} + +#[test] +fn an_oracle_implied_row_without_evidence_is_refused() { + let row = " +[[statements]] +id = 'FS-EQN-901' +statement = 'An oracle-implied row states something long enough to be a real sentence here.' +tier = 'OracleImplied' +oracle = 'OpenModelica (omc)' +latitude_note = 'the specification leaves this open' +pin_kind = 'Record' +pin_file = 'crates/rumoca-contracts/data/formal_statements.toml' +pin_anchor = 'FS-EQN-001' +pin_polarity = 'PinsDivergence' +status = 'RecordedDivergence' +"; + let error = parse_formal_statements(row) + .expect_err("an OracleImplied row without evidence is not a statement"); + assert!( + error.to_string().contains("evidence"), + "the refusal must name the missing evidence pointer: {error}" + ); +} + +#[test] +fn a_row_mixing_the_two_tiers_is_refused() { + let row = WELL_FORMED_ROW.replace( + "status = 'Enforced'", + "oracle = 'OpenModelica (omc)'\nstatus = 'Enforced'", + ); + let error = parse_formal_statements(&row) + .expect_err("a SpecSourced row carrying an oracle is not a statement"); + assert!( + error.to_string().contains("oracle"), + "the refusal must name the foreign field: {error}" + ); +} + +#[test] +fn an_unknown_category_prefix_is_refused() { + let row = WELL_FORMED_ROW.replace("FS-EQN-900", "FS-NOPE-900"); + let error = + parse_formal_statements(&row).expect_err("an unknown category prefix is not a statement"); + assert!( + error.to_string().contains("NOPE"), + "the refusal must name the unknown prefix: {error}" + ); +} + +#[test] +fn an_unknown_field_is_refused() { + let row = WELL_FORMED_ROW.replace("status = 'Enforced'", "status = 'Enforced'\ntypo = 'x'"); + parse_formal_statements(&row).expect_err("an unknown field is a typo, not a statement"); +} + +#[test] +fn an_enforced_row_whose_pin_records_a_divergence_is_refused() { + let row = WELL_FORMED_ROW.replace( + "pin_polarity = 'Asserts'", + "pin_polarity = 'PinsDivergence'", + ); + let error = parse_formal_statements(&row) + .expect_err("an Enforced row cannot pin what the compiler does instead"); + assert!( + error.to_string().contains("Asserts"), + "the refusal must name the required polarity: {error}" + ); +} + +#[test] +fn a_recorded_divergence_that_claims_to_assert_is_refused() { + // The fixture already declares `pin_polarity = 'Asserts'`, so changing only + // the status is exactly the mistake this guard exists to catch. + let row = WELL_FORMED_ROW.replace("status = 'Enforced'", "status = 'RecordedDivergence'"); + let error = parse_formal_statements(&row) + .expect_err("a divergence record cannot claim its pin asserts the statement"); + assert!( + error.to_string().contains("PinsDivergence"), + "the refusal must name the required polarity: {error}" + ); +} + +#[test] +fn a_spec_silent_row_without_alternatives_is_refused() { + let row = " +[[statements]] +id = 'FS-EQN-902' +statement = 'The specification does not decide which end of the chain the surviving slope comes from.' +tier = 'SpecSilent' +rationale = 'MLS §3.7.4.5 states the rule over a chain without naming an endpoint.' +pin_kind = 'Site' +pin_file = 'crates/rumoca-contracts/src/registry/formal.rs' +pin_anchor = 'load_all_formal_statements' +pin_polarity = 'Asserts' +status = 'Enforced' +"; + let error = parse_formal_statements(row) + .expect_err("a SpecSilent row without alternatives_considered is not a statement"); + assert!( + error.to_string().contains("alternatives_considered"), + "the refusal must name the missing field: {error}" + ); +} + +#[test] +fn a_spec_silent_row_carrying_an_oracle_field_is_refused() { + let row = " +[[statements]] +id = 'FS-EQN-903' +statement = 'The specification does not decide which end of the chain the surviving slope comes from.' +tier = 'SpecSilent' +rationale = 'MLS §3.7.4.5 states the rule over a chain without naming an endpoint.' +alternatives_considered = 'Either endpoint satisfies the rule; the compiler takes declaration order.' +oracle = 'OpenModelica (omc)' +pin_kind = 'Site' +pin_file = 'crates/rumoca-contracts/src/registry/formal.rs' +pin_anchor = 'load_all_formal_statements' +pin_polarity = 'Asserts' +status = 'Enforced' +"; + let error = parse_formal_statements(row) + .expect_err("`oracle` belongs to OracleImplied; SpecSilent carries `oracle_consulted`"); + assert!( + error.to_string().contains("oracle"), + "the refusal must name the foreign field: {error}" + ); +} diff --git a/crates/rumoca-contracts/tests/func_contracts.rs b/crates/rumoca-contracts/tests/func_contracts.rs index 05c346ece..dfc0f99f3 100644 --- a/crates/rumoca-contracts/tests/func_contracts.rs +++ b/crates/rumoca-contracts/tests/func_contracts.rs @@ -1,6 +1,6 @@ //! FUNC (Function) contract tests - MLS §12 //! -//! Tests for the 35 function contracts defined in SPEC_0022. +//! Tests for the 38 function contracts defined in SPEC_0022. use rumoca_compile::compile::FailedPhase; use rumoca_contracts::test_support::{ @@ -600,6 +600,11 @@ fn func_013_partial_function_call_rejected() { // flatten-time diagnostic is not possible because flatten legitimately // converts partial base functions that redeclares later make concrete // (the MSL Media pattern). + // + // `ED005` (`UnresolvedFunctionCall`) was retired when DAE construction + // became valid-by-construction. SPEC_0008 requires retiring rather than + // reusing a code, so the surviving DAE-boundary code for a call whose + // callee has no resolved owner is `ED008` (`UnresolvedReference`). expect_failure_in_phase_with_code( r#" model M @@ -612,7 +617,7 @@ fn func_013_partial_function_call_rejected() { "#, "M", FailedPhase::ToDae, - "ED005", + "ED008", ); } @@ -821,26 +826,19 @@ fn func_023_cyclic_function_bindings_rejected() { ); } -// ============================================================================= -// FUNC-029: Conditional components in target record: it is an error -// ============================================================================= - #[test] -fn func_029_constructor_for_conditional_record_rejected() { - expect_failure_in_phase_with_code( +fn record_constructor_with_statically_present_conditional_field_accepted() { + expect_success( r#" model M - parameter Boolean has = true; record R Real x; - Real y if has; + Real y if true; end R; R r = R(1.0, 2.0); end M; "#, "M", - FailedPhase::Flatten, - "EF018", ); } @@ -962,6 +960,215 @@ fn func_032_external_function_without_purity_warns() { ); } +/// MLS 3.7 §12.3 states two facts about the deprecated bare external form, and +/// the compiler must carry both from the declaration to the DAE: such a +/// function "shall be treated as impure", while writing no prefix "is +/// deprecated" — reported, not rejected. MLS 3.6 §12.3 (historical; 3.7 +/// deprecates the bare form) put both in one sentence: "assumed to be impure, +/// but without any restriction on calling them". +/// +/// So the body of `F` is impure — never pure by omission, which would license +/// the optimizations MLS forbids on impure calls — while the continuous-time +/// call to it is still accepted. `G` proves the written `pure` prefix is not +/// lost along the same path. +#[test] +fn func_032_external_function_without_purity_is_impure_but_unrestricted() { + let result = expect_success( + r#" + model M + function F + input Real u; + output Real y; + external "C" y = my_func(u); + end F; + pure function G + input Real u; + output Real y; + external "C" y = my_pure_func(u); + end G; + Real z = F(time) + G(time); + end M; + "#, + "M", + ); + + let purities = result.dae.inspect(|view| { + (0..view.function_count()) + .filter_map(|index| { + let function = view.function(view.function_id(index)?)?; + let external = function.external()?; + Some(( + function.name().as_str().to_string(), + external.purity().is_pure(), + )) + }) + .collect::>() + }); + + let bare = purities + .iter() + .find(|(name, _)| name.ends_with('F')) + .expect("the bare external function reaches the DAE"); + assert!( + !bare.1, + "an external function declaring no purity prefix has an impure body (MLS §12.3)" + ); + let declared = purities + .iter() + .find(|(name, _)| name.ends_with('G')) + .expect("the pure external function reaches the DAE"); + assert!( + declared.1, + "the written `pure` prefix is the only thing that makes an external body pure" + ); +} + +/// MLS 3.7 §12.3 makes an external function without explicit purity "treated as +/// impure", and impurity is a fact about the body, so an initial algorithm that +/// determines a discrete value with such a call is rejected by the owner that +/// knows why: initialization applies its updates until they stop changing, and +/// an impure call never settles. The bare form must fail there — with the +/// initial-algorithm owner naming the call — rather than surviving to a later, +/// vaguer rejection. +#[test] +fn func_032_bare_external_cannot_determine_a_discrete_initial_value() { + expect_failure_in_phase_with_code( + r#" + model BareDiscInit + function f + input Real u; + output Real y; + external "C" y = my_func(u); + end f; + discrete Real d; + Real z; + initial algorithm + d := f(1.0); + equation + when time > 0.5 then + d = pre(d) + 1; + end when; + z = d * time; + end BareDiscInit; + "#, + "BareDiscInit", + FailedPhase::ToDae, + "ED013", + ); +} + +/// MLS §12.3 lists `pure(impureFunction(…))` among the contexts an impure call +/// may occupy: "which allows calling impure functions in any pure context". +/// Rumoca does not accept it yet, and this test pins the exact deviation so it +/// is visible rather than assumed working. +/// +/// The wrapper is recognized and erased during lowering, but suppressing the +/// callee's purity check needs a fact Flat can still see at DAE construction, +/// where the rule is re-proven by callee identity. Carrying it takes a call-site +/// marker on the Flat call node (144 construction sites) or a new builtin +/// variant threaded through every exhaustive builtin match — filed as task #57. +/// Until then the impure case is rejected by its earliest owner, Resolve, with +/// the call's own span. +#[test] +fn func_022_pure_wrapper_around_an_impure_call_is_not_yet_accepted() { + expect_resolve_failure_with_code( + r#" + model PureWrap + impure function f + input Real u; + output Real y; + external "C" y = my_func(u); + end f; + Real z; + equation + z = pure(f(time)); + end PureWrap; + "#, + "PureWrap", + "ER088", + ); +} + +/// The wrapper is not a purity blanket: wrapping a pure call is legal and +/// changes nothing about it. +#[test] +fn func_022_pure_wrapper_around_a_pure_call_is_transparent() { + let result = expect_success( + r#" + model PureOfPure + pure function g + input Real u; + output Real y; + external "C" y = my_pure(u); + end g; + Real z; + equation + z = pure(g(time)); + end PureOfPure; + "#, + "PureOfPure", + ); + + assert!( + result.dae.inspect(|view| { + view.function_id(0) + .and_then(|id| view.function(id)) + .and_then(|function| function.external()) + .is_some_and(|external| external.purity().is_pure()) + }), + "a wrapped pure external body is still pure" + ); +} + +/// The wrapper is typed by what it wraps, whatever that is. MLS §12.3 spells +/// it around a call, but the grammar admits any expression, and one that +/// contains no call bypasses nothing and must still mean its own value — the +/// type checker gives `pure(1 + 2)` the type of `1 + 2` and lowering erases the +/// wrapper, so the model is an ordinary one. +#[test] +fn func_022_pure_wrapper_around_a_call_free_expression_is_transparent() { + let result = expect_success( + r#" + model PureLiteral + Real z; + equation + z = pure(1 + 2) * time; + end PureLiteral; + "#, + "PureLiteral", + ); + + assert_eq!( + result.dae.inspect(|view| view.function_count()), + 0, + "the wrapper is erased, so nothing about it survives as a callable" + ); +} + +/// MLS §12.3 spells the wrapper `pure(impureFunction(…))`: exactly one call. +/// The grammar admits other argument lists, and those bypass nothing, so they +/// are rejected with their own span instead of being lowered to a guess. +#[test] +fn func_022_pure_wrapper_without_exactly_one_argument_is_rejected() { + expect_failure_in_phase_with_code( + r#" + model PureArity + pure function g + input Real u; + output Real y; + external "C" y = my_pure(u); + end g; + Real z; + equation + z = pure(g(time), g(time)); + end PureArity; + "#, + "PureArity", + FailedPhase::Typecheck, + "ET008", + ); +} + // ============================================================================= // FUNC-027: Array arguments have to be the same size // ============================================================================= @@ -990,3 +1197,130 @@ fn func_027_vectorized_args_size_mismatch_rejected() { "EF016", ); } + +// ============================================================================= +// FUNC-036: ExternalObject lifecycle shape (MLS §12.9.7) +// "The owner uses the `class` restriction, directly extends ExternalObject and +// owns exactly a non-replaceable constructor and destructor function" +// ============================================================================= + +#[test] +fn func_036_external_object_without_destructor_rejected() { + expect_resolve_failure_with_code( + r#" + class Handle + extends ExternalObject; + function constructor + output Handle object; + external "C" object = create(); + end constructor; + end Handle; + + model M + Real x(start = 0, fixed = true); + equation + der(x) = 1; + end M; + "#, + "M", + "ER132", + ); +} + +#[test] +fn func_036_external_object_replaceable_constructor_rejected() { + expect_resolve_failure_with_code( + r#" + class Handle + extends ExternalObject; + replaceable function constructor + output Handle object; + external "C" object = create(); + end constructor; + function destructor + input Handle object; + external "C" release(object); + end destructor; + end Handle; + + model M + Real x(start = 0, fixed = true); + equation + der(x) = 1; + end M; + "#, + "M", + "ER132", + ); +} + +// ============================================================================= +// FUNC-037: ExternalObject lifecycle signatures (MLS §12.9.7) +// "constructor has one output of the owning type; destructor has one input of +// that type and no outputs" +// ============================================================================= + +#[test] +fn func_037_external_object_destructor_with_output_rejected() { + expect_resolve_failure_with_code( + r#" + class Handle + extends ExternalObject; + function constructor + output Handle object; + external "C" object = create(); + end constructor; + function destructor + input Handle object; + output Integer status; + external "C" status = release(object); + end destructor; + end Handle; + + model M + Real x(start = 0, fixed = true); + equation + der(x) = 1; + end M; + "#, + "M", + "ER133", + ); +} + +// ============================================================================= +// FUNC-038: ExternalObject lifecycle calls (MLS §12.9.7) +// "constructor and destructor cannot be called explicitly" +// +// Registry status is Partial: only the clause tested below is enforced. The +// second clause ("each constructed object is constructed and destroyed exactly +// once") has no implementation, so this test is deliberately absent from +// `data/contract_cases.toml` and FUNC-038 is not in IMPLEMENTED_CONTRACT_IDS. +// ============================================================================= + +#[test] +fn func_038_explicit_destructor_call_rejected() { + expect_resolve_failure_with_code( + r#" + class Handle + extends ExternalObject; + function constructor + output Handle object; + external "C" object = create(); + end constructor; + function destructor + input Handle object; + external "C" release(object); + end destructor; + end Handle; + + model M + Handle object; + algorithm + Handle.destructor(object); + end M; + "#, + "M", + "ER134", + ); +} diff --git a/crates/rumoca-contracts/tests/inst_contracts.rs b/crates/rumoca-contracts/tests/inst_contracts.rs index ca2887f2e..0f2316a99 100644 --- a/crates/rumoca-contracts/tests/inst_contracts.rs +++ b/crates/rumoca-contracts/tests/inst_contracts.rs @@ -4,8 +4,8 @@ use rumoca_compile::compile::FailedPhase; use rumoca_contracts::test_support::{ - expect_balanced, expect_compile_failure, expect_failure_in_phase_with_code, - expect_parse_err_with_code, expect_resolve_failure_with_code, expect_success, + expect_balanced, expect_failure_in_phase_with_code, expect_parse_err_with_code, + expect_resolve_failure_with_code, expect_success, }; fn flat_var_is_protected(result: &rumoca_compile::compile::CompilationResult, name: &str) -> bool { @@ -26,6 +26,23 @@ fn flat_var_exists(result: &rumoca_compile::compile::CompilationResult, name: &s .any(|var_name| var_name.as_str() == name) } +/// Extent of a scalarized component array, counted from the flat variables. +/// +/// A component array is flattened per element (`a[1].x`, `a[2].x`, …) rather +/// than kept as one variable with `dims`, so its extent is the number of +/// consecutive elements that carry `member`. Counting from 1 upwards also +/// proves the extent is not *larger* than expected, which is what the +/// dimension-replacing redeclarations below need to pin. +fn redeclared_array_extent( + result: &rumoca_compile::compile::CompilationResult, + array: &str, + member: &str, +) -> usize { + (1..) + .take_while(|index| flat_var_exists(result, &format!("{array}[{index}].{member}"))) + .count() +} + fn flat_var_dims( result: &rumoca_compile::compile::CompilationResult, name: &str, @@ -85,7 +102,11 @@ fn inst_002_outer_overrides_inner() { ); // The DAE should have p=5, not p=1 assert!( - !result.dae.variables.parameters.is_empty(), + result.dae.inspect(|view| { + view.variables().any(|(_, variable)| { + variable.role() == rumoca_compile::compile::VariableRole::Parameter + }) + }), "Should have parameters in DAE" ); } @@ -471,6 +492,252 @@ fn inst_014_non_replaceable_nested_class_cannot_be_redeclared() { ); } +// ----------------------------------------------------------------------------- +// MLS §7.3 / §A.2.5: an element-redeclaration is a whole component declaration +// (`component-clause1` -> `declaration` -> `IDENT [ array-subscripts ]`), so the +// subscripts it writes replace the replaced declaration's array dimensions, and +// a redeclaration that writes none leaves them standing. Each case below was +// checked against OpenModelica on the same source. +// ----------------------------------------------------------------------------- + +/// Shared fixture: `Holder` declares `a` with `holder_dims`, `Test` redeclares it. +fn redeclared_dims_source(holder_dims: &str, redeclare: &str, extra_decls: &str) -> String { + format!( + r#" + model BaseType + Real x; + equation + x = 1; + end BaseType; + + model DerivedType + extends BaseType; + Real y; + equation + y = 2; + end DerivedType; + + partial model Holder + replaceable BaseType a{holder_dims} constrainedby BaseType; + end Holder; + + model Test + {extra_decls} + extends Holder({redeclare}); + end Test; + "# + ) +} + +#[test] +fn redeclare_dimensions_replace_a_scalar_declaration() { + // `replaceable BaseType a;` redeclared as `DerivedType a[3]`. + // OMC on the same source: a[1..3], each with x and y. + let result = expect_success( + &redeclared_dims_source("", "redeclare DerivedType a[3]", ""), + "Test", + ); + assert_eq!( + redeclared_array_extent(&result, "a", "x"), + 3, + "a rank-raising redeclaration must reshape the component" + ); + assert!( + flat_var_exists(&result, "a[1].y"), + "the redeclared type's own members must be instantiated" + ); +} + +#[test] +fn redeclare_dimensions_replace_a_declared_extent() { + // `replaceable BaseType a[2];` redeclared as `DerivedType a[4]`. + // OMC: a[1..4]. The replaced extent must not survive. + let result = expect_success( + &redeclared_dims_source("[2]", "redeclare DerivedType a[4]", ""), + "Test", + ); + assert_eq!( + redeclared_array_extent(&result, "a", "x"), + 4, + "the redeclaration's extent must replace the declared one" + ); +} + +#[test] +fn redeclare_dimensions_replace_a_declared_rank() { + // `replaceable BaseType a[2,2];` redeclared as `DerivedType a[4]`. + // OMC: a[1..4] — the rank drops from 2 to 1, and that is not an error. + let result = expect_success( + &redeclared_dims_source("[2,2]", "redeclare DerivedType a[4]", ""), + "Test", + ); + assert_eq!( + redeclared_array_extent(&result, "a", "x"), + 4, + "the redeclaration's rank must replace the declared one" + ); +} + +#[test] +fn redeclare_without_dimensions_keeps_the_declared_dimensions() { + // Ablation guard for the three cases above: propagating a redeclaration's + // dimensions must not be read as "a redeclaration always clears them". + // `replaceable BaseType a[3];` redeclared as `DerivedType a` (no + // subscripts) stays a[1..3] — OMC agrees. + let result = expect_success( + &redeclared_dims_source("[3]", "redeclare DerivedType a", ""), + "Test", + ); + assert_eq!( + redeclared_array_extent(&result, "a", "x"), + 3, + "a redeclaration that states no dimensions must keep the declared ones" + ); +} + +#[test] +fn redeclare_dimension_accepts_boolean_as_an_extent() { + // MLS §10.5: the type name `Boolean` is a dimension of extent 2. A + // redeclaration must read it exactly as a declaration does — OMC flattens + // both to `a[false], a[true]`. + // + // This is the case a private copy of the literal-dimension helper got + // wrong: without the `Boolean` arm the subscript decided nothing, the + // component collapsed to a scalar, and the model compiled clean with no + // diagnostic at all. + let result = expect_success( + &redeclared_dims_source("[3]", "redeclare DerivedType a[Boolean]", ""), + "Test", + ); + assert_eq!( + redeclared_array_extent(&result, "a", "x"), + 2, + "`Boolean` as a redeclared dimension must give extent 2, not a scalar" + ); +} + +#[test] +fn declared_dimension_accepts_boolean_as_an_extent() { + // Control for the case above: the declaration path reads `Boolean` as + // extent 2 both before and after this change, which is what makes a + // redeclaration that disagrees with it a defect rather than a policy. + let result = expect_success( + r#" + model BaseType + Real x; + equation + x = 1; + end BaseType; + + model Test + BaseType a[Boolean]; + end Test; + "#, + "Test", + ); + assert_eq!( + redeclared_array_extent(&result, "a", "x"), + 2, + "`Boolean` as a declared dimension must give extent 2" + ); +} + +#[test] +fn redeclare_dimension_expression_resolves_where_it_is_written() { + // The redeclaration's dimension expression is evaluated in the class that + // writes the redeclaration, not in the replaced declaration's own scope + // (OMC: `Holder h(n = 5, redeclare B a[k])` with a local `k = 2` yields + // a[1..2] while `h.n` stays 5). + let result = expect_success( + &redeclared_dims_source( + "[2]", + "redeclare DerivedType a[k]", + "parameter Integer k = 3;", + ), + "Test", + ); + assert_eq!( + redeclared_array_extent(&result, "a", "x"), + 3, + "a parameter-expression dimension must resolve against the redeclaring class" + ); +} + +#[test] +fn redeclare_dimensions_apply_to_connector_arrays() { + // The shape that used to reach typecheck as `has 0 dimension(s)`: a + // connector array whose extent only the redeclaration states. + // OMC: pin[1..3]. + let result = expect_success( + r#" + connector Pin + Real v; + flow Real i; + end Pin; + + connector PinAlt + extends Pin; + end PinAlt; + + partial model Plug + replaceable Pin pin[2] constrainedby Pin; + end Plug; + + model Test + extends Plug(redeclare PinAlt pin[3]); + equation + for k in 1:3 loop + pin[k].v = 0; + end for; + end Test; + "#, + "Test", + ); + assert_eq!( + redeclared_array_extent(&result, "pin", "v"), + 3, + "a redeclared connector array must carry the redeclaration's extent" + ); +} + +#[test] +fn redeclare_with_dimensions_still_requires_a_replaceable_element() { + // MLS §7.3.3 still rejects a redeclaration of a non-replaceable element + // when that redeclaration also restates dimensions. + // + // Dimension propagation cannot weaken this by itself — the replaceable / + // final / constant / constrainedby check is dimension-blind, and + // `collect_redeclarations` propagates its error with `?` before any + // collected shape is applied. What this guards is that the new + // dimension-carrying path did not swallow or bypass that propagation: a + // dimensioned redeclaration must still surface `EI014`, not a reshaped + // component. + expect_failure_in_phase_with_code( + r#" + model BaseType + Real x; + equation + x = 1; + end BaseType; + + model DerivedType + extends BaseType; + end DerivedType; + + partial model Holder + BaseType a; + end Holder; + + model Test + extends Holder(redeclare DerivedType a[3]); + end Test; + "#, + "Test", + FailedPhase::Instantiate, + "EI014", + ); +} + // ============================================================================= // INST-022: Constant not redeclared // "An element declared as constant cannot be redeclared" @@ -753,7 +1020,7 @@ fn inst_034_encapsulated_basic() { expect_success( r#" model Container - parameter Real g = 9.81; + constant Real g = 9.81; model Inner Real x; equation @@ -906,7 +1173,7 @@ fn inst_component_modification() { // ============================================================================= // INST-013: Constant-only references // "Enclosing class variables accessible only if declared constant" -// (MLS §5.3.2) +// (MLS §5.3.1) // ============================================================================= #[test] @@ -931,7 +1198,7 @@ fn inst_013_enclosing_package_constant_accessible() { #[test] fn inst_013_enclosing_non_constant_rejected() { - expect_compile_failure( + expect_resolve_failure_with_code( r#" package P model Holder @@ -949,6 +1216,45 @@ fn inst_013_enclosing_non_constant_rejected() { model Test P.Holder h; end Test; + "#, + "Test", + "ER130", + ); +} + +#[test] +fn inst_013_enclosing_parameter_rejected() { + expect_resolve_failure_with_code( + r#" + model M + parameter Boolean enabled = true; + model Inner + Real x if enabled; + end Inner; + Inner inner_model; + end M; + "#, + "M", + "ER130", + ); +} + +#[test] +fn inst_013_short_class_modifier_uses_enclosing_instance_scope() { + expect_balanced( + r#" + model Resistor + parameter Real R; + Real v; + equation + v = R; + end Resistor; + + model Test + parameter Real R = 2; + replaceable model Load = Resistor(R = R); + Load load; + end Test; "#, "Test", ); diff --git a/crates/rumoca-contracts/tests/oprec_contracts.rs b/crates/rumoca-contracts/tests/oprec_contracts.rs index 435ba128e..58f07cbfc 100644 --- a/crates/rumoca-contracts/tests/oprec_contracts.rs +++ b/crates/rumoca-contracts/tests/oprec_contracts.rs @@ -1,6 +1,9 @@ //! Operator record contract tests - MLS §14 -use rumoca_contracts::test_support::{expect_resolve_failure_with_code, expect_success}; +use rumoca_compile::compile::FailedPhase; +use rumoca_contracts::test_support::{ + expect_failure_in_phase_with_code, expect_resolve_failure_with_code, expect_success, +}; #[test] fn oprec_operator_record_structure_allows_record_fields_and_operator_declarations() { @@ -11,6 +14,7 @@ fn oprec_operator_record_structure_allows_record_fields_and_operator_declaration Real im; encapsulated operator '+' + import Complex; function add input Complex a; input Complex b; @@ -45,6 +49,7 @@ fn oprec_001_encapsulated_operator_ok() { Real im; encapsulated operator '+' + import Complex; function add input Complex a; input Complex b; @@ -74,6 +79,7 @@ fn oprec_001_unencapsulated_operator_rejected() { Real im; operator '+' + import Complex; function add input Complex a; input Complex b; @@ -109,6 +115,7 @@ fn oprec_002_single_output_ok() { Real im; encapsulated operator '+' + import Complex; function add input Complex a; input Complex b; @@ -138,6 +145,7 @@ fn oprec_002_multiple_outputs_rejected() { Real im; encapsulated operator '+' + import Complex; function add input Complex a; input Complex b; @@ -175,6 +183,7 @@ fn oprec_003_record_input_ok() { Real im; encapsulated operator '+' + import Complex; function add input Complex a; input Complex b; @@ -204,6 +213,7 @@ fn oprec_003_missing_record_input_rejected() { Real im; encapsulated operator '+' + import Complex; function add input Real a; input Real b; @@ -239,6 +249,7 @@ fn oprec_004_constructor_output_ok() { Real im; encapsulated operator 'constructor' + import Complex; function from_real input Real x; output Complex c; @@ -267,6 +278,7 @@ fn oprec_004_constructor_output_rejected() { Real im; encapsulated operator 'constructor' + import Complex; function from_real input Real x; output Real y; @@ -301,6 +313,7 @@ fn oprec_008_zero_operator_single_zero_input_ok() { Real im; encapsulated operator '0' + import Complex; function zero output Complex c; algorithm @@ -328,6 +341,7 @@ fn oprec_008_zero_operator_multiple_functions_rejected() { Real im; encapsulated operator '0' + import Complex; function zero output Complex c; algorithm @@ -362,6 +376,7 @@ fn oprec_008_zero_operator_with_input_rejected() { Real im; encapsulated operator '0' + import Complex; function zero input Complex a; output Complex c; @@ -396,6 +411,7 @@ fn oprec_010_string_output_ok() { Real im; encapsulated operator 'String' + import Complex; function to_string input Complex a; output String s; @@ -424,6 +440,7 @@ fn oprec_010_non_string_output_rejected() { Real im; encapsulated operator 'String' + import Complex; function to_string input Complex a; output Real y; @@ -600,7 +617,7 @@ fn oprec_009_cross_constructor_pair_rejected() { #[test] fn oprec_011_zero_inner_dimension_product_rejected() { - rumoca_contracts::test_support::expect_resolve_failure_with_code( + expect_failure_in_phase_with_code( r#" package P operator record OR @@ -614,8 +631,32 @@ fn oprec_011_zero_inner_dimension_product_rejected() { c = a * b; end M; end P; + "#, + "P.M", + FailedPhase::Typecheck, + "ET011", + ); +} + +#[test] +fn oprec_011_unused_zero_sized_array_is_legal() { + expect_success( + r#" + package P + operator record OR + Real re; + end OR; + model Unused + parameter Integer n = 0; + OR values[n]; + end Unused; + model M + Real x; + equation + x = 1; + end M; + end P; "#, "P.M", - "ER129", ); } diff --git a/crates/rumoca-contracts/tests/registry_invariants.rs b/crates/rumoca-contracts/tests/registry_invariants.rs index 9d599648a..c197fbfce 100644 --- a/crates/rumoca-contracts/tests/registry_invariants.rs +++ b/crates/rumoca-contracts/tests/registry_invariants.rs @@ -90,3 +90,57 @@ fn implemented_id_list_is_unique_and_exists_in_registry() { ); } } + +/// The SPEC_0022 catalog is the source of truth for which contracts exist; +/// `data/contracts.toml` is its machine-readable mirror. Adding a catalog row +/// without a registry row (or the reverse) is drift, so pin set equality here +/// rather than only the per-category counts. +#[test] +fn registry_ids_match_spec_0022_catalog() { + let catalog = std::fs::read_to_string(spec_0022_path()) + .unwrap_or_else(|e| panic!("failed to read {}: {e}", spec_0022_path().display())); + let catalog_ids = catalog_contract_ids(&catalog); + assert!( + !catalog_ids.is_empty(), + "SPEC_0022 catalog parsed to zero contract rows; the table format changed" + ); + + let registry_ids: BTreeSet = create_registry() + .all() + .map(|contract| contract.id.to_string()) + .collect(); + + let missing_in_registry: Vec<&String> = catalog_ids.difference(®istry_ids).collect(); + let missing_in_catalog: Vec<&String> = registry_ids.difference(&catalog_ids).collect(); + assert!( + missing_in_registry.is_empty() && missing_in_catalog.is_empty(), + "SPEC_0022 catalog and contract registry disagree; \ + in catalog only: {missing_in_registry:?}, in registry only: {missing_in_catalog:?}" + ); +} + +fn spec_0022_path() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../spec/SPEC_0022_MLS_COMPILER_COMPLIANCE.md") +} + +/// Collect the leading cell of every catalog row that is shaped like a +/// contract ID (`| XXX-NNN | ... |`), ignoring the spec's other tables. +fn catalog_contract_ids(catalog: &str) -> BTreeSet { + catalog + .lines() + .filter_map(|line| { + let (cell, _) = line.strip_prefix('|')?.split_once('|')?; + contract_id_shaped(cell.trim()).map(str::to_string) + }) + .collect() +} + +fn contract_id_shaped(cell: &str) -> Option<&str> { + let (prefix, digits) = cell.split_once('-')?; + let shaped = !prefix.is_empty() + && prefix.chars().all(|c| c.is_ascii_uppercase()) + && digits.len() == 3 + && digits.chars().all(|c| c.is_ascii_digit()); + shaped.then_some(cell) +} diff --git a/crates/rumoca-contracts/tests/sim_contracts.rs b/crates/rumoca-contracts/tests/sim_contracts.rs index 2cd0169d5..6142cfb69 100644 --- a/crates/rumoca-contracts/tests/sim_contracts.rs +++ b/crates/rumoca-contracts/tests/sim_contracts.rs @@ -1,16 +1,84 @@ //! SIM (Simulation) contract tests - MLS §8.6, App B //! -//! Tests for the 9 simulation contracts defined in SPEC_0022. +//! Tests for the 10 simulation contracts defined in SPEC_0022. -use rumoca_compile::compile::FailedPhase; -use rumoca_compile::compile::core as rumoca_core; -use rumoca_compile::compile::core::ExpressionVisitor; +use rumoca_compile::compile::{Dae, FailedPhase, VariableRole}; use rumoca_compile::{Session, SessionConfig}; use rumoca_contracts::test_support::{ expect_balanced, expect_failure_in_phase_with_code, expect_resolve_failure_with_code, expect_success, is_standalone_simulatable, unbound_fixed_parameter_names, }; +fn variable_count(dae: &Dae, role: VariableRole) -> usize { + dae.inspect(|view| { + view.variables() + .filter(|(_, variable)| variable.role() == role) + .count() + }) +} + +fn variable_attributes(dae: &Dae, role: VariableRole, name: &str) -> Option<(bool, Option)> { + dae.inspect(|view| { + view.variables() + .find(|(_, variable)| variable.role() == role && variable.name().as_str() == name) + .map(|(_, variable)| (variable.start().is_some(), variable.fixed())) + }) +} + +fn owner_target_index(dae: &Dae, owner_index: usize, target_name: &str) -> Option { + dae.inspect(|view| { + let owner_id = view.discrete_value_owner_id(owner_index)?; + let owner = view.discrete_value_owner(owner_id)?; + owner + .targets() + .iter() + .enumerate() + .find_map(|(target_index, target)| { + let variable = view.variable(target.into())?; + (variable.name().as_str() == target_name).then_some(target_index) + }) + }) +} + +fn owner_target_reads_pre(dae: &Dae, owner_index: usize, target_index: usize) -> bool { + dae.inspect(|view| { + let Some(owner) = view + .discrete_value_owner_id(owner_index) + .and_then(|id| view.discrete_value_owner(id)) + else { + return false; + }; + let Some(target) = owner.targets().get(target_index) else { + return false; + }; + owner.branches().iter().any(|branch| { + let Some((value, _)) = branch.values().get(target_index) else { + return false; + }; + let mut reads_pre = false; + rumoca_compile::compile::for_each_expression(view, value, |_, expression| { + reads_pre |= matches!( + expression.operation(), + rumoca_compile::compile::ExpressionOperation::Coordinate( + rumoca_compile::compile::CoordinateView::PreDiscreteValue(candidate) + ) if candidate == target + ); + }); + reads_pre + }) + }) +} + +fn owner_reads_its_pre_fallback(dae: &Dae, owner_index: usize, target_name: &str) -> bool { + owner_target_index(dae, owner_index, target_name) + .is_some_and(|target_index| owner_target_reads_pre(dae, owner_index, target_index)) +} + +fn dae_reads_pre_fallback(dae: &Dae, target_name: &str) -> bool { + let owner_count = dae.inspect(|view| view.discrete_value_owner_count()); + (0..owner_count).any(|index| owner_reads_its_pre_fallback(dae, index, target_name)) +} + // ============================================================================= // SIM-002: Initialization fixed // "Continuous Real with fixed=true adds equation vc = startExpression" @@ -29,12 +97,12 @@ fn sim_002_initialization_fixed() { "Test", ); // Check that start value is present in DAE - assert!( - !result.dae.variables.states.is_empty(), - "Should have state variables" + assert_eq!(variable_count(&result.dae, VariableRole::State), 1); + assert_eq!( + variable_attributes(&result.dae, VariableRole::State, "x"), + Some((true, None)), + "state x should retain its start value without inventing fixed=true" ); - let state = result.dae.variables.states.values().next().unwrap(); - assert!(state.start.is_some(), "State should have start value"); } // ============================================================================= @@ -56,7 +124,7 @@ fn sim_003_parameter_fixed_default() { "Test", ); assert!( - !result.dae.variables.parameters.is_empty(), + variable_count(&result.dae, VariableRole::Parameter) > 0, "Should have parameters in DAE" ); } @@ -128,25 +196,15 @@ fn sim_004_non_parameter_variable_defaults_fixed_false() { "Test", ); - let state = result - .dae - .variables - .states - .iter() - .find(|(name, _)| name.as_str() == "x") - .map(|(_, state)| state) - .unwrap_or_else(|| { - panic!( - "expected state x, got states={:?}", - result.dae.variables.states.keys() - ) - }); assert_eq!( - state.fixed, None, + variable_attributes(&result.dae, VariableRole::State, "x"), + Some((true, None)), "non-parameter variables should not default to fixed=true" ); assert!( - result.dae.initialization.equations.is_empty(), + result + .dae + .inspect(|view| view.initialization_equation_count() == 0), "start value without fixed=true must not add an initialization equation" ); } @@ -169,11 +227,13 @@ fn sim_009_dae_has_ode_equations() { "Test", ); assert!( - !result.dae.continuous.equations.is_empty(), + result + .dae + .inspect(|view| view.continuous_equation_count() > 0), "DAE should have continuous equations (f_x)" ); assert!( - !result.dae.variables.states.is_empty(), + variable_count(&result.dae, VariableRole::State) > 0, "DAE should have state variables" ); } @@ -192,7 +252,9 @@ fn sim_009_dae_has_algebraic_equations() { ); // The model has equations (no der) assert!( - !result.dae.continuous.equations.is_empty(), + result + .dae + .inspect(|view| view.continuous_equation_count() > 0), "DAE should have equations (f_x)" ); } @@ -212,11 +274,13 @@ fn sim_009_dae_structure_ode_and_algebraic() { "Test", ); assert!( - !result.dae.variables.states.is_empty(), + variable_count(&result.dae, VariableRole::State) > 0, "Should have state variables for ODE" ); assert!( - !result.dae.continuous.equations.is_empty(), + result + .dae + .inspect(|view| view.continuous_equation_count() > 0), "Should have continuous equations (f_x)" ); } @@ -237,8 +301,11 @@ fn sim_basic_integrator() { "#, "Integrator", ); - assert_eq!(result.dae.variables.states.len(), 1); - assert_eq!(result.dae.continuous.equations.len(), 1); + assert_eq!(variable_count(&result.dae, VariableRole::State), 1); + assert_eq!( + result.dae.inspect(|view| view.continuous_equation_count()), + 1 + ); } #[test] @@ -257,7 +324,7 @@ fn sim_spring_mass() { "#, "SpringMass", ); - assert_eq!(result.dae.variables.states.len(), 2); + assert_eq!(variable_count(&result.dae, VariableRole::State), 2); } #[test] @@ -273,8 +340,8 @@ fn sim_with_parameters() { "#, "Test", ); - assert!(!result.dae.variables.parameters.is_empty()); - assert!(!result.dae.variables.states.is_empty()); + assert!(variable_count(&result.dae, VariableRole::Parameter) > 0); + assert!(variable_count(&result.dae, VariableRole::State) > 0); } #[test] @@ -293,13 +360,15 @@ fn sim_with_when_clause() { "Test", ); assert!( - !result.dae.conditions.relations.is_empty() && !result.dae.conditions.equations.is_empty(), + result + .dae + .inspect(|view| view.relation_count() > 0 && view.condition_count() > 0), "DAE should expose canonical condition equations" ); } #[test] -fn sim_009_sample_in_fx_lowers_to_internal_runtime_operator() { +fn sim_009_sample_in_fx_lowers_to_ordinary_dae_and_schedule_metadata() { let result = expect_success( r#" model Test @@ -314,23 +383,19 @@ fn sim_009_sample_in_fx_lowers_to_internal_runtime_operator() { assert!( result .dae - .continuous - .equations - .iter() - .any(|eq| expression_contains_function( - &eq.rhs, - rumoca_core::INTERNAL_SAMPLE_FUNCTION_NAME - )), - "sample() in f_x should lower to the internal runtime sample operator" - ); - assert!( - !result - .dae - .continuous - .equations - .iter() - .any(|eq| expression_contains_builtin_sample(&eq.rhs)), - "source-level BuiltinFunction::Sample must not survive the DAE boundary" + .inspect(|view| (0..view.clock_count()).any(|index| { + let clock = view + .clock(view.clock_id(index).expect("dense checked clock")) + .expect("checked clock resolves"); + matches!( + clock.operation(), + rumoca_compile::compile::ClockOperation::Periodic(lattice) + if lattice.period().numerator() == 1 + && lattice.period().denominator() == 10 + && lattice.phase().is_zero() + ) + })), + "the periodic sample must remain represented by canonical DAE schedule metadata" ); } @@ -368,8 +433,10 @@ fn sim_009_sample_allowed_in_discrete_when_condition() { ); assert!( - !result.dae.discrete.valued_updates.is_empty(), - "sample() in when-condition should lower to discrete partition equations" + result + .dae + .inspect(|view| view.discrete_value_owner_count() > 0), + "sample() in when-condition should lower to checked B.1c owners" ); } @@ -390,82 +457,19 @@ fn sim_009_runtime_metadata_consistent_for_hybrid_model() { "Test", ); - assert_eq!( - result.dae.conditions.equations.len(), - result.dae.conditions.relations.len(), - "f_c and relation must stay aligned for hybrid models" - ); assert!( result .dae - .events - .scheduled_time_events - .iter() - .any(|event| (*event - 0.5).abs() <= 1.0e-12), + .inspect(|view| (0..view.time_event_count()).any(|index| { + let event = view + .time_event(view.time_event_id(index).expect("dense time event")) + .expect("checked time event resolves"); + event + .instant() + .is_some_and(|instant| instant.numerator() == 1 && instant.denominator() == 2) + })), "time-driven discontinuity should be reflected in scheduled_time_events" ); - assert!( - result - .dae - .events - .scheduled_time_events - .iter() - .all(|event| event.is_finite()), - "scheduled_time_events must contain finite values" - ); -} - -fn expression_contains_function(expr: &rumoca_core::Expression, target: &str) -> bool { - let mut visitor = FunctionNameFinder { - target, - found: false, - }; - visitor.visit_expression(expr); - visitor.found -} - -struct FunctionNameFinder<'a> { - target: &'a str, - found: bool, -} - -impl ExpressionVisitor for FunctionNameFinder<'_> { - fn visit_function_call( - &mut self, - name: &rumoca_core::Reference, - args: &[rumoca_core::Expression], - is_constructor: bool, - ) { - if name.as_str() == self.target { - self.found = true; - return; - } - self.walk_function_call(name, args, is_constructor); - } -} - -fn expression_contains_builtin_sample(expr: &rumoca_core::Expression) -> bool { - let mut visitor = BuiltinSampleFinder { found: false }; - visitor.visit_expression(expr); - visitor.found -} - -struct BuiltinSampleFinder { - found: bool, -} - -impl ExpressionVisitor for BuiltinSampleFinder { - fn visit_builtin_call( - &mut self, - function: &rumoca_core::BuiltinFunction, - args: &[rumoca_core::Expression], - ) { - if *function == rumoca_core::BuiltinFunction::Sample { - self.found = true; - return; - } - self.walk_builtin_call(function, args); - } } #[test] @@ -486,23 +490,25 @@ fn sim_009_fc_relation_covers_if_and_when_conditions() { ); assert_eq!( - result.dae.conditions.equations.len(), - result.dae.conditions.relations.len(), - "f_c and relation must remain 1:1" - ); - assert_eq!( - result.dae.conditions.relations.len(), + result.dae.inspect(|view| view.relation_count()), 2, "expected both if-condition and when-condition in relation" ); - let relation_text: Vec = result - .dae - .conditions - .relations - .iter() - .map(|expr| format!("{expr:?}")) - .collect(); + let relation_text = result.dae.inspect(|view| { + (0..view.relation_count()) + .map(|index| { + let relation = view + .relation(view.relation_id(index).expect("dense relation")) + .expect("checked relation resolves"); + result + .dae + .source_text(relation.provenance()) + .expect("source relation has exact provenance") + .to_string() + }) + .collect::>() + }); assert!( relation_text.iter().any(|expr| expr.contains("0.3")), "if-condition should be present in relation: {relation_text:?}" @@ -527,11 +533,11 @@ fn sim_009_fc_relation_ignores_noevent_conditions() { ); assert!( - result.dae.conditions.relations.is_empty(), + result.dae.inspect(|view| view.relation_count() == 0), "noEvent condition must not generate relation entries" ); assert!( - result.dae.conditions.equations.is_empty(), + result.dae.inspect(|view| view.condition_count() == 0), "noEvent condition must not generate f_c entries" ); } @@ -556,11 +562,8 @@ fn sim_005_discrete_solved_form_acyclic_dependency() { assert!( result .dae - .discrete - .valued_updates - .iter() - .all(|eq| eq.lhs.is_some()), - "f_m equations must be explicit assignments" + .inspect(|view| view.discrete_value_definition_count() == 2), + "the checked discrete partition must contain two typed B.1c definitions" ); } @@ -583,23 +586,9 @@ fn sim_005_conditional_when_missing_branch_uses_pre_fallback() { "Test", ); - let k_eq = result - .dae - .discrete - .valued_updates - .iter() - .find(|eq| eq.lhs.as_ref().is_some_and(|lhs| lhs.as_str() == "k")) - .unwrap_or_else(|| { - panic!( - "expected explicit f_m assignment for k; f_m={:?}", - result.dae.discrete.valued_updates - ) - }); - - let rhs_debug = format!("{:?}", k_eq.rhs); assert!( - rhs_debug.contains("__pre__.k"), - "conditional when lowering must preserve lowered pre(k) fallback in missing branches; rhs={rhs_debug}" + dae_reads_pre_fallback(&result.dae, "k"), + "conditional when lowering must preserve the typed pre(k) fallback" ); } @@ -845,3 +834,608 @@ fn eqn_035_initialization_pre_consistency() { ); assert_eq!(trace.final_value("x"), 6.0); } + +// ============================================================================= +// MLS §3.7.5: `pre(y)` is the left limit `y(t^pre)`, and it is defined for a +// continuous-time `y` where the read is itself a discrete-time expression. +// +// The canonical MSL shape is `Modelica.Blocks.Math.Mean` +// (`Modelica 4.1.0/Blocks/Math.mo:2269-2274`): +// +// der(x) = u; +// when sample(t0 + 1/f, 1/f) then +// y_last = if not yGreaterOrEqualZero then f*pre(x) else max(0.0, f*pre(x)); +// reinit(x, 0); +// end when; +// +// `x` is a continuous state, `pre(x)` is read in the when-body, and the same +// body reinitializes `x`. The tick must observe the integral accumulated up to +// the event, not the reinitialized value. +// ============================================================================= + +#[test] +fn sim_009_pre_of_continuous_state_in_when_body_is_left_limit_before_reinit() { + // `Modelica.Blocks.Math.Mean` reduced to its semantic core: with `u = 1` + // and `f = 1`, one period accumulates exactly 1.0. Reading the post-reinit + // value would give 0.0 instead. + // + // `x` starts at 5, not 0, so the MLS §8.6 seed is observable: the tick at + // t_start reads pre(x) = x(t_start) = 5. With start = 0 a seeded lane and + // an unseeded one are indistinguishable. + let trace = rumoca_contracts::test_support::simulate_model( + r#" + model M + Real x(start = 5, fixed = true); + discrete Real y_last(start = -1, fixed = true); + equation + der(x) = 1; + when sample(0, 1) then + y_last = pre(x); + reinit(x, 0); + end when; + end M; + "#, + "M", + 3.5, + ); + let y_last = trace.channel("y_last"); + assert!( + y_last.iter().any(|value| (value - 5.0).abs() < 1.0e-6), + "MLS §8.6 seeds pre(v) = v at t_start, so the tick at t_start must read \ + x(t_start) = 5; got {y_last:?}" + ); + for (index, value) in y_last.iter().enumerate() { + assert!( + (value + 1.0).abs() < 1.0e-6 + || (value - 5.0).abs() < 1.0e-6 + || (value - 1.0).abs() < 1.0e-6, + "after the first tick every tick must read the period integral 1.0 \ + (the left limit), not the reinitialized 0.0; sample {index} was {value}" + ); + } + assert!( + (trace.final_value("y_last") - 1.0).abs() < 1.0e-6, + "the last tick must still read the left limit, got {}", + trace.final_value("y_last") + ); +} + +#[test] +fn sim_009_pre_of_continuous_state_in_a_clocked_when_is_rejected() { + // MLS §16.5/§16.8.1: a clock partition has no continuous-time left limit. + // It reads its own coordinates with `previous()`, and a continuous value + // enters only through `sample()`. OMC rejects this shape outright + // ("Argument 1 of pre must be a discrete expression, but x is continuous"). + expect_failure_in_phase_with_code( + r#" + model M + Real x(start = 0, fixed = true); + Clock c = Clock(0.1); + discrete Real y(start = -1, fixed = true); + equation + der(x) = 1; + when c then + y = pre(x); + end when; + end M; + "#, + "M", + FailedPhase::ToDae, + "ED019", + ); +} + +#[test] +fn sim_009_pre_of_a_continuous_state_inside_a_reinit_value_is_the_left_limit() { + // `reinit(x, pre(x) + 1)` evaluates its value at the event instant, so + // `pre(x)` is the ordinary left limit. Rewriting it to a plain `x` (as this + // wave's predecessor did) makes the equation `reinit(x, x + 1)`, which has + // no solution and diverges. + // + // OMC integrates x from 0, so the pre-event values at the ticks are + // 1, 3, 5 and x jumps to 2, 4, 6 respectively. + let trace = rumoca_contracts::test_support::simulate_model( + r#" + model M + Real x(start = 0, fixed = true); + discrete Real n(start = 0, fixed = true); + equation + der(x) = 1; + when sample(1, 1) then + n = pre(n) + 1; + reinit(x, pre(x) + 1); + end when; + end M; + "#, + "M", + 3.5, + ); + // Post-tick values follow OMC exactly: 1 -> 2 at t=1, 3 -> 4 at t=2, + // 5 -> 6 at t=3, then free integration to 6.5 at t=3.5. + assert!( + (trace.final_value("x") - 6.5).abs() < 1.0e-4, + "x must follow the OMC sequence and reach 6.5 at t = 3.5, got {}", + trace.final_value("x") + ); + assert!( + (trace.final_value("n") - 3.0).abs() < 1.0e-9, + "three ticks must have fired, got n = {}", + trace.final_value("n") + ); +} + +#[test] +fn sim_009_continuous_signal_extrema_body_tracks_min_and_max() { + // `Modelica.Blocks.Math.ContinuousSignalExtrema` (Blocks/Math.mo:2589-2599) + // is the second MSL site that reads `pre()` of continuous coordinates — + // `pre(u)` on a continuous algebraic plus `pre(y_min)`/`pre(y_max)`/ + // `pre(t_min)`/`pre(t_max)`. This is its body with a scalar `sample()` + // trigger; the block's own vector-when form is a separate construct that + // only becomes reachable after the in-flight vector-when fix. + // + // Values are OMC's, from a dassl run at tolerance 1e-8. + let trace = rumoca_contracts::test_support::simulate_model( + r#" + model M + Real u; + Real y_min; + Real y_max; + Real t_min; + Real t_max; + // The block itself is stateless; this carries one continuous state + // so the model is an ODE the solver can advance. + Real ramp(start = 0, fixed = true); + initial equation + y_min = u; + y_max = u; + t_min = time; + t_max = time; + equation + der(ramp) = 1; + u = sin(6.2831853 * time); + when sample(0.05, 0.05) then + y_min = min({pre(y_min), u, pre(u)}); + y_max = max({pre(y_max), u, pre(u)}); + t_min = if y_min < pre(y_min) then time else pre(t_min); + t_max = if y_max > pre(y_max) then time else pre(t_max); + end when; + end M; + "#, + "M", + 1.0, + ); + // Over a full period of a unit sine the extrema are +/-1, found at the + // quarter points; OMC reports t_max = 0.25 and t_min = 0.75. + assert!( + (trace.final_value("y_max") - 1.0).abs() < 1.0e-3, + "y_max must reach +1, got {}", + trace.final_value("y_max") + ); + assert!( + (trace.final_value("y_min") + 1.0).abs() < 1.0e-3, + "y_min must reach -1, got {}", + trace.final_value("y_min") + ); + assert!( + (trace.final_value("t_max") - 0.25).abs() < 0.05, + "t_max must be the first quarter point, got {}", + trace.final_value("t_max") + ); + assert!( + (trace.final_value("t_min") - 0.75).abs() < 0.05, + "t_min must be the third quarter point, got {}", + trace.final_value("t_min") + ); +} + +#[test] +fn sim_009_msl_mean_block_body_averages_over_its_period() { + // The body of `Modelica.Blocks.Math.Mean` verbatim, with the single change + // that `t0` is a plain parameter instead of `parameter SI.Time t0(fixed = + // false)` fixed by `initial equation t0 = time` — that spelling is a + // separate unsupported construct (a non-parameter-evaluable `sample` start) + // and would mask this one. The input and frequency match the OMC reference + // run for this block (`Mean(f = 1)` fed a constant 2), which reports y = 2.0 + // exactly at every tick from t = 1 on. That value is only reachable if + // `pre(x)` reads the state accumulated up to the tick rather than the value + // `reinit(x, 0)` installs, which would give 0.0. + let trace = rumoca_contracts::test_support::simulate_model( + r#" + model M + parameter Real f = 1 "Base frequency"; + parameter Real x0 = 0 "Start value of integrator state"; + parameter Real y0 = 0 "Start value of output"; + parameter Boolean yGreaterOrEqualZero = false; + parameter Real t0 = 0 "Start time of simulation"; + Real u; + Real y; + Real x "Integrator state"; + discrete Real y_last "Last sampled mean value"; + initial equation + x = x0; + y_last = y0; + equation + u = 2; + der(x) = u; + when sample(t0 + 1/f, 1/f) then + y_last = if not yGreaterOrEqualZero then f*pre(x) else max(0.0, f*pre(x)); + reinit(x, 0); + end when; + y = y_last; + end M; + "#, + "M", + 2.5, + ); + assert!( + (trace.final_value("y") - 2.0).abs() < 1.0e-6, + "the mean of u = 2 over a 1 s period is 2.0 (the OMC reference value), got {}", + trace.final_value("y") + ); +} + +#[test] +fn sim_009_pre_of_continuous_algebraic_in_when_body_snapshots_event_entry() { + // The discriminating case: `a` depends on a discrete the same event + // updates, so `pre(a)` and `a` differ at the tick. An implementation that + // aliased `pre(a)` to `a` would read 11 at t = 1 instead of 1. + let trace = rumoca_contracts::test_support::simulate_model( + r#" + model M + Real ramp(start = 0, fixed = true); + Real a; + discrete Real d(start = 0, fixed = true); + discrete Real a_pre(start = 0, fixed = true); + equation + der(ramp) = 1; + a = 10 * d + time; + when sample(1, 1) then + d = pre(d) + 1; + a_pre = pre(a); + end when; + end M; + "#, + "M", + 1.5, + ); + // The tolerance only has to separate the left limit from the live value, + // which differ by 10; it absorbs the solver's event-localization error. + assert!( + (trace.final_value("a_pre") - 1.0).abs() < 1.0e-3, + "pre(a) must be the left limit a(t^pre) = 10*0 + 1, got {}", + trace.final_value("a_pre") + ); + // The live `a` has already moved to 10*1 + t by the same event, so an + // implementation that aliased `pre(a)` to `a` could not have passed the + // assertion above. + assert!( + (trace.final_value("a") - 11.5).abs() < 1.0e-3, + "the live `a` after the tick is 10*1 + 1.5, got {}", + trace.final_value("a") + ); +} + +#[test] +fn sim_009_pre_of_a_steep_algebraic_reads_the_exact_event_time_generation() { + // The generation discriminator. `a` has slope 1e6, so evaluating the + // entry snapshot's algebraic lanes at the event's *left probe* time + // instead of the event time itself moves `pre(a)` by ~1e3 - three orders + // of magnitude above the assertion band below. Only a snapshot whose + // every lane belongs to the exact event-time generation passes. + let trace = rumoca_contracts::test_support::simulate_model( + r#" + model M + Real ramp(start = 0, fixed = true); + Real a; + discrete Real a_pre(start = 0, fixed = true); + equation + der(ramp) = 1; + a = 1e6 * time; + when sample(1, 1) then + a_pre = pre(a); + end when; + end M; + "#, + "M", + 1.5, + ); + assert!( + (trace.final_value("a_pre") - 1.0e6).abs() < 1.0, + "pre(a) must be the left limit a(1) = 1e6, got {}", + trace.final_value("a_pre") + ); +} + +#[test] +fn sim_009_pre_of_continuous_state_outside_when_clause_is_rejected() { + // Ablation for the accept cases above: the same `pre(x)` on the same + // continuous state is a typed rejection when no when-clause owns the read. + // OMC rejects it too ("Argument 1 of pre must be a discrete expression, + // but x is continuous"). + expect_failure_in_phase_with_code( + r#" + model M + Real x(start = 0, fixed = true); + discrete Real y(start = 0, fixed = true); + equation + der(x) = 1; + y = pre(x); + end M; + "#, + "M", + FailedPhase::ToDae, + "ED019", + ); +} + +#[test] +fn sim_009_pre_of_continuous_state_in_when_condition_is_rejected() { + // A when-clause's activation condition decides whether the event happens, + // so it is not itself inside the event: there is no left limit to read. + // OMC rejects this shape with the same discrete-expression diagnostic. + expect_failure_in_phase_with_code( + r#" + model M + Real x(start = 0, fixed = true); + discrete Real y(start = 0, fixed = true); + equation + der(x) = 1; + when pre(x) > 0.5 then + y = 1; + end when; + end M; + "#, + "M", + FailedPhase::ToDae, + "ED019", + ); +} + +// ============================================================================= +// SIM-010: Clocked event-iteration participation (MLS App B) +// "Clocked variables use previous values, and a clock partition is solved once +// per tick, in the first event iteration of that tick"; that restriction +// bounds fixed-point RE-ITERATION of the partition, not value exchange between +// producers inside that single solution, and clocked lanes do not participate +// in ordinary z == pre(z), m == pre(m) convergence. +// +// Registry status stays Partial, deliberately. The once-per-tick restriction +// and the excluded clocked lanes are covered by the counter test below plus +// the bounded proof in +// `crates/rumoca-solver/src/verification/event_iteration.rs`, whose atomic +// pre-advance property leaves clocked lanes unchanged. Same-tick value +// exchange between producers inside that single partition solution IS +// implemented, by the SPEC_0040 SOLVE-C57 owner +// (`dev/2026-08-11-clock-partition-transaction-design.md`, implemented toward +// the SPEC_0046 SDO-001/SDO-002 read semantics): construction issues the +// ordered producer list (`DiscreteSolveSystem::clock_partition_order`) and the +// runtime replays it over private work state, so an ordinary same-instant read +// consumes this tick's value and only `pre`/`previous`/`sample(u)` consumes a +// history lane. The exchange cases below are the design's §4 rows 2 and 3 +// (reverse-ordered unconditional B.1b chain; mixed B.1b/B.1c chain and its +// rejected cycle) plus the §4 row 1 inactive-guard hold observation. +// +// Promotion is nevertheless held: SPEC_0046 SDO-227 fails a green SIM-010 +// claim without the ResidualSccOwner, and a coupled simultaneous discrete +// residual SCC is still a typed rejection with that owner only preregistered +// (SDO-021/SDO-023). Per the crate Partial convention these tests are +// therefore absent from `data/contract_cases.toml` and SIM-010 is absent from +// IMPLEMENTED_CONTRACT_IDS; promote both together once the ResidualSccOwner +// lands. Note what promotion will NOT require: an inactive guarded producer's +// target holds (SOLVE-C07/C10), and a same-tick reader of a held target +// observes the held entry value — under SDO-001 that observation is +// unchanged, because `next` of an inactive producer *is* the held entry value. +// ============================================================================= + +#[test] +fn sim_010_clocked_counter_advances_once_per_tick() { + // `previous(k)` is owned by the clock, not by the ordinary `pre` fixed + // point: the clocked equation runs once in the first event iteration of a + // tick. If the clocked lane were advanced with the ordinary `z == pre(z)` + // lanes, the counter would gain one increment per extra event iteration + // and outrun the tick count. + let trace = rumoca_contracts::test_support::simulate_model( + r#" + model M + Clock c = Clock(0.1); + discrete Real k(start = 0, fixed = true); + Real x(start = 0, fixed = true); + equation + der(x) = 1; + when c then + k = previous(k) + 1; + end when; + end M; + "#, + "M", + 0.35, + ); + // Clock(0.1) ticks at t = 0, 0.1, 0.2 and 0.3 within the horizon, so the + // clocked counter must read exactly 4. + assert_eq!(trace.final_value("k"), 4.0); +} + +#[test] +fn sim_010_reverse_ordered_b1b_chain_exchanges_values_on_the_same_tick() { + // SOLVE-C57 §4 row 2: same-tick exchange holds independently of source and + // row order. The consumer `b = 2*a` is written *before* its producer; an + // ordinary same-instant read consumes this tick's value (SDO-002), so `b` + // must track `2*a` on every tick, never last tick's `a`. + let trace = rumoca_contracts::test_support::simulate_model( + r#" + model M + discrete Real b(start = 0, fixed = true); + discrete Real a(start = 0, fixed = true); + Real x(start = 0, fixed = true); + equation + der(x) = 1; + when sample(0.0, 0.1) then + b = 2.0 * a; + a = pre(a) + 1.0; + end when; + end M; + "#, + "M", + 0.35, + ); + // Ticks at t = 0, 0.1, 0.2, 0.3: a counts 1..4 and b = 2*a on the same + // tick. A one-tick lag would leave b at 2*(a-1) = 6. + assert_eq!(trace.final_value("a"), 4.0); + assert_eq!(trace.final_value("b"), 8.0); +} + +#[test] +fn sim_010_mixed_b1b_b1c_chain_exchanges_values_on_the_same_tick() { + // SOLVE-C57 §4 row 3 (chain): a discrete-value (B.1c) producer and a + // discrete-Real (B.1b) consumer are admitted under one dependency proof, + // and the consumer reads the producer's this-tick value. + let trace = rumoca_contracts::test_support::simulate_model( + r#" + model M + discrete Real y(start = 0, fixed = true); + discrete Integer n(start = 0, fixed = true); + Real x(start = 0, fixed = true); + equation + der(x) = 1; + when sample(0.0, 0.1) then + y = 0.5 * n; + n = pre(n) + 1; + end when; + end M; + "#, + "M", + 0.35, + ); + assert_eq!(trace.final_value("n"), 4.0); + assert_eq!(trace.final_value("y"), 2.0); +} + +#[test] +fn sim_010_mixed_b1b_b1c_same_tick_cycle_is_rejected_at_construction() { + // SOLVE-C57 §4 row 3 (cycle): a directed same-tick cycle among producers + // with no pre()/previous() boundary fails construction with a typed + // error, never a runtime fallback or an invented order (SDO-021). + let result = rumoca_contracts::test_support::expect_success( + r#" + model M + discrete Real y(start = 0, fixed = true); + discrete Boolean b(start = false, fixed = true); + Real x(start = 0, fixed = true); + equation + der(x) = 1; + when sample(0.0, 0.1) then + y = if b then 1.0 else 2.0; + b = y > 0.5; + end when; + end M; + "#, + "M", + ); + let opts = rumoca_sim::SimOptions { + t_end: 0.35, + ..rumoca_sim::SimOptions::default() + }; + let error = rumoca_sim::simulate_with_diagnostics(&result.dae, &opts) + .err() + .expect("a same-tick discrete cycle must be rejected"); + let message = error.to_string(); + // The same-clock shape is caught by the earlier clocked-feedback owner + // (`reject_clocked_continuous_feedback`); both diagnostics are typed + // construction rejections that name the loop, never an invented order. + assert!( + message.contains("algebraic loop") || message.contains("causally reachable"), + "rejection must name the same-tick discrete loop, got: {message}" + ); +} + +#[test] +fn sim_010_cross_clock_coincident_same_tick_cycle_is_rejected_at_construction() { + // SOLVE-C57 §4 row 3 (cycle), cross-partition shape: two commensurate + // clocks whose producers read each other (through exact algebraic alias + // definitions, the only DAE-admissible cross-partition read) are + // unschedulable at their coincident ticks. Only the issued same-tick + // order sees this alias-followed cycle (the same-clock feedback owner + // cannot), and it rejects at construction with the discrete + // algebraic-loop diagnostic. + let result = rumoca_contracts::test_support::expect_success( + r#" + model M + discrete Real a(start = 0, fixed = true); + discrete Real b(start = 0, fixed = true); + Real aAlias; + Real bAlias; + Real x(start = 0, fixed = true); + equation + der(x) = 1; + aAlias = a; + bAlias = b; + when sample(0.0, 0.1) then + a = bAlias + 1.0; + end when; + when sample(0.0, 0.2) then + b = aAlias + 1.0; + end when; + end M; + "#, + "M", + ); + let opts = rumoca_sim::SimOptions { + t_end: 0.35, + ..rumoca_sim::SimOptions::default() + }; + let error = rumoca_sim::simulate_with_diagnostics(&result.dae, &opts) + .err() + .expect("a cross-clock coincident same-tick cycle must be rejected"); + let message = error.to_string(); + assert!( + message.contains("algebraic loop"), + "rejection must name the same-tick discrete algebraic loop, got: {message}" + ); +} + +#[test] +fn sim_010_inactive_narrower_guard_holds_and_its_same_tick_reader_sees_the_held_value() { + // SOLVE-C57 §4 row 1: an `And(clock, predicate)` producer whose predicate + // is false on a tick does not define its target; the target holds + // (SOLVE-C07/C10) and a same-tick reader observes that held value rather + // than a value the producer never computed. Issuing the same-tick order + // must not turn a narrower guard into an always-fresh definition. + // + // Under SPEC_0046 SDO-001 this observation is unchanged and needs no + // member kind: `next` of an inactive producer *is* its held entry value, + // so the ordinary same-instant read of SDO-002 returns exactly what the + // hold rows already specify. + let trace = rumoca_contracts::test_support::simulate_model( + r#" + model M + discrete Real k(start = 0, fixed = true); + discrete Real g(start = -1, fixed = true); + discrete Real r(start = -1, fixed = true); + Real x(start = 0, fixed = true); + equation + der(x) = 1; + when sample(0.0, 0.1) then + k = pre(k) + 1.0; + end when; + when sample(0.0, 0.1) and pre(k) < 1.5 then + g = 10.0 * k; + end when; + when sample(0.0, 0.1) then + r = g; + end when; + end M; + "#, + "M", + 0.35, + ); + // Ticks at t = 0, 0.1, 0.2, 0.3 give k = 1, 2, 3, 4. The narrower guard + // `pre(k) < 1.5` is true on the first two ticks (pre(k) = 0, 1) and false + // afterwards, so `g` is defined to 10 and 20 and then holds at 20. + assert_eq!(trace.final_value("k"), 4.0); + assert_eq!(trace.final_value("g"), 20.0); + // `r = g` is an ordinary same-instant read on every tick: on an active + // tick it consumes this tick's `g`, and on an inactive tick it consumes + // the held value — never a stale one-tick-lagged value (which would be + // 10 here) and never a value the inactive producer did not compute. + assert_eq!(trace.final_value("r"), 20.0); +} diff --git a/crates/rumoca-contracts/tests/type_contracts.rs b/crates/rumoca-contracts/tests/type_contracts.rs index 731ad7a6e..58466ac8b 100644 --- a/crates/rumoca-contracts/tests/type_contracts.rs +++ b/crates/rumoca-contracts/tests/type_contracts.rs @@ -4,8 +4,8 @@ use rumoca_compile::compile::FailedPhase; use rumoca_contracts::test_support::{ - expect_balanced, expect_failure_in_phase_with_code, expect_resolve_failure_with_code, - expect_success, + expect_balanced, expect_failure_in_phase_reporting_code, expect_failure_in_phase_with_code, + expect_resolve_failure_with_code, expect_success, }; // ============================================================================= @@ -469,11 +469,12 @@ fn type_012_redeclared_class_conditional_member_rejected() { expect_failure_in_phase_with_code( r#" model M - parameter Boolean has = true; block Base + parameter Boolean has = true; Real y = 1; end Base; block Bad + parameter Boolean has = true; Real y = 1 if has; end Bad; model Holder @@ -859,8 +860,8 @@ fn type_018_sibling_function_missing_named_input_rejected() { // ============================================================================= #[test] -fn type_019_sibling_function_extra_output_rejected() { - expect_failure_in_phase_with_code( +fn type_019_sibling_function_trailing_output_accepted() { + expect_success( r#" model M partial function FCommon @@ -888,6 +889,46 @@ fn type_019_sibling_function_extra_output_rejected() { end Use; Use u; end M; + "#, + "M", + ); +} + +#[test] +fn type_019_sibling_function_interleaved_output_rejected() { + expect_failure_in_phase_with_code( + r#" + model M + partial function FCommon + input Real u; + end FCommon; + function FBase + extends FCommon; + output Real y; + output Real z; + algorithm + y := u; + z := 2 * u; + end FBase; + function FBad + extends FCommon; + output Real y; + output Real diagnostic; + output Real z; + algorithm + y := u; + diagnostic := 0; + z := 2 * u; + end FBad; + model Holder + replaceable function F = FBase; + Real z = F(1.0); + end Holder; + model Use + extends Holder(redeclare function F = FBad); + end Use; + Use u; + end M; "#, "M", FailedPhase::Instantiate, @@ -1043,9 +1084,14 @@ fn type_026_final_constraint_requires_final_replacement_rejected() { // same named elements // ============================================================================= +// Type checking reports two distinct codes here: the generic branch-type +// mismatch (`ET002`) and the if-expression record-compatibility check +// (`ET009`, MLS 3.7 §10.6.1). The summary `PhaseResult::error_code` is +// therefore the `ET000` multi-code sentinel, so this case asserts on the +// reported diagnostics. #[test] fn type_032_if_expression_mixing_record_types_rejected() { - expect_failure_in_phase_with_code( + expect_failure_in_phase_reporting_code( r#" model M record RA @@ -1073,9 +1119,12 @@ fn type_032_if_expression_mixing_record_types_rejected() { // have same one // ============================================================================= +// Same two-diagnostic shape as TYPE-032: `ET002` for the branch-type mismatch +// plus `ET009` for the operator-record compatibility check, collapsing the +// summary code to the `ET000` multi-code sentinel. #[test] fn type_035_if_expression_mixing_operator_records_rejected() { - expect_failure_in_phase_with_code( + expect_failure_in_phase_reporting_code( r#" model M operator record CA @@ -1144,9 +1193,25 @@ fn type_007_external_object_mismatch_rejected() { model M class EoBase extends ExternalObject; + function constructor + output EoBase handle; + external "C"; + end constructor; + function destructor + input EoBase handle; + external "C"; + end destructor; end EoBase; class EoOther extends ExternalObject; + function constructor + output EoOther handle; + external "C"; + end constructor; + function destructor + input EoOther handle; + external "C"; + end destructor; end EoOther; model Holder replaceable EoBase handle; diff --git a/crates/xtask/Cargo.toml b/crates/xtask/Cargo.toml index 2d86503e7..d469ba4d2 100644 --- a/crates/xtask/Cargo.toml +++ b/crates/xtask/Cargo.toml @@ -15,14 +15,14 @@ name = "rumoca-traversal-policy-check" path = "src/bin/rumoca-traversal-policy-check.rs" # xtask carries NO `rumoca-*` workspace dependency: it parses args, moves files, -# and shells out. Heavy, compiler-linked work runs on demand via `cargo run -p -# ` (rumoca-test-msl for MSL tooling, rumoca-tool-docs for the docs cache). -# The CI arch check (ci.yml) enforces this — keep this list free of rumoca-*. +# and shells out. Compiler-linked work runs on demand through commands in the +# owning package. The CI arch check enforces this boundary. [dependencies] anyhow = { workspace = true } clap = { workspace = true } clap_complete = { workspace = true } mimalloc = { workspace = true } +num_cpus = { workspace = true } serde_json = { workspace = true } serde = { workspace = true } blake3 = { workspace = true } @@ -32,7 +32,7 @@ lsp-types = { workspace = true } tempfile = { workspace = true } ureq = "2.9" walkdir = "2.5" -syn = { version = "2.0", features = ["full", "visit"] } +syn = { workspace = true } zip = { version = "2.4", default-features = false, features = ["deflate"] } [lints] diff --git a/crates/xtask/src/bin/rumoca-traversal-policy-check.rs b/crates/xtask/src/bin/rumoca-traversal-policy-check.rs index 8e56bf827..ecdee6a92 100644 --- a/crates/xtask/src/bin/rumoca-traversal-policy-check.rs +++ b/crates/xtask/src/bin/rumoca-traversal-policy-check.rs @@ -1,416 +1,3 @@ -use anyhow::{Context, Result, bail}; -use std::collections::{BTreeMap, BTreeSet, HashMap}; -use std::fs; -use std::path::{Path, PathBuf}; -use syn::visit::Visit; -use syn::{ - FnArg, GenericArgument, ImplItem, Item, ItemFn, ItemImpl, PathArguments, Type, TypeParamBound, -}; - -const COVERED_FILES: &[&str] = &[ - "crates/rumoca-phase-resolve/src/contents.rs", - "crates/rumoca-phase-resolve/src/validation.rs", - "crates/rumoca-phase-resolve/src/semantic_checks/mod.rs", - "crates/rumoca-phase-resolve/src/semantic_checks/expr.rs", - "crates/rumoca-phase-typecheck/src/typechecker/late_methods.rs", - "crates/rumoca-compile/src/session/dependency_fingerprint.rs", - "crates/rumoca-tool-lsp/src/handlers/semantic_tokens.rs", - "crates/rumoca-tool-lsp/src/handlers/inlay_hints.rs", - "crates/rumoca-phase-dae/src/scalar_inference/parts.rs", -]; - -#[allow(dead_code)] -fn main() -> Result<()> { - run() -} - -pub(crate) fn run() -> Result<()> { - let repo_root = repo_root(); - let mut violations = Vec::new(); - - for rel_path in COVERED_FILES { - let file_path = repo_root.join(rel_path); - let source = fs::read_to_string(&file_path) - .with_context(|| format!("failed to read {}", file_path.display()))?; - let syntax = syn::parse_file(&source) - .with_context(|| format!("failed to parse {}", file_path.display()))?; - - let candidates = collect_candidates(syntax.items); - - if candidates.is_empty() { - continue; - } - - let allowed_recursive = allowed_recursive_functions(rel_path); - let mut graph: BTreeMap> = BTreeMap::new(); - for function in candidates.values() { - if allowed_recursive.contains(function.simple_name.as_str()) { - continue; - } - let outgoing = candidates - .values() - .filter(|candidate| { - candidate.scope == function.scope - && function.calls.contains(&candidate.simple_name) - && !allowed_recursive.contains(candidate.simple_name.as_str()) - }) - .map(|candidate| candidate.key.clone()) - .collect(); - graph.insert(function.key.clone(), outgoing); - } - - if let Some(cycle) = detect_cycle(&graph) { - violations.push(format!( - "{}: recursive traversal helpers are disallowed in covered modules: {}", - rel_path, - cycle.join(" -> ") - )); - } - } - - if !violations.is_empty() { - eprintln!("Traversal policy check failed:"); - for violation in violations { - eprintln!(" - {violation}"); - } - bail!("traversal policy violations detected"); - } - - println!( - "Traversal policy check passed for {} covered files.", - COVERED_FILES.len() - ); - Ok(()) -} - -fn collect_candidates(items: Vec) -> BTreeMap { - let mut candidates = BTreeMap::new(); - for item in items { - match item { - Item::Fn(item_fn) => { - insert_candidate_if_traversal( - &mut candidates, - CandidateFunction::from_top_level_fn(item_fn), - ); - } - Item::Impl(item_impl) => { - for function in CandidateFunction::from_impl(item_impl) { - insert_candidate_if_traversal(&mut candidates, function); - } - } - _ => {} - } - } - candidates -} - -fn insert_candidate_if_traversal( - candidates: &mut BTreeMap, - function: CandidateFunction, -) { - if !function.is_traversal_candidate { - return; - } - candidates.insert(function.key.clone(), function); -} - -fn allowed_recursive_functions(file: &str) -> BTreeSet<&'static str> { - match file { - // Class-container recursion over nested classes remains intentional in these modules. - "crates/rumoca-phase-resolve/src/contents.rs" => BTreeSet::from(["resolve_contents_class"]), - "crates/rumoca-phase-resolve/src/validation.rs" => BTreeSet::from(["visit_class_def"]), - "crates/rumoca-phase-resolve/src/semantic_checks/mod.rs" => { - BTreeSet::from(["visit_class_def"]) - } - "crates/rumoca-phase-resolve/src/semantic_checks/expr.rs" => { - BTreeSet::from(["visit_class_def"]) - } - "crates/rumoca-phase-typecheck/src/typechecker/late_methods.rs" => { - BTreeSet::from(["check_class", "infer_expression_type"]) - } - // This recursion decomposes nested array literals for scalar sizing. - "crates/rumoca-phase-dae/src/scalar_inference/parts.rs" => { - BTreeSet::from(["count_array_lhs_scalar_elements"]) - } - _ => BTreeSet::new(), - } -} - -fn repo_root() -> PathBuf { - let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - manifest_dir - .ancestors() - .nth(2) - .unwrap_or(manifest_dir) - .to_path_buf() -} - -#[derive(Debug, Clone)] -struct CandidateFunction { - key: String, - simple_name: String, - scope: CandidateScope, - is_traversal_candidate: bool, - calls: BTreeSet, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum CandidateScope { - TopLevel, - Impl(String), -} - -impl CandidateFunction { - fn from_top_level_fn(item_fn: ItemFn) -> Self { - let simple_name = item_fn.sig.ident.to_string(); - let is_traversal_candidate = item_fn.sig.inputs.iter().any(is_traversal_param_fn_arg); - let mut collector = CallCollector::default(); - collector.visit_block(&item_fn.block); - Self { - key: simple_name.clone(), - simple_name, - scope: CandidateScope::TopLevel, - is_traversal_candidate, - calls: collector.calls, - } - } - - fn from_impl(item_impl: ItemImpl) -> Vec { - let scope_name = impl_scope_name(&item_impl); - let scope = CandidateScope::Impl(scope_name.clone()); - let mut functions = Vec::new(); - for item in item_impl.items { - let ImplItem::Fn(item_fn) = item else { - continue; - }; - let simple_name = item_fn.sig.ident.to_string(); - let is_traversal_candidate = item_fn.sig.inputs.iter().any(is_traversal_param_fn_arg); - let mut collector = CallCollector::default(); - collector.visit_block(&item_fn.block); - functions.push(Self { - key: format!("impl::{scope_name}::{simple_name}"), - simple_name, - scope: scope.clone(), - is_traversal_candidate, - calls: collector.calls, - }); - } - functions - } -} - -fn impl_scope_name(item_impl: &ItemImpl) -> String { - let self_ty_name = type_display_name(item_impl.self_ty.as_ref()); - if let Some((_, path, _)) = &item_impl.trait_ { - let trait_name = path - .segments - .last() - .map(|segment| segment.ident.to_string()) - .unwrap_or_else(|| "Trait".to_string()); - format!("{trait_name} for {self_ty_name}") - } else { - self_ty_name - } -} - -fn type_display_name(ty: &Type) -> String { - match ty { - Type::Path(type_path) => type_path - .path - .segments - .last() - .map(|segment| segment.ident.to_string()) - .unwrap_or_else(|| "Type".to_string()), - Type::Reference(reference) => type_display_name(reference.elem.as_ref()), - Type::Paren(paren) => type_display_name(paren.elem.as_ref()), - Type::Group(group) => type_display_name(group.elem.as_ref()), - _ => "Type".to_string(), - } -} - -#[derive(Default)] -struct CallCollector { - calls: BTreeSet, -} - -impl<'ast> Visit<'ast> for CallCollector { - fn visit_expr_call(&mut self, node: &'ast syn::ExprCall) { - if let syn::Expr::Path(path_expr) = node.func.as_ref() { - let segments = &path_expr.path.segments; - let Some(last) = segments.last() else { - syn::visit::visit_expr_call(self, node); - return; - }; - let accepts_call = path_expr.qself.is_some() - || segments.len() == 1 - || (segments.len() == 2 && segments[0].ident == "Self"); - if accepts_call { - self.calls.insert(last.ident.to_string()); - } - } - syn::visit::visit_expr_call(self, node); - } - - fn visit_expr_method_call(&mut self, node: &'ast syn::ExprMethodCall) { - if receiver_is_self(node.receiver.as_ref()) { - self.calls.insert(node.method.to_string()); - } - syn::visit::visit_expr_method_call(self, node); - } -} - -fn receiver_is_self(expr: &syn::Expr) -> bool { - matches!( - expr, - syn::Expr::Path(path_expr) - if path_expr.qself.is_none() - && path_expr.path.segments.len() == 1 - && path_expr.path.segments[0].ident == "self" - ) -} - -fn is_traversal_param_fn_arg(arg: &FnArg) -> bool { - match arg { - FnArg::Typed(pat_type) => type_mentions_tree_type(&pat_type.ty), - FnArg::Receiver(_) => false, - } -} - -fn type_mentions_tree_type(ty: &Type) -> bool { - match ty { - Type::Path(type_path) => path_mentions_tree_type(&type_path.path), - Type::Reference(reference) => type_mentions_tree_type(&reference.elem), - Type::Slice(slice) => type_mentions_tree_type(&slice.elem), - Type::Array(array) => type_mentions_tree_type(&array.elem), - Type::Tuple(tuple) => tuple.elems.iter().any(type_mentions_tree_type), - Type::Paren(paren) => type_mentions_tree_type(&paren.elem), - Type::Group(group) => type_mentions_tree_type(&group.elem), - Type::Ptr(ptr) => type_mentions_tree_type(&ptr.elem), - Type::ImplTrait(impl_trait) => impl_trait.bounds.iter().any(bound_mentions_tree_type), - Type::TraitObject(trait_object) => trait_object.bounds.iter().any(bound_mentions_tree_type), - _ => false, - } -} - -fn bound_mentions_tree_type(bound: &TypeParamBound) -> bool { - match bound { - TypeParamBound::Trait(trait_bound) => path_mentions_tree_type(&trait_bound.path), - TypeParamBound::Lifetime(_) => false, - _ => false, - } -} - -fn path_mentions_tree_type(path: &syn::Path) -> bool { - path.segments.iter().any(|segment| { - is_tree_type_name(segment.ident.to_string().as_str()) - || match &segment.arguments { - PathArguments::AngleBracketed(args) => args.args.iter().any(|arg| match arg { - GenericArgument::Type(ty) => type_mentions_tree_type(ty), - GenericArgument::AssocType(assoc_type) => { - type_mentions_tree_type(&assoc_type.ty) - } - GenericArgument::Constraint(constraint) => { - constraint.bounds.iter().any(bound_mentions_tree_type) - } - GenericArgument::AssocConst(_) - | GenericArgument::Lifetime(_) - | GenericArgument::Const(_) => false, - _ => false, - }), - PathArguments::Parenthesized(parenthesized) => { - parenthesized.inputs.iter().any(type_mentions_tree_type) - || match &parenthesized.output { - syn::ReturnType::Default => false, - syn::ReturnType::Type(_, ty) => type_mentions_tree_type(ty), - } - } - PathArguments::None => false, - } - }) -} - -fn is_tree_type_name(name: &str) -> bool { - matches!( - name, - "Expression" - | "Statement" - | "Equation" - | "Subscript" - | "StoredDefinition" - | "ClassDef" - | "ClassSection" - | "Class" - | "Element" - | "ComponentReference" - | "ComprehensionIndex" - | "ForIndex" - | "StatementBlock" - | "TypeName" - | "Import" - | "NamedArgument" - | "ExtendsClause" - ) -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum VisitState { - Visiting, - Done, -} - -fn dfs_cycle( - node: &str, - graph: &BTreeMap>, - states: &mut HashMap, - stack: &mut Vec, -) -> Option> { - states.insert(node.to_string(), VisitState::Visiting); - stack.push(node.to_string()); - - if let Some(neighbors) = graph.get(node) { - for neighbor in neighbors { - if let Some(cycle) = traverse_neighbor(neighbor, graph, states, stack) { - return Some(cycle); - } - } - } - - stack.pop(); - states.insert(node.to_string(), VisitState::Done); - None -} - -fn traverse_neighbor( - neighbor: &str, - graph: &BTreeMap>, - states: &mut HashMap, - stack: &mut Vec, -) -> Option> { - match states.get(neighbor).copied() { - Some(VisitState::Done) => None, - Some(VisitState::Visiting) => cycle_from_back_edge(stack, neighbor), - None => dfs_cycle(neighbor, graph, states, stack), - } -} - -fn cycle_from_back_edge(stack: &[String], neighbor: &str) -> Option> { - let start = stack.iter().position(|name| name == neighbor)?; - let mut cycle = stack[start..].to_vec(); - cycle.push(neighbor.to_string()); - Some(cycle) -} - -fn detect_cycle(graph: &BTreeMap>) -> Option> { - let mut states: HashMap = HashMap::new(); - let mut stack = Vec::new(); - - for node in graph.keys() { - if states.contains_key(node) { - continue; - } - if let Some(cycle) = dfs_cycle(node, graph, &mut states, &mut stack) { - return Some(cycle); - } - } - None +fn main() -> anyhow::Result<()> { + xtask::run_traversal_policy_check() } diff --git a/crates/xtask/src/docs_cmd.rs b/crates/xtask/src/docs_cmd.rs index 660eb1d43..f4b29102f 100644 --- a/crates/xtask/src/docs_cmd.rs +++ b/crates/xtask/src/docs_cmd.rs @@ -185,7 +185,7 @@ fn stage_user_guide_source_roots(root: &Path, out_dir: &Path) -> Result Result Result<()> { if source_root_specs.is_empty() { return Ok(()); @@ -232,14 +230,13 @@ fn build_source_root_cache( cmd.current_dir(root) .arg("run") .arg("--package") - .arg("rumoca-tool-docs") + .arg("rumoca-compile") .arg("--bin") - .arg("rumoca-docs-cache") + .arg("rumoca-source-root-cache") .arg("--") - .arg("--out") .arg(cache_path); - for spec in source_root_specs { - cmd.arg("--source-root").arg(spec); + for (key, path) in source_root_specs { + cmd.arg(key).arg(path); } run_status(cmd) } diff --git a/crates/xtask/src/lib.rs b/crates/xtask/src/lib.rs index 77b8b8325..eeb1aed18 100644 --- a/crates/xtask/src/lib.rs +++ b/crates/xtask/src/lib.rs @@ -1,6 +1,5 @@ pub mod web_assets; -#[path = "bin/rumoca-traversal-policy-check.rs"] mod traversal_policy_check; pub fn run_traversal_policy_check() -> anyhow::Result<()> { diff --git a/crates/xtask/src/lsp_benchmark_cmd.rs b/crates/xtask/src/lsp_benchmark_cmd.rs index 4112c40f8..5a2d6f2aa 100644 --- a/crates/xtask/src/lsp_benchmark_cmd.rs +++ b/crates/xtask/src/lsp_benchmark_cmd.rs @@ -379,6 +379,12 @@ impl LspStdioClient { }) { Ok(response) => response, Err(error) => { + // Name the request that stalled: a bare timeout message cannot + // be traced back to a method once the report is partial. + let error = error.context(format!( + "rumoca-lsp request `{method}` (id {id}) produced no response within {}s", + timeout.as_secs() + )); if method == "textDocument/completion" && let Some(detail) = self.latest_completion_progress_detail() { diff --git a/crates/xtask/src/lsp_benchmark_cmd/render.rs b/crates/xtask/src/lsp_benchmark_cmd/render.rs index 0f8b1c9fa..c3a90a880 100644 --- a/crates/xtask/src/lsp_benchmark_cmd/render.rs +++ b/crates/xtask/src/lsp_benchmark_cmd/render.rs @@ -882,7 +882,7 @@ mod tests { kind: "req".to_string(), ok: true, client_ms: Some(5), - detail: "caps ok exec=8 inlay=off".to_string(), + detail: "caps ok exec=8 inlay=on".to_string(), }, LspApiValidationEntry { operation: "didOpen".to_string(), diff --git a/crates/xtask/src/lsp_benchmark_cmd/runtime.rs b/crates/xtask/src/lsp_benchmark_cmd/runtime.rs index 48f2ef5fb..a0cf137ab 100644 --- a/crates/xtask/src/lsp_benchmark_cmd/runtime.rs +++ b/crates/xtask/src/lsp_benchmark_cmd/runtime.rs @@ -647,6 +647,38 @@ fn validate_synthetic_outline_requests( "documentLink should expose URL and file targets" ); + let (inlay_ms, inlay_response) = client.request_timed( + "textDocument/inlayHint", + json!({ + "textDocument": { "uri": workspace.synthetic_uri }, + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 20, "character": 0 } + } + }), + VALIDATION_TIMEOUT, + )?; + let inlay_hints = response_result(&inlay_response) + .as_array() + .cloned() + .context("inlayHint should return a hint array")?; + let inlay_labels = inlay_hints + .iter() + .filter_map(|hint| hint.get("label").and_then(Value::as_str)) + .collect::>(); + // Both special-case hint families the server advertises must be live: the + // array-dimension hint for `Real arr[2, 3]` and the builtin parameter-name + // hint for `sin(helperInst.gain)`. + ensure!( + inlay_labels.iter().any(|label| label.contains("[2x3]")), + "inlayHint should expose the array-dimension hint: {inlay_labels:?}" + ); + ensure!( + inlay_labels.contains(&"u:"), + "inlayHint should expose the builtin parameter-name hint: {inlay_labels:?}" + ); + let inlay_count = inlay_hints.len(); + Ok(vec![ ok_validation( "signatureHelp", @@ -666,6 +698,12 @@ fn validate_synthetic_outline_requests( Some(link_ms), format!("synthetic count={link_count}"), ), + ok_validation( + "inlayHint", + "req", + Some(inlay_ms), + format!("synthetic count={inlay_count} dim+param"), + ), ]) } @@ -1655,40 +1693,70 @@ pub(crate) fn run_lsp_api_validation( }) } -fn validate_initialize_response( - response: &Value, - startup_timing_path: Option<&Path>, -) -> Result { - let result = response_result(response); - let capabilities = result - .get("capabilities") - .context("initialize response missing capabilities")?; - for capability in [ - "textDocumentSync", - "hoverProvider", - "completionProvider", - "documentSymbolProvider", - "semanticTokensProvider", - "definitionProvider", - "referencesProvider", - "renameProvider", - "workspaceSymbolProvider", - "signatureHelpProvider", - "foldingRangeProvider", - "documentFormattingProvider", - "codeLensProvider", - "codeActionProvider", - "documentLinkProvider", - "executeCommandProvider", - ] { +/// Every capability the editor surfaces depend on. `inlayHintProvider` is part +/// of the contract: the special-case hints (array dimensions and builtin +/// parameter names) are implemented and UTF-16-correct, so the server +/// advertises them. +const REQUIRED_INITIALIZE_CAPABILITIES: &[&str] = &[ + "textDocumentSync", + "hoverProvider", + "completionProvider", + "documentSymbolProvider", + "semanticTokensProvider", + "definitionProvider", + "referencesProvider", + "renameProvider", + "workspaceSymbolProvider", + "signatureHelpProvider", + "foldingRangeProvider", + "documentFormattingProvider", + "codeLensProvider", + "codeActionProvider", + "documentLinkProvider", + "executeCommandProvider", + "inlayHintProvider", +]; + +const EXPECTED_EXECUTE_COMMANDS: &[&str] = &[ + "rumoca.scenario.getSimulationConfig", + "rumoca.scenario.setSimulationPreset", + "rumoca.scenario.resetSimulationPreset", + "rumoca.scenario.getVisualizationConfig", + "rumoca.scenario.setVisualizationConfig", + "rumoca.scenario.getCodegenConfig", + "rumoca.scenario.setCodegenConfig", + "rumoca.scenario.getSourceRoots", + "rumoca.scenario.setSourceRoots", + "rumoca.scenario.getScenarioConfig", + "rumoca.scenario.getScenarioConfigFull", + "rumoca.scenario.renderScenarioConfig", + "rumoca.scenario.setScenarioConfig", + "rumoca.scenario.simulate", + "rumoca.scenario.getSimulationModels", + "rumoca.scenario.setSelectedSimulationModel", + "rumoca.scenario.startSimulation", + "rumoca.scenario.prepareSimulationModels", + "rumoca.model.parameterMetadata", + "rumoca.workspace.getBuiltinTargets", + "rumoca.workspace.renderTarget", +]; + +fn validate_initialize_capabilities(capabilities: &Value) -> Result<()> { + for capability in REQUIRED_INITIALIZE_CAPABILITIES { ensure!( capabilities.get(capability).is_some(), "initialize must advertise {capability}" ); } + // Inlay hints carry their full label, so lazy resolve must stay off. ensure!( - capabilities.get("inlayHintProvider").is_none(), - "initialize should keep inlay hints disabled until special-case hints are re-enabled" + capabilities + .get("inlayHintProvider") + .and_then(|provider| provider.get("resolveProvider")) + .and_then(Value::as_bool) + == Some(false), + "initialize must advertise inlay hints without lazy resolve: {:?}", + capabilities.get("inlayHintProvider") ); let commands = capabilities .get("executeCommandProvider") @@ -1698,35 +1766,24 @@ fn validate_initialize_response( .iter() .filter_map(Value::as_str) .collect::>(); - let expected_commands = [ - "rumoca.scenario.getSimulationConfig", - "rumoca.scenario.setSimulationPreset", - "rumoca.scenario.resetSimulationPreset", - "rumoca.scenario.getVisualizationConfig", - "rumoca.scenario.setVisualizationConfig", - "rumoca.scenario.getCodegenConfig", - "rumoca.scenario.setCodegenConfig", - "rumoca.scenario.getSourceRoots", - "rumoca.scenario.setSourceRoots", - "rumoca.scenario.getScenarioConfig", - "rumoca.scenario.getScenarioConfigFull", - "rumoca.scenario.renderScenarioConfig", - "rumoca.scenario.setScenarioConfig", - "rumoca.scenario.simulate", - "rumoca.scenario.getSimulationModels", - "rumoca.scenario.setSelectedSimulationModel", - "rumoca.scenario.startSimulation", - "rumoca.scenario.prepareSimulationModels", - "rumoca.model.parameterMetadata", - "rumoca.workspace.getBuiltinTargets", - "rumoca.workspace.renderTarget", - ]; ensure!( - commands == expected_commands, + commands == EXPECTED_EXECUTE_COMMANDS, "initialize executeCommandProvider drifted: {:?}", commands ); - let base = format!("caps ok exec={} inlay=off", expected_commands.len()); + Ok(()) +} + +fn validate_initialize_response( + response: &Value, + startup_timing_path: Option<&Path>, +) -> Result { + let result = response_result(response); + let capabilities = result + .get("capabilities") + .context("initialize response missing capabilities")?; + validate_initialize_capabilities(capabilities)?; + let base = format!("caps ok exec={} inlay=on", EXPECTED_EXECUTE_COMMANDS.len()); let Some(path) = startup_timing_path else { return Ok(base); }; @@ -1785,6 +1842,7 @@ fn ensure_required_lsp_validation_entries(entries: &[LspApiValidationEntry]) -> "signatureHelp", "foldingRange", "documentLink", + "inlayHint", "memberCompletion", "aliasHover", "formatting", diff --git a/crates/xtask/src/lsp_benchmark_cmd/surface_contracts.rs b/crates/xtask/src/lsp_benchmark_cmd/surface_contracts.rs index e8feac5c1..7531a33d8 100644 --- a/crates/xtask/src/lsp_benchmark_cmd/surface_contracts.rs +++ b/crates/xtask/src/lsp_benchmark_cmd/surface_contracts.rs @@ -140,7 +140,7 @@ pub(super) const WASM_SURFACE_SPECS: &[SurfaceCoverageSpec] = &[ surface: "clear_source_root_cache", kind: "cache", source_path: "crates/rumoca-bind-wasm/src/source_root_api.rs", - source_pattern: "pub fn clear_source_root_cache() -> Result<(), JsValue>", + source_pattern: "pub fn clear_source_root_cache() -> Result<(), WasmError>", proof_path: "crates/rumoca-bind-wasm/src/tests.rs", proof_pattern: "fn test_clear_source_root_cache_clears_the_singleton_session()", proof_label: "bind-wasm:clear_cache", @@ -149,7 +149,7 @@ pub(super) const WASM_SURFACE_SPECS: &[SurfaceCoverageSpec] = &[ surface: "get_source_root_document_count", kind: "cache", source_path: "crates/rumoca-bind-wasm/src/source_root_api.rs", - source_pattern: "pub fn get_source_root_document_count() -> Result", + source_pattern: "pub fn get_source_root_document_count() -> Result", proof_path: "crates/rumoca-bind-wasm/src/tests.rs", proof_pattern: "fn test_clear_source_root_cache_clears_the_singleton_session()", proof_label: "bind-wasm:source_root_document_count", diff --git a/crates/xtask/src/main.rs b/crates/xtask/src/main.rs index 6f7683e1c..4076e24cf 100644 --- a/crates/xtask/src/main.rs +++ b/crates/xtask/src/main.rs @@ -10,6 +10,7 @@ mod modelica_dependency_cache; mod playground_cmd; mod release_cmd; mod repo_cli_cmd; +mod resource_budget; mod review_packet_cmd; mod review_scan_cmd; mod static_server; @@ -633,6 +634,7 @@ pub(crate) fn exe_name(base: &str) -> String { } pub(crate) fn run_status(mut command: Command) -> Result<()> { + resource_budget::apply_to_child(&mut command); let rendered = format!("{command:?}"); let status = command .status() @@ -644,6 +646,7 @@ pub(crate) fn run_status(mut command: Command) -> Result<()> { } pub(crate) fn run_status_quiet(mut command: Command) -> Result<()> { + resource_budget::apply_to_child(&mut command); let rendered = format!("{command:?}"); let output = command .output() @@ -663,6 +666,7 @@ pub(crate) fn run_status_quiet(mut command: Command) -> Result<()> { } fn run_capture(mut command: Command) -> Result { + resource_budget::apply_to_child(&mut command); let rendered = format!("{command:?}"); let output = command .output() diff --git a/crates/xtask/src/main_tests.rs b/crates/xtask/src/main_tests.rs index f8cc92603..2001f5f3b 100644 --- a/crates/xtask/src/main_tests.rs +++ b/crates/xtask/src/main_tests.rs @@ -34,7 +34,7 @@ fn classify_marks_zero_callsite_private_as_dead_likely() { #[test] fn rust_line_count_policy_only_excludes_generated_files() { assert!(!is_line_count_excluded_rust_file( - "crates/rumoca/tests/architecture_hardening_test.rs" + "crates/rumoca/tests/architecture_hardening_test/main.rs" )); assert!(!is_line_count_excluded_rust_file( "crates/foo/src/lower/tests.rs" @@ -134,6 +134,7 @@ fn cli_parses_verify_template_runtimes_job() { Commands::Verify(args) => match args.command { VerifyCommand::TemplateRuntimes(args) => { assert_eq!(args.backend, TemplateRuntimeBackend::All); + assert!(!args.require_external_tools); } other => panic!("expected template runtimes command, got {other:?}"), }, @@ -162,6 +163,29 @@ fn cli_parses_verify_template_runtimes_backend() { } } +#[test] +fn cli_parses_verify_template_runtimes_required_tools_policy() { + let cli = Cli::try_parse_from([ + "xtask", + "verify", + "template-runtimes", + "--backend", + "cuda", + "--require-external-tools", + ]) + .expect("parse required external tool policy"); + match cli.command { + Commands::Verify(args) => match args.command { + VerifyCommand::TemplateRuntimes(args) => { + assert_eq!(args.backend, TemplateRuntimeBackend::Cuda); + assert!(args.require_external_tools); + } + other => panic!("expected template runtimes command, got {other:?}"), + }, + other => panic!("expected verify command, got {other:?}"), + } +} + #[test] fn cli_parses_verify_examples_job() { let cli = Cli::try_parse_from(["xtask", "verify", "examples"]).expect("parse verify examples"); @@ -309,6 +333,35 @@ fn cli_parses_verify_msl_parity_prebuilt_workers() { } } +/// The comparator-evidence check is on by default; opting out has to be typed. +/// If this flag ever becomes a default, an unmeasured cohort run goes quiet +/// again — which is the exact regression the check exists to stop. +#[test] +fn verify_msl_parity_requires_an_explicit_opt_out_to_accept_unmeasured_parity() { + let default = Cli::try_parse_from(["xtask", "verify", "msl-parity"]).expect("parse bare"); + match default.command { + Commands::Verify(args) => match args.command { + VerifyCommand::MslParity(parity) => assert!( + !parity.allows_unmeasured_parity(), + "a bare msl-parity run must enforce the comparator check" + ), + other => panic!("expected msl-parity, got {other:?}"), + }, + other => panic!("expected verify command, got {other:?}"), + } + + let opted_out = + Cli::try_parse_from(["xtask", "verify", "msl-parity", "--allow-unmeasured-parity"]) + .expect("parse --allow-unmeasured-parity"); + match opted_out.command { + Commands::Verify(args) => match args.command { + VerifyCommand::MslParity(parity) => assert!(parity.allows_unmeasured_parity()), + other => panic!("expected msl-parity, got {other:?}"), + }, + other => panic!("expected verify command, got {other:?}"), + } +} + #[test] fn cli_parses_verify_msl_hotspots_job() { let cli = Cli::try_parse_from(["xtask", "verify", "msl-hotspots"]) diff --git a/crates/xtask/src/resource_budget.rs b/crates/xtask/src/resource_budget.rs new file mode 100644 index 000000000..8271255d0 --- /dev/null +++ b/crates/xtask/src/resource_budget.rs @@ -0,0 +1,537 @@ +//! Host-aware resource budgets inherited by repository-tool subprocesses. +//! +//! Two things bound how much work the Rust toolchain may run in parallel: CPU +//! topology and host memory. The memory bound exists because the development +//! hosts run `earlyoom -m10`, which SIGKILLs the largest process as soon as +//! available memory falls below 10% of total RAM (~6.3 GiB on a 62 GiB box). +//! A job count derived from CPU count alone starts 28 concurrent jobs here, and +//! a single link peaks around 1 GiB resident, so the link-heavy tail of a +//! workspace build walks straight into that threshold. Deriving the job count +//! from `/proc/meminfo` `MemAvailable` as well keeps the peak under the floor +//! instead of relying on an out-of-memory-killer exemption. + +use std::process::Command; +use std::sync::atomic::{AtomicUsize, Ordering}; + +const CARGO_BUILD_JOBS: &str = "CARGO_BUILD_JOBS"; +const RUST_TEST_THREADS: &str = "RUST_TEST_THREADS"; +const RAYON_NUM_THREADS: &str = "RAYON_NUM_THREADS"; +const MAX_RESERVED_PHYSICAL_CORES: usize = 2; + +/// Measured peak resident set of one Cargo job slot while linking, in MiB. +/// +/// Calibration (32-logical-CPU / 16-core host, `profile.dev` carrying +/// `debug = "line-tables-only"` and `split-debuginfo = "unpacked"`): +/// `CARGO_BUILD_JOBS=1 cargo test -p rumoca --no-run`, run after touching +/// `crates/rumoca/src/lib.rs`, relinks the package's ~60 integration-test +/// binaries one at a time. Sampling `VmHWM` from `/proc//status` across +/// that run at 20 Hz: +/// +/// * `rust-lld` peaked at 1043 MiB (851-1000 MiB was the common band), +/// * the `rustc` driving each link peaked at 502 MiB, +/// * the whole build process tree peaked at 1346 MiB resident, of which 179 MiB +/// is the one `cargo` parent that does not repeat per job. +/// +/// One job slot therefore costs ~1.17 GiB at its peak, rounded up here. Re-run +/// the same measurement whenever the debuginfo or codegen profile changes: the +/// pre-`line-tables-only` binaries were 806 MiB instead of 116-168 MiB, and the +/// link peak scaled with them. +const RUST_JOB_LINK_PEAK_MIB: usize = 1_250; + +/// Safety factor applied to [`RUST_JOB_LINK_PEAK_MIB`]. +/// +/// Peaks do not all land at once - an 8-job build of the same package peaked at +/// 5.2 GiB rather than 8 GiB - but the calibration covers one package's links on +/// one host, codegen-bound jobs in the heavier compiler crates cost more than +/// link-bound ones, and the failure modes are asymmetric: overshooting the +/// estimate costs a few percent of build throughput, undershooting it costs a +/// SIGKILL from earlyoom part-way through a gate. +const RUST_JOB_MEMORY_SAFETY_FACTOR: usize = 2; + +/// Memory charged against the host budget per Cargo job slot, in MiB. +const RUST_JOB_MEMORY_MIB: usize = RUST_JOB_LINK_PEAK_MIB * RUST_JOB_MEMORY_SAFETY_FACTOR; + +/// Memory charged against the host budget per `libtest` thread, in MiB. +/// +/// Sampled the same way while running `cargo test -p rumoca` at 32 test +/// threads: the heaviest test process in the package peaked at 81 MiB resident. +/// 256 MiB keeps a comparable margin to the build estimate, so the derived cap +/// only binds when the host is genuinely short of memory rather than trimming +/// test parallelism on a healthy host. +const RUST_TEST_THREAD_MEMORY_MIB: usize = 256; + +/// Available-memory percentage at which `earlyoom -m10` starts killing the +/// largest process. Budgets must stay clear of this line by construction. +const EARLYOOM_KILL_PERCENT_OF_TOTAL: usize = 10; + +/// Share of total RAM held back from the build budget: the earlyoom kill line +/// plus five points of slack. +/// +/// The slack means a job whose peak overshoots the per-job estimate still has +/// 5% of RAM (~3 GiB here) before the kill threshold, and expressing the reserve +/// as a percentage keeps it scaled to the host rather than a fixed number that +/// shrinks into insignificance on large machines. +const HOST_MEMORY_RESERVE_PERCENT: usize = EARLYOOM_KILL_PERCENT_OF_TOTAL + 5; + +/// Lower bound on the host reserve, for hosts where 15% is a small absolute +/// number. Also covers the editor, language server, and page cache that share +/// the box with a gate run. +const MIN_HOST_MEMORY_RESERVE_MIB: usize = 4_096; + +const MIB_PER_KIB: usize = 1_024; + +/// Total and available memory as reported by `/proc/meminfo`, in MiB. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct HostMemory { + total_mib: usize, + available_mib: usize, +} + +impl HostMemory { + fn detect() -> Option { + std::fs::read_to_string("/proc/meminfo") + .ok() + .as_deref() + .and_then(Self::parse_meminfo) + } + + /// Parse `MemTotal` and `MemAvailable` (both reported in kB) into MiB. + fn parse_meminfo(meminfo: &str) -> Option { + let field_mib = |key: &str| { + meminfo.lines().find_map(|line| { + let rest = line.strip_prefix(key)?; + let rest = rest.strip_prefix(':')?; + let kib = rest.split_whitespace().next()?.parse::().ok()?; + Some(kib / MIB_PER_KIB) + }) + }; + let total_mib = field_mib("MemTotal")?; + let available_mib = field_mib("MemAvailable")?; + Some(Self { + total_mib, + available_mib, + }) + } + + /// Memory withheld from every derived budget so the host stays above the + /// earlyoom kill threshold. + fn reserve_mib(self) -> usize { + let proportional = self + .total_mib + .saturating_mul(HOST_MEMORY_RESERVE_PERCENT) + .div_ceil(100); + proportional.max(MIN_HOST_MEMORY_RESERVE_MIB) + } + + /// Memory a budget may actually spend. + fn usable_mib(self) -> usize { + self.available_mib.saturating_sub(self.reserve_mib()) + } + + /// Worker count this host can afford at `per_worker_mib` each, at least one. + fn workers_for(self, per_worker_mib: usize) -> usize { + (self.usable_mib() / per_worker_mib.max(1)).max(1) + } +} + +/// Which bound decided the job count, for the budget notice. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum JobBound { + /// CPU topology was the tighter bound (or memory could not be read). + CpuTopology, + /// Host memory was the tighter bound. + HostMemory, +} + +impl JobBound { + fn as_str(self) -> &'static str { + match self { + Self::CpuTopology => "cpu-bound", + Self::HostMemory => "memory-bound", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct RustJobBudget { + logical_cpus: usize, + physical_cores: usize, + reserved_physical_cores: usize, + /// Jobs the CPU topology alone would allow. + cpu_jobs: usize, + /// Host memory, when `/proc/meminfo` could be read. + memory: Option, + /// Jobs host memory alone would allow. + memory_jobs: Option, + /// Effective budget: the tighter of the two bounds. + jobs: usize, + bound: JobBound, +} + +impl RustJobBudget { + fn detect() -> Self { + let logical_cpus = std::thread::available_parallelism() + .map(usize::from) + .unwrap_or(1); + let physical_cores = num_cpus::get_physical().clamp(1, logical_cpus); + Self::for_host(logical_cpus, physical_cores, HostMemory::detect()) + } + + fn for_host(logical_cpus: usize, physical_cores: usize, memory: Option) -> Self { + let logical_cpus = logical_cpus.max(1); + let physical_cores = physical_cores.clamp(1, logical_cpus); + let requested_reserve = match logical_cpus { + 1..=3 => 0, + 4..=7 => 1, + _ => MAX_RESERVED_PHYSICAL_CORES, + }; + let reserved_physical_cores = requested_reserve.min(physical_cores.saturating_sub(1)); + let reserved_logical_cpus = logical_cpus + .saturating_mul(reserved_physical_cores) + .div_ceil(physical_cores); + let cpu_jobs = logical_cpus.saturating_sub(reserved_logical_cpus).max(1); + let memory_jobs = memory.map(|memory| memory.workers_for(RUST_JOB_MEMORY_MIB)); + let jobs = memory_jobs.map_or(cpu_jobs, |memory_jobs| cpu_jobs.min(memory_jobs)); + let bound = if memory_jobs.is_some_and(|memory_jobs| memory_jobs < cpu_jobs) { + JobBound::HostMemory + } else { + JobBound::CpuTopology + }; + Self { + logical_cpus, + physical_cores, + reserved_physical_cores, + cpu_jobs, + memory, + memory_jobs, + jobs, + bound, + } + } + + /// Test-thread cap, or `None` when memory allows the libtest default of one + /// thread per logical CPU. The budget only ever lowers test parallelism. + fn test_threads(self) -> Option { + let threads = self + .memory? + .workers_for(RUST_TEST_THREAD_MEMORY_MIB) + .min(self.logical_cpus); + (threads < self.logical_cpus).then_some(threads) + } + + fn describe_jobs(self) -> String { + let topology = format!( + "{} logical CPUs / {} physical cores, reserving {} physical cores", + self.logical_cpus, self.physical_cores, self.reserved_physical_cores + ); + let reason = match (self.memory, self.memory_jobs) { + (Some(memory), Some(memory_jobs)) => format!( + "{}; cpu allows {} ({topology}); memory allows {memory_jobs} = ({} MiB MemAvailable - {} MiB host reserve) / {RUST_JOB_MEMORY_MIB} MiB per job", + self.bound.as_str(), + self.cpu_jobs, + memory.available_mib, + memory.reserve_mib(), + ), + _ => format!( + "{}; cpu allows {} ({topology}); /proc/meminfo unavailable, no memory bound applied", + self.bound.as_str(), + self.cpu_jobs, + ), + }; + format!( + "Rust build budget: {} jobs ({reason}); override with {CARGO_BUILD_JOBS}", + self.jobs + ) + } + + fn describe_test_threads(self, threads: usize) -> String { + let detail = match self.memory { + Some(memory) => format!( + "memory-bound; ({} MiB MemAvailable - {} MiB host reserve) / {RUST_TEST_THREAD_MEMORY_MIB} MiB per test thread", + memory.available_mib, + memory.reserve_mib(), + ), + None => "memory-bound".to_string(), + }; + format!( + "Rust test budget: {threads} test threads ({detail}, below {} logical CPUs); override with {RUST_TEST_THREADS}", + self.logical_cpus + ) + } +} + +/// Last job count announced by [`apply_to_child`]; 0 before the first notice. +static ANNOUNCED_JOBS: AtomicUsize = AtomicUsize::new(0); +/// Last test-thread cap announced by [`apply_to_child`]; 0 means "uncapped". +static ANNOUNCED_TEST_THREADS: AtomicUsize = AtomicUsize::new(0); + +/// Apply the automatic Cargo and libtest budgets to a child process and +/// everything it launches. Explicit overrides remain authoritative per variable. +/// +/// The budget is re-derived for every child rather than sampled once: a gate +/// spawns children over tens of minutes, and the memory available when it starts +/// says nothing about the memory available when its last child links. The notice +/// is reprinted whenever the derived budget changes, so the logged value always +/// describes the child that is about to run. +pub(crate) fn apply_to_child(command: &mut Command) { + let jobs_overridden = std::env::var_os(CARGO_BUILD_JOBS).is_some(); + let test_threads_overridden = std::env::var_os(RUST_TEST_THREADS).is_some(); + let rayon_overridden = std::env::var_os(RAYON_NUM_THREADS).is_some(); + if jobs_overridden && test_threads_overridden && rayon_overridden { + return; + } + let budget = RustJobBudget::detect(); + // Rayon pools default to every logical CPU; the harness's parallel MSL + // parse fans out in one burst, which reads as a runaway spawn to + // growth-based process killers and oversubscribes the host regardless. + // Cap the pool at the same derived job budget unless the caller chose. + if !rayon_overridden { + command.env(RAYON_NUM_THREADS, budget.jobs.to_string()); + } + let test_threads = budget.test_threads(); + if !jobs_overridden { + command.env(CARGO_BUILD_JOBS, budget.jobs.to_string()); + if ANNOUNCED_JOBS.swap(budget.jobs, Ordering::Relaxed) != budget.jobs { + eprintln!("{}", budget.describe_jobs()); + } + } + if !test_threads_overridden { + let announced = ANNOUNCED_TEST_THREADS.swap(test_threads.unwrap_or(0), Ordering::Relaxed); + match test_threads { + Some(threads) => { + command.env(RUST_TEST_THREADS, threads.to_string()); + if announced != threads { + eprintln!("{}", budget.describe_test_threads(threads)); + } + } + // Memory recovered: say so rather than leaving the last cap notice + // as the standing description of a run that is no longer capped. + None if announced != 0 => eprintln!( + "Rust test budget: test-thread cap lifted, back to {} logical CPUs", + budget.logical_cpus + ), + None => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A host with memory to spare, so topology tests exercise the CPU bound. + fn unconstrained_memory() -> Option { + Some(HostMemory { + total_mib: 1_024 * 1_024, + available_mib: 1_024 * 1_024, + }) + } + + fn for_topology(logical_cpus: usize, physical_cores: usize) -> RustJobBudget { + RustJobBudget::for_host(logical_cpus, physical_cores, unconstrained_memory()) + } + + #[test] + fn small_hosts_keep_all_logical_cpus() { + assert_eq!(for_topology(1, 1).jobs, 1); + assert_eq!(for_topology(3, 2).jobs, 3); + } + + #[test] + fn medium_hosts_reserve_one_physical_core() { + let budget = for_topology(4, 2); + assert_eq!(budget.reserved_physical_cores, 1); + assert_eq!(budget.jobs, 2); + + let budget = for_topology(7, 4); + assert_eq!(budget.reserved_physical_cores, 1); + assert_eq!(budget.jobs, 5); + } + + #[test] + fn large_hosts_reserve_at_most_two_physical_cores() { + let budget = for_topology(8, 4); + assert_eq!(budget.reserved_physical_cores, 2); + assert_eq!(budget.jobs, 4); + + let budget = for_topology(32, 16); + assert_eq!(budget.reserved_physical_cores, 2); + assert_eq!(budget.cpu_jobs, 28); + + let budget = for_topology(128, 64); + assert_eq!(budget.reserved_physical_cores, 2); + assert_eq!(budget.cpu_jobs, 124); + } + + #[test] + fn budget_always_leaves_one_physical_core_for_building() { + let budget = for_topology(8, 1); + assert_eq!(budget.reserved_physical_cores, 0); + assert_eq!(budget.jobs, 8); + } + + #[test] + fn meminfo_parser_reads_total_and_available() { + let meminfo = + "MemTotal: 65744636 kB\nMemFree: 1234 kB\nMemAvailable: 59825652 kB\n"; + let memory = HostMemory::parse_meminfo(meminfo).expect("parsed"); + assert_eq!(memory.total_mib, 64_203); + assert_eq!(memory.available_mib, 58_423); + } + + #[test] + fn meminfo_parser_rejects_incomplete_or_unparsable_input() { + assert_eq!(HostMemory::parse_meminfo("MemTotal: 65744636 kB\n"), None); + assert_eq!( + HostMemory::parse_meminfo("MemAvailable: 59825652 kB\n"), + None + ); + assert_eq!( + HostMemory::parse_meminfo("MemTotal: lots kB\nMemAvailable: 100 kB\n"), + None + ); + // A prefix match must not be mistaken for the field itself. + assert_eq!( + HostMemory::parse_meminfo("MemTotalHuge: 1 kB\nMemAvailable: 1024 kB\n"), + None + ); + } + + #[test] + fn host_reserve_scales_with_total_memory_above_a_floor() { + let large = HostMemory { + total_mib: 64_203, + available_mib: 58_423, + }; + assert_eq!(large.reserve_mib(), 9_631); + + let small = HostMemory { + total_mib: 8_192, + available_mib: 6_000, + }; + assert_eq!(small.reserve_mib(), MIN_HOST_MEMORY_RESERVE_MIB); + } + + #[test] + fn host_reserve_stays_clear_of_the_earlyoom_kill_threshold() { + for total_gib in [4usize, 8, 16, 32, 62, 128, 512] { + let total_mib = total_gib * 1_024; + let memory = HostMemory { + total_mib, + available_mib: total_mib, + }; + let kill_threshold_mib = total_mib * EARLYOOM_KILL_PERCENT_OF_TOTAL / 100; + assert!( + memory.reserve_mib() > kill_threshold_mib, + "reserve {} MiB must exceed the earlyoom floor {kill_threshold_mib} MiB on a {total_gib} GiB host", + memory.reserve_mib(), + ); + } + } + + #[test] + fn memory_bound_divides_usable_memory_by_the_per_job_estimate() { + let memory = HostMemory { + total_mib: 64_203, + available_mib: 58_423, + }; + // (58_423 - 9_631) / 2_500 = 19 + assert_eq!(memory.usable_mib(), 48_792); + assert_eq!(memory.workers_for(RUST_JOB_MEMORY_MIB), 19); + } + + #[test] + fn memory_bound_caps_the_cpu_derived_job_count() { + let memory = HostMemory { + total_mib: 64_203, + available_mib: 30_000, + }; + let budget = RustJobBudget::for_host(32, 16, Some(memory)); + assert_eq!(budget.cpu_jobs, 28); + // (30_000 - 9_631) / 2_500 = 8 + assert_eq!(budget.memory_jobs, Some(8)); + assert_eq!(budget.jobs, 8); + assert_eq!(budget.bound, JobBound::HostMemory); + } + + #[test] + fn cpu_bound_wins_when_memory_is_plentiful() { + let memory = HostMemory { + total_mib: 64_203, + available_mib: 58_423, + }; + let budget = RustJobBudget::for_host(8, 4, Some(memory)); + assert_eq!(budget.memory_jobs, Some(19)); + assert_eq!(budget.jobs, 4); + assert_eq!(budget.bound, JobBound::CpuTopology); + } + + #[test] + fn exhausted_memory_still_yields_one_job() { + let memory = HostMemory { + total_mib: 64_203, + available_mib: 1_000, + }; + let budget = RustJobBudget::for_host(32, 16, Some(memory)); + assert_eq!(budget.jobs, 1); + assert_eq!(budget.bound, JobBound::HostMemory); + } + + #[test] + fn missing_meminfo_falls_back_to_the_cpu_bound() { + let budget = RustJobBudget::for_host(32, 16, None); + assert_eq!(budget.memory_jobs, None); + assert_eq!(budget.jobs, 28); + assert_eq!(budget.bound, JobBound::CpuTopology); + assert!(budget.describe_jobs().contains("/proc/meminfo unavailable")); + } + + #[test] + fn test_threads_are_uncapped_until_memory_is_short() { + let healthy = HostMemory { + total_mib: 64_203, + available_mib: 58_423, + }; + assert_eq!( + RustJobBudget::for_host(32, 16, Some(healthy)).test_threads(), + None + ); + + let tight = HostMemory { + total_mib: 64_203, + available_mib: 12_000, + }; + // (12_000 - 9_631) / 256 = 9 + assert_eq!( + RustJobBudget::for_host(32, 16, Some(tight)).test_threads(), + Some(9) + ); + } + + #[test] + fn test_threads_never_exceed_logical_cpus_and_never_reach_zero() { + let tight = HostMemory { + total_mib: 64_203, + available_mib: 9_700, + }; + let budget = RustJobBudget::for_host(4, 2, Some(tight)); + assert_eq!(budget.test_threads(), Some(1)); + } + + #[test] + fn notice_names_the_binding_reason_and_the_arithmetic() { + let memory = HostMemory { + total_mib: 64_203, + available_mib: 30_000, + }; + let notice = RustJobBudget::for_host(32, 16, Some(memory)).describe_jobs(); + assert!( + notice.starts_with("Rust build budget: 8 jobs (memory-bound;"), + "{notice}" + ); + assert!(notice.contains("cpu allows 28"), "{notice}"); + assert!(notice.contains("30000 MiB MemAvailable"), "{notice}"); + assert!(notice.contains("9631 MiB host reserve"), "{notice}"); + assert!(notice.contains("2500 MiB per job"), "{notice}"); + } +} diff --git a/crates/xtask/src/test_cmd.rs b/crates/xtask/src/test_cmd.rs index 7f1d9e14b..34a2e096f 100644 --- a/crates/xtask/src/test_cmd.rs +++ b/crates/xtask/src/test_cmd.rs @@ -15,12 +15,14 @@ pub(crate) fn run_workspace_fmt_check(root: &Path) -> Result<()> { /// - `architecture_hardening_test` — crate layering, file-size, and the /// RUMOCA_* env-var registry (a regression here means a new unregistered env /// var: remove it or route debug output through `--trace`). -/// - `spec_budget_test` — SPEC set size / per-spec budgets. -/// - `code_size_budget_test` — SPEC_0021 source size guard. -/// - `history_policy_test` — git-history policy. +/// - `suite_gates` — the umbrella binary holding `spec_budget_test` (SPEC set +/// size / per-spec budgets), `code_size_budget_test` (SPEC_0021 source size +/// guard), and `history_policy_test` (git-history policy). /// -/// Add new architecture/policy test targets here so they're grouped in one fast -/// gate rather than only discovered by the full workspace test run. +/// Both targets link no `rumoca` library, which is what keeps this gate fast. +/// Add new architecture/policy checks as members of `tests/suite_gates/main.rs` so +/// they're grouped in one fast gate rather than only discovered by the full +/// workspace test run. pub(crate) fn run_architecture_gates(root: &Path) -> Result<()> { run_cargo( root, @@ -31,11 +33,7 @@ pub(crate) fn run_architecture_gates(root: &Path) -> Result<()> { "--test", "architecture_hardening_test", "--test", - "spec_budget_test", - "--test", - "code_size_budget_test", - "--test", - "history_policy_test", + "suite_gates", ], ) } @@ -74,10 +72,22 @@ const WORKSPACE_TEST_EXCLUDES: &[&str] = &[ "rumoca-bind-wasm", ]; +/// Required Cargo-feature-selected regressions that plain workspace defaults +/// do not discover. CI stages the pinned MSL cache before this command. +const WORKSPACE_TEST_FEATURES: &[&str] = &["--features", "rumoca/msl-sim-tests"]; + +/// Unit + integration tests under nextest, then doctests. nextest schedules +/// individual tests across every core in isolated processes; plain +/// `cargo test` runs one binary at a time, so suites that serialize on an +/// in-process lock (LSP server tests, singleton sessions) collapse the whole +/// lane to one busy core. Measured on the 2026-07-30 tree: 4,831 tests in +/// 32.7s under nextest versus a many-minute serialized tail under libtest. pub(crate) fn run_workspace_tests(root: &Path) -> Result<()> { - let mut args = vec!["test", "--workspace", "--verbose"]; + let mut args = vec!["nextest", "run", "--workspace"]; args.extend_from_slice(WORKSPACE_TEST_EXCLUDES); - run_cargo(root, &args) + args.extend_from_slice(WORKSPACE_TEST_FEATURES); + run_cargo(root, &args)?; + run_workspace_doctests(root) } /// Doctests only. `cargo nextest` cannot run doctests, so the sharded CI lane @@ -86,6 +96,7 @@ pub(crate) fn run_workspace_tests(root: &Path) -> Result<()> { pub(crate) fn run_workspace_doctests(root: &Path) -> Result<()> { let mut args = vec!["test", "--doc", "--workspace", "--verbose"]; args.extend_from_slice(WORKSPACE_TEST_EXCLUDES); + args.extend_from_slice(WORKSPACE_TEST_FEATURES); run_cargo(root, &args) } @@ -97,17 +108,18 @@ pub(crate) fn run_workspace_nextest_partition(root: &Path, partition: &str) -> R let partition_arg = format!("count:{partition}"); let mut args = vec!["nextest", "run", "--workspace", "--verbose"]; args.extend_from_slice(WORKSPACE_TEST_EXCLUDES); + args.extend_from_slice(WORKSPACE_TEST_FEATURES); args.push("--partition"); args.push(&partition_arg); run_cargo(root, &args) } /// CLI options for `verify workspace`, co-located with the workspace-test -/// runners they dispatch to. With no flags this runs the full workspace -/// `cargo test` (unit + integration + doctests), exactly as before, so the -/// verify-suite step (`cargo xtask verify workspace`) is unchanged. The flags -/// let CI split that lane across parallel shards without duplicating the -/// load-bearing crate-exclude list in YAML. +/// runners they dispatch to. With no flags this runs the full workspace suite +/// (unit + integration under nextest, then doctests), preserving exactly the +/// coverage plain `cargo test --workspace` provided. The flags let CI split +/// that lane across parallel shards without duplicating the load-bearing +/// crate-exclude list in YAML. #[derive(Debug, clap::Args, Clone, PartialEq, Eq)] pub(crate) struct WorkspaceArgs { /// Run only the unit + integration tests in this `count:M/N` nextest shard diff --git a/crates/xtask/src/traversal_policy_check.rs b/crates/xtask/src/traversal_policy_check.rs new file mode 100644 index 000000000..b71c93f80 --- /dev/null +++ b/crates/xtask/src/traversal_policy_check.rs @@ -0,0 +1,408 @@ +use anyhow::{Context, Result, bail}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::fs; +use std::path::{Path, PathBuf}; +use syn::visit::Visit; +use syn::{ + FnArg, GenericArgument, ImplItem, Item, ItemFn, ItemImpl, PathArguments, Type, TypeParamBound, +}; + +const COVERED_FILES: &[&str] = &[ + "crates/rumoca-phase-resolve/src/contents.rs", + "crates/rumoca-phase-resolve/src/validation.rs", + "crates/rumoca-phase-resolve/src/semantic_checks/mod.rs", + "crates/rumoca-phase-resolve/src/semantic_checks/expr.rs", + "crates/rumoca-phase-typecheck/src/typechecker/late_methods.rs", + "crates/rumoca-compile/src/session/dependency_fingerprint.rs", + "crates/rumoca-tool-lsp/src/handlers/semantic_tokens.rs", + "crates/rumoca-tool-lsp/src/handlers/inlay_hints.rs", +]; + +pub(crate) fn run() -> Result<()> { + let repo_root = repo_root(); + let mut violations = Vec::new(); + + for rel_path in COVERED_FILES { + let file_path = repo_root.join(rel_path); + let source = fs::read_to_string(&file_path) + .with_context(|| format!("failed to read {}", file_path.display()))?; + let syntax = syn::parse_file(&source) + .with_context(|| format!("failed to parse {}", file_path.display()))?; + + let candidates = collect_candidates(syntax.items); + + if candidates.is_empty() { + continue; + } + + let allowed_recursive = allowed_recursive_functions(rel_path); + let mut graph: BTreeMap> = BTreeMap::new(); + for function in candidates.values() { + if allowed_recursive.contains(function.simple_name.as_str()) { + continue; + } + let outgoing = candidates + .values() + .filter(|candidate| { + candidate.scope == function.scope + && function.calls.contains(&candidate.simple_name) + && !allowed_recursive.contains(candidate.simple_name.as_str()) + }) + .map(|candidate| candidate.key.clone()) + .collect(); + graph.insert(function.key.clone(), outgoing); + } + + if let Some(cycle) = detect_cycle(&graph) { + violations.push(format!( + "{}: recursive traversal helpers are disallowed in covered modules: {}", + rel_path, + cycle.join(" -> ") + )); + } + } + + if !violations.is_empty() { + eprintln!("Traversal policy check failed:"); + for violation in violations { + eprintln!(" - {violation}"); + } + bail!("traversal policy violations detected"); + } + + println!( + "Traversal policy check passed for {} covered files.", + COVERED_FILES.len() + ); + Ok(()) +} + +fn collect_candidates(items: Vec) -> BTreeMap { + let mut candidates = BTreeMap::new(); + for item in items { + match item { + Item::Fn(item_fn) => { + insert_candidate_if_traversal( + &mut candidates, + CandidateFunction::from_top_level_fn(item_fn), + ); + } + Item::Impl(item_impl) => { + for function in CandidateFunction::from_impl(item_impl) { + insert_candidate_if_traversal(&mut candidates, function); + } + } + _ => {} + } + } + candidates +} + +fn insert_candidate_if_traversal( + candidates: &mut BTreeMap, + function: CandidateFunction, +) { + if !function.is_traversal_candidate { + return; + } + candidates.insert(function.key.clone(), function); +} + +fn allowed_recursive_functions(file: &str) -> BTreeSet<&'static str> { + match file { + // Class-container recursion over nested classes remains intentional in these modules. + "crates/rumoca-phase-resolve/src/contents.rs" => { + BTreeSet::from(["resolve_contents_class", "resolve_component_types_class"]) + } + "crates/rumoca-phase-resolve/src/validation.rs" => BTreeSet::from(["visit_class_def"]), + "crates/rumoca-phase-resolve/src/semantic_checks/mod.rs" => { + BTreeSet::from(["visit_class_def"]) + } + "crates/rumoca-phase-resolve/src/semantic_checks/expr.rs" => { + BTreeSet::from(["visit_class_def"]) + } + "crates/rumoca-phase-typecheck/src/typechecker/late_methods.rs" => { + BTreeSet::from(["check_class", "infer_expression_type"]) + } + _ => BTreeSet::new(), + } +} + +fn repo_root() -> PathBuf { + let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + manifest_dir + .ancestors() + .nth(2) + .unwrap_or(manifest_dir) + .to_path_buf() +} + +#[derive(Debug, Clone)] +struct CandidateFunction { + key: String, + simple_name: String, + scope: CandidateScope, + is_traversal_candidate: bool, + calls: BTreeSet, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum CandidateScope { + TopLevel, + Impl(String), +} + +impl CandidateFunction { + fn from_top_level_fn(item_fn: ItemFn) -> Self { + let simple_name = item_fn.sig.ident.to_string(); + let is_traversal_candidate = item_fn.sig.inputs.iter().any(is_traversal_param_fn_arg); + let mut collector = CallCollector::default(); + collector.visit_block(&item_fn.block); + Self { + key: simple_name.clone(), + simple_name, + scope: CandidateScope::TopLevel, + is_traversal_candidate, + calls: collector.calls, + } + } + + fn from_impl(item_impl: ItemImpl) -> Vec { + let scope_name = impl_scope_name(&item_impl); + let scope = CandidateScope::Impl(scope_name.clone()); + let mut functions = Vec::new(); + for item in item_impl.items { + let ImplItem::Fn(item_fn) = item else { + continue; + }; + let simple_name = item_fn.sig.ident.to_string(); + let is_traversal_candidate = item_fn.sig.inputs.iter().any(is_traversal_param_fn_arg); + let mut collector = CallCollector::default(); + collector.visit_block(&item_fn.block); + functions.push(Self { + key: format!("impl::{scope_name}::{simple_name}"), + simple_name, + scope: scope.clone(), + is_traversal_candidate, + calls: collector.calls, + }); + } + functions + } +} + +fn impl_scope_name(item_impl: &ItemImpl) -> String { + let self_ty_name = type_display_name(item_impl.self_ty.as_ref()); + if let Some((_, path, _)) = &item_impl.trait_ { + let trait_name = path + .segments + .last() + .map(|segment| segment.ident.to_string()) + .unwrap_or_else(|| "Trait".to_string()); + format!("{trait_name} for {self_ty_name}") + } else { + self_ty_name + } +} + +fn type_display_name(ty: &Type) -> String { + match ty { + Type::Path(type_path) => type_path + .path + .segments + .last() + .map(|segment| segment.ident.to_string()) + .unwrap_or_else(|| "Type".to_string()), + Type::Reference(reference) => type_display_name(reference.elem.as_ref()), + Type::Paren(paren) => type_display_name(paren.elem.as_ref()), + Type::Group(group) => type_display_name(group.elem.as_ref()), + _ => "Type".to_string(), + } +} + +#[derive(Default)] +struct CallCollector { + calls: BTreeSet, +} + +impl<'ast> Visit<'ast> for CallCollector { + fn visit_expr_call(&mut self, node: &'ast syn::ExprCall) { + if let syn::Expr::Path(path_expr) = node.func.as_ref() { + let segments = &path_expr.path.segments; + let Some(last) = segments.last() else { + syn::visit::visit_expr_call(self, node); + return; + }; + let accepts_call = path_expr.qself.is_some() + || segments.len() == 1 + || (segments.len() == 2 && segments[0].ident == "Self"); + if accepts_call { + self.calls.insert(last.ident.to_string()); + } + } + syn::visit::visit_expr_call(self, node); + } + + fn visit_expr_method_call(&mut self, node: &'ast syn::ExprMethodCall) { + if receiver_is_self(node.receiver.as_ref()) { + self.calls.insert(node.method.to_string()); + } + syn::visit::visit_expr_method_call(self, node); + } +} + +fn receiver_is_self(expr: &syn::Expr) -> bool { + matches!( + expr, + syn::Expr::Path(path_expr) + if path_expr.qself.is_none() + && path_expr.path.segments.len() == 1 + && path_expr.path.segments[0].ident == "self" + ) +} + +fn is_traversal_param_fn_arg(arg: &FnArg) -> bool { + match arg { + FnArg::Typed(pat_type) => type_mentions_tree_type(&pat_type.ty), + FnArg::Receiver(_) => false, + } +} + +fn type_mentions_tree_type(ty: &Type) -> bool { + match ty { + Type::Path(type_path) => path_mentions_tree_type(&type_path.path), + Type::Reference(reference) => type_mentions_tree_type(&reference.elem), + Type::Slice(slice) => type_mentions_tree_type(&slice.elem), + Type::Array(array) => type_mentions_tree_type(&array.elem), + Type::Tuple(tuple) => tuple.elems.iter().any(type_mentions_tree_type), + Type::Paren(paren) => type_mentions_tree_type(&paren.elem), + Type::Group(group) => type_mentions_tree_type(&group.elem), + Type::Ptr(ptr) => type_mentions_tree_type(&ptr.elem), + Type::ImplTrait(impl_trait) => impl_trait.bounds.iter().any(bound_mentions_tree_type), + Type::TraitObject(trait_object) => trait_object.bounds.iter().any(bound_mentions_tree_type), + _ => false, + } +} + +fn bound_mentions_tree_type(bound: &TypeParamBound) -> bool { + match bound { + TypeParamBound::Trait(trait_bound) => path_mentions_tree_type(&trait_bound.path), + TypeParamBound::Lifetime(_) => false, + _ => false, + } +} + +fn path_mentions_tree_type(path: &syn::Path) -> bool { + path.segments.iter().any(|segment| { + is_tree_type_name(segment.ident.to_string().as_str()) + || match &segment.arguments { + PathArguments::AngleBracketed(args) => args.args.iter().any(|arg| match arg { + GenericArgument::Type(ty) => type_mentions_tree_type(ty), + GenericArgument::AssocType(assoc_type) => { + type_mentions_tree_type(&assoc_type.ty) + } + GenericArgument::Constraint(constraint) => { + constraint.bounds.iter().any(bound_mentions_tree_type) + } + GenericArgument::AssocConst(_) + | GenericArgument::Lifetime(_) + | GenericArgument::Const(_) => false, + _ => false, + }), + PathArguments::Parenthesized(parenthesized) => { + parenthesized.inputs.iter().any(type_mentions_tree_type) + || match &parenthesized.output { + syn::ReturnType::Default => false, + syn::ReturnType::Type(_, ty) => type_mentions_tree_type(ty), + } + } + PathArguments::None => false, + } + }) +} + +fn is_tree_type_name(name: &str) -> bool { + matches!( + name, + "Expression" + | "Statement" + | "Equation" + | "Subscript" + | "StoredDefinition" + | "ClassDef" + | "ClassSection" + | "Class" + | "Element" + | "ComponentReference" + | "ComprehensionIndex" + | "ForIndex" + | "StatementBlock" + | "TypeName" + | "Import" + | "NamedArgument" + | "ExtendsClause" + ) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum VisitState { + Visiting, + Done, +} + +fn dfs_cycle( + node: &str, + graph: &BTreeMap>, + states: &mut HashMap, + stack: &mut Vec, +) -> Option> { + states.insert(node.to_string(), VisitState::Visiting); + stack.push(node.to_string()); + + if let Some(neighbors) = graph.get(node) { + for neighbor in neighbors { + if let Some(cycle) = traverse_neighbor(neighbor, graph, states, stack) { + return Some(cycle); + } + } + } + + stack.pop(); + states.insert(node.to_string(), VisitState::Done); + None +} + +fn traverse_neighbor( + neighbor: &str, + graph: &BTreeMap>, + states: &mut HashMap, + stack: &mut Vec, +) -> Option> { + match states.get(neighbor).copied() { + Some(VisitState::Done) => None, + Some(VisitState::Visiting) => cycle_from_back_edge(stack, neighbor), + None => dfs_cycle(neighbor, graph, states, stack), + } +} + +fn cycle_from_back_edge(stack: &[String], neighbor: &str) -> Option> { + let start = stack.iter().position(|name| name == neighbor)?; + let mut cycle = stack[start..].to_vec(); + cycle.push(neighbor.to_string()); + Some(cycle) +} + +fn detect_cycle(graph: &BTreeMap>) -> Option> { + let mut states: HashMap = HashMap::new(); + let mut stack = Vec::new(); + + for node in graph.keys() { + if states.contains_key(node) { + continue; + } + if let Some(cycle) = dfs_cycle(node, graph, &mut states, &mut stack) { + return Some(cycle); + } + } + None +} diff --git a/crates/xtask/src/verify_cmd.rs b/crates/xtask/src/verify_cmd.rs index 8f338e77b..0142b302e 100644 --- a/crates/xtask/src/verify_cmd.rs +++ b/crates/xtask/src/verify_cmd.rs @@ -6,10 +6,7 @@ use std::fs; use std::io::{Cursor, Read}; use std::path::{Path, PathBuf}; use std::process::Command; -use std::sync::{ - Arc, - atomic::{AtomicBool, Ordering}, -}; +use std::sync::mpsc::{self, RecvTimeoutError}; use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant}; @@ -18,16 +15,30 @@ use crate::{ vscode_cmd, wasm_smoke, }; +mod fuzz; +mod kani; mod msl_cargo_setup_timing; +mod msl_local_run; mod msl_quality_baseline; +mod msl_results_cleanup; +mod parity_budgets; +mod parity_comparator; +use fuzz::VerifyFuzzArgs; use msl_cargo_setup_timing::{ MslCargoSetupStepMetadata, MslCargoSetupTimingStep, run_msl_cargo_setup_step, - write_msl_cargo_setup_timing_report, + run_msl_cargo_setup_step_with, write_msl_cargo_setup_timing_report, +}; +use msl_local_run::{ + MSL_BUILD_PROFILE, MslTestBinaries, msl_test_binary_command, optimized_msl_artifact_build, + run_optimized_msl_artifact_build, }; use msl_quality_baseline::resolve_msl_quality_baseline; +use msl_results_cleanup::clean_msl_results_dir; +use parity_comparator::check_comparator_evidence; const MSL_VERSION: &str = "4.1.0"; +const MSL_FULL_TEST_FEATURE: &str = "msl-full-test"; const MSL_RELEASE_ZIP_URL: &str = "https://github.com/modelica/ModelicaStandardLibrary/releases/download/v4.1.0/ModelicaStandardLibrary_v4.1.0.zip"; const MSL_MODELICA_DIR_NAME: &str = "Modelica 4.1.0"; const MSL_MODELICA_SERVICES_DIR_NAME: &str = "ModelicaServices 4.1.0"; @@ -56,11 +67,46 @@ pub(crate) struct VerifySuiteArgs { pub(crate) early_exit: bool, } +#[derive(Debug, Args, Clone, PartialEq, Eq, Default)] +pub(crate) struct VerifyKaniArgs { + /// Prove only deterministic manifest stripe `m` of `n` (`--shard m/n`, + /// 1-based). CI runs every stripe as a required matrix job. + #[arg(long, value_name = "M/N")] + shard: Option, +} + +impl VerifyKaniArgs { + fn parse_shard(&self) -> Result> { + let Some(raw) = self.shard.as_deref() else { + return Ok(None); + }; + let (index, count) = raw + .split_once('/') + .with_context(|| format!("invalid Kani shard `{raw}`; expected m/n"))?; + let index = index + .parse::() + .with_context(|| format!("invalid Kani shard index `{index}`"))?; + let count = count + .parse::() + .with_context(|| format!("invalid Kani shard count `{count}`"))?; + ensure!(count > 0, "Kani shard count must be greater than zero"); + ensure!( + (1..=count).contains(&index), + "Kani shard index must be in 1..={count}, found {index}" + ); + Ok(Some((index, count))) + } +} + #[derive(Debug, Args, Clone, Copy, PartialEq, Eq, Default)] pub(crate) struct VerifyTemplateRuntimeArgs { /// Template backend group to verify. The default runs all backend groups. #[arg(long, value_enum, default_value_t = TemplateRuntimeBackend::All)] pub(crate) backend: TemplateRuntimeBackend, + + /// Fail when an external toolchain required by the selected backend is absent. + #[arg(long)] + pub(crate) require_external_tools: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Default)] @@ -68,15 +114,14 @@ pub(crate) enum TemplateRuntimeBackend { #[default] All, Render, - Native, - EmbeddedC, - Fmi, + C, Casadi, - Sympy, - Symforce, - Onnx, + Cuda, + Fmi, Jax, - Julia, + Modelica, + Rust, + Wasm, } #[derive(Debug, Args, Clone, PartialEq, Eq, Default)] @@ -120,6 +165,9 @@ pub(crate) struct VerifyMslParityArgs { /// Compile/balance stage worker count (default: host-derived) #[arg(long)] stage_parallelism: Option, + /// Per-model compile/simulation worker resident-plus-swap ceiling in MB + #[arg(long)] + model_worker_memory_mb: Option, /// Simulation worker count (default: stage parallelism, memory-capped) #[arg(long)] sim_parallelism: Option, @@ -129,6 +177,8 @@ pub(crate) struct VerifyMslParityArgs { /// Total simulation memory budget in MB (caps the sim worker count) #[arg(long)] sim_total_memory_mb: Option, + #[command(flatten)] + budgets: parity_budgets::MslParityBudgetArgs, /// Explicit MSL quality baseline JSON for baseline-relative gates #[arg(long)] quality_baseline: Option, @@ -137,8 +187,9 @@ pub(crate) struct VerifyMslParityArgs { no_remote_quality_baseline: bool, /// Run only shard `m` of `n` (`--shard m/n`, 1-based). The slowest-first /// model set is striped round-robin across shards so the slow/timeout tail - /// spreads evenly. A shard skips the aggregate baseline ratchet (the fan-in - /// `repo msl merge-results` job runs the gate once on the merged results). + /// spreads evenly. A shard skips the aggregate baseline ratchet; the fan-in + /// `verify msl-parity --merge-shards ` job runs the gate once on the + /// merged results. #[arg(long, value_name = "M/N")] shard: Option, /// Fan-in mode: merge the shard partials under DIR (`shard-*/msl_results.json`) @@ -147,7 +198,7 @@ pub(crate) struct VerifyMslParityArgs { #[arg(long, value_name = "DIR", conflicts_with = "shard")] merge_shards: Option, /// Run a prebuilt `msl_tests` libtest binary (built once by Nix/crane and - /// shared via Cachix) instead of recompiling the workspace. The gate does its + /// shared through CI) instead of recompiling the workspace. The gate does its /// normal config/baseline setup, then executes this binary directly — no /// `cargo test`, so no workspace compile + LTO in the consuming job. #[arg(long, value_name = "PATH")] @@ -163,6 +214,12 @@ pub(crate) struct VerifyMslParityArgs { /// fan-in merge, which runs no simulations. #[arg(long, value_name = "PATH", requires = "prebuilt_test_binary")] prebuilt_sim_worker: Option, + /// Accept a cohort-shaped run whose OMC comparator produced no agreement + /// bands. The run still prints "parity unmeasured: comparator did not run" + /// and still reports no parity number; this only stops that from failing + /// the command. Without it, an unmeasured cohort run is an error. + #[arg(long)] + allow_unmeasured_parity: bool, } impl VerifyMslParityArgs { @@ -210,6 +267,9 @@ impl VerifyMslParityArgs { if let Some(value) = self.stage_parallelism { config.insert("stage_parallelism".into(), value.into()); } + if let Some(value) = self.model_worker_memory_mb { + config.insert("model_worker_memory_mb".into(), value.into()); + } if let Some(value) = self.sim_parallelism { config.insert("sim_parallelism".into(), value.into()); } @@ -219,6 +279,7 @@ impl VerifyMslParityArgs { if let Some(value) = self.sim_total_memory_mb { config.insert("sim_total_memory_mb".into(), value.into()); } + self.budgets.insert_into(&mut config); if let Some(value) = &self.quality_baseline { config.insert( "quality_baseline_file".into(), @@ -265,15 +326,21 @@ impl VerifyMslParityArgs { Ok(Some((index, count))) } + /// Whether this run accepts a cohort-shaped result with no OMC comparison. + pub(crate) fn allows_unmeasured_parity(&self) -> bool { + self.allow_unmeasured_parity + } + fn requires_selected_targets_success(&self) -> bool { self.require_selected_targets_success - || self.sim_targets_file.is_some() - || !self.sim_match.is_empty() - || self.sim_limit.is_some() } fn uses_baseline_relative_quality_gate(&self) -> bool { - if self.requires_selected_targets_success() { + if self.requires_selected_targets_success() + || self.sim_targets_file.is_some() + || !self.sim_match.is_empty() + || self.sim_limit.is_some() + { return false; } // A shard runs only its stripe, so it never enforces the aggregate @@ -295,6 +362,36 @@ fn parity_config_path(root: &Path) -> PathBuf { root.join("target/msl/parity-config.json") } +/// Exclusive ownership of the fixed xtask->libtest parity-config channel. +/// +/// Libtest cannot receive these knobs through argv, so SPEC_0018 permits one +/// inspectable fixed-path file. The lock must outlive both writing and every +/// consumer: otherwise a concurrent focused run can replace the selected model +/// set while the first harness is starting and silently certify the wrong run. +struct ParityConfigLock { + _file: fs::File, +} + +impl ParityConfigLock { + fn acquire(root: &Path) -> Result { + let path = root.join("target/msl/parity-config.lock"); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + let file = fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&path) + .with_context(|| format!("failed to open {}", path.display()))?; + file.lock() + .with_context(|| format!("failed to lock {}", path.display()))?; + Ok(Self { _file: file }) + } +} + /// Write the parity config to the fixed path before the libtest gate runs. The /// file is rewritten every invocation so a previous run's config never leaks. fn write_parity_config(root: &Path, args: &VerifyMslParityArgs) -> Result<()> { @@ -318,6 +415,8 @@ pub(crate) enum VerifyCommand { Architecture, /// Rust formatting, traversal policy, and clippy Lint, + /// Required bounded proofs under the repository-pinned Kani toolchain + Kani(VerifyKaniArgs), /// Workspace tests that mirror the main test matrix Workspace(test_cmd::WorkspaceArgs), /// Environment-dependent example template runtime checks @@ -344,6 +443,8 @@ pub(crate) enum VerifyCommand { MslParity(Box), /// Generate real flamegraph SVGs for the hottest compile and sim models from the latest MSL run MslHotspots, + /// Bounded libFuzzer run of the standalone `fuzz/` parser fuzz target + Fuzz(VerifyFuzzArgs), } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -367,7 +468,7 @@ const VERIFY_SUITE_STEPS: &[VerifyStep] = &[ // it runs before lower-risk heavyweight surfaces. VerifyStep { label: "MSL parity", - args: &["verify", "msl-parity"], + args: &["verify", "msl-parity", "--no-remote-quality-baseline"], include_in_full: true, include_in_quick: true, }, @@ -482,6 +583,7 @@ impl VerifySuite { pub(crate) fn run(args: VerifyArgs, root: &Path) -> Result<()> { match args.command { VerifyCommand::Lint => run_lint_job(root), + VerifyCommand::Kani(args) => kani::run(root, &args), VerifyCommand::Workspace(args) => args.run(root), VerifyCommand::TemplateRuntimes(args) => run_template_runtime_checks(root, args), VerifyCommand::Examples => run_examples_smoke(root), @@ -498,6 +600,7 @@ pub(crate) fn run(args: VerifyArgs, root: &Path) -> Result<()> { VerifyCommand::Docs => test_cmd::run_workspace_docs(root), VerifyCommand::MslParity(args) => run_msl_quality_gate(root, &args), VerifyCommand::MslHotspots => run_msl_hotspot_flamegraphs(root), + VerifyCommand::Fuzz(args) => fuzz::run(&args, root), } } @@ -510,7 +613,7 @@ fn run_examples_smoke(root: &Path) -> Result<()> { .arg("--features") .arg("examples-smoke-tests") .arg("--test") - .arg("examples_smoke") + .arg("suite_examples_smoke") .arg("--") .arg("--nocapture") .current_dir(root); @@ -623,66 +726,78 @@ struct TemplateRuntimeTestGroup { filters: &'static [&'static str], } +/// The `template-runtime-tests` sources all live in one Cargo test target now +/// (`crates/rumoca/tests/suite_template_runtime.rs` includes them as `#[path]` +/// modules, so ~60 whole-compiler links collapse into a handful). libtest names +/// then carry the source file's module prefix, which is exactly what lets the +/// groups below keep selecting one member file at a time. +const TEMPLATE_RUNTIME_TEST: &str = "suite_template_runtime"; + const TEMPLATE_RUNTIME_GROUPS: &[TemplateRuntimeTestGroup] = &[ TemplateRuntimeTestGroup { backend: TemplateRuntimeBackend::Render, - test: "template_target_ci", - filters: &[], + test: TEMPLATE_RUNTIME_TEST, + filters: &["template_target_ci::"], }, TemplateRuntimeTestGroup { backend: TemplateRuntimeBackend::Render, - test: "standalone_template_regression", - filters: &[], - }, - TemplateRuntimeTestGroup { - backend: TemplateRuntimeBackend::Native, - test: "backend_template_runtime_regression", - filters: &["native_simulates"], + test: TEMPLATE_RUNTIME_TEST, + filters: &["codegen_example_regression::"], }, + // The projected template schema pin lives in the backend runtime test but + // needs no external toolchain, so the render group runs it directly. TemplateRuntimeTestGroup { - backend: TemplateRuntimeBackend::EmbeddedC, - test: "backend_template_runtime_regression", - filters: &["embedded_c_"], + backend: TemplateRuntimeBackend::Render, + test: TEMPLATE_RUNTIME_TEST, + filters: &[ + "dae_template_context_", + "explicit_rhs_targets_reject_implicit_algebraic_models", + ], }, TemplateRuntimeTestGroup { - backend: TemplateRuntimeBackend::Fmi, - test: "backend_template_runtime_regression", - filters: &["fmi2_", "fmi3_"], + backend: TemplateRuntimeBackend::C, + test: TEMPLATE_RUNTIME_TEST, + filters: &["c_ode_"], }, TemplateRuntimeTestGroup { backend: TemplateRuntimeBackend::Casadi, - test: "backend_template_runtime_regression", + test: TEMPLATE_RUNTIME_TEST, filters: &["casadi_"], }, TemplateRuntimeTestGroup { - backend: TemplateRuntimeBackend::Sympy, - test: "sympy_template_regression", - filters: &[], + backend: TemplateRuntimeBackend::Cuda, + test: TEMPLATE_RUNTIME_TEST, + filters: &["cuda_ode_"], }, TemplateRuntimeTestGroup { - backend: TemplateRuntimeBackend::Sympy, - test: "backend_template_runtime_regression", - filters: &["sympy_"], - }, - TemplateRuntimeTestGroup { - backend: TemplateRuntimeBackend::Symforce, - test: "symforce_template_regression", - filters: &[], + backend: TemplateRuntimeBackend::Fmi, + test: "suite_fmi", + filters: &["cli_target_fmi::", "fmi_ls_dae_contract::"], }, TemplateRuntimeTestGroup { - backend: TemplateRuntimeBackend::Onnx, - test: "backend_template_runtime_regression", - filters: &["onnx_"], + backend: TemplateRuntimeBackend::Fmi, + test: TEMPLATE_RUNTIME_TEST, + filters: &["only_fmi3_", "fmi3_rejects_", "fmi3_exact_runtime_"], }, TemplateRuntimeTestGroup { backend: TemplateRuntimeBackend::Jax, - test: "backend_template_runtime_regression", + test: TEMPLATE_RUNTIME_TEST, filters: &["jax_"], }, TemplateRuntimeTestGroup { - backend: TemplateRuntimeBackend::Julia, - test: "backend_template_runtime_regression", - filters: &["julia_"], + backend: TemplateRuntimeBackend::Modelica, + test: TEMPLATE_RUNTIME_TEST, + filters: &["modelica_interchange_runtime::"], + }, + TemplateRuntimeTestGroup { + backend: TemplateRuntimeBackend::Rust, + test: TEMPLATE_RUNTIME_TEST, + filters: &["rust_ode_", "rust_fixed_ode_"], + }, + TemplateRuntimeTestGroup { + backend: TemplateRuntimeBackend::Wasm, + test: TEMPLATE_RUNTIME_TEST, + filters: &["fmi_ls_wasm_"], }, ]; @@ -692,7 +807,20 @@ impl TemplateRuntimeTestGroup { } } +/// Cargo test-target names the template runtime gate drives, derived from the +/// group table so the artifact trimmer can never pin a stale target name. +fn template_runtime_test_stems() -> Vec<&'static str> { + let mut stems: Vec<&'static str> = Vec::new(); + for group in TEMPLATE_RUNTIME_GROUPS { + if !stems.contains(&group.test) { + stems.push(group.test); + } + } + stems +} + fn run_template_runtime_checks(root: &Path, args: VerifyTemplateRuntimeArgs) -> Result<()> { + let _required_tools = RequiredExternalToolsMarker::new(root, args.require_external_tools)?; trim_template_runtime_artifacts(root)?; for group in TEMPLATE_RUNTIME_GROUPS @@ -705,17 +833,49 @@ fn run_template_runtime_checks(root: &Path, args: VerifyTemplateRuntimeArgs) -> trim_template_runtime_artifacts(root) } +struct RequiredExternalToolsMarker { + path: PathBuf, + created: bool, +} + +impl RequiredExternalToolsMarker { + fn new(root: &Path, required: bool) -> Result { + let path = root.join("target/template-runtimes/strict"); + let created = required && !path.exists(); + if created { + let parent = path + .parent() + .context("template-runtime strict marker must have a parent directory")?; + fs::create_dir_all(parent)?; + fs::write(&path, b"required by cargo xtask verify template-runtimes\n")?; + } + Ok(Self { path, created }) + } +} + +impl Drop for RequiredExternalToolsMarker { + fn drop(&mut self) { + if self.created { + let _ = fs::remove_file(&self.path); + } + } +} + fn run_template_runtime_group(root: &Path, group: TemplateRuntimeTestGroup) -> Result<()> { if group.filters.is_empty() { - return run_template_runtime_test(root, group.test, None); + return run_template_runtime_test(root, group, None); } for filter in group.filters { - run_template_runtime_test(root, group.test, Some(filter))?; + run_template_runtime_test(root, group, Some(filter))?; } Ok(()) } -fn run_template_runtime_test(root: &Path, test: &str, filter: Option<&str>) -> Result<()> { +fn run_template_runtime_test( + root: &Path, + group: TemplateRuntimeTestGroup, + filter: Option<&str>, +) -> Result<()> { let mut cmd = Command::new("cargo"); cmd.arg("test") .arg("--verbose") @@ -723,10 +883,9 @@ fn run_template_runtime_test(root: &Path, test: &str, filter: Option<&str>) -> R .arg("1") .arg("-p") .arg("rumoca") - .arg("--features") - .arg("template-runtime-tests") + .args(template_runtime_features(group.backend)) .arg("--test") - .arg(test); + .arg(group.test); if let Some(filter) = filter { cmd.arg(filter); } @@ -734,6 +893,25 @@ fn run_template_runtime_test(root: &Path, test: &str, filter: Option<&str>) -> R run_status(cmd) } +fn template_runtime_features(backend: TemplateRuntimeBackend) -> &'static [&'static str] { + if matches!( + backend, + TemplateRuntimeBackend::Fmi | TemplateRuntimeBackend::Wasm + ) { + &[ + "--no-default-features", + "--features", + "template-runtime-tests,fmu-packaging", + ] + } else { + &[ + "--no-default-features", + "--features", + "template-runtime-tests", + ] + } +} + fn trim_template_runtime_artifacts(root: &Path) -> Result<()> { let target_dir = cargo_target_dir(root); remove_dir_if_exists(&target_dir.join("debug").join("incremental"))?; @@ -743,13 +921,7 @@ fn trim_template_runtime_artifacts(root: &Path) -> Result<()> { return Ok(()); } - let test_stems = [ - "template_target_ci", - "standalone_template_regression", - "sympy_template_regression", - "symforce_template_regression", - "backend_template_runtime_regression", - ]; + let test_stems = template_runtime_test_stems(); for entry in fs::read_dir(&deps_dir) .with_context(|| format!("read Cargo deps directory {}", deps_dir.display()))? { @@ -1116,6 +1288,10 @@ fn run_msl_quality_gate(root: &Path, args: &VerifyMslParityArgs) -> Result<()> { let ci_env = MslCiEnvironment::from_args(root, args); ci_env.print_notice(); ci_env.clean_stale_results()?; + // Held through the libtest run and comparator-evidence check: the config is + // an argv-equivalent channel, so changing it while any consumer is alive + // would change the meaning of that invocation. + let _parity_config_lock = ParityConfigLock::acquire(root)?; write_parity_config(root, args)?; let _cleanup = MslResultsCleanupGuard::new(ci_env.results_dir.clone(), ci_env.clean_results); let _monitor = MslResourceMonitor::start(ci_env.clone()); @@ -1145,11 +1321,15 @@ fn run_msl_quality_gate(root: &Path, args: &VerifyMslParityArgs) -> Result<()> { } else if let Err(error) = write_result { eprintln!("failed to write MSL Cargo setup timing report: {error:#}"); } - result + result?; + // Second, independent boundary: the harness gate can be skipped (shards, + // focused runs), but "did anything get compared against OMC?" is answered + // from what landed on disk, for every cohort-shaped run. + check_comparator_evidence(&ci_env.results_dir, args.allows_unmeasured_parity()) } /// Run a specific libtest from a prebuilt `msl_tests` binary (built once by -/// Nix/crane and shared via Cachix) instead of recompiling. The gate's config + +/// Nix/crane and shared through CI) instead of recompiling. The gate's config + /// baseline setup has already run, so this only executes the binary with the /// right test filter — no `cargo test`, hence no workspace compile + LTO in the /// consuming job. Sim-running gates spawn `rumoca-sim-worker`, which the harness @@ -1162,49 +1342,19 @@ fn run_prebuilt_msl_test( test_target: &str, cargo_setup_steps: &mut Vec, ) -> Result<()> { - ensure!( - binary.is_file(), - "prebuilt msl_tests binary not found at {}", - binary.display() - ); - let target_dir = cargo_target_dir(root); - let mut run = Command::new(binary); - run.arg(test_target) - .arg("--exact") - .arg("--nocapture") - .env("RUST_BACKTRACE", "full") - .current_dir(root); - if let Some(worker) = model_worker { - ensure!( - worker.is_file(), - "prebuilt rumoca-worker not found at {}", - worker.display() - ); - run.env("CARGO_BIN_EXE_rumoca-worker", worker); - } - if let Some(worker) = sim_worker { - ensure!( - worker.is_file(), - "prebuilt rumoca-sim-worker not found at {}", - worker.display() - ); - run.env("CARGO_BIN_EXE_rumoca-sim-worker", worker); - } - if let Some(tools) = prebuilt_sibling_binary(binary, "rumoca-msl-tools") { - run.env("CARGO_BIN_EXE_rumoca-msl-tools", &tools); - run.env("CARGO_BIN_EXE_rumoca_msl_tools", tools); - } - run_msl_cargo_setup_step( + let tools = prebuilt_sibling_binary(binary, "rumoca-msl-tools"); + let binaries = MslTestBinaries { + test_binary: binary, + model_worker, + sim_worker, + msl_tools: tools.as_deref(), + }; + run_msl_test_binary( + root, + binaries, + test_target, + MslTestRunSource::Prebuilt, cargo_setup_steps, - MslCargoSetupStepMetadata::new( - "run prebuilt MSL test", - "run", - "rumoca-test-msl", - "prebuilt", - vec!["msl-full-test".to_string()], - &target_dir, - ), - run, ) } @@ -1223,71 +1373,63 @@ fn run_msl_quality_gate_cargo_commands( // The merge-and-gate fan-in entry runs NO simulations: it loads the per-shard // `msl_results.json`, concatenates them, and runs the quality ratchet on the - // merged aggregate. So it needs neither the release `rumoca-sim-worker` / - // `rumoca-msl-tools` binaries (only the sharded sim run spawns those) nor a - // release + LTO build of the harness. Building just the merge test in debug - // avoids a ~20min release recompile of the whole workspace in the fan-in job, + // merged aggregate. So it needs neither the optimized `rumoca-sim-worker` / + // `rumoca-msl-tools` binaries (only the sharded sim run spawns those) nor an + // optimized build of the harness. Building just the merge test in debug + // avoids rebuilding the whole workspace in the fan-in job, // which otherwise runs sequentially after the shards and inflates the gate. - if !merge_only { - let mut build_sim_worker = Command::new("cargo"); - build_sim_worker - .arg("build") - .arg("--verbose") - .arg("--release") - .arg("--package") - .arg("rumoca-test-msl") - .arg("--bin") - .arg("rumoca-sim-worker") - .current_dir(root); - let result = run_msl_cargo_setup_step( + if local_msl_run_plan(merge_only) == LocalMslRunPlan::ReleaseArtifacts { + // Include the integration test and every spawned runtime in one Cargo + // graph. The test's dev-dependencies participate in feature unification; + // separate binary builds therefore cannot be reused reliably even when + // their top-level feature flag matches this test. + let build = optimized_msl_artifact_build(root); + let artifacts = run_msl_cargo_setup_step_with( cargo_setup_steps, MslCargoSetupStepMetadata::new( - "build rumoca-sim-worker", + "build optimized MSL artifacts", "build", - "rumoca-test-msl", - "release", - Vec::new(), + "rumoca-worker + rumoca-test-msl", + MSL_BUILD_PROFILE, + vec![format!("rumoca-test-msl/{MSL_FULL_TEST_FEATURE}")], &target_dir, ), - build_sim_worker, - ); - result?; - - let mut build_msl_tools = Command::new("cargo"); - build_msl_tools - .arg("build") - .arg("--verbose") - .arg("--release") - .arg("--package") - .arg("rumoca-test-msl") - .arg("--bin") - .arg("rumoca-msl-tools") - .current_dir(root); - let result = run_msl_cargo_setup_step( + build, + run_optimized_msl_artifact_build, + )?; + return run_msl_test_binary( + root, + artifacts.binaries(), + test_target, + MslTestRunSource::Optimized, cargo_setup_steps, - MslCargoSetupStepMetadata::new( - "build rumoca-msl-tools", - "build", - "rumoca-test-msl", - "release", - Vec::new(), - &target_dir, - ), - build_msl_tools, ); - result?; } - let profile = if merge_only { "debug" } else { "release" }; - let mut gate = Command::new("cargo"); - gate.arg("test").arg("--verbose"); - if !merge_only { - gate.arg("--release"); - } - gate.arg("--package") + let gate = debug_msl_merge_test_command(root, test_target); + run_msl_cargo_setup_step( + cargo_setup_steps, + MslCargoSetupStepMetadata::new( + "run debug MSL merge test", + "test", + "rumoca-test-msl", + "debug", + vec![MSL_FULL_TEST_FEATURE.to_string()], + &target_dir, + ), + gate, + ) +} + +fn debug_msl_merge_test_command(root: &Path, test_target: &str) -> Command { + let mut command = Command::new("cargo"); + command + .arg("test") + .arg("--verbose") + .arg("--package") .arg("rumoca-test-msl") .arg("--features") - .arg("msl-full-test") + .arg(MSL_FULL_TEST_FEATURE) .arg("--test") .arg("msl_tests") .arg(test_target) @@ -1295,21 +1437,65 @@ fn run_msl_quality_gate_cargo_commands( .arg("--nocapture") .env("RUST_BACKTRACE", "full") .current_dir(root); + command +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LocalMslRunPlan { + MergeOnly, + ReleaseArtifacts, +} + +fn local_msl_run_plan(merge_only: bool) -> LocalMslRunPlan { + if merge_only { + LocalMslRunPlan::MergeOnly + } else { + LocalMslRunPlan::ReleaseArtifacts + } +} + +#[derive(Debug, Clone, Copy)] +enum MslTestRunSource { + Prebuilt, + Optimized, +} + +impl MslTestRunSource { + fn label(self) -> &'static str { + match self { + Self::Prebuilt => "run prebuilt MSL test", + Self::Optimized => "run optimized MSL test", + } + } + + fn profile(self) -> &'static str { + match self { + Self::Prebuilt => "prebuilt", + Self::Optimized => MSL_BUILD_PROFILE, + } + } +} + +fn run_msl_test_binary( + root: &Path, + binaries: MslTestBinaries<'_>, + test_target: &str, + source: MslTestRunSource, + cargo_setup_steps: &mut Vec, +) -> Result<()> { + let target_dir = cargo_target_dir(root); + let run = msl_test_binary_command(root, binaries, test_target)?; run_msl_cargo_setup_step( cargo_setup_steps, MslCargoSetupStepMetadata::new( - if merge_only { - "run debug MSL merge test" - } else { - "run release MSL test" - }, - "test", + source.label(), + "run", "rumoca-test-msl", - profile, - vec!["msl-full-test".to_string()], + source.profile(), + vec![MSL_FULL_TEST_FEATURE.to_string()], &target_dir, ), - gate, + run, ) } @@ -1322,41 +1508,6 @@ struct MslCiEnvironment { github_actions: bool, } -const MSL_RESULTS_PRESERVED_DIRS: &[&str] = &["omc_parity_cache"]; - -fn should_preserve_msl_results_entry(entry_path: &Path) -> bool { - entry_path.is_dir() - && entry_path - .file_name() - .and_then(OsStr::to_str) - .is_some_and(|name| MSL_RESULTS_PRESERVED_DIRS.contains(&name)) -} - -fn clean_msl_results_dir(results_dir: &Path) -> std::io::Result<()> { - if !results_dir.is_dir() { - return Ok(()); - } - - for entry in fs::read_dir(results_dir)? { - let entry = entry?; - let path = entry.path(); - if should_preserve_msl_results_entry(&path) { - continue; - } - if path.is_dir() { - fs::remove_dir_all(&path)?; - } else { - fs::remove_file(&path)?; - } - } - - if fs::read_dir(results_dir)?.next().is_none() { - fs::remove_dir(results_dir)?; - } - - Ok(()) -} - impl MslCiEnvironment { fn from_args(root: &Path, args: &VerifyMslParityArgs) -> Self { let results_dir = args @@ -1440,7 +1591,7 @@ impl Drop for MslResultsCleanupGuard { struct MslResourceMonitor { config: MslCiEnvironment, - done: Arc, + stop: Option>, worker: Option>, } @@ -1450,20 +1601,19 @@ impl MslResourceMonitor { let Some(interval) = config.monitor_interval else { return Self { config, - done: Arc::new(AtomicBool::new(true)), + stop: None, worker: None, }; }; - let done = Arc::new(AtomicBool::new(false)); - let done_flag = Arc::clone(&done); + let (stop, stop_receiver) = mpsc::channel(); let config_for_worker = config.clone(); let worker = thread::spawn(move || { - run_resource_monitor_loop(done_flag, interval, config_for_worker); + run_resource_monitor_loop(stop_receiver, interval, config_for_worker); }); Self { config, - done, + stop: Some(stop), worker: Some(worker), } } @@ -1471,7 +1621,7 @@ impl MslResourceMonitor { impl Drop for MslResourceMonitor { fn drop(&mut self) { - self.done.store(true, Ordering::Relaxed); + self.stop.take(); if let Some(worker) = self.worker.take() { let _ = worker.join(); } @@ -1480,17 +1630,13 @@ impl Drop for MslResourceMonitor { } fn run_resource_monitor_loop( - done_flag: Arc, + stop: mpsc::Receiver<()>, interval: Duration, config: MslCiEnvironment, ) { let mut last_cpu_sample = Instant::now(); let mut last_print: Option = None; - while !done_flag.load(Ordering::Relaxed) { - thread::sleep(interval); - if done_flag.load(Ordering::Relaxed) { - break; - } + while let Err(RecvTimeoutError::Timeout) = stop.recv_timeout(interval) { // Throttle the (verbose) periodic snapshot to the floor, independent of // the wake interval, so a small `--monitor-interval-secs` does not spam. if last_print.is_some_and(|at| at.elapsed() < MSL_RESOURCE_PERIODIC_MIN_INTERVAL) { @@ -1521,12 +1667,16 @@ fn print_resource_snapshot(phase: &str, config: &MslCiEnvironment, include_cpu: } log_command_output("free -h", "free", ["-h"]); log_command_output("df -h", "df", ["-h", ".", "/tmp"]); - log_path_size("target/msl", &config.root.join("target/msl")); - log_path_size("msl_results", &config.results_dir); + // `df` is the bounded disk-capacity monitor. Recursively walking the large + // shared target trees here used to add minutes to otherwise focused gates. + eprintln!( + "target_msl_path={}", + config.root.join("target/msl").display() + ); + eprintln!("msl_results_path={}", config.results_dir.display()); if !concise { log_command_output("uptime", "uptime", std::iter::empty::<&str>()); log_command_output("df -ih", "df", ["-ih", ".", "/tmp"]); - log_path_size("target", &config.root.join("target")); print_results_dir_summary("results-breakdown", &config.results_dir); } if !should_log_process_tables(config) { @@ -1542,24 +1692,6 @@ fn should_log_process_tables(config: &MslCiEnvironment) -> bool { !config.github_actions } -fn log_path_size(label: &str, path: &Path) { - if !path.exists() { - return; - } - let output = Command::new("du").arg("-sh").arg(path).output(); - match output { - Ok(output) if output.status.success() => { - let summary = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if !summary.is_empty() { - eprintln!("{label}: {summary} ({})", path.display()); - } - } - Ok(_) | Err(_) => { - eprintln!("{label}: {}", path.display()); - } - } -} - fn print_results_dir_summary(label: &str, results_dir: &Path) { if !results_dir.is_dir() { return; @@ -1574,7 +1706,15 @@ fn print_results_dir_summary(label: &str, results_dir: &Path) { }; for entry in entries.flatten() { let path = entry.path(); - log_path_size(" entry", &path); + match entry.metadata() { + Ok(metadata) if metadata.is_file() => { + eprintln!(" file: {} bytes ({})", metadata.len(), path.display()); + } + Ok(metadata) if metadata.is_dir() => { + eprintln!(" dir: {}", path.display()); + } + Ok(_) | Err(_) => eprintln!(" entry: {}", path.display()), + } } } @@ -1617,330 +1757,9 @@ where } #[cfg(test)] -mod tests { - use super::{ - MslCargoSetupTimingStep, MslCiEnvironment, MslHotspotModelResult, MslHotspotSummary, - VERIFY_SUITE_STEPS, VerifyMslParityArgs, VerifySuite, VerifyTimingReport, VerifyTimingStep, - hottest_compile_model, hottest_sim_model, msl_cache_layout_valid, prebuilt_sibling_binary, - render_verify_timing_markdown, should_log_process_tables, - write_msl_cargo_setup_timing_report, write_verify_timing_report, - }; - use std::path::PathBuf; - use std::time::Duration; - - fn step_argvs(suite: VerifySuite) -> Vec> { - VERIFY_SUITE_STEPS - .iter() - .filter(|step| suite.includes(step)) - .map(|step| step.args.to_vec()) - .collect() - } - - #[test] - fn quick_suite_runs_format_tests_architecture_and_msl_parity() { - let steps = step_argvs(VerifySuite::Quick); - assert_eq!( - steps, - vec![ - vec!["verify", "lint"], - vec!["verify", "msl-parity"], - vec!["verify", "architecture"], - vec!["verify", "workspace"], - ] - ); - assert!(!steps.contains(&vec!["verify", "examples"])); - assert!(!steps.contains(&vec!["verify", "binaries"])); - assert!(!steps.contains(&vec!["verify", "template-runtimes"])); - assert!(!steps.contains(&vec!["verify", "docs"])); - assert!(!steps.contains(&vec!["vscode", "test"])); - assert!(!steps.contains(&vec!["coverage", "run"])); - assert!(!steps.contains(&vec!["playground", "test"])); - assert!(!steps.contains(&vec!["verify", "lsp-msl-completion-timings"])); - } - - #[test] - fn full_suite_runs_msl_parity_before_lower_signal_heavy_gates() { - let steps = step_argvs(VerifySuite::Full); - assert_eq!(steps.get(1), Some(&vec!["verify", "msl-parity"])); - assert!(steps.contains(&vec!["verify", "architecture"])); - assert!(steps.contains(&vec!["verify", "workspace"])); - assert!(steps.contains(&vec!["verify", "examples"])); - assert!(steps.contains(&vec!["verify", "binaries"])); - assert!(steps.contains(&vec!["verify", "template-runtimes"])); - assert!(steps.contains(&vec!["coverage", "run"])); - assert!(steps.contains(&vec!["playground", "test"])); - assert!(steps.contains(&vec!["verify", "lsp-msl-completion-timings"])); - assert!(steps.contains(&vec!["verify", "msl-parity"])); - } - - #[test] - fn focused_msl_match_requires_selected_targets_success() { - let args = VerifyMslParityArgs { - sim_match: vec!["Modelica.Blocks.Examples.BooleanNetwork1".to_string()], - sim_match_exact: true, - ..VerifyMslParityArgs::default() - }; - let config = args.to_parity_config_json(); - - assert_eq!( - config - .get("require_selected_targets_success") - .and_then(serde_json::Value::as_bool), - Some(true) - ); - assert_eq!( - config - .get("sim_match_exact") - .and_then(serde_json::Value::as_bool), - Some(true) - ); - assert!(!args.uses_baseline_relative_quality_gate()); - } - - #[test] - fn verify_timing_markdown_preserves_step_order() { - let report = VerifyTimingReport::new( - VerifySuite::Quick, - Duration::from_millis(1500), - vec![ - VerifyTimingStep { - label: "lint".to_string(), - command: "cargo xtask verify lint".to_string(), - status: "pass".to_string(), - elapsed_seconds: 0.5, - }, - VerifyTimingStep { - label: "workspace tests".to_string(), - command: "cargo xtask verify workspace".to_string(), - status: "fail".to_string(), - elapsed_seconds: 1.0, - }, - ], - ); - - let markdown = render_verify_timing_markdown(&report); - assert!(markdown.contains("# verify quick")); - assert!(markdown.contains("- success: false")); - assert!( - markdown.find("| lint | pass | 0.500 |").unwrap() - < markdown.find("| workspace tests | fail | 1.000 |").unwrap() - ); - } - - #[test] - fn verify_timing_report_writes_fixed_target_artifacts() { - let root = tempfile::tempdir().expect("temp root"); - let report = VerifyTimingReport::new( - VerifySuite::Quick, - Duration::from_secs(1), - vec![VerifyTimingStep { - label: "lint".to_string(), - command: "cargo xtask verify lint".to_string(), - status: "pass".to_string(), - elapsed_seconds: 1.0, - }], - ); - - write_verify_timing_report(root.path(), &report).expect("write timing report"); - - let json_path = root.path().join("target/verify-timings/quick.json"); - let markdown_path = root.path().join("target/verify-timings/quick.md"); - assert!(json_path.is_file()); - assert!(markdown_path.is_file()); - let json = std::fs::read_to_string(json_path).expect("read timing json"); - assert!(json.contains(r#""suite": "verify quick""#)); - let markdown = std::fs::read_to_string(markdown_path).expect("read timing markdown"); - assert!(markdown.contains("| lint | pass | 1.000 |")); - } - - #[test] - fn msl_cargo_setup_timing_report_writes_fixed_result_artifacts() { - let root = tempfile::tempdir().expect("temp root"); - let results_dir = root.path().join("target/msl/results"); - let steps = vec![ - MslCargoSetupTimingStep { - label: "build rumoca-sim-worker".to_string(), - cargo_action: "build".to_string(), - package: "rumoca-test-msl".to_string(), - profile: "release".to_string(), - features: Vec::new(), - target_dir: root.path().join("target").display().to_string(), - command: "\"cargo\" \"build\"".to_string(), - status: "pass".to_string(), - elapsed_seconds: 0.2, - }, - MslCargoSetupTimingStep { - label: "run release MSL test".to_string(), - cargo_action: "test".to_string(), - package: "rumoca-test-msl".to_string(), - profile: "release".to_string(), - features: vec!["msl-full-test".to_string()], - target_dir: root.path().join("target").display().to_string(), - command: "\"cargo\" \"test\"".to_string(), - status: "fail".to_string(), - elapsed_seconds: 1.3, - }, - ]; - - write_msl_cargo_setup_timing_report(&results_dir, &steps) - .expect("write MSL Cargo setup timing report"); - - let json_path = results_dir.join("msl_cargo_setup_timing.json"); - let markdown_path = results_dir.join("msl_cargo_setup_timing.md"); - assert!(json_path.is_file()); - assert!(markdown_path.is_file()); - let json = std::fs::read_to_string(json_path).expect("read setup timing json"); - assert!(json.contains(r#""success": false"#)); - assert!(json.contains(r#""label": "build rumoca-sim-worker""#)); - assert!(json.contains(r#""package": "rumoca-test-msl""#)); - assert!(json.contains(r#""features": ["#)); - let markdown = std::fs::read_to_string(markdown_path).expect("read setup timing markdown"); - assert!(markdown.contains("# MSL Cargo Setup Timing")); - assert!(markdown.contains("| run release MSL test | fail | 1.300 | rumoca-test-msl |")); - assert!(markdown.contains("| release | msl-full-test |")); - } - - #[test] - fn prebuilt_sibling_binary_finds_tools_next_to_msl_tests() { - let root = tempfile::tempdir().expect("tempdir"); - let bin_dir = root.path().join("bin"); - std::fs::create_dir_all(&bin_dir).expect("mkdir bin"); - let msl_tests = bin_dir.join("msl_tests"); - let tools = bin_dir.join("rumoca-msl-tools"); - std::fs::write(&msl_tests, "").expect("write msl_tests"); - std::fs::write(&tools, "").expect("write tools"); - - assert_eq!( - prebuilt_sibling_binary(&msl_tests, "rumoca-msl-tools"), - Some(tools) - ); - } - - #[test] - fn hotspot_selection_uses_max_compile_and_sim_wall_times() { - let summary = MslHotspotSummary { - model_results: vec![ - MslHotspotModelResult { - model_name: "A".to_string(), - compile_seconds: Some(1.5), - sim_wall_seconds: Some(8.0), - }, - MslHotspotModelResult { - model_name: "B".to_string(), - compile_seconds: Some(3.0), - sim_wall_seconds: Some(2.0), - }, - MslHotspotModelResult { - model_name: "C".to_string(), - compile_seconds: None, - sim_wall_seconds: Some(9.0), - }, - ], - }; - - assert_eq!(hottest_compile_model(&summary), Some(("B", 3.0))); - assert_eq!(hottest_sim_model(&summary), Some(("C", 9.0))); - } - - #[test] - fn msl_cache_layout_requires_editor_smoke_packages() { - let temp = tempfile::tempdir().expect("tempdir"); - let msl_root = temp.path(); - std::fs::write(msl_root.join("Complex.mo"), "").expect("write Complex.mo"); - std::fs::create_dir_all(msl_root.join("Modelica 4.1.0")).expect("mkdir Modelica"); - std::fs::write(msl_root.join("Modelica 4.1.0/package.mo"), "") - .expect("write Modelica package"); - - assert!( - !msl_cache_layout_valid(msl_root), - "ModelicaServices is required by editor MSL smoke asset preparation" - ); - - std::fs::create_dir_all(msl_root.join("ModelicaServices 4.1.0")) - .expect("mkdir ModelicaServices"); - std::fs::write(msl_root.join("ModelicaServices 4.1.0/package.mo"), "") - .expect("write ModelicaServices package"); - - assert!(msl_cache_layout_valid(msl_root)); - } - - #[test] - fn msl_ci_environment_cleans_stale_results_before_run() { - let temp = tempfile::tempdir().expect("tempdir"); - let results_dir = temp.path().join("results"); - std::fs::create_dir_all(&results_dir).expect("mkdir"); - std::fs::write(results_dir.join("stale.json"), "{}").expect("write stale file"); - let env = MslCiEnvironment { - root: PathBuf::from(temp.path()), - results_dir: results_dir.clone(), - monitor_interval: None, - clean_results: true, - github_actions: false, - }; - env.clean_stale_results().expect("cleanup should succeed"); - assert!( - !results_dir.exists(), - "pre-run cleanup should remove stale results directory" - ); - } - - #[test] - fn msl_ci_environment_preserves_keyed_omc_parity_cache() { - let temp = tempfile::tempdir().expect("tempdir"); - let results_dir = temp.path().join("results"); - let parity_cache_dir = results_dir.join("omc_parity_cache"); - std::fs::create_dir_all(&parity_cache_dir).expect("mkdir parity cache"); - std::fs::write(results_dir.join("stale.json"), "{}").expect("write stale file"); - std::fs::write(parity_cache_dir.join("compile.json"), "{}").expect("write cache file"); - - let env = MslCiEnvironment { - root: PathBuf::from(temp.path()), - results_dir: results_dir.clone(), - monitor_interval: None, - clean_results: true, - github_actions: false, - }; - env.clean_stale_results().expect("cleanup should succeed"); - - assert!( - results_dir.is_dir(), - "results dir should remain when keyed parity cache is preserved" - ); - assert!( - parity_cache_dir.join("compile.json").is_file(), - "cleanup should preserve keyed OMC parity cache contents" - ); - assert!( - !results_dir.join("stale.json").exists(), - "cleanup should remove stale non-cache artifacts" - ); - } - - #[test] - fn msl_resource_snapshot_skips_process_tables_on_github_actions() { - let temp = tempfile::tempdir().expect("tempdir"); - let env = MslCiEnvironment { - root: PathBuf::from(temp.path()), - results_dir: temp.path().join("results"), - monitor_interval: None, - clean_results: false, - github_actions: true, - }; +#[path = "verify_cmd/template_runtime_tests.rs"] +mod template_runtime_tests; - assert!(!should_log_process_tables(&env)); - } - - #[test] - fn msl_resource_snapshot_keeps_process_tables_for_local_runs() { - let temp = tempfile::tempdir().expect("tempdir"); - let env = MslCiEnvironment { - root: PathBuf::from(temp.path()), - results_dir: temp.path().join("results"), - monitor_interval: None, - clean_results: false, - github_actions: false, - }; - - assert!(should_log_process_tables(&env)); - } -} +#[cfg(test)] +#[path = "verify_cmd/tests.rs"] +mod tests; diff --git a/crates/xtask/src/verify_cmd/fuzz.rs b/crates/xtask/src/verify_cmd/fuzz.rs new file mode 100644 index 000000000..a6e8130ab --- /dev/null +++ b/crates/xtask/src/verify_cmd/fuzz.rs @@ -0,0 +1,125 @@ +//! `cargo xtask verify fuzz` — bounded libFuzzer runs of the parser fuzz target. +//! +//! The fuzz crate lives under `infra/fuzz/`, outside the cargo +//! workspace, because `cargo fuzz` builds with nightly sanitizer flags that must +//! not leak into normal workspace builds. This command is deliberately *not* a +//! member of `VERIFY_SUITE_STEPS`: `verify full`/`verify quick` must stay +//! bounded, so fuzzing runs on demand and from the nightly workflow. + +use anyhow::{Context, Result, bail}; +use clap::Parser; +use std::path::Path; +use std::process::Command; + +use crate::run_status; + +/// Memory ceiling for one libFuzzer worker. Parsing a Modelica buffer should +/// never approach this; crossing it is itself a finding. +const FUZZ_RSS_LIMIT_MB: u64 = 2048; + +#[derive(Debug, Parser, Clone, PartialEq, Eq)] +pub(crate) struct VerifyFuzzArgs { + /// Wall-clock budget for the fuzz run, in seconds. + #[arg(long, default_value_t = 60)] + pub(crate) max_total_secs: u64, + /// Fuzz target name (a `[[bin]]` in `infra/fuzz/Cargo.toml`). + #[arg(long, default_value = "parse_modelica")] + pub(crate) target: String, + /// Additional libFuzzer `-max_len` cap on generated inputs. + #[arg(long, default_value_t = 8192)] + pub(crate) max_len: u64, +} + +pub(crate) fn run(args: &VerifyFuzzArgs, root: &Path) -> Result<()> { + let fuzz_dir = root.join("infra/fuzz"); + if !fuzz_dir.join("Cargo.toml").is_file() { + bail!( + "fuzz crate not found at {} — expected the standalone cargo-fuzz manifest", + fuzz_dir.display() + ); + } + ensure_cargo_fuzz_available(root)?; + + let mut cmd = Command::new("cargo"); + cmd.arg("fuzz") + .arg("run") + .arg(&args.target) + .arg("--") + .arg(format!("-max_total_time={}", args.max_total_secs)) + .arg(format!("-rss_limit_mb={FUZZ_RSS_LIMIT_MB}")) + .arg(format!("-max_len={}", args.max_len)) + .current_dir(&fuzz_dir); + run_status(cmd).with_context(|| { + format!( + "fuzz target '{}' reported a failure; reproducers are written to {}", + args.target, + fuzz_dir.join("artifacts").join(&args.target).display() + ) + }) +} + +fn ensure_cargo_fuzz_available(root: &Path) -> Result<()> { + let mut probe = Command::new("cargo"); + probe.arg("fuzz").arg("--version").current_dir(root); + crate::resource_budget::apply_to_child(&mut probe); + let output = probe + .output() + .context("failed to run `cargo fuzz --version`")?; + if output.status.success() { + return Ok(()); + } + bail!( + "`cargo fuzz` is not available; install it with `cargo install cargo-fuzz` \ + (it also needs a nightly toolchain for the sanitizer flags)" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Debug, Parser)] + struct Harness { + #[command(flatten)] + args: VerifyFuzzArgs, + } + + #[test] + fn fuzz_args_default_to_a_bounded_parser_run() { + let parsed = Harness::parse_from(["verify-fuzz"]); + assert_eq!(parsed.args.max_total_secs, 60); + assert_eq!(parsed.args.target, "parse_modelica"); + assert_eq!(parsed.args.max_len, 8192); + } + + #[test] + fn fuzz_args_accept_an_explicit_budget_and_target() { + let parsed = Harness::parse_from([ + "verify-fuzz", + "--max-total-secs", + "900", + "--target", + "parse_modelica", + ]); + assert_eq!(parsed.args.max_total_secs, 900); + assert_eq!(parsed.args.target, "parse_modelica"); + } + + #[test] + fn missing_fuzz_crate_is_reported_rather_than_silently_skipped() { + let empty = tempfile::tempdir().expect("temp dir"); + let error = run( + &VerifyFuzzArgs { + max_total_secs: 1, + target: "parse_modelica".to_string(), + max_len: 64, + }, + empty.path(), + ) + .expect_err("missing fuzz crate must fail"); + assert!( + error.to_string().contains("fuzz crate not found"), + "unexpected error: {error}" + ); + } +} diff --git a/crates/xtask/src/verify_cmd/kani.rs b/crates/xtask/src/verify_cmd/kani.rs new file mode 100644 index 000000000..4cf0b7683 --- /dev/null +++ b/crates/xtask/src/verify_cmd/kani.rs @@ -0,0 +1,779 @@ +//! Manifest-driven bounded verification with the repository-pinned Kani release. + +use anyhow::{Context, Result, ensure}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; +use std::fs; +use std::path::{Component, Path}; +use std::process::Command; +use std::time::Instant; + +const MANIFEST_PATH: &str = "infra/verification/kani-proofs.json"; +/// Bumped 2 -> 3 when `assumptions` became a mandatory per-proof field: a +/// version-2 manifest omits it and is no longer admissible. +const MANIFEST_SCHEMA_VERSION: u32 = 3; +const REQUIRED_KANI_VERSION: &str = "0.67.0"; +const SOLVER_PACKAGE: &str = "rumoca-solver"; +const SOLVER_SOURCE_ROOT: &str = "crates/rumoca-solver/src"; +const SUMMARY_PATH: &str = "target/verification/kani-summary.json"; +const KNOWN_CLAIM_IDS: &[&str] = &[ + "FS-EQN-001", + "FS-EQN-002", + "ME-LIFE-001", + "ME-LIFE-002", + "ME-LIFE-003", + "ME-LIFE-004", + "ME-ERR-001", + "ME-BUF-001", + "ME-STATE-001", + "ME-BRAND-001", + "SIM-010", + "BEHAVIOR-PIN", +]; + +use super::VerifyKaniArgs; + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct KaniProofManifest { + schema_version: u32, + kani_version: String, + proofs: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct KaniProof { + package: String, + harness: String, + source: String, + claims: Vec, + selection: ProofSelection, + /// Trusted premises the harness relies on, required by SPEC_0037:256. + assumptions: Vec, + bound: ProofBound, + covers: u32, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct ProofSelection { + production_kernel: String, + symbolic_inputs: String, + exhaustive_test_infeasible_because: String, + counterexample_means: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +enum ProofBound { + Unwind { value: u32, domain: String }, + FiniteDomain { domain: String }, +} + +pub(super) fn run(root: &Path, args: &VerifyKaniArgs) -> Result<()> { + let manifest = load_manifest(root)?; + verify_installed_version(root, &manifest.kani_version)?; + let shard = args.parse_shard()?; + let selected = select_manifest_shard(&manifest, shard)?; + println!( + "Running {} of {} required Kani {} proof harnesses from {}{}", + selected.proofs.len(), + manifest.proofs.len(), + selected.kani_version, + MANIFEST_PATH, + shard.map_or_else(String::new, |(index, count)| format!( + " (shard {index}/{count})" + )) + ); + for proof in &selected.proofs { + println!(" {} [{}]", proof.harness, proof.claims.join(", ")); + } + run_solver_proofs(root, &selected, manifest.proofs.len(), shard) +} + +fn select_manifest_shard( + manifest: &KaniProofManifest, + shard: Option<(usize, usize)>, +) -> Result { + let Some((index, count)) = shard else { + return Ok(manifest.clone()); + }; + let proofs = manifest + .proofs + .iter() + .enumerate() + .filter(|(proof_index, _)| proof_index % count == index - 1) + .map(|(_, proof)| proof.clone()) + .collect::>(); + ensure!( + !proofs.is_empty(), + "Kani shard {index}/{count} selects no proof harnesses" + ); + Ok(KaniProofManifest { + schema_version: manifest.schema_version, + kani_version: manifest.kani_version.clone(), + proofs, + }) +} + +fn load_manifest(root: &Path) -> Result { + let path = root.join(MANIFEST_PATH); + let raw = fs::read_to_string(&path) + .with_context(|| format!("failed to read Kani proof manifest {}", path.display()))?; + let manifest: KaniProofManifest = serde_json::from_str(&raw) + .with_context(|| format!("failed to parse Kani proof manifest {}", path.display()))?; + validate_manifest(root, &manifest)?; + Ok(manifest) +} + +fn validate_manifest(root: &Path, manifest: &KaniProofManifest) -> Result<()> { + ensure!( + manifest.schema_version == MANIFEST_SCHEMA_VERSION, + "unsupported Kani proof manifest schema {}; expected {}", + manifest.schema_version, + MANIFEST_SCHEMA_VERSION + ); + ensure!( + manifest.kani_version == REQUIRED_KANI_VERSION, + "Kani proof manifest must pin version {REQUIRED_KANI_VERSION}, found {}", + manifest.kani_version + ); + ensure!(!manifest.proofs.is_empty(), "Kani proof manifest is empty"); + let mut selectors = BTreeSet::new(); + for proof in &manifest.proofs { + ensure!( + proof.package == SOLVER_PACKAGE, + "Kani proof package must be {SOLVER_PACKAGE}, found {}", + proof.package + ); + ensure!(!proof.harness.is_empty(), "Kani proof harness is empty"); + ensure!(!proof.claims.is_empty(), "{} has no claims", proof.harness); + ensure!( + proof.claims.iter().all(|claim| !claim.trim().is_empty()), + "{} has an empty claim", + proof.harness + ); + for claim in &proof.claims { + let claim_id = claim.split_once(':').map_or(claim.as_str(), |(id, _)| id); + ensure!( + KNOWN_CLAIM_IDS.contains(&claim_id), + "{} names unknown proof claim `{claim}`", + proof.harness + ); + } + validate_selection(proof)?; + validate_assumptions(proof)?; + ensure!( + selectors.insert((proof.package.as_str(), proof.harness.as_str())), + "duplicate Kani proof selector {}::{}", + proof.package, + proof.harness + ); + let source = Path::new(&proof.source); + ensure!( + !source.is_absolute() + && source + .components() + .all(|component| matches!(component, Component::Normal(_))), + "Kani proof source must be a workspace-relative path: {}", + proof.source + ); + ensure!( + root.join(source).is_file(), + "Kani proof source does not exist: {}", + proof.source + ); + let source_text = fs::read_to_string(root.join(source)) + .with_context(|| format!("failed to read Kani proof source {}", proof.source))?; + let marker = format!("fn {}(", proof.harness); + let harness_offset = source_text.find(&marker).with_context(|| { + format!( + "Kani harness {} was not found in {}", + proof.harness, proof.source + ) + })?; + let proof_offset = source_text[..harness_offset] + .rfind("#[kani::proof]") + .with_context(|| format!("{} is not a Kani proof harness", proof.harness))?; + ensure!( + harness_offset - proof_offset < 256, + "{} is not the harness annotated by the preceding #[kani::proof]", + proof.harness + ); + validate_declared_bound(proof, &source_text[proof_offset..harness_offset])?; + } + let listed: BTreeSet<_> = manifest + .proofs + .iter() + .map(|proof| proof.harness.clone()) + .collect(); + let discovered = discover_solver_proofs(root)?; + let missing: Vec<_> = discovered.difference(&listed).cloned().collect(); + let extra: Vec<_> = listed.difference(&discovered).cloned().collect(); + ensure!( + missing.is_empty() && extra.is_empty(), + "Kani manifest inventory differs from {SOLVER_SOURCE_ROOT}: missing={missing:?}, extra={extra:?}" + ); + Ok(()) +} + +fn validate_selection(proof: &KaniProof) -> Result<()> { + for (field, value) in [ + ("production_kernel", &proof.selection.production_kernel), + ("symbolic_inputs", &proof.selection.symbolic_inputs), + ( + "exhaustive_test_infeasible_because", + &proof.selection.exhaustive_test_infeasible_because, + ), + ( + "counterexample_means", + &proof.selection.counterexample_means, + ), + ] { + ensure!( + !value.trim().is_empty(), + "{} has an empty selection.{field}", + proof.harness + ); + } + Ok(()) +} + +/// SPEC_0037:256 requires every manifest entry to identify the trusted +/// premises under which its property holds, so an entry without at least one +/// non-empty assumption is inadmissible. +fn validate_assumptions(proof: &KaniProof) -> Result<()> { + ensure!( + !proof.assumptions.is_empty(), + "{} declares no assumptions", + proof.harness + ); + ensure!( + proof + .assumptions + .iter() + .all(|assumption| !assumption.trim().is_empty()), + "{} has an empty assumption", + proof.harness + ); + Ok(()) +} + +fn validate_declared_bound(proof: &KaniProof, harness_attributes: &str) -> Result<()> { + match &proof.bound { + ProofBound::Unwind { value, domain } => { + ensure!(*value > 0, "{} has a zero unwind bound", proof.harness); + ensure!( + !domain.trim().is_empty(), + "{} has an empty bound domain", + proof.harness + ); + ensure!( + harness_attributes.contains(&format!("#[kani::unwind({value})]")), + "{} manifest unwind {value} differs from its source attribute", + proof.harness + ); + } + ProofBound::FiniteDomain { domain } => { + ensure!( + !domain.trim().is_empty(), + "{} has an empty finite domain", + proof.harness + ); + ensure!( + !harness_attributes.contains("#[kani::unwind("), + "{} declares a finite domain but has an unwind attribute", + proof.harness + ); + } + } + Ok(()) +} + +fn discover_solver_proofs(root: &Path) -> Result> { + let mut harnesses = BTreeSet::new(); + for entry in walkdir::WalkDir::new(root.join(SOLVER_SOURCE_ROOT)) { + let entry = entry.context("failed to walk solver sources for Kani proofs")?; + if !entry.file_type().is_file() || entry.path().extension().is_none_or(|ext| ext != "rs") { + continue; + } + let source = fs::read_to_string(entry.path()) + .with_context(|| format!("failed to read {}", entry.path().display()))?; + for harness in discover_file_proofs(&source, entry.path())? { + ensure!( + harnesses.insert(harness.clone()), + "duplicate Kani harness name `{harness}` in {SOLVER_SOURCE_ROOT}" + ); + } + } + Ok(harnesses) +} + +fn discover_file_proofs(source: &str, path: &Path) -> Result> { + let mut harnesses = Vec::new(); + let mut proof_pending = false; + for line in source.lines().map(str::trim) { + if line == "#[kani::proof]" { + proof_pending = true; + continue; + } + if !proof_pending { + continue; + } + let Some(signature) = line.strip_prefix("fn ") else { + continue; + }; + let harness = signature + .split_once('(') + .with_context(|| format!("malformed Kani harness in {}", path.display()))? + .0 + .trim() + .to_string(); + harnesses.push(harness); + proof_pending = false; + } + Ok(harnesses) +} + +#[derive(Clone, Copy, Default)] +struct ParsedHarnessResult { + success: Option, + elapsed_seconds: Option, + covers_satisfied: Option, + covers_total: Option, +} + +struct KaniRunSummary<'a> { + parsed: &'a [ParsedHarnessResult], + kani_summary: &'a [String], + package_elapsed_seconds: f64, + command_success: bool, + cover_obligations_satisfied: bool, + manifest_proof_count: usize, + shard: Option<(usize, usize)>, +} + +fn run_solver_proofs( + root: &Path, + manifest: &KaniProofManifest, + manifest_proof_count: usize, + shard: Option<(usize, usize)>, +) -> Result<()> { + let mut command = Command::new("cargo"); + command + // Kani 0.67's parallel text output does not identify the harness on + // each result block, so per-harness timing and cover attribution is + // fail-closed only when those blocks remain sequential. Individual + // CBMC processes can also consume most of a verification host's RAM. + .args([ + "kani", + "--package", + SOLVER_PACKAGE, + "--no-default-features", + "--jobs", + "1", + ]) + .current_dir(root); + if shard.is_some() { + for proof in &manifest.proofs { + command.args(["--harness", &proof.harness]); + } + } + crate::resource_budget::apply_to_child(&mut command); + let started = Instant::now(); + let output = command + .output() + .context("failed to execute the Kani proof package")?; + let package_elapsed_seconds = started.elapsed().as_secs_f64(); + print!("{}", String::from_utf8_lossy(&output.stdout)); + eprint!("{}", String::from_utf8_lossy(&output.stderr)); + let combined = format!( + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let parsed = parse_kani_results(&combined, manifest); + let cover_obligations_satisfied = manifest + .proofs + .iter() + .zip(&parsed) + .all(|(proof, result)| covers_match(proof.covers, *result)); + let kani_summary: Vec<_> = combined + .lines() + .filter(|line| { + line.contains("SUMMARY") + || line.contains("VERIFICATION") + || line.contains("Verification Time:") + }) + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(str::to_string) + .collect(); + write_summary( + root, + manifest, + KaniRunSummary { + parsed: &parsed, + kani_summary: &kani_summary, + package_elapsed_seconds, + command_success: output.status.success(), + cover_obligations_satisfied, + manifest_proof_count, + shard, + }, + )?; + ensure!( + output.status.success(), + "Kani proof package failed with status {}", + output.status + ); + for (proof, result) in manifest.proofs.iter().zip(&parsed) { + ensure!( + result.success == Some(true) && result.elapsed_seconds.is_some(), + "successful Kani output lacked a complete result for {}", + proof.harness + ); + } + ensure!( + cover_obligations_satisfied, + "one or more Kani reachability-cover obligations were not satisfied" + ); + Ok(()) +} + +fn parse_kani_results(output: &str, manifest: &KaniProofManifest) -> Vec { + let mut results = vec![ParsedHarnessResult::default(); manifest.proofs.len()]; + let mut current = None; + for line in output.lines() { + if line.contains("Checking harness ") { + current = manifest + .proofs + .iter() + .enumerate() + .filter(|(_, proof)| line.contains(&proof.harness)) + .max_by_key(|(_, proof)| proof.harness.len()) + .map(|(index, _)| index); + continue; + } + let Some(index) = current else { + continue; + }; + if line.contains("VERIFICATION:- SUCCESSFUL") { + results[index].success = Some(true); + } else if line.contains("VERIFICATION:- FAILED") { + results[index].success = Some(false); + } + if let Some((_, elapsed)) = line.split_once("Verification Time:") { + results[index].elapsed_seconds = elapsed.trim().trim_end_matches('s').parse().ok(); + } + if line.contains("cover properties satisfied") { + let fields: Vec<_> = line.split_whitespace().collect(); + results[index].covers_satisfied = fields.get(1).and_then(|field| field.parse().ok()); + results[index].covers_total = fields.get(3).and_then(|field| field.parse().ok()); + } + } + results +} + +fn covers_match(expected: u32, result: ParsedHarnessResult) -> bool { + match (result.covers_satisfied, result.covers_total) { + (Some(satisfied), Some(total)) => satisfied == expected && total == expected, + (None, None) => expected == 0, + _ => false, + } +} + +fn write_summary(root: &Path, manifest: &KaniProofManifest, run: KaniRunSummary<'_>) -> Result<()> { + let path = run.shard.map_or_else( + || root.join(SUMMARY_PATH), + |(index, count)| { + root.join(format!( + "target/verification/kani-summary-shard-{index}-of-{count}.json" + )) + }, + ); + fs::create_dir_all(path.parent().expect("summary path has a parent"))?; + let proofs: Vec<_> = manifest + .proofs + .iter() + .zip(run.parsed) + .map(|(proof, result)| { + let elapsed_seconds = result + .elapsed_seconds + .unwrap_or(run.package_elapsed_seconds); + let elapsed_source = if result.elapsed_seconds.is_some() { + "kani_harness" + } else { + "package_total_fallback" + }; + serde_json::json!({ + "harness": proof.harness, + "claims": proof.claims, + "selection": proof.selection, + "assumptions": proof.assumptions, + "declared_bound": proof.bound, + "expected_cover_obligations": proof.covers, + "covers_satisfied": result.covers_satisfied, + "covers_total": result.covers_total, + "elapsed_seconds": elapsed_seconds, + "elapsed_source": elapsed_source, + "success": result.success == Some(true) + && result.elapsed_seconds.is_some() + && covers_match(proof.covers, *result) + }) + }) + .collect(); + let success = run.command_success + && run.cover_obligations_satisfied + && run + .parsed + .iter() + .all(|result| result.success == Some(true) && result.elapsed_seconds.is_some()); + let summary = serde_json::json!({ + "schema_version": 1, + "verifier": "kani", + "kani_version": manifest.kani_version, + "package": SOLVER_PACKAGE, + "manifest_proof_count": run.manifest_proof_count, + "proof_count": proofs.len(), + "shard": run.shard.map(|(index, count)| serde_json::json!({ + "index": index, + "count": count, + })), + "proofs": proofs, + "package_elapsed_seconds": run.package_elapsed_seconds, + "cover_obligations_satisfied": run.cover_obligations_satisfied, + "kani_summary": run.kani_summary, + "success": success + }); + fs::write(&path, serde_json::to_string_pretty(&summary)?) + .with_context(|| format!("failed to write {}", path.display()))?; + println!( + "Kani recorded {} manifest harnesses; summary written to {}", + manifest.proofs.len(), + path.display() + ); + Ok(()) +} + +fn verify_installed_version(root: &Path, expected: &str) -> Result<()> { + let output = Command::new("cargo") + .args(["kani", "--version"]) + .current_dir(root) + .output() + .context("failed to execute `cargo kani --version`; enter `nix develop .#kani`")?; + ensure!( + output.status.success(), + "`cargo kani --version` failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + let reported = String::from_utf8(output.stdout).context("Kani version output was not UTF-8")?; + ensure!( + reported.split_whitespace().any(|field| field == expected), + "Kani version mismatch: manifest requires {expected}, command reported `{}`", + reported.trim() + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ + KaniProof, MANIFEST_SCHEMA_VERSION, ParsedHarnessResult, REQUIRED_KANI_VERSION, + covers_match, discover_solver_proofs, load_manifest, parse_kani_results, + select_manifest_shard, validate_assumptions, validate_manifest, + }; + use std::collections::BTreeSet; + use std::path::Path; + + #[test] + fn checked_in_manifest_names_the_required_solver_proofs() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let manifest = load_manifest(&root).expect("checked-in Kani manifest should be valid"); + assert_eq!(manifest.kani_version, REQUIRED_KANI_VERSION); + let listed: BTreeSet<_> = manifest + .proofs + .iter() + .map(|proof| (proof.package.clone(), proof.harness.clone())) + .collect(); + let discovered = discover_solver_proofs(&root) + .expect("discover solver proofs") + .into_iter() + .map(|harness| ("rumoca-solver".to_string(), harness)) + .collect::>(); + assert_eq!(listed, discovered, "manifest must list every solver proof"); + } + + #[test] + fn checked_in_manifest_states_assumptions_for_every_harness() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let manifest = load_manifest(&root).expect("checked-in Kani manifest should be valid"); + for proof in &manifest.proofs { + assert!( + !proof.assumptions.is_empty(), + "{} must state its assumptions", + proof.harness + ); + } + } + + /// A schema-3 manifest entry with every field except `assumptions`, which + /// each caller supplies to exercise one arm of the assumptions rule. + fn example_manifest_entry() -> serde_json::Value { + serde_json::json!({ + "package": "rumoca-solver", + "harness": "example", + "source": "crates/rumoca-solver/src/verification.rs", + "claims": ["SIM-010"], + "selection": { + "production_kernel": "kernel", + "symbolic_inputs": "inputs", + "exhaustive_test_infeasible_because": "barrier", + "counterexample_means": "meaning" + }, + "bound": { "kind": "finite_domain", "domain": "domain" }, + "covers": 0 + }) + } + + #[test] + fn manifest_entry_without_assumptions_is_rejected() { + let entry = example_manifest_entry(); + let parsed = serde_json::from_value::(entry.clone()); + assert!( + parsed.is_err(), + "a manifest entry without assumptions must not parse" + ); + + let mut with_assumptions = entry; + with_assumptions["assumptions"] = serde_json::json!([" "]); + let proof = serde_json::from_value::(with_assumptions) + .expect("an entry with an assumptions list parses"); + assert!( + validate_assumptions(&proof).is_err(), + "a blank assumption must not satisfy the assumptions requirement" + ); + } + + /// `assumptions` became mandatory at schema 3, so a manifest still + /// declaring the schema-2 shape must be rejected outright rather than + /// silently validated against the newer rules. + #[test] + fn superseded_schema_version_two_manifest_is_rejected() { + assert_eq!(MANIFEST_SCHEMA_VERSION, 3); + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let mut manifest = load_manifest(&root).expect("checked-in Kani manifest should be valid"); + manifest.schema_version = 2; + let error = validate_manifest(&root, &manifest) + .expect_err("a schema-2 manifest must not validate against schema 3"); + assert!( + error + .to_string() + .contains("unsupported Kani proof manifest schema 2"), + "unexpected rejection reason: {error}" + ); + } + + #[test] + fn manifest_entry_with_empty_assumptions_list_is_rejected() { + let mut proof = example_manifest_entry(); + proof["assumptions"] = serde_json::json!([]); + let proof = serde_json::from_value::(proof) + .expect("an entry with an empty assumptions list parses"); + let error = validate_assumptions(&proof) + .expect_err("an empty assumptions list must not satisfy the assumptions requirement"); + assert!( + error.to_string().contains("declares no assumptions"), + "unexpected rejection reason: {error}" + ); + } + + #[test] + fn deterministic_shards_partition_the_complete_manifest() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let manifest = load_manifest(&root).expect("checked-in Kani manifest should be valid"); + let expected = manifest + .proofs + .iter() + .map(|proof| proof.harness.clone()) + .collect::>(); + let mut selected = BTreeSet::new(); + let shard = select_manifest_shard(&manifest, Some((1, 1))) + .expect("the complete shard should select the proof"); + for proof in shard.proofs { + assert!( + selected.insert(proof.harness), + "a proof must occur in exactly one deterministic shard" + ); + } + assert_eq!(selected, expected); + } + + #[test] + fn kani_output_records_each_harness_result_and_elapsed_time() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let manifest = load_manifest(&root).expect("checked-in Kani manifest should be valid"); + let harness = &manifest.proofs[0].harness; + let output = format!( + "Checking harness rumoca_solver::verification::{harness}...\n\ + VERIFICATION:- SUCCESSFUL\n\ + Verification Time: 0.125s\n" + ); + let parsed = parse_kani_results(&output, &manifest); + assert_eq!(parsed[0].success, Some(true)); + assert_eq!(parsed[0].elapsed_seconds, Some(0.125)); + } + + #[test] + fn interleaved_parallel_output_is_not_accepted_as_complete() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let mut manifest = load_manifest(&root).expect("checked-in Kani manifest should be valid"); + let mut second_proof = manifest.proofs[0].clone(); + second_proof.harness.push_str("_second"); + manifest.proofs.push(second_proof); + let first = &manifest.proofs[0].harness; + let second = &manifest.proofs[1].harness; + let output = format!( + "Thread 0: Checking harness rumoca_solver::verification::{first}...\n\ + Thread 1: Checking harness rumoca_solver::verification::{second}...\n\ + VERIFICATION:- SUCCESSFUL\n\ + Verification Time: 0.125s\n\ + VERIFICATION:- SUCCESSFUL\n\ + Verification Time: 0.250s\n" + ); + let parsed = parse_kani_results(&output, &manifest); + assert_eq!(parsed[0].success, None); + assert_eq!(parsed[0].elapsed_seconds, None); + assert_eq!(parsed[1].success, Some(true)); + assert_eq!(parsed[1].elapsed_seconds, Some(0.250)); + assert!( + parsed + .iter() + .any(|result| result.success != Some(true) || result.elapsed_seconds.is_none()), + "interleaved output must not look like a complete manifest result" + ); + } + + #[test] + fn per_harness_cover_summary_fails_closed() { + assert!(covers_match(0, ParsedHarnessResult::default())); + assert!(!covers_match(1, ParsedHarnessResult::default())); + assert!(covers_match( + 3, + ParsedHarnessResult { + covers_satisfied: Some(3), + covers_total: Some(3), + ..ParsedHarnessResult::default() + } + )); + assert!(!covers_match( + 3, + ParsedHarnessResult { + covers_satisfied: Some(2), + covers_total: Some(3), + ..ParsedHarnessResult::default() + } + )); + } +} diff --git a/crates/xtask/src/verify_cmd/msl_cargo_setup_timing.rs b/crates/xtask/src/verify_cmd/msl_cargo_setup_timing.rs index 16a0c6747..4a8cf070d 100644 --- a/crates/xtask/src/verify_cmd/msl_cargo_setup_timing.rs +++ b/crates/xtask/src/verify_cmd/msl_cargo_setup_timing.rs @@ -73,9 +73,19 @@ pub(super) fn run_msl_cargo_setup_step( metadata: MslCargoSetupStepMetadata, command: Command, ) -> Result<()> { + run_msl_cargo_setup_step_with(steps, metadata, command, run_status) +} + +pub(super) fn run_msl_cargo_setup_step_with( + steps: &mut Vec, + metadata: MslCargoSetupStepMetadata, + command: Command, + run: impl FnOnce(Command) -> Result, +) -> Result { let command_display = format!("{command:?}"); + println!("Running command: {command_display}"); let started = Instant::now(); - let result = run_status_logged(command); + let result = run(command); steps.push(MslCargoSetupTimingStep { label: metadata.label, cargo_action: metadata.cargo_action, @@ -90,11 +100,6 @@ pub(super) fn run_msl_cargo_setup_step( result } -fn run_status_logged(command: Command) -> Result<()> { - println!("Running command: {command:?}"); - run_status(command) -} - pub(super) fn write_msl_cargo_setup_timing_report( results_dir: &Path, steps: &[MslCargoSetupTimingStep], diff --git a/crates/xtask/src/verify_cmd/msl_local_run.rs b/crates/xtask/src/verify_cmd/msl_local_run.rs new file mode 100644 index 000000000..75cd58026 --- /dev/null +++ b/crates/xtask/src/verify_cmd/msl_local_run.rs @@ -0,0 +1,360 @@ +use anyhow::{Context, Result, ensure}; +use serde_json::Value; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use super::MSL_FULL_TEST_FEATURE; + +const MODEL_WORKER: &str = "rumoca-worker"; +const SIM_WORKER: &str = "rumoca-sim-worker"; +const MSL_TOOLS: &str = "rumoca-msl-tools"; +const MSL_TESTS: &str = "msl_tests"; +pub(super) const MSL_BUILD_PROFILE: &str = "msl-fast"; + +#[derive(Debug)] +pub(super) struct MslRuntimeArtifacts { + test_binary: PathBuf, + model_worker: PathBuf, + sim_worker: PathBuf, + msl_tools: PathBuf, +} + +impl MslRuntimeArtifacts { + pub(super) fn binaries(&self) -> MslTestBinaries<'_> { + MslTestBinaries { + test_binary: &self.test_binary, + model_worker: Some(&self.model_worker), + sim_worker: Some(&self.sim_worker), + msl_tools: Some(&self.msl_tools), + } + } +} + +#[derive(Debug, Clone, Copy)] +pub(super) struct MslTestBinaries<'a> { + pub(super) test_binary: &'a Path, + pub(super) model_worker: Option<&'a Path>, + pub(super) sim_worker: Option<&'a Path>, + pub(super) msl_tools: Option<&'a Path>, +} + +pub(super) fn optimized_msl_artifact_build(root: &Path) -> Command { + let mut command = Command::new("cargo"); + command + .arg("build") + .arg("--verbose") + .arg("--profile") + .arg(MSL_BUILD_PROFILE) + .arg("--package") + .arg(MODEL_WORKER) + .arg("--package") + .arg("rumoca-test-msl") + .arg("--features") + .arg(format!("rumoca-test-msl/{MSL_FULL_TEST_FEATURE}")) + .arg("--bin") + .arg(MODEL_WORKER) + .arg("--bin") + .arg(SIM_WORKER) + .arg("--bin") + .arg(MSL_TOOLS) + .arg("--test") + .arg(MSL_TESTS) + .arg("--message-format") + .arg("json-render-diagnostics") + .current_dir(root); + command +} + +pub(super) fn run_optimized_msl_artifact_build( + mut command: Command, +) -> Result { + let mut child = command + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .context("failed to start the optimized MSL artifact build")?; + let stdout = child + .stdout + .take() + .context("optimized MSL artifact build stdout was not captured")?; + let artifacts = parse_cargo_artifacts(BufReader::new(stdout), true); + let status = child + .wait() + .context("failed to wait for the optimized MSL artifact build")?; + ensure!( + status.success(), + "optimized MSL artifact build failed with status {status}" + ); + artifacts +} + +pub(super) fn msl_test_binary_command( + root: &Path, + binaries: MslTestBinaries<'_>, + test_target: &str, +) -> Result { + ensure_artifact(binaries.test_binary, MSL_TESTS)?; + let mut command = Command::new(binaries.test_binary); + command + .arg(test_target) + .arg("--exact") + .arg("--nocapture") + .env("RUST_BACKTRACE", "full") + .current_dir(root); + if let Some(worker) = binaries.model_worker { + ensure_artifact(worker, MODEL_WORKER)?; + command.env("CARGO_BIN_EXE_rumoca-worker", worker); + } + if let Some(worker) = binaries.sim_worker { + ensure_artifact(worker, SIM_WORKER)?; + command.env("CARGO_BIN_EXE_rumoca-sim-worker", worker); + command.env("CARGO_BIN_EXE_rumoca_sim_worker", worker); + } + if let Some(tools) = binaries.msl_tools { + ensure_artifact(tools, MSL_TOOLS)?; + command.env("CARGO_BIN_EXE_rumoca-msl-tools", tools); + command.env("CARGO_BIN_EXE_rumoca_msl_tools", tools); + } + Ok(command) +} + +fn ensure_artifact(path: &Path, name: &str) -> Result<()> { + ensure!( + path.is_file(), + "MSL runtime artifact {name} not found at {}", + path.display() + ); + Ok(()) +} + +#[derive(Default)] +struct CargoArtifacts { + test_binary: Vec, + model_worker: Vec, + sim_worker: Vec, + msl_tools: Vec, +} + +fn parse_cargo_artifacts( + reader: impl BufRead, + render_diagnostics: bool, +) -> Result { + let mut artifacts = CargoArtifacts::default(); + for line in reader.lines() { + let line = line.context("failed to read Cargo JSON output")?; + let message: Value = serde_json::from_str(&line) + .with_context(|| format!("invalid Cargo JSON message: {line}"))?; + render_cargo_diagnostic(&message, render_diagnostics); + collect_cargo_artifact(&message, &mut artifacts); + } + Ok(MslRuntimeArtifacts { + test_binary: unique_artifact(MSL_TESTS, artifacts.test_binary)?, + model_worker: unique_artifact(MODEL_WORKER, artifacts.model_worker)?, + sim_worker: unique_artifact(SIM_WORKER, artifacts.sim_worker)?, + msl_tools: unique_artifact(MSL_TOOLS, artifacts.msl_tools)?, + }) +} + +fn render_cargo_diagnostic(message: &Value, enabled: bool) { + if !enabled || message.get("reason").and_then(Value::as_str) != Some("compiler-message") { + return; + } + if let Some(rendered) = message.pointer("/message/rendered").and_then(Value::as_str) { + eprint!("{rendered}"); + } +} + +fn collect_cargo_artifact(message: &Value, artifacts: &mut CargoArtifacts) { + if message.get("reason").and_then(Value::as_str) != Some("compiler-artifact") { + return; + } + let Some(name) = message.pointer("/target/name").and_then(Value::as_str) else { + return; + }; + let Some(executable) = message.get("executable").and_then(Value::as_str) else { + return; + }; + let mut target_kind = message + .pointer("/target/kind") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str); + let is_test = target_kind.clone().any(|kind| kind == "test"); + let is_binary = target_kind.any(|kind| kind == "bin"); + match (name, is_test, is_binary) { + (MSL_TESTS, true, _) => artifacts.test_binary.push(executable.into()), + (MODEL_WORKER, _, true) => artifacts.model_worker.push(executable.into()), + (SIM_WORKER, _, true) => artifacts.sim_worker.push(executable.into()), + (MSL_TOOLS, _, true) => artifacts.msl_tools.push(executable.into()), + _ => {} + } +} + +fn unique_artifact(name: &str, mut paths: Vec) -> Result { + paths.sort(); + paths.dedup(); + ensure!( + paths.len() == 1, + "Cargo reported {} executable artifacts for {name}; expected exactly one", + paths.len() + ); + Ok(paths.pop().expect("one artifact was established")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::OsStr; + use std::io::Cursor; + + fn artifact(name: &str, kind: &str, executable: &str) -> String { + serde_json::json!({ + "reason": "compiler-artifact", + "target": { "name": name, "kind": [kind] }, + "executable": executable, + }) + .to_string() + } + + fn complete_artifact_json(test_executable: &str) -> String { + [ + artifact(MODEL_WORKER, "bin", "/target/msl-fast/rumoca-worker"), + artifact(SIM_WORKER, "bin", "/target/msl-fast/rumoca-sim-worker"), + artifact(MSL_TOOLS, "bin", "/target/msl-fast/rumoca-msl-tools"), + artifact(MSL_TESTS, "test", test_executable), + ] + .join("\n") + } + + #[test] + fn optimized_build_requests_one_complete_cargo_graph() { + let root = PathBuf::from("/workspace"); + let command = optimized_msl_artifact_build(&root); + let args = command.get_args().collect::>(); + + assert_eq!( + args, + [ + "build", + "--verbose", + "--profile", + "msl-fast", + "--package", + "rumoca-worker", + "--package", + "rumoca-test-msl", + "--features", + "rumoca-test-msl/msl-full-test", + "--bin", + "rumoca-worker", + "--bin", + "rumoca-sim-worker", + "--bin", + "rumoca-msl-tools", + "--test", + "msl_tests", + "--message-format", + "json-render-diagnostics", + ] + .map(OsStr::new) + ); + assert_eq!(command.get_current_dir(), Some(root.as_path())); + } + + #[test] + fn cargo_json_captures_exact_runtime_artifacts() { + let artifacts = parse_cargo_artifacts( + Cursor::new(complete_artifact_json("/target/release/deps/msl_tests-abc")), + false, + ) + .expect("complete artifact stream"); + + assert_eq!( + artifacts.test_binary, + PathBuf::from("/target/release/deps/msl_tests-abc") + ); + } + + #[test] + fn cargo_json_rejects_missing_test_artifact() { + let input = complete_artifact_json("/target/release/deps/msl_tests-abc") + .lines() + .filter(|line| !line.contains(r#""name":"msl_tests""#)) + .collect::>() + .join("\n"); + let error = parse_cargo_artifacts(Cursor::new(input), false).unwrap_err(); + + assert!( + error + .to_string() + .contains("0 executable artifacts for msl_tests") + ); + } + + #[test] + fn cargo_json_rejects_ambiguous_test_artifacts() { + let input = format!( + "{}\n{}", + complete_artifact_json("/target/release/deps/msl_tests-abc"), + artifact(MSL_TESTS, "test", "/target/release/deps/msl_tests-def") + ); + let error = parse_cargo_artifacts(Cursor::new(input), false).unwrap_err(); + + assert!( + error + .to_string() + .contains("2 executable artifacts for msl_tests") + ); + } + + #[test] + fn direct_test_command_sets_runtime_paths_and_libtest_args() { + let temp = tempfile::tempdir().expect("tempdir"); + let paths = + [MSL_TESTS, MODEL_WORKER, SIM_WORKER, MSL_TOOLS].map(|name| temp.path().join(name)); + for path in &paths { + std::fs::write(path, "").expect("create artifact"); + } + let binaries = MslTestBinaries { + test_binary: &paths[0], + model_worker: Some(&paths[1]), + sim_worker: Some(&paths[2]), + msl_tools: Some(&paths[3]), + }; + let command = msl_test_binary_command(temp.path(), binaries, "suite::test_msl_all") + .expect("direct test command"); + let envs = command + .get_envs() + .map(|(key, value)| (key.to_owned(), value.map(ToOwned::to_owned))) + .collect::>(); + + assert_eq!( + command.get_args().collect::>(), + ["suite::test_msl_all", "--exact", "--nocapture"].map(OsStr::new) + ); + assert_eq!( + envs.get(OsStr::new("CARGO_BIN_EXE_rumoca-worker")), + Some(&Some(paths[1].as_os_str().to_owned())) + ); + for key in [ + "CARGO_BIN_EXE_rumoca-sim-worker", + "CARGO_BIN_EXE_rumoca_sim_worker", + ] { + assert_eq!( + envs.get(OsStr::new(key)), + Some(&Some(paths[2].as_os_str().to_owned())) + ); + } + for key in [ + "CARGO_BIN_EXE_rumoca-msl-tools", + "CARGO_BIN_EXE_rumoca_msl_tools", + ] { + assert_eq!( + envs.get(OsStr::new(key)), + Some(&Some(paths[3].as_os_str().to_owned())) + ); + } + } +} diff --git a/crates/xtask/src/verify_cmd/msl_quality_baseline.rs b/crates/xtask/src/verify_cmd/msl_quality_baseline.rs index e9e28c772..ac752c6df 100644 --- a/crates/xtask/src/verify_cmd/msl_quality_baseline.rs +++ b/crates/xtask/src/verify_cmd/msl_quality_baseline.rs @@ -1,5 +1,10 @@ +#[cfg(test)] +mod tests; + use anyhow::{Context, Result, bail, ensure}; use serde::{Deserialize, Deserializer}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; use std::fs; use std::io::Read; use std::path::{Path, PathBuf}; @@ -9,21 +14,101 @@ use super::VerifyMslParityArgs; const MSL_QUALITY_BASELINE_ASSET_URL: &str = "https://github.com/CogniPilot/rumoca/releases/download/msl-quality-baseline/msl_quality_baseline.json"; const MSL_QUALITY_BASELINE_FALLBACK_REL: &str = "crates/rumoca-test-msl/tests/msl_tests/msl_quality_baseline.json"; -const MSL_QUALITY_GATE_VERSION: u64 = 1; +const MSL_QUALITY_GATE_VERSION: u64 = 4; +const PREVIOUS_MSL_QUALITY_GATE_VERSION: u64 = 3; +const COMPARATOR_MIGRATION_FROM_QUALITY_GATE_VERSION: u64 = 2; +const BRIDGED_PROMOTED_QUALITY_GATE_VERSION: u64 = 1; const MSL_QUALITY_RUN_SCOPE: &str = "full"; - -#[derive(Debug, Deserialize)] +const BRIDGED_PROMOTED_GIT_COMMIT: &str = "08fac54846fd73a3471bafc6609a0d34e74f9fe3"; +const BRIDGED_PROMOTED_SHA256: &str = + "2b0a478b922583106342272bbf85411c539d50d9b9e0ca54c4dbb87621f05fe8"; +const BRIDGED_PROMOTED_EVIDENCE_COMMITS: [&str; 4] = [ + "a499eb8f15bf6af9d28f5a7011e82edfe73803b4", + "3fc9a6cb9c60e1137eb6151f29cb87e9ad35064b", + "6d57e9644b4da542a5498ee42510551e7e7ade70", + "5394156facb1e5ff9f099f21c0e833c4870c506f", +]; +const CHECKED_DAE_FROM_CONTRACT: &str = "permissive-dae-v1"; +const CHECKED_DAE_TO_CONTRACT: &str = "checked-dae-v1"; +const V3_MIGRATION_CHANGE: &str = "reviewed-pointwise-oracle-boundaries-v1"; +const V3_STRICT_HIGH_BEFORE: usize = 118; +const V3_STRICT_HIGH_AFTER: usize = 113; +const V3_POLICY_EXCLUDED_AFTER: usize = 9; +const V3_EXCLUDED_STRICT_HIGH_BEFORE: usize = 5; +const V3_EXCLUDED_NON_HIGH_BEFORE: usize = 4; +const V3_EXCLUSIONS_FILE: &str = + "crates/rumoca-test-msl/tests/msl_tests/msl_trace_compare_exclusions.json"; +const V3_EXCLUSIONS_SHA256: &str = + "e064ffb80771c1e231e849afcaa25cc2a08b8b7f9bf449bf8651905e5dcdc4d0"; +const V4_MIGRATION_CHANGE: &str = "source-static-partial-cohort-v1"; +const V4_EVIDENCE_GIT_COMMIT: &str = "5394156facb1e5ff9f099f21c0e833c4870c506f"; +const V4_AFFECTED_DIAGNOSTIC_COHORT: &str = + "failed-before-success-with-null-partial-classification"; +const V4_PARTIAL_MODELS_BEFORE: usize = 11; +const V4_PARTIAL_MODELS_AFTER: usize = 13; +#[derive(Debug, Clone, Deserialize)] struct MslQualityBaselineHeader { quality_gate_version: u64, + #[serde(skip)] + document_sha256: String, run_scope: String, + #[serde(default)] + git_commit: String, #[serde(deserialize_with = "deserialize_omc_version")] omc_version: String, sim_target_models: usize, #[serde(default)] omc_context_migration: Option, + #[serde(default)] + metric_schema_migration: Option, + #[serde(default)] + partial_classification_migration: Option, + #[serde(default)] + compiler_contract_migration: Option, + #[serde(default)] + promoted_baseline_bridge: Option, + simulatable_attempted: usize, + parse_models: usize, + flatten_models: usize, + dae_models: usize, + compiled_models: usize, + solve_models: usize, + balanced_models: usize, + unbalanced_models: usize, + partial_models: usize, + #[serde(default)] + partial_model_names: BTreeSet, + balance_denominator: usize, + initial_balanced_models: usize, + initial_unbalanced_models: usize, + sim_attempted: usize, + ic_attempted: usize, + ic_ok: usize, + ic_solver_fail: usize, + sim_ok: usize, + runtime_ratio_stats: RuntimeRatioStats, + trace_accuracy_stats: TraceAccuracyStats, +} + +#[derive(Debug, Clone, Deserialize)] +struct CompilerContractMigrationHeader { + from_contract: String, + to_contract: String, + evidence_git_commit: String, + sim_target_models: usize, +} + +#[derive(Debug, Clone, Deserialize)] +struct PromotedBaselineBridge { + from_quality_gate_version: u64, + from_git_commit: String, + from_sha256: String, + to_quality_gate_version: u64, + sim_target_models: usize, + evidence_git_commits: Vec, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] struct OmcContextMigration { #[serde(deserialize_with = "deserialize_omc_version")] from_omc_version: String, @@ -32,6 +117,107 @@ struct OmcContextMigration { sim_target_models: usize, } +#[derive(Debug, Clone, Deserialize)] +struct MetricSchemaMigration { + from_quality_gate_version: u64, + to_quality_gate_version: u64, + change: String, + strict_high_before: usize, + strict_high_after: usize, + policy_excluded_after: usize, + excluded_strict_high_before: usize, + excluded_non_high_before: usize, + exclusions_file: String, + exclusions_sha256: String, +} + +#[derive(Debug, Clone, Deserialize)] +struct PartialClassificationMigration { + from_quality_gate_version: u64, + to_quality_gate_version: u64, + change: String, + evidence_git_commit: String, + sim_target_models: usize, + partial_models_before: usize, + partial_models_after: usize, + affected_diagnostic_cohort: String, + affected_models: BTreeSet, + partial_model_names_after: BTreeSet, +} + +fn reviewed_partial_model_names() -> BTreeSet { + [ + "Modelica.Electrical.Analog.Examples.OpAmps.OpAmpCircuits.PartialOpAmp", + "Modelica.Electrical.PowerConverters.Examples.ACAC.ExampleTemplates.Dimmer", + "Modelica.Electrical.PowerConverters.Examples.ACDC.ExampleTemplates.Thyristor1Pulse", + "Modelica.Electrical.PowerConverters.Examples.ACDC.ExampleTemplates.ThyristorBridge2Pulse", + "Modelica.Electrical.PowerConverters.Examples.ACDC.ExampleTemplates.ThyristorBridge2mPulse", + "Modelica.Electrical.PowerConverters.Examples.ACDC.ExampleTemplates.ThyristorCenterTap2Pulse", + "Modelica.Electrical.PowerConverters.Examples.ACDC.ExampleTemplates.ThyristorCenterTap2mPulse", + "Modelica.Electrical.PowerConverters.Examples.ACDC.ExampleTemplates.ThyristorCenterTapmPulse", + "Modelica.Electrical.PowerConverters.Examples.DCAC.ExampleTemplates.SinglePhaseTwoLevel", + "Modelica.Electrical.PowerConverters.Examples.DCDC.ExampleTemplates.ChopperBuckBoost", + "Modelica.Electrical.PowerConverters.Examples.DCDC.ExampleTemplates.ChopperStepDown", + "Modelica.Electrical.PowerConverters.Examples.DCDC.ExampleTemplates.ChopperStepUp", + "Modelica.Electrical.PowerConverters.Examples.DCDC.ExampleTemplates.HBridge", + ] + .into_iter() + .map(str::to_string) + .collect() +} + +fn reviewed_affected_partial_models() -> BTreeSet { + [ + "Modelica.Electrical.Analog.Examples.OpAmps.OpAmpCircuits.PartialOpAmp", + "Modelica.Electrical.PowerConverters.Examples.ACAC.ExampleTemplates.Dimmer", + ] + .into_iter() + .map(str::to_string) + .collect() +} + +#[derive(Debug, Clone, Deserialize)] +struct RuntimeRatioStats { + system_ratio_both_success: DistributionMedian, + wall_ratio_both_success: DistributionMedian, +} + +#[derive(Debug, Clone, Deserialize)] +struct DistributionMedian { + median: f64, +} + +#[derive(Debug, Clone, Deserialize)] +struct TraceAccuracyStats { + models_compared: usize, + #[serde(default)] + policy_excluded_models: usize, + agreement_high: usize, + agreement_minor: usize, + agreement_deviation: usize, + bad_channels_total: usize, + severe_channels_total: usize, + models_with_severe_channel: usize, + models_with_any_channel_deviation: usize, + violation_mass_total: f64, + initial_condition: InitialConditionStats, + state_selection: StateSelectionStats, +} + +#[derive(Debug, Clone, Deserialize)] +struct InitialConditionStats { + deviation_channels_total: usize, + severe_channels_total: usize, + violation_mass_total: f64, +} + +#[derive(Debug, Clone, Deserialize)] +struct StateSelectionStats { + exact_state_set_match_models: usize, + total_rumoca_only_states: usize, + total_omc_only_states: usize, +} + fn deserialize_omc_version<'de, D>(deserializer: D) -> std::result::Result where D: Deserializer<'de>, @@ -80,12 +266,12 @@ pub(super) fn resolve_msl_quality_baseline( if !args.no_remote_quality_baseline && let Some(promoted) = download_msl_quality_baseline_asset(root)? { - let promoted_header = load_baseline_header(&promoted)?; + let promoted_header = load_promoted_baseline_header(&promoted)?; match choose_baseline(&promoted_header, &checked_in_header)? { BaselineChoice::Promoted => return Ok(promoted), BaselineChoice::CheckedInMigration => { println!( - "MSL quality baseline: checked-in baseline declares an OMC context migration; using {}", + "MSL quality baseline: checked-in baseline declares a context/schema migration; using {}", checked_in.display() ); return Ok(checked_in); @@ -112,6 +298,58 @@ fn choose_baseline( checked_in: &MslQualityBaselineHeader, ) -> Result { validate_context_migration(checked_in)?; + validate_metric_schema_migration(checked_in)?; + validate_partial_classification_migration(checked_in)?; + validate_promoted_baseline_bridge(checked_in)?; + if promoted.quality_gate_version != checked_in.quality_gate_version { + if bridge_matches_promoted(promoted, checked_in)? { + return Ok(BaselineChoice::CheckedInMigration); + } + let Some(migration) = checked_in.partial_classification_migration.as_ref() else { + bail!( + "MSL quality schema differs without an explicit migration (promoted={}, checked-in={})", + promoted.quality_gate_version, + checked_in.quality_gate_version + ); + }; + ensure!( + migration.from_quality_gate_version == promoted.quality_gate_version + && migration.to_quality_gate_version == checked_in.quality_gate_version, + "MSL quality schema migration differs from baseline contexts (declared={} -> {}, actual={} -> {})", + migration.from_quality_gate_version, + migration.to_quality_gate_version, + promoted.quality_gate_version, + checked_in.quality_gate_version + ); + ensure!( + promoted.sim_target_models == checked_in.sim_target_models, + "MSL quality schema migration target set differs (promoted={}, checked-in={})", + promoted.sim_target_models, + checked_in.sim_target_models + ); + let omc_context_changed = promoted.omc_version != checked_in.omc_version; + if omc_context_changed { + let Some(omc_migration) = checked_in.omc_context_migration.as_ref() else { + bail!( + "MSL quality schema and OMC contexts both differ, but no OMC migration is declared" + ); + }; + ensure!( + omc_migration.from_omc_version == promoted.omc_version, + "MSL OMC context migration source differs (declared={}, promoted={})", + omc_migration.from_omc_version, + promoted.omc_version + ); + } + validate_migration_metric_integrity( + promoted, + checked_in, + false, + true, + omc_context_changed, + )?; + return Ok(BaselineChoice::CheckedInMigration); + } if promoted.omc_version == checked_in.omc_version { return Ok(BaselineChoice::Promoted); } @@ -135,9 +373,87 @@ fn choose_baseline( promoted.sim_target_models, checked_in.sim_target_models ); + validate_migration_metric_integrity(promoted, checked_in, false, false, true)?; Ok(BaselineChoice::CheckedInMigration) } +fn bridge_matches_promoted( + promoted: &MslQualityBaselineHeader, + checked_in: &MslQualityBaselineHeader, +) -> Result { + let Some(bridge) = checked_in.promoted_baseline_bridge.as_ref() else { + return Ok(false); + }; + if bridge.from_quality_gate_version != promoted.quality_gate_version { + return Ok(false); + } + ensure!( + promoted.document_sha256 == bridge.from_sha256, + "promoted MSL baseline digest differs from the reviewed lineage bridge" + ); + ensure!( + promoted.git_commit == bridge.from_git_commit, + "promoted MSL baseline commit differs from the reviewed lineage bridge" + ); + ensure!( + promoted.sim_target_models == bridge.sim_target_models, + "promoted MSL baseline target set differs from the reviewed lineage bridge" + ); + let omc_migration = checked_in + .omc_context_migration + .as_ref() + .context("promoted baseline bridge requires the reviewed OMC context migration")?; + ensure!( + omc_migration.from_omc_version == promoted.omc_version, + "promoted MSL baseline OMC context differs from the reviewed lineage bridge" + ); + Ok(true) +} + +fn validate_promoted_baseline_bridge(baseline: &MslQualityBaselineHeader) -> Result<()> { + let Some(bridge) = baseline.promoted_baseline_bridge.as_ref() else { + return Ok(()); + }; + ensure!( + bridge.from_quality_gate_version == BRIDGED_PROMOTED_QUALITY_GATE_VERSION + && bridge.to_quality_gate_version == MSL_QUALITY_GATE_VERSION, + "MSL promoted baseline bridge must be the reviewed version-1 to version-4 lineage" + ); + ensure!( + bridge.from_git_commit == BRIDGED_PROMOTED_GIT_COMMIT + && bridge.from_sha256 == BRIDGED_PROMOTED_SHA256, + "MSL promoted baseline bridge source identity differs from the reviewed release asset" + ); + ensure!( + bridge.sim_target_models == baseline.sim_target_models, + "MSL promoted baseline bridge target set differs from the checked-in baseline" + ); + ensure!( + bridge.evidence_git_commits == BRIDGED_PROMOTED_EVIDENCE_COMMITS.map(str::to_string), + "MSL promoted baseline bridge evidence chain differs from the reviewed migrations" + ); + let compiler_migration = baseline + .compiler_contract_migration + .as_ref() + .context("MSL promoted baseline bridge requires compiler-contract evidence")?; + ensure!( + compiler_migration.from_contract == CHECKED_DAE_FROM_CONTRACT + && compiler_migration.to_contract == CHECKED_DAE_TO_CONTRACT + && compiler_migration.evidence_git_commit == BRIDGED_PROMOTED_EVIDENCE_COMMITS[1] + && compiler_migration.sim_target_models == baseline.sim_target_models, + "MSL promoted baseline bridge compiler-contract evidence differs from the reviewed cutover" + ); + ensure!( + baseline.metric_schema_migration.is_some(), + "MSL promoted baseline bridge requires comparator-policy migration evidence" + ); + ensure!( + baseline.partial_classification_migration.is_some(), + "MSL promoted baseline bridge requires partial-classification migration evidence" + ); + Ok(()) +} + fn validate_context_migration(baseline: &MslQualityBaselineHeader) -> Result<()> { let Some(migration) = baseline.omc_context_migration.as_ref() else { return Ok(()); @@ -161,16 +477,419 @@ fn validate_context_migration(baseline: &MslQualityBaselineHeader) -> Result<()> Ok(()) } +fn validate_metric_schema_migration(baseline: &MslQualityBaselineHeader) -> Result<()> { + let Some(migration) = baseline.metric_schema_migration.as_ref() else { + return Ok(()); + }; + ensure!( + migration.from_quality_gate_version == COMPARATOR_MIGRATION_FROM_QUALITY_GATE_VERSION + && migration.to_quality_gate_version == PREVIOUS_MSL_QUALITY_GATE_VERSION, + "MSL metric schema migration must be the reviewed version-2 to version-3 correction" + ); + ensure!( + migration.change == V3_MIGRATION_CHANGE, + "MSL metric schema migration change differs from the reviewed correction" + ); + ensure!( + migration.strict_high_before == V3_STRICT_HIGH_BEFORE + && migration.strict_high_after == V3_STRICT_HIGH_AFTER + && migration.policy_excluded_after == V3_POLICY_EXCLUDED_AFTER, + "MSL metric schema migration headline counts differ from the reviewed correction" + ); + ensure!( + migration.excluded_strict_high_before == V3_EXCLUDED_STRICT_HIGH_BEFORE + && migration.excluded_non_high_before == V3_EXCLUDED_NON_HIGH_BEFORE, + "MSL metric schema migration prior classifications differ from the reviewed correction" + ); + ensure!( + migration.strict_high_before + == migration.strict_high_after + migration.excluded_strict_high_before, + "MSL metric schema migration strict-high accounting is inconsistent" + ); + ensure!( + migration.policy_excluded_after + == migration.excluded_strict_high_before + migration.excluded_non_high_before, + "MSL metric schema migration exclusion accounting is inconsistent" + ); + ensure!( + migration.exclusions_file == V3_EXCLUSIONS_FILE + && migration.exclusions_sha256 == V3_EXCLUSIONS_SHA256, + "MSL metric schema migration exclusion artifact differs from the reviewed correction" + ); + Ok(()) +} + +fn validate_partial_classification_migration(baseline: &MslQualityBaselineHeader) -> Result<()> { + let Some(migration) = baseline.partial_classification_migration.as_ref() else { + ensure!( + baseline.quality_gate_version != MSL_QUALITY_GATE_VERSION, + "MSL baseline requires the reviewed partial-classification migration" + ); + return Ok(()); + }; + ensure!( + migration.from_quality_gate_version == PREVIOUS_MSL_QUALITY_GATE_VERSION + && migration.to_quality_gate_version == MSL_QUALITY_GATE_VERSION, + "MSL partial-classification migration must be the reviewed version-3 to version-4 correction" + ); + ensure!( + migration.to_quality_gate_version == baseline.quality_gate_version + && migration.change == V4_MIGRATION_CHANGE + && migration.evidence_git_commit == V4_EVIDENCE_GIT_COMMIT, + "MSL partial-classification migration identity differs from the reviewed correction" + ); + ensure!( + migration.sim_target_models == baseline.sim_target_models, + "MSL partial-classification migration target set differs from the baseline" + ); + ensure!( + migration.partial_models_before == V4_PARTIAL_MODELS_BEFORE + && migration.partial_models_after == V4_PARTIAL_MODELS_AFTER + && migration.partial_models_after == migration.partial_model_names_after.len(), + "MSL partial-classification migration counts are inconsistent" + ); + ensure!( + migration.affected_diagnostic_cohort == V4_AFFECTED_DIAGNOSTIC_COHORT + && migration.affected_models == reviewed_affected_partial_models(), + "MSL partial-classification affected cohort differs from the reviewed correction" + ); + ensure!( + migration.partial_model_names_after == reviewed_partial_model_names() + && baseline.partial_model_names == migration.partial_model_names_after + && baseline.partial_models == migration.partial_models_after, + "MSL partial-classification roster differs from the reviewed correction" + ); + Ok(()) +} + +fn validate_migration_metric_integrity( + promoted: &MslQualityBaselineHeader, + checked_in: &MslQualityBaselineHeader, + trace_is_migrated: bool, + partial_is_migrated: bool, + omc_context_changed: bool, +) -> Result<()> { + ensure!( + promoted.simulatable_attempted == checked_in.simulatable_attempted, + "MSL migration denominator changed (promoted={}, checked-in={})", + promoted.simulatable_attempted, + checked_in.simulatable_attempted + ); + let higher_is_better = [ + ( + "parse models", + promoted.parse_models, + checked_in.parse_models, + ), + ("DAE models", promoted.dae_models, checked_in.dae_models), + ( + "compiled models", + promoted.compiled_models, + checked_in.compiled_models, + ), + ( + "solve models", + promoted.solve_models, + checked_in.solve_models, + ), + ( + "balanced models", + promoted.balanced_models, + checked_in.balanced_models, + ), + ( + "balance denominator", + promoted.balance_denominator, + checked_in.balance_denominator, + ), + ( + "initial balanced models", + promoted.initial_balanced_models, + checked_in.initial_balanced_models, + ), + ( + "simulation attempts", + promoted.sim_attempted, + checked_in.sim_attempted, + ), + ( + "initial-condition attempts", + promoted.ic_attempted, + checked_in.ic_attempted, + ), + ("initial-condition solves", promoted.ic_ok, checked_in.ic_ok), + ("successful simulations", promoted.sim_ok, checked_in.sim_ok), + ]; + for (label, promoted_value, checked_in_value) in higher_is_better { + ensure_not_lowered(label, promoted_value, checked_in_value)?; + } + ensure_not_lowered( + "flatten models", + promoted.flatten_models, + checked_in.flatten_models, + )?; + + validate_partial_migration_metric_integrity(promoted, checked_in, partial_is_migrated)?; + + for (label, promoted_value, checked_in_value) in [ + ( + "unbalanced models", + promoted.unbalanced_models, + checked_in.unbalanced_models, + ), + ( + "initial unbalanced models", + promoted.initial_unbalanced_models, + checked_in.initial_unbalanced_models, + ), + ( + "initial-condition solver failures", + promoted.ic_solver_fail, + checked_in.ic_solver_fail, + ), + ] { + ensure_not_raised(label, promoted_value, checked_in_value)?; + } + + if !omc_context_changed && !trace_is_migrated { + validate_omc_dependent_metric_integrity(promoted, checked_in)?; + } else if trace_is_migrated { + let migration = checked_in + .metric_schema_migration + .as_ref() + .expect("trace schema migration was established by choose_baseline"); + ensure!( + promoted.trace_accuracy_stats.agreement_high == migration.strict_high_before + && checked_in.trace_accuracy_stats.agreement_high == migration.strict_high_after + && checked_in.trace_accuracy_stats.policy_excluded_models + == migration.policy_excluded_after, + "MSL trace-classification migration counts do not match the compared baselines" + ); + } + Ok(()) +} + +fn validate_partial_migration_metric_integrity( + promoted: &MslQualityBaselineHeader, + checked_in: &MslQualityBaselineHeader, + partial_is_migrated: bool, +) -> Result<()> { + if !partial_is_migrated { + return ensure_not_raised( + "partial models", + promoted.partial_models, + checked_in.partial_models, + ); + } + let migration = checked_in + .partial_classification_migration + .as_ref() + .expect("partial schema migration was established by choose_baseline"); + ensure!( + promoted.partial_models == migration.partial_models_before + && checked_in.partial_models == migration.partial_models_after, + "MSL partial-classification migration counts do not match the compared baselines" + ); + Ok(()) +} + +fn validate_omc_dependent_metric_integrity( + promoted: &MslQualityBaselineHeader, + checked_in: &MslQualityBaselineHeader, +) -> Result<()> { + let promoted_trace = &promoted.trace_accuracy_stats; + let checked_trace = &checked_in.trace_accuracy_stats; + for (label, promoted_value, checked_in_value) in [ + ( + "trace models compared", + promoted_trace.models_compared, + checked_trace.models_compared, + ), + ( + "high trace agreement", + promoted_trace.agreement_high, + checked_trace.agreement_high, + ), + ( + "state-set exact matches", + promoted_trace.state_selection.exact_state_set_match_models, + checked_trace.state_selection.exact_state_set_match_models, + ), + ] { + ensure_not_lowered(label, promoted_value, checked_in_value)?; + } + let promoted_high_minor = promoted_trace + .agreement_high + .checked_add(promoted_trace.agreement_minor) + .context("promoted high+minor trace agreement overflowed")?; + let checked_high_minor = checked_trace + .agreement_high + .checked_add(checked_trace.agreement_minor) + .context("checked-in high+minor trace agreement overflowed")?; + ensure_not_lowered( + "high+minor trace agreement", + promoted_high_minor, + checked_high_minor, + )?; + ensure_not_lowered( + "trace models without severe channels", + promoted_trace + .models_compared + .saturating_sub(promoted_trace.models_with_severe_channel), + checked_trace + .models_compared + .saturating_sub(checked_trace.models_with_severe_channel), + )?; + validate_omc_error_metric_integrity(promoted_trace, checked_trace)?; + validate_runtime_metric_integrity(promoted, checked_in) +} + +fn validate_omc_error_metric_integrity( + promoted_trace: &TraceAccuracyStats, + checked_trace: &TraceAccuracyStats, +) -> Result<()> { + for (label, promoted_value, checked_in_value) in [ + ( + "trace deviation models", + promoted_trace.agreement_deviation, + checked_trace.agreement_deviation, + ), + ( + "trace bad channels", + promoted_trace.bad_channels_total, + checked_trace.bad_channels_total, + ), + ( + "trace severe channels", + promoted_trace.severe_channels_total, + checked_trace.severe_channels_total, + ), + ( + "trace models with bad channels", + promoted_trace.models_with_any_channel_deviation, + checked_trace.models_with_any_channel_deviation, + ), + ( + "initial-condition deviation channels", + promoted_trace.initial_condition.deviation_channels_total, + checked_trace.initial_condition.deviation_channels_total, + ), + ( + "initial-condition severe channels", + promoted_trace.initial_condition.severe_channels_total, + checked_trace.initial_condition.severe_channels_total, + ), + ( + "state-set rumoca-only states", + promoted_trace.state_selection.total_rumoca_only_states, + checked_trace.state_selection.total_rumoca_only_states, + ), + ( + "state-set OMC-only states", + promoted_trace.state_selection.total_omc_only_states, + checked_trace.state_selection.total_omc_only_states, + ), + ] { + ensure_not_raised(label, promoted_value, checked_in_value)?; + } + ensure_float_not_raised( + "trace violation mass", + promoted_trace.violation_mass_total, + checked_trace.violation_mass_total, + )?; + ensure_float_not_raised( + "initial-condition violation mass", + promoted_trace.initial_condition.violation_mass_total, + checked_trace.initial_condition.violation_mass_total, + ) +} + +fn validate_runtime_metric_integrity( + promoted: &MslQualityBaselineHeader, + checked_in: &MslQualityBaselineHeader, +) -> Result<()> { + ensure_runtime_speedup_not_regressed( + "runtime system speedup median", + promoted + .runtime_ratio_stats + .system_ratio_both_success + .median, + checked_in + .runtime_ratio_stats + .system_ratio_both_success + .median, + )?; + ensure_runtime_speedup_not_regressed( + "runtime wall speedup median", + promoted.runtime_ratio_stats.wall_ratio_both_success.median, + checked_in + .runtime_ratio_stats + .wall_ratio_both_success + .median, + ) +} + +fn ensure_not_lowered(label: &str, promoted: usize, checked_in: usize) -> Result<()> { + ensure!( + checked_in >= promoted, + "MSL migration lowers unrelated {label} (promoted={promoted}, checked-in={checked_in})" + ); + Ok(()) +} + +fn ensure_not_raised(label: &str, promoted: usize, checked_in: usize) -> Result<()> { + ensure!( + checked_in <= promoted, + "MSL migration raises unrelated {label} (promoted={promoted}, checked-in={checked_in})" + ); + Ok(()) +} + +fn ensure_float_not_raised(label: &str, promoted: f64, checked_in: f64) -> Result<()> { + ensure!( + checked_in <= promoted + 1.0e-9, + "MSL migration raises unrelated {label} (promoted={promoted:.6e}, checked-in={checked_in:.6e})" + ); + Ok(()) +} + +fn ensure_runtime_speedup_not_regressed(label: &str, promoted: f64, checked_in: f64) -> Result<()> { + ensure!( + checked_in >= promoted * 0.65, + "MSL migration regresses unrelated {label} by more than 35% (promoted={promoted:.6e}, checked-in={checked_in:.6e})" + ); + Ok(()) +} + fn load_baseline_header(path: &Path) -> Result { + load_baseline_header_for_source(path, false) +} + +fn load_promoted_baseline_header(path: &Path) -> Result { + load_baseline_header_for_source(path, true) +} + +fn load_baseline_header_for_source( + path: &Path, + is_promoted_source: bool, +) -> Result { let data = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?; - let baseline: MslQualityBaselineHeader = serde_json::from_slice(&data).map_err(|error| { - anyhow::anyhow!( - "invalid MSL quality baseline JSON in {}: {error}", - path.display() - ) - })?; + let mut baseline: MslQualityBaselineHeader = + serde_json::from_slice(&data).map_err(|error| { + anyhow::anyhow!( + "invalid MSL quality baseline JSON in {}: {error}", + path.display() + ) + })?; + baseline.document_sha256 = format!("{:x}", Sha256::digest(&data)); + let version_supported = baseline.quality_gate_version == MSL_QUALITY_GATE_VERSION + || (is_promoted_source + && matches!( + baseline.quality_gate_version, + PREVIOUS_MSL_QUALITY_GATE_VERSION | BRIDGED_PROMOTED_QUALITY_GATE_VERSION + )); ensure!( - baseline.quality_gate_version == MSL_QUALITY_GATE_VERSION, + version_supported, "unsupported MSL quality_gate_version={} in {}", baseline.quality_gate_version, path.display() @@ -188,6 +907,16 @@ fn load_baseline_header(path: &Path) -> Result { ); validate_context_migration(&baseline) .with_context(|| format!("invalid OMC context migration in {}", path.display()))?; + validate_metric_schema_migration(&baseline) + .with_context(|| format!("invalid metric schema migration in {}", path.display()))?; + validate_partial_classification_migration(&baseline).with_context(|| { + format!( + "invalid partial-classification migration in {}", + path.display() + ) + })?; + validate_promoted_baseline_bridge(&baseline) + .with_context(|| format!("invalid promoted baseline bridge in {}", path.display()))?; Ok(baseline) } @@ -253,137 +982,3 @@ fn download_msl_quality_baseline_asset(root: &Path) -> Result> { ); Ok(Some(output_path)) } - -#[cfg(test)] -mod tests { - use super::{ - super::VerifyMslParityArgs, BaselineChoice, MslQualityBaselineHeader, OmcContextMigration, - choose_baseline, load_baseline_header, validate_context_migration, - }; - use serde_json::json; - use std::{fs, path::PathBuf}; - - fn header(omc_version: &str) -> MslQualityBaselineHeader { - MslQualityBaselineHeader { - quality_gate_version: 1, - run_scope: "full".to_string(), - omc_version: omc_version.to_string(), - sim_target_models: 566, - omc_context_migration: None, - } - } - - fn migration(from: &str, to: &str) -> OmcContextMigration { - OmcContextMigration { - from_omc_version: from.to_string(), - to_omc_version: to.to_string(), - sim_target_models: 566, - } - } - - #[test] - fn msl_parity_config_forwards_resolved_quality_baseline_path() { - let args = VerifyMslParityArgs { - quality_baseline: Some(PathBuf::from( - "target/msl/baselines/msl_quality_baseline.json", - )), - ..VerifyMslParityArgs::default() - }; - let config = args.to_parity_config_json(); - - assert_eq!( - config - .get("quality_baseline_file") - .and_then(serde_json::Value::as_str), - Some("target/msl/baselines/msl_quality_baseline.json") - ); - } - - #[test] - fn default_msl_parity_uses_baseline_relative_quality_gate() { - assert!(VerifyMslParityArgs::default().uses_baseline_relative_quality_gate()); - let short_run = VerifyMslParityArgs { - sim_set: Some("short".to_string()), - ..VerifyMslParityArgs::default() - }; - assert!(!short_run.uses_baseline_relative_quality_gate()); - } - - #[test] - fn checked_in_baseline_declares_omc_context_migration() { - let promoted = header("OpenModelica 1.27.0"); - let mut checked_in = header("a96aa1a-cmake"); - checked_in.omc_context_migration = Some(migration("OpenModelica 1.27.0", "a96aa1a-cmake")); - - assert_eq!( - choose_baseline(&promoted, &checked_in).expect("declared migration should select"), - BaselineChoice::CheckedInMigration - ); - } - - #[test] - fn same_omc_context_keeps_promoted_baseline() { - assert_eq!( - choose_baseline(&header("a96aa1a-cmake"), &header("a96aa1a-cmake")) - .expect("same context should select"), - BaselineChoice::Promoted - ); - } - - #[test] - fn changed_omc_context_requires_exact_migration_declaration() { - let promoted = header("old"); - let mut checked_in = header("new"); - assert!(choose_baseline(&promoted, &checked_in).is_err()); - - checked_in.omc_context_migration = Some(migration("new", "old")); - assert!(choose_baseline(&promoted, &checked_in).is_err()); - - checked_in.omc_context_migration = Some(migration("old", "new")); - checked_in - .omc_context_migration - .as_mut() - .unwrap() - .sim_target_models = 565; - assert!(choose_baseline(&promoted, &checked_in).is_err()); - } - - #[test] - fn migration_must_be_internally_consistent_without_promoted_baseline() { - let mut baseline = header("new"); - baseline.omc_context_migration = Some(migration("old", "other")); - assert!(validate_context_migration(&baseline).is_err()); - - baseline.omc_context_migration = Some(migration("new", "new")); - assert!(validate_context_migration(&baseline).is_err()); - - baseline.omc_context_migration = Some(migration("old", "new")); - baseline - .omc_context_migration - .as_mut() - .unwrap() - .sim_target_models = 565; - assert!(validate_context_migration(&baseline).is_err()); - } - - #[test] - fn baseline_header_rejects_missing_or_invalid_omc_version() { - let temp = tempfile::tempdir().expect("temporary directory should be available"); - let invalid_versions = [None, Some(json!(null)), Some(json!(7)), Some(json!(" "))]; - - for (index, version) in invalid_versions.into_iter().enumerate() { - let path = temp.path().join(format!("invalid-{index}.json")); - let mut baseline = json!({ - "quality_gate_version": 1, - "run_scope": "full", - "sim_target_models": 566 - }); - if let Some(version) = version { - baseline["omc_version"] = version; - } - fs::write(&path, baseline.to_string()).expect("fixture should be writable"); - let error = load_baseline_header(&path).expect_err("invalid context must fail"); - assert!(error.to_string().contains("omc_version"), "{error}"); - } - } -} diff --git a/crates/xtask/src/verify_cmd/msl_results_cleanup.rs b/crates/xtask/src/verify_cmd/msl_results_cleanup.rs new file mode 100644 index 000000000..89332956a --- /dev/null +++ b/crates/xtask/src/verify_cmd/msl_results_cleanup.rs @@ -0,0 +1,193 @@ +//! What a `--clean-results` wipe of the MSL results directory removes, and what +//! it must leave behind. +//! +//! A results directory is regenerated by every run, so wiping it before a run is +//! the default. Two kinds of entry are exceptions, and both are exceptions for +//! the same reason: they carry state the next run *reads* rather than state the +//! next run rewrites. +//! +//! * `omc_parity_cache/` — the keyed OMC reference cache. Deleting it costs a +//! full OMC re-run for no benefit. +//! * `msl_band_table.json` and `msl_band_table_previous.json` — the previous +//! certification's per-model cohort evidence. The next run rotates the table +//! aside and diffs against it to say which models entered or left the compared +//! set. Wiping it makes every run a first certification: no diff, no +//! departures, and a model can stop being compared with nothing on record. +//! +//! Preserving the table is safe because the table is self-describing: it carries +//! the content digest of the comparator output it was derived from, so a stale +//! one is *detected* by the harness rather than believed (see +//! `rumoca_test_msl::msl_tools::band_table::ensure_bound_to_dir`). + +use std::ffi::OsStr; +use std::fs; +use std::path::Path; + +const MSL_RESULTS_PRESERVED_DIRS: &[&str] = &["omc_parity_cache"]; + +/// Files a results-directory wipe must leave behind. See the module docs. +pub(super) const MSL_RESULTS_PRESERVED_FILES: &[&str] = + &[BAND_TABLE_FILE, PREVIOUS_BAND_TABLE_FILE]; + +/// The band-table file names, spelled here rather than imported: `xtask` +/// deliberately links no compiler crates, and `rumoca-test-msl` (which owns +/// these names) pulls the whole stack in. The pair is pinned against its owner +/// by [`tests::msl_results_cleanup_preserves_the_band_table_names_the_harness_writes`]. +const BAND_TABLE_FILE: &str = "msl_band_table.json"; +const PREVIOUS_BAND_TABLE_FILE: &str = "msl_band_table_previous.json"; + +fn should_preserve_msl_results_entry(entry_path: &Path) -> bool { + let Some(name) = entry_path.file_name().and_then(OsStr::to_str) else { + return false; + }; + if entry_path.is_dir() { + return MSL_RESULTS_PRESERVED_DIRS.contains(&name); + } + MSL_RESULTS_PRESERVED_FILES.contains(&name) +} + +/// Remove everything a run regenerates, keeping the preserved entries. Removes +/// the directory itself when nothing was preserved. +pub(super) fn clean_msl_results_dir(results_dir: &Path) -> std::io::Result<()> { + if !results_dir.is_dir() { + return Ok(()); + } + + for entry in fs::read_dir(results_dir)? { + let entry = entry?; + let path = entry.path(); + if should_preserve_msl_results_entry(&path) { + continue; + } + if path.is_dir() { + fs::remove_dir_all(&path)?; + } else { + fs::remove_file(&path)?; + } + } + + if fs::read_dir(results_dir)?.next().is_none() { + fs::remove_dir(results_dir)?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::repo_root; + + #[test] + fn a_stale_results_directory_is_removed_outright() { + let temp = tempfile::tempdir().expect("tempdir"); + let results_dir = temp.path().join("results"); + fs::create_dir_all(&results_dir).expect("mkdir"); + fs::write(results_dir.join("stale.json"), "{}").expect("write stale file"); + + clean_msl_results_dir(&results_dir).expect("cleanup should succeed"); + + assert!( + !results_dir.exists(), + "a directory holding nothing preserved should be removed" + ); + } + + #[test] + fn the_keyed_omc_parity_cache_survives() { + let temp = tempfile::tempdir().expect("tempdir"); + let results_dir = temp.path().join("results"); + let parity_cache_dir = results_dir.join("omc_parity_cache"); + fs::create_dir_all(&parity_cache_dir).expect("mkdir parity cache"); + fs::write(results_dir.join("stale.json"), "{}").expect("write stale file"); + fs::write(parity_cache_dir.join("compile.json"), "{}").expect("write cache file"); + + clean_msl_results_dir(&results_dir).expect("cleanup should succeed"); + + assert!( + parity_cache_dir.join("compile.json").is_file(), + "cleanup should preserve keyed OMC parity cache contents" + ); + assert!( + !results_dir.join("stale.json").exists(), + "cleanup should remove stale non-cache artifacts" + ); + } + + /// The band table is the previous certification's cohort evidence. Wiping it + /// before every run makes every run a first certification: nothing to diff, + /// so a model can leave the compared set with no record that it did. + #[test] + fn the_per_model_band_tables_survive() { + let temp = tempfile::tempdir().expect("tempdir"); + let results_dir = temp.path().join("results"); + fs::create_dir_all(&results_dir).expect("mkdir results"); + fs::write(results_dir.join(BAND_TABLE_FILE), "{\"schema\":\"a\"}") + .expect("write band table"); + fs::write( + results_dir.join(PREVIOUS_BAND_TABLE_FILE), + "{\"schema\":\"b\"}", + ) + .expect("write previous band table"); + fs::write(results_dir.join("msl_results.json"), "{}").expect("write stale results"); + + clean_msl_results_dir(&results_dir).expect("cleanup should succeed"); + + assert_eq!( + fs::read_to_string(results_dir.join(BAND_TABLE_FILE)) + .expect("the cohort table must survive a results wipe"), + "{\"schema\":\"a\"}" + ); + assert!( + results_dir.join(PREVIOUS_BAND_TABLE_FILE).is_file(), + "the rotated table must survive too; it is what the next diff reads" + ); + assert!( + !results_dir.join("msl_results.json").exists(), + "everything the run regenerates must still be wiped" + ); + } + + /// The preserved names are spelled in `xtask` (which links no compiler + /// crates) and must not drift from the harness module that writes them. + /// + /// The pin reads the harness's **constant declarations**, not the file text: + /// a substring search would keep passing if the names survived only in a + /// comment, a doc example, or a variable that no longer names the file the + /// harness writes. + #[test] + fn msl_results_cleanup_preserves_the_band_table_names_the_harness_writes() { + let module = repo_root().join("crates/rumoca-test-msl/src/msl_tools/band_table.rs"); + let source = fs::read_to_string(&module).expect("read the band table module"); + let declared = |name: &str| -> String { + let marker = format!("pub const {name}: &str = "); + let start = source + .find(&marker) + .unwrap_or_else(|| panic!("{} declares no `{name}`", module.display())) + + marker.len(); + let rest = &source[start..]; + let value_start = rest.find('"').expect("constant has no string literal") + 1; + let value_end = value_start + + rest[value_start..] + .find('"') + .expect("unterminated string literal"); + rest[value_start..value_end].to_string() + }; + + assert_eq!( + declared("BAND_TABLE_FILE"), + BAND_TABLE_FILE, + "the harness writes a different current-table file name than the results wipe preserves" + ); + assert_eq!( + declared("PREVIOUS_BAND_TABLE_FILE"), + PREVIOUS_BAND_TABLE_FILE, + "the harness writes a different rotated-table file name than the results wipe preserves" + ); + assert_eq!( + MSL_RESULTS_PRESERVED_FILES, + &[BAND_TABLE_FILE, PREVIOUS_BAND_TABLE_FILE], + "both band-table files must be on the preserved list" + ); + } +} diff --git a/crates/xtask/src/verify_cmd/parity_budgets.rs b/crates/xtask/src/verify_cmd/parity_budgets.rs new file mode 100644 index 000000000..6920482cd --- /dev/null +++ b/crates/xtask/src/verify_cmd/parity_budgets.rs @@ -0,0 +1,96 @@ +//! Wall-budget knobs for `cargo xtask verify msl-parity`. +//! +//! These exist for the long-budget diagnostic lanes (the nightly event cohort), +//! where models that legitimately need minutes rather than seconds are being +//! investigated. Every budget is *raise-only* on the harness side: the committed +//! quality baseline was measured with the default budgets, so shortening them +//! would silently make the gate easier by turning real failures into timeouts. +//! +//! Budgets are whole seconds — sub-second precision is meaningless for a wall +//! budget and `f64` would block the `Eq` derive the CLI arg structs rely on. + +use clap::Args; +use serde_json::{Map, Value}; + +#[derive(Debug, Args, Clone, PartialEq, Eq, Default)] +pub(crate) struct MslParityBudgetArgs { + /// Per-model solver wall budget in seconds (raise-only; clamped to at least + /// the harness default) + #[arg(long, value_name = "SECS")] + sim_timeout_secs: Option, + /// Per-model Solve-IR lowering wall budget in seconds (raise-only) + #[arg(long, value_name = "SECS")] + ir_solve_timeout_secs: Option, + /// Per-model, per-phase wall budget in seconds (10s default, raise-only; + /// one attempt per model) + #[arg(long, value_name = "SECS")] + model_attempt_timeout_secs: Option, + /// Build the OMC reference for every simulation target rather than only the + /// models rumoca already simulates. Needed by diagnostic lanes that compare + /// models which are not yet `sim_ok`. + #[arg(long)] + all_omc_targets: bool, +} + +impl MslParityBudgetArgs { + /// Write the explicitly set budgets into the harness config map. + pub(crate) fn insert_into(&self, config: &mut Map) { + for (key, value) in [ + ("sim_timeout_secs", self.sim_timeout_secs), + ("ir_solve_timeout_secs", self.ir_solve_timeout_secs), + ( + "model_attempt_timeout_secs", + self.model_attempt_timeout_secs, + ), + ] { + if let Some(secs) = value { + config.insert(key.into(), (secs as f64).into()); + } + } + if self.all_omc_targets { + config.insert("all_omc_targets".into(), true.into()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + #[derive(Debug, Parser)] + struct Harness { + #[command(flatten)] + budgets: MslParityBudgetArgs, + } + + fn config_for(argv: &[&str]) -> Map { + let parsed = Harness::parse_from(argv); + let mut config = Map::new(); + parsed.budgets.insert_into(&mut config); + config + } + + #[test] + fn unset_budgets_write_nothing_so_harness_defaults_apply() { + assert!(config_for(&["verify-msl-parity"]).is_empty()); + } + + #[test] + fn budgets_are_written_as_seconds_for_the_harness_config() { + let config = config_for(&[ + "verify-msl-parity", + "--sim-timeout-secs", + "300", + "--ir-solve-timeout-secs", + "60", + "--model-attempt-timeout-secs", + "420", + "--all-omc-targets", + ]); + assert_eq!(config["sim_timeout_secs"], Value::from(300.0)); + assert_eq!(config["ir_solve_timeout_secs"], Value::from(60.0)); + assert_eq!(config["model_attempt_timeout_secs"], Value::from(420.0)); + assert_eq!(config["all_omc_targets"], Value::from(true)); + } +} diff --git a/crates/xtask/src/verify_cmd/parity_comparator.rs b/crates/xtask/src/verify_cmd/parity_comparator.rs new file mode 100644 index 000000000..cc1c85beb --- /dev/null +++ b/crates/xtask/src/verify_cmd/parity_comparator.rs @@ -0,0 +1,317 @@ +//! Comparator-evidence check for `cargo xtask verify msl-parity`. +//! +//! # Why this exists at the xtask boundary +//! +//! The harness now fails its own quality gate when the OMC comparator produced +//! no bands. That check runs *inside* the libtest, so it can only fire on runs +//! that reach the gate — and a sharded run deliberately skips the aggregate +//! gate, so a shard that silently produced no comparator artifacts would still +//! exit 0 and be merged. This module is the second, independent boundary: after +//! the harness process exits, xtask looks at what actually landed on disk and +//! refuses to call a run finished when no comparison happened. +//! +//! # Acceptance contract (SPEC 0008) +//! +//! [`check_comparator_evidence`] runs for every `verify msl-parity` invocation +//! that is expected to simulate against the cohort target set. +//! +//! It **accepts**: +//! +//! * a results directory carrying a non-empty `omc_simulation_reference.json` +//! whose `trace_comparison.models_compared` is greater than zero, alongside a +//! `sim_trace_comparison.json` with the same model count and zero unmeasured +//! initialization models; +//! * any run passing `--allow-unmeasured-parity`, which prints the same +//! "parity unmeasured" headline as a warning instead of failing. The flag is +//! the ONLY way to get a green cohort-shaped run with no comparison, and it +//! has to be typed out. +//! +//! It **rejects** (exit nonzero, headline +//! [`PARITY_UNMEASURED_HEADLINE`]): a missing or empty reference, a missing or +//! invalid trace comparison, a reference whose comparator compared zero models, +//! or any compared model without exact-start initialization evidence. +//! +//! Owner: this module. The artifact names it guards are written by +//! `rumoca-msl-tools omc-simulation-reference` +//! (`crates/rumoca-test-msl/src/msl_tools/omc_simulation_reference/output.rs`), +//! and the in-harness counterpart is `MslParityMeasurement` in +//! `crates/rumoca-test-msl/tests/balance_pipeline/balance_pipeline_quality_gate/parity_measurement.rs`. + +use anyhow::{Result, bail}; +use std::fs; +use std::path::Path; + +/// Fixed headline shared with the harness. Operators and CI summaries grep for +/// this exact text, so both boundaries must spell it identically. +pub(crate) const PARITY_UNMEASURED_HEADLINE: &str = "parity unmeasured: comparator did not run"; + +const OMC_REFERENCE_FILE: &str = "omc_simulation_reference.json"; +const TRACE_COMPARISON_FILE: &str = "sim_trace_comparison.json"; + +/// What the comparator left behind, or why there is nothing to read. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ComparatorEvidence { + /// `models_compared` models were compared against OMC. + Measured { models_compared: usize }, + /// Named reason there is no reading. + Unmeasured { reason: String }, +} + +/// Read the comparator artifacts a run left in `results_dir`. +pub(crate) fn read_comparator_evidence(results_dir: &Path) -> ComparatorEvidence { + let reference = results_dir.join(OMC_REFERENCE_FILE); + let comparison = results_dir.join(TRACE_COMPARISON_FILE); + for path in [&reference, &comparison] { + match fs::metadata(path) { + Ok(meta) if meta.len() > 0 => {} + Ok(_) => { + return ComparatorEvidence::Unmeasured { + reason: format!("{} is empty", path.display()), + }; + } + Err(error) => { + return ComparatorEvidence::Unmeasured { + reason: format!("{} is missing ({error})", path.display()), + }; + } + } + } + let raw = match fs::read_to_string(&reference) { + Ok(raw) => raw, + Err(error) => { + return ComparatorEvidence::Unmeasured { + reason: format!("{} is unreadable ({error})", reference.display()), + }; + } + }; + let payload: serde_json::Value = match serde_json::from_str(&raw) { + Ok(payload) => payload, + Err(error) => { + return ComparatorEvidence::Unmeasured { + reason: format!("{} is not valid JSON ({error})", reference.display()), + }; + } + }; + let comparison_raw = match fs::read_to_string(&comparison) { + Ok(raw) => raw, + Err(error) => { + return ComparatorEvidence::Unmeasured { + reason: format!("{} is unreadable ({error})", comparison.display()), + }; + } + }; + let comparison_payload: serde_json::Value = match serde_json::from_str(&comparison_raw) { + Ok(payload) => payload, + Err(error) => { + return ComparatorEvidence::Unmeasured { + reason: format!("{} is not valid JSON ({error})", comparison.display()), + }; + } + }; + let models_compared = payload + .pointer("/trace_comparison/models_compared") + .and_then(serde_json::Value::as_u64); + match models_compared { + Some(count) if count > 0 => { + let trace_count = comparison_payload + .pointer("/models_compared") + .and_then(serde_json::Value::as_u64); + if trace_count != Some(count) { + return ComparatorEvidence::Unmeasured { + reason: format!( + "{} models_compared {:?} does not match reference count {count}", + comparison.display(), + trace_count + ), + }; + } + let unmeasured_initial = comparison_payload + .pointer("/summary/initial_condition/models_with_unmeasured_initial_conditions") + .and_then(serde_json::Value::as_u64); + match unmeasured_initial { + Some(0) => ComparatorEvidence::Measured { + models_compared: count as usize, + }, + Some(unmeasured) => ComparatorEvidence::Unmeasured { + reason: format!( + "{} reports {unmeasured} model(s) without exact-start initialization evidence", + comparison.display() + ), + }, + None => ComparatorEvidence::Unmeasured { + reason: format!( + "{} carries no summary.initial_condition.models_with_unmeasured_initial_conditions", + comparison.display() + ), + }, + } + } + Some(_) => ComparatorEvidence::Unmeasured { + reason: format!( + "{} reports trace_comparison.models_compared = 0", + reference.display() + ), + }, + None => ComparatorEvidence::Unmeasured { + reason: format!( + "{} carries no trace_comparison.models_compared", + reference.display() + ), + }, + } +} + +/// Enforce the contract in the module docs. `allow_unmeasured` downgrades the +/// rejection to a warning. +pub(crate) fn check_comparator_evidence(results_dir: &Path, allow_unmeasured: bool) -> Result<()> { + match read_comparator_evidence(results_dir) { + ComparatorEvidence::Measured { models_compared } => { + println!( + "MSL parity comparator check: {models_compared} model(s) compared against OMC in {}.", + results_dir.display() + ); + Ok(()) + } + ComparatorEvidence::Unmeasured { reason } => { + if allow_unmeasured { + eprintln!( + "warning: MSL {PARITY_UNMEASURED_HEADLINE} ({reason}); \ + --allow-unmeasured-parity was passed, so this run reports no parity number" + ); + return Ok(()); + } + bail!( + "MSL {PARITY_UNMEASURED_HEADLINE} ({reason}). \ + sim_ok is completion, never parity: a run with no OMC comparison has no parity \ + number to report. Install `omc`, or pass --allow-unmeasured-parity to accept a \ + run that measures nothing." + ) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn write(dir: &Path, name: &str, body: &str) { + fs::write(dir.join(name), body).expect("write fixture"); + } + + fn complete_reference(models_compared: usize) -> String { + format!(r#"{{"trace_comparison":{{"models_compared":{models_compared}}}}}"#) + } + + fn complete_comparison(models_compared: usize, unmeasured_initial: usize) -> String { + format!( + r#"{{"models_compared":{models_compared},"summary":{{"initial_condition":{{"models_with_unmeasured_initial_conditions":{unmeasured_initial}}}}}}}"# + ) + } + + #[test] + fn a_complete_pair_of_artifacts_is_measured() { + let dir = tempdir().expect("tempdir"); + write(dir.path(), OMC_REFERENCE_FILE, &complete_reference(48)); + write( + dir.path(), + TRACE_COMPARISON_FILE, + &complete_comparison(48, 0), + ); + assert_eq!( + read_comparator_evidence(dir.path()), + ComparatorEvidence::Measured { + models_compared: 48 + } + ); + check_comparator_evidence(dir.path(), false).expect("a measured run passes"); + } + + #[test] + fn a_missing_reference_is_unmeasured_and_fails() { + let dir = tempdir().expect("tempdir"); + assert!(matches!( + read_comparator_evidence(dir.path()), + ComparatorEvidence::Unmeasured { .. } + )); + let error = check_comparator_evidence(dir.path(), false) + .expect_err("a run with no OMC reference must fail"); + let message = format!("{error:#}"); + assert!( + message.contains(PARITY_UNMEASURED_HEADLINE), + "got: {message}" + ); + assert!(message.contains(OMC_REFERENCE_FILE), "got: {message}"); + } + + #[test] + fn a_reference_without_a_trace_comparison_file_is_unmeasured() { + let dir = tempdir().expect("tempdir"); + write(dir.path(), OMC_REFERENCE_FILE, &complete_reference(48)); + let error = check_comparator_evidence(dir.path(), false) + .expect_err("a reference with no trace comparison is not a comparison"); + assert!(format!("{error:#}").contains(TRACE_COMPARISON_FILE)); + } + + #[test] + fn a_reference_that_compared_nothing_is_unmeasured() { + let dir = tempdir().expect("tempdir"); + write(dir.path(), OMC_REFERENCE_FILE, &complete_reference(0)); + write( + dir.path(), + TRACE_COMPARISON_FILE, + &complete_comparison(0, 0), + ); + let error = check_comparator_evidence(dir.path(), false) + .expect_err("comparing zero models is not a parity reading"); + assert!(format!("{error:#}").contains("models_compared = 0")); + } + + #[test] + fn an_empty_reference_file_is_unmeasured() { + let dir = tempdir().expect("tempdir"); + write(dir.path(), OMC_REFERENCE_FILE, ""); + write(dir.path(), TRACE_COMPARISON_FILE, "{}"); + let error = check_comparator_evidence(dir.path(), false) + .expect_err("an empty reference is not a reading"); + assert!(format!("{error:#}").contains("is empty")); + } + + #[test] + fn a_corrupt_reference_is_unmeasured_rather_than_ignored() { + let dir = tempdir().expect("tempdir"); + write(dir.path(), OMC_REFERENCE_FILE, "{not json"); + write(dir.path(), TRACE_COMPARISON_FILE, "{}"); + let error = check_comparator_evidence(dir.path(), false) + .expect_err("unparseable reference must not read as a pass"); + assert!(format!("{error:#}").contains("not valid JSON")); + } + + #[test] + fn a_model_without_exact_start_initialization_evidence_is_unmeasured() { + let dir = tempdir().expect("tempdir"); + write(dir.path(), OMC_REFERENCE_FILE, &complete_reference(48)); + write( + dir.path(), + TRACE_COMPARISON_FILE, + &complete_comparison(48, 1), + ); + let error = check_comparator_evidence(dir.path(), false) + .expect_err("missing initialization evidence must fail closed"); + assert!(format!("{error:#}").contains("without exact-start initialization evidence")); + } + + #[test] + fn a_focused_run_has_no_unmeasured_bypass() { + let dir = tempdir().expect("tempdir"); + check_comparator_evidence(dir.path(), false) + .expect_err("selecting named targets must not suppress comparator evidence"); + } + + #[test] + fn the_escape_hatch_downgrades_the_failure_but_keeps_the_headline() { + let dir = tempdir().expect("tempdir"); + check_comparator_evidence(dir.path(), true) + .expect("--allow-unmeasured-parity accepts a run that measures nothing"); + } +} diff --git a/crates/xtask/src/verify_cmd/template_runtime_tests.rs b/crates/xtask/src/verify_cmd/template_runtime_tests.rs new file mode 100644 index 000000000..0452ab7a8 --- /dev/null +++ b/crates/xtask/src/verify_cmd/template_runtime_tests.rs @@ -0,0 +1,308 @@ +//! Drift guards for the `verify template-runtimes` gate. +//! +//! The gate pins Cargo test-target names and libtest filters as string +//! literals. Renaming or deleting a test in `crates/rumoca/tests` used to leave +//! those pins stale: `cargo test --test ` fails the whole gate, and a +//! filter that matches nothing silently drops a runtime check. These tests read +//! the real manifest and the real test sources so both failure modes surface as +//! a precise `xtask` unit-test failure instead of an opaque gate abort. + +use super::{RequiredExternalToolsMarker, TEMPLATE_RUNTIME_GROUPS, template_runtime_test_stems}; +use crate::util::workspace_root_from_manifest_dir; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Feature the gate passes to `cargo test`; every pinned target must require it +/// so the target is actually buildable under that invocation. +const GATE_FEATURE: &str = "template-runtime-tests"; + +#[test] +fn required_external_tools_marker_is_scoped_to_the_gate() { + let root = tempfile::tempdir().expect("temporary workspace root"); + let path = root.path().join("target/template-runtimes/strict"); + { + let _marker = + RequiredExternalToolsMarker::new(root.path(), true).expect("create strict marker"); + assert!(path.is_file()); + } + assert!( + !path.exists(), + "gate-created marker must be removed on exit" + ); +} + +#[test] +fn required_external_tools_marker_preserves_explicit_local_policy() { + let root = tempfile::tempdir().expect("temporary workspace root"); + let path = root.path().join("target/template-runtimes/strict"); + fs::create_dir_all(path.parent().expect("marker parent")).expect("create marker directory"); + fs::write(&path, b"local policy\n").expect("write local marker"); + { + let _marker = + RequiredExternalToolsMarker::new(root.path(), true).expect("reuse strict marker"); + } + assert!(path.is_file(), "pre-existing marker belongs to the caller"); +} + +fn rumoca_manifest_path() -> PathBuf { + workspace_root_from_manifest_dir(env!("CARGO_MANIFEST_DIR")) + .join("crates") + .join("rumoca") + .join("Cargo.toml") +} + +struct ManifestTestTarget { + path: PathBuf, + required_features: Vec, +} + +/// `required-features` of one `[[test]]` table, empty when the key is absent. +fn required_features(test: &toml::Value, name: &str) -> Vec { + let Some(features) = test + .get("required-features") + .and_then(toml::Value::as_array) + else { + return Vec::new(); + }; + features + .iter() + .map(|feature| { + feature + .as_str() + .unwrap_or_else(|| panic!("[[test]] {name} required-features are strings")) + .to_string() + }) + .collect() +} + +/// Read every `[[test]]` target declared by the `rumoca` package. +fn manifest_test_targets() -> BTreeMap { + let manifest_path = rumoca_manifest_path(); + let manifest_text = fs::read_to_string(&manifest_path) + .unwrap_or_else(|err| panic!("read {}: {err}", manifest_path.display())); + let manifest: toml::Value = toml::from_str(&manifest_text) + .unwrap_or_else(|err| panic!("parse {}: {err}", manifest_path.display())); + let package_dir = manifest_path + .parent() + .expect("rumoca manifest has a package directory") + .to_path_buf(); + + let tests = manifest + .get("test") + .and_then(toml::Value::as_array) + .unwrap_or_else(|| panic!("{} declares [[test]] targets", manifest_path.display())); + + let mut targets = BTreeMap::new(); + for test in tests { + let name = test + .get("name") + .and_then(toml::Value::as_str) + .unwrap_or_else(|| panic!("every [[test]] in {} has a name", manifest_path.display())) + .to_string(); + let path = test + .get("path") + .and_then(toml::Value::as_str) + .unwrap_or_else(|| panic!("[[test]] {name} has an explicit path")); + let required_features = required_features(test, &name); + targets.insert( + name, + ManifestTestTarget { + path: package_dir.join(path), + required_features, + }, + ); + } + targets +} + +/// Resolve an out-of-line module through Rust's content-based layout. +fn module_target(item: &syn::ItemMod, owner: &Path) -> PathBuf { + let parent = owner + .parent() + .unwrap_or_else(|| panic!("{} has a parent directory", owner.display())); + let stem = owner + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or_else(|| panic!("{} has a UTF-8 file stem", owner.display())); + let module_dir = if matches!(stem, "main" | "mod") { + parent.to_path_buf() + } else { + parent.join(stem) + }; + let direct = module_dir.join(format!("{}.rs", item.ident)); + let nested = module_dir.join(item.ident.to_string()).join("mod.rs"); + match (direct.is_file(), nested.is_file()) { + (true, false) => direct, + (false, true) => nested, + (false, false) => panic!( + "module `{}` in {} has no normal source at {} or {}", + item.ident, + owner.display(), + direct.display(), + nested.display() + ), + (true, true) => panic!( + "module `{}` in {} has two normal sources: {} and {}", + item.ident, + owner.display(), + direct.display(), + nested.display() + ), + } +} + +/// Libtest paths of every `#[test]` function reachable from a test source file, +/// following normal out-of-line modules and prefixing each name with its module +/// path exactly the way libtest reports it. +fn declared_test_functions(path: &Path) -> BTreeSet { + let mut names = BTreeSet::new(); + collect_test_functions(path, "", &mut names); + assert!( + !names.is_empty(), + "{} declares no #[test] functions", + path.display() + ); + names +} + +fn collect_test_functions(path: &Path, prefix: &str, names: &mut BTreeSet) { + let source = + fs::read_to_string(path).unwrap_or_else(|err| panic!("read {}: {err}", path.display())); + let file = + syn::parse_file(&source).unwrap_or_else(|err| panic!("parse {}: {err}", path.display())); + for item in &file.items { + match item { + syn::Item::Fn(function) => { + let is_test = function + .attrs + .iter() + .any(|attr| attr.path().is_ident("test")); + if is_test { + names.insert(format!("{prefix}{}", function.sig.ident)); + } + } + syn::Item::Mod(module) if module.content.is_none() => { + let target = module_target(module, path); + let nested = format!("{prefix}{}::", module.ident); + collect_test_functions(&target, &nested, names); + } + _ => {} + } + } +} + +#[test] +fn gate_pins_only_test_targets_that_exist_and_require_the_gate_feature() { + let targets = manifest_test_targets(); + for group in TEMPLATE_RUNTIME_GROUPS { + let target = targets.get(group.test).unwrap_or_else(|| { + panic!( + "template runtime gate pins test target `{}`, which crates/rumoca/Cargo.toml does not declare; declared targets: {:?}", + group.test, + targets.keys().collect::>() + ) + }); + assert!( + target.path.is_file(), + "test target `{}` points at missing source {}", + group.test, + target.path.display() + ); + assert!( + target + .required_features + .iter() + .any(|feature| feature == GATE_FEATURE), + "test target `{}` must require `{GATE_FEATURE}` because the gate builds it with that feature; found {:?}", + group.test, + target.required_features + ); + } +} + +#[test] +fn gate_filters_select_at_least_one_declared_test() { + let targets = manifest_test_targets(); + for group in TEMPLATE_RUNTIME_GROUPS { + let target = targets + .get(group.test) + .unwrap_or_else(|| panic!("test target `{}` is declared", group.test)); + let declared = declared_test_functions(&target.path); + for filter in group.filters { + assert!( + declared.iter().any(|name| name.contains(filter)), + "template runtime filter `{filter}` for target `{}` matches no #[test] in {}; declared: {declared:?}", + group.test, + target.path.display() + ); + } + } +} + +#[test] +fn gate_runs_every_declared_test_of_every_pinned_target() { + let targets = manifest_test_targets(); + let mut filters_by_target: BTreeMap<&str, Vec<&str>> = BTreeMap::new(); + let mut unfiltered_targets: BTreeSet<&str> = BTreeSet::new(); + for group in TEMPLATE_RUNTIME_GROUPS { + if group.filters.is_empty() { + unfiltered_targets.insert(group.test); + } + filters_by_target + .entry(group.test) + .or_default() + .extend(group.filters.iter().copied()); + } + + for (test, filters) in filters_by_target { + if unfiltered_targets.contains(test) { + continue; + } + let target = targets + .get(test) + .unwrap_or_else(|| panic!("test target `{test}` is declared")); + for name in declared_test_functions(&target.path) { + assert!( + filters.iter().any(|filter| name.contains(filter)), + "`{name}` in {} is never selected by the template runtime gate; filters for `{test}`: {filters:?}", + target.path.display() + ); + } + } +} + +#[test] +fn artifact_trimmer_covers_exactly_the_pinned_targets() { + let stems = template_runtime_test_stems(); + let expected: Vec<&str> = { + let mut seen: Vec<&str> = Vec::new(); + for group in TEMPLATE_RUNTIME_GROUPS { + if !seen.contains(&group.test) { + seen.push(group.test); + } + } + seen + }; + assert_eq!( + stems, expected, + "the artifact trimmer must trim exactly the gate's test targets" + ); + + let targets = manifest_test_targets(); + let reachable: BTreeSet = expected + .iter() + .flat_map(|test| { + let target = targets + .get(*test) + .unwrap_or_else(|| panic!("test target `{test}` is declared")); + declared_test_functions(&target.path) + }) + .collect(); + assert!( + reachable + .iter() + .any(|name| name.starts_with("backend_template_runtime_regression::")), + "the backend runtime suite stays part of the gate; reachable tests: {reachable:?}" + ); +} diff --git a/crates/xtask/src/verify_cmd/tests.rs b/crates/xtask/src/verify_cmd/tests.rs new file mode 100644 index 000000000..2c061586f --- /dev/null +++ b/crates/xtask/src/verify_cmd/tests.rs @@ -0,0 +1,473 @@ +use super::{ + LocalMslRunPlan, MSL_FULL_TEST_FEATURE, MslCargoSetupTimingStep, MslCiEnvironment, + MslHotspotModelResult, MslHotspotSummary, ParityConfigLock, VERIFY_SUITE_STEPS, + VerifyMslParityArgs, VerifySuite, VerifyTimingReport, VerifyTimingStep, + debug_msl_merge_test_command, hottest_compile_model, hottest_sim_model, local_msl_run_plan, + msl_cache_layout_valid, prebuilt_sibling_binary, render_verify_timing_markdown, + run_resource_monitor_loop, should_log_process_tables, write_msl_cargo_setup_timing_report, + write_verify_timing_report, +}; +use std::path::PathBuf; +use std::sync::mpsc; +use std::thread; +use std::time::{Duration, Instant}; + +fn step_argvs(suite: VerifySuite) -> Vec> { + VERIFY_SUITE_STEPS + .iter() + .filter(|step| suite.includes(step)) + .map(|step| step.args.to_vec()) + .collect() +} + +#[test] +fn parity_config_lock_serializes_fixed_path_harness_configuration() { + let temp = tempfile::tempdir().expect("tempdir"); + let first = ParityConfigLock::acquire(temp.path()).expect("first lock"); + let root = temp.path().to_path_buf(); + let (started_tx, started_rx) = mpsc::channel(); + let (acquired_tx, acquired_rx) = mpsc::channel(); + let waiter = thread::spawn(move || { + started_tx.send(()).expect("announce lock attempt"); + let second = ParityConfigLock::acquire(&root).expect("second lock"); + acquired_tx.send(()).expect("announce acquisition"); + drop(second); + }); + + started_rx.recv().expect("waiter started"); + assert_eq!( + acquired_rx.recv_timeout(Duration::from_millis(100)), + Err(mpsc::RecvTimeoutError::Timeout), + "a concurrent parity invocation must wait while the config is live" + ); + drop(first); + acquired_rx + .recv_timeout(Duration::from_secs(1)) + .expect("waiter acquires after the first invocation finishes"); + waiter.join().expect("waiter exits"); +} + +#[test] +fn quick_suite_runs_format_tests_architecture_and_msl_parity() { + let steps = step_argvs(VerifySuite::Quick); + assert_eq!( + steps, + vec![ + vec!["verify", "lint"], + vec!["verify", "msl-parity", "--no-remote-quality-baseline"], + vec!["verify", "architecture"], + vec!["verify", "workspace"], + ] + ); + assert!(!steps.contains(&vec!["verify", "examples"])); + assert!(!steps.contains(&vec!["verify", "binaries"])); + assert!(!steps.contains(&vec!["verify", "template-runtimes"])); + assert!(!steps.contains(&vec!["verify", "docs"])); + assert!(!steps.contains(&vec!["vscode", "test"])); + assert!(!steps.contains(&vec!["coverage", "run"])); + assert!(!steps.contains(&vec!["playground", "test"])); + assert!(!steps.contains(&vec!["verify", "lsp-msl-completion-timings"])); +} + +#[test] +fn full_suite_runs_msl_parity_before_lower_signal_heavy_gates() { + let steps = step_argvs(VerifySuite::Full); + assert_eq!( + steps.get(1), + Some(&vec![ + "verify", + "msl-parity", + "--no-remote-quality-baseline" + ]) + ); + assert!(steps.contains(&vec!["verify", "architecture"])); + assert!(steps.contains(&vec!["verify", "workspace"])); + assert!(steps.contains(&vec!["verify", "examples"])); + assert!(steps.contains(&vec!["verify", "binaries"])); + assert!(steps.contains(&vec!["verify", "template-runtimes"])); + assert!(steps.contains(&vec!["coverage", "run"])); + assert!(steps.contains(&vec!["playground", "test"])); + assert!(steps.contains(&vec!["verify", "lsp-msl-completion-timings"])); + assert!(steps.contains(&vec![ + "verify", + "msl-parity", + "--no-remote-quality-baseline" + ])); +} + +#[test] +fn focused_msl_match_does_not_imply_selected_target_success_gate() { + let args = VerifyMslParityArgs { + sim_match: vec!["Modelica.Blocks.Examples.BooleanNetwork1".to_string()], + sim_match_exact: true, + ..VerifyMslParityArgs::default() + }; + let config = args.to_parity_config_json(); + + assert!(config.get("require_selected_targets_success").is_none()); + assert_eq!( + config + .get("sim_match_exact") + .and_then(serde_json::Value::as_bool), + Some(true) + ); + assert!(!args.requires_selected_targets_success()); + assert!(!args.uses_baseline_relative_quality_gate()); +} + +#[test] +fn explicit_selected_target_success_gate_is_forwarded() { + let args = VerifyMslParityArgs { + require_selected_targets_success: true, + ..VerifyMslParityArgs::default() + }; + let config = args.to_parity_config_json(); + + assert_eq!( + config + .get("require_selected_targets_success") + .and_then(serde_json::Value::as_bool), + Some(true) + ); + assert!(args.requires_selected_targets_success()); +} + +#[test] +fn msl_parity_config_forwards_model_worker_memory_ceiling() { + let args = VerifyMslParityArgs { + model_worker_memory_mb: Some(6144), + ..VerifyMslParityArgs::default() + }; + let config = args.to_parity_config_json(); + + assert_eq!( + config + .get("model_worker_memory_mb") + .and_then(serde_json::Value::as_u64), + Some(6144) + ); +} + +#[test] +fn verify_timing_markdown_preserves_step_order() { + let report = VerifyTimingReport::new( + VerifySuite::Quick, + Duration::from_millis(1500), + vec![ + VerifyTimingStep { + label: "lint".to_string(), + command: "cargo xtask verify lint".to_string(), + status: "pass".to_string(), + elapsed_seconds: 0.5, + }, + VerifyTimingStep { + label: "workspace tests".to_string(), + command: "cargo xtask verify workspace".to_string(), + status: "fail".to_string(), + elapsed_seconds: 1.0, + }, + ], + ); + + let markdown = render_verify_timing_markdown(&report); + assert!(markdown.contains("# verify quick")); + assert!(markdown.contains("- success: false")); + assert!( + markdown.find("| lint | pass | 0.500 |").unwrap() + < markdown.find("| workspace tests | fail | 1.000 |").unwrap() + ); +} + +#[test] +fn verify_timing_report_writes_fixed_target_artifacts() { + let root = tempfile::tempdir().expect("temp root"); + let report = VerifyTimingReport::new( + VerifySuite::Quick, + Duration::from_secs(1), + vec![VerifyTimingStep { + label: "lint".to_string(), + command: "cargo xtask verify lint".to_string(), + status: "pass".to_string(), + elapsed_seconds: 1.0, + }], + ); + + write_verify_timing_report(root.path(), &report).expect("write timing report"); + + let json_path = root.path().join("target/verify-timings/quick.json"); + let markdown_path = root.path().join("target/verify-timings/quick.md"); + assert!(json_path.is_file()); + assert!(markdown_path.is_file()); + let json = std::fs::read_to_string(json_path).expect("read timing json"); + assert!(json.contains(r#""suite": "verify quick""#)); + let markdown = std::fs::read_to_string(markdown_path).expect("read timing markdown"); + assert!(markdown.contains("| lint | pass | 1.000 |")); +} + +#[test] +fn msl_cargo_setup_timing_report_writes_fixed_result_artifacts() { + let root = tempfile::tempdir().expect("temp root"); + let results_dir = root.path().join("target/msl/results"); + let steps = vec![ + MslCargoSetupTimingStep { + label: "build release MSL artifacts".to_string(), + cargo_action: "build".to_string(), + package: "rumoca-worker + rumoca-test-msl".to_string(), + profile: "release".to_string(), + features: vec![format!("rumoca-test-msl/{MSL_FULL_TEST_FEATURE}")], + target_dir: root.path().join("target").display().to_string(), + command: "\"cargo\" \"build\"".to_string(), + status: "pass".to_string(), + elapsed_seconds: 0.2, + }, + MslCargoSetupTimingStep { + label: "run release MSL test".to_string(), + cargo_action: "run".to_string(), + package: "rumoca-test-msl".to_string(), + profile: "release".to_string(), + features: vec!["msl-full-test".to_string()], + target_dir: root.path().join("target").display().to_string(), + command: "\"target/release/deps/msl_tests-abc\"".to_string(), + status: "fail".to_string(), + elapsed_seconds: 1.3, + }, + ]; + + write_msl_cargo_setup_timing_report(&results_dir, &steps) + .expect("write MSL Cargo setup timing report"); + + let json_path = results_dir.join("msl_cargo_setup_timing.json"); + let markdown_path = results_dir.join("msl_cargo_setup_timing.md"); + assert!(json_path.is_file()); + assert!(markdown_path.is_file()); + let json = std::fs::read_to_string(json_path).expect("read setup timing json"); + assert!(json.contains(r#""success": false"#)); + assert!(json.contains(r#""label": "build release MSL artifacts""#)); + assert!(json.contains(r#""package": "rumoca-worker + rumoca-test-msl""#)); + assert!(json.contains("rumoca-test-msl/msl-full-test")); + assert!(json.contains(r#""features": ["#)); + let markdown = std::fs::read_to_string(markdown_path).expect("read setup timing markdown"); + assert!(markdown.contains("# MSL Cargo Setup Timing")); + assert!(markdown.contains("| run release MSL test | fail | 1.300 | rumoca-test-msl |")); + assert!(markdown.contains("| release | msl-full-test |")); +} + +#[test] +fn prebuilt_sibling_binary_finds_tools_next_to_msl_tests() { + let root = tempfile::tempdir().expect("tempdir"); + let bin_dir = root.path().join("bin"); + std::fs::create_dir_all(&bin_dir).expect("mkdir bin"); + let msl_tests = bin_dir.join("msl_tests"); + let tools = bin_dir.join("rumoca-msl-tools"); + std::fs::write(&msl_tests, "").expect("write msl_tests"); + std::fs::write(&tools, "").expect("write tools"); + + assert_eq!( + prebuilt_sibling_binary(&msl_tests, "rumoca-msl-tools"), + Some(tools) + ); +} + +#[test] +fn local_msl_run_plan_keeps_merge_and_release_paths_distinct() { + assert_eq!(local_msl_run_plan(true), LocalMslRunPlan::MergeOnly); + assert_eq!(local_msl_run_plan(false), LocalMslRunPlan::ReleaseArtifacts); + + let root = PathBuf::from("/workspace"); + let command = debug_msl_merge_test_command(&root, "suite::test_merge"); + assert_eq!( + command + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(), + [ + "test", + "--verbose", + "--package", + "rumoca-test-msl", + "--features", + "msl-full-test", + "--test", + "msl_tests", + "suite::test_merge", + "--", + "--nocapture", + ] + ); + assert_eq!(command.get_current_dir(), Some(root.as_path())); +} + +#[test] +fn hotspot_selection_uses_max_compile_and_sim_wall_times() { + let summary = MslHotspotSummary { + model_results: vec![ + MslHotspotModelResult { + model_name: "A".to_string(), + compile_seconds: Some(1.5), + sim_wall_seconds: Some(8.0), + }, + MslHotspotModelResult { + model_name: "B".to_string(), + compile_seconds: Some(3.0), + sim_wall_seconds: Some(2.0), + }, + MslHotspotModelResult { + model_name: "C".to_string(), + compile_seconds: None, + sim_wall_seconds: Some(9.0), + }, + ], + }; + + assert_eq!(hottest_compile_model(&summary), Some(("B", 3.0))); + assert_eq!(hottest_sim_model(&summary), Some(("C", 9.0))); +} + +#[test] +fn msl_cache_layout_requires_editor_smoke_packages() { + let temp = tempfile::tempdir().expect("tempdir"); + let msl_root = temp.path(); + std::fs::write(msl_root.join("Complex.mo"), "").expect("write Complex.mo"); + std::fs::create_dir_all(msl_root.join("Modelica 4.1.0")).expect("mkdir Modelica"); + std::fs::write(msl_root.join("Modelica 4.1.0/package.mo"), "").expect("write Modelica package"); + + assert!( + !msl_cache_layout_valid(msl_root), + "ModelicaServices is required by editor MSL smoke asset preparation" + ); + + std::fs::create_dir_all(msl_root.join("ModelicaServices 4.1.0")) + .expect("mkdir ModelicaServices"); + std::fs::write(msl_root.join("ModelicaServices 4.1.0/package.mo"), "") + .expect("write ModelicaServices package"); + + assert!(msl_cache_layout_valid(msl_root)); +} + +#[test] +fn msl_ci_environment_cleans_stale_results_before_run() { + let temp = tempfile::tempdir().expect("tempdir"); + let results_dir = temp.path().join("results"); + std::fs::create_dir_all(&results_dir).expect("mkdir"); + std::fs::write(results_dir.join("stale.json"), "{}").expect("write stale file"); + let env = MslCiEnvironment { + root: PathBuf::from(temp.path()), + results_dir: results_dir.clone(), + monitor_interval: None, + clean_results: true, + github_actions: false, + }; + env.clean_stale_results().expect("cleanup should succeed"); + assert!( + !results_dir.exists(), + "pre-run cleanup should remove stale results directory" + ); +} + +/// The preservation rules have to hold through the flag that actually +/// invokes them: a wipe that spared the cohort table in isolation but ran +/// unconditionally from `--clean-results` would still delete it on every run. +#[test] +fn msl_ci_environment_preserves_the_cohort_table_through_the_clean_results_flag() { + let temp = tempfile::tempdir().expect("tempdir"); + let results_dir = temp.path().join("results"); + std::fs::create_dir_all(&results_dir).expect("mkdir"); + std::fs::write( + results_dir.join("msl_band_table.json"), + "{\"schema\":\"a\"}", + ) + .expect("write band table"); + std::fs::write(results_dir.join("msl_results.json"), "{}").expect("write stale results"); + let env = MslCiEnvironment { + root: PathBuf::from(temp.path()), + results_dir: results_dir.clone(), + monitor_interval: None, + clean_results: true, + github_actions: false, + }; + + env.clean_stale_results().expect("cleanup should succeed"); + + assert!( + results_dir.join("msl_band_table.json").is_file(), + "the previous certification's cohort evidence must survive --clean-results" + ); + assert!( + !results_dir.join("msl_results.json").exists(), + "everything the run regenerates must still be wiped" + ); +} + +/// With the flag off, nothing is removed at all — the wipe is opt-in, so a +/// run that never asked for it cannot lose a certification. +#[test] +fn msl_ci_environment_removes_nothing_when_clean_results_is_off() { + let temp = tempfile::tempdir().expect("tempdir"); + let results_dir = temp.path().join("results"); + std::fs::create_dir_all(&results_dir).expect("mkdir"); + std::fs::write(results_dir.join("msl_results.json"), "{}").expect("write results"); + let env = MslCiEnvironment { + root: PathBuf::from(temp.path()), + results_dir: results_dir.clone(), + monitor_interval: None, + clean_results: false, + github_actions: false, + }; + + env.clean_stale_results().expect("cleanup should succeed"); + + assert!(results_dir.join("msl_results.json").is_file()); +} + +#[test] +fn msl_resource_snapshot_skips_process_tables_on_github_actions() { + let temp = tempfile::tempdir().expect("tempdir"); + let env = MslCiEnvironment { + root: PathBuf::from(temp.path()), + results_dir: temp.path().join("results"), + monitor_interval: None, + clean_results: false, + github_actions: true, + }; + + assert!(!should_log_process_tables(&env)); +} + +#[test] +fn msl_resource_snapshot_keeps_process_tables_for_local_runs() { + let temp = tempfile::tempdir().expect("tempdir"); + let env = MslCiEnvironment { + root: PathBuf::from(temp.path()), + results_dir: temp.path().join("results"), + monitor_interval: None, + clean_results: false, + github_actions: false, + }; + + assert!(should_log_process_tables(&env)); +} + +#[test] +fn msl_resource_monitor_shutdown_interrupts_the_sampling_wait() { + let temp = tempfile::tempdir().expect("tempdir"); + let env = MslCiEnvironment { + root: PathBuf::from(temp.path()), + results_dir: temp.path().join("results"), + monitor_interval: Some(Duration::from_secs(30)), + clean_results: false, + github_actions: true, + }; + let (stop, stop_receiver) = mpsc::channel(); + let worker = thread::spawn(move || { + run_resource_monitor_loop(stop_receiver, Duration::from_secs(30), env); + }); + let started = Instant::now(); + + drop(stop); + worker.join().expect("resource monitor should stop cleanly"); + + assert!( + started.elapsed() < Duration::from_secs(1), + "monitor shutdown must not wait for the next sampling interval" + ); +} diff --git a/crates/xtask/src/vscode_cmd.rs b/crates/xtask/src/vscode_cmd.rs index 3a773fa7a..dccc25121 100644 --- a/crates/xtask/src/vscode_cmd.rs +++ b/crates/xtask/src/vscode_cmd.rs @@ -1184,7 +1184,7 @@ fn build_and_stage_vscode_lsp(root: &Path, vscode_dir: &Path, release: bool) -> .arg("--bin") .arg("rumoca-lsp") .arg("--bin") - .arg("rumoca-galec-lsp") + .arg("rumoca-lsp-galec") .arg("--bin") .arg("rumoca"); if release { @@ -1210,7 +1210,7 @@ fn build_and_stage_vscode_lsp(root: &Path, vscode_dir: &Path, release: bool) -> }; stage_bin("rumoca-lsp")?; - stage_bin("rumoca-galec-lsp")?; + stage_bin("rumoca-lsp-galec")?; stage_bin("rumoca")?; Ok(()) } @@ -1334,7 +1334,7 @@ fn build_vscode_release_binaries(root: &Path, target: VscodePackageTarget) -> Re .arg("--bin") .arg("rumoca-lsp") .arg("--bin") - .arg("rumoca-galec-lsp") + .arg("rumoca-lsp-galec") .arg("--bin") .arg("rumoca") .current_dir(root) @@ -1394,8 +1394,8 @@ fn stage_vscode_release_binaries( stage_named_binary( &release_dir, &bin_dir, - "rumoca-galec-lsp", - "rumoca-galec-lsp", + "rumoca-lsp-galec", + "rumoca-lsp-galec", )?; stage_named_binary(&release_dir, &bin_dir, "rumoca", "rumoca")?; Ok(()) diff --git a/docs/dev-guide/src/SUMMARY.md b/docs/dev-guide/src/SUMMARY.md index 5d636ad63..0a0cb2dea 100644 --- a/docs/dev-guide/src/SUMMARY.md +++ b/docs/dev-guide/src/SUMMARY.md @@ -32,7 +32,6 @@ - [Docs and Pages](./tooling/docs-and-pages.md) - [Scenario Config and VS Code](./tooling/scenario-config.md) -- [Workspace and Scenario Roadmap](./tooling/workspace-scenario-roadmap.md) - [MSL Quality Gate](./tooling/msl-quality-gate.md) - [MSL Baseline Promotion Analysis, 2026-06-05](./tooling/msl-baseline-promotion-analysis-2026-06-05.md) diff --git a/docs/dev-guide/src/compiler/front-end.md b/docs/dev-guide/src/compiler/front-end.md index dfe8dae33..d7213e503 100644 --- a/docs/dev-guide/src/compiler/front-end.md +++ b/docs/dev-guide/src/compiler/front-end.md @@ -56,7 +56,7 @@ function bodies stay structured, and Modelica operators (`der`, `pre`, ## Seeing It ```bash -rumoca compile Model.mo --emit ast-mo # what the parser saw +rumoca compile Model.mo --emit ast-json # checked semantic tree rumoca compile Model.mo --emit flat-mo # what flattening produced ``` diff --git a/docs/dev-guide/src/compiler/irs.md b/docs/dev-guide/src/compiler/irs.md index 99a6ac0f3..9940be745 100644 --- a/docs/dev-guide/src/compiler/irs.md +++ b/docs/dev-guide/src/compiler/irs.md @@ -13,7 +13,9 @@ pretty-printers, and doc generators work here because they need the original syntax. Every node carries a `Span`, and that provenance must survive any AST merging. -Dump it: `rumoca compile Model.mo --emit ast-mo` (or `ast-json`). +Dump it as checked JSON: `rumoca compile Model.mo --emit ast-json`. Rumoca does +not reconstruct Modelica from this semantic tree because doing so would lose +source syntax; use the formatter for source-preserving output. ## Flat (`rumoca-ir-flat`) diff --git a/docs/dev-guide/src/compiler/pipeline-overview.md b/docs/dev-guide/src/compiler/pipeline-overview.md index da88c6800..7ba500249 100644 --- a/docs/dev-guide/src/compiler/pipeline-overview.md +++ b/docs/dev-guide/src/compiler/pipeline-overview.md @@ -64,10 +64,10 @@ The narrative below is the mental model. You can watch every step on a real model: ```bash -rumoca compile Model.mo --emit ast-mo # or flat-mo, dae-mo, *-json +rumoca compile Model.mo --emit ast-json # flat-mo, dae-mo, and later *-json are also available rumoca compile Model.mo --emit solve-json rumoca compile Model.mo --inspect structure -rumoca compile Model.mo --target sympy -o /tmp/out -v # phase timing lines +rumoca compile Model.mo --target c-ode -o /tmp/out -v # phase timing lines ``` ## Try It Here diff --git a/docs/dev-guide/src/compiler/solve.md b/docs/dev-guide/src/compiler/solve.md index 9dc9a32c6..839e2a2fe 100644 --- a/docs/dev-guide/src/compiler/solve.md +++ b/docs/dev-guide/src/compiler/solve.md @@ -42,9 +42,9 @@ rewrites, no template policy: | `rumoca-exec-mlir` | MLIR-based compilation path | | `rumoca-exec-wasm` | WASM execution backend | -The generated-code targets (`rust-solve`, `c-solve`, `embedded-c`, -`cuda-c`, `cuda-nvrtc-solve-jit`, `fmi2`/`fmi3`) consume Solve through the -codegen engine instead — see +The generated-code targets (`rust-ode`, `c-ode`, `cuda-ode`, +`cuda-ode`, `wgsl-ode`, and the symbolic ODE RHS targets) +consume Solve through the codegen engine instead — see [Code Generation Engine](../runtime/codegen.md). ## Adding a New Backend @@ -53,7 +53,7 @@ The pathway for a new execution backend (the same one a future WebGPU/WGSL backend would take): 1. Decide the consumption model: a codegen *target* (templates rendering - kernels, like `cuda-c`) or an execution *adapter* (an API wrapper, like + kernels, like `cuda-ode`) or an execution *adapter* (an API wrapper, like `rumoca-exec-cranelift`) — or both, like the NVRTC JIT. 2. Consume Solve IR. If the backend needs tensor structure, use the tensor program nodes; every valid node has a fallible scalar fallback, so a backend diff --git a/docs/dev-guide/src/contributing/specs-process.md b/docs/dev-guide/src/contributing/specs-process.md index 5a77897f3..d7eb39506 100644 --- a/docs/dev-guide/src/contributing/specs-process.md +++ b/docs/dev-guide/src/contributing/specs-process.md @@ -17,7 +17,7 @@ it contains no rules itself, and neither does this book. | Diagnostics, spans, error codes, tracing | [SPEC_0008](https://github.com/CogniPilot/rumoca/blob/main/spec/SPEC_0008_PHASE_ERRORS.md) | | Tool config (`rumoca-tool-*`, env-var policy) | [SPEC_0018](https://github.com/CogniPilot/rumoca/blob/main/spec/SPEC_0018_TOOL_CONFIG.md) | | Function length, nesting, file size, determinism | [SPEC_0021](https://github.com/CogniPilot/rumoca/blob/main/spec/SPEC_0021_CODE_COMPLEXITY.md) | -| Development workflow, bug triage, root-cause proof | [SPEC_0032](https://github.com/CogniPilot/rumoca/blob/main/spec/SPEC_0032_DEVELOPMENT_PROCESS.md) | +| Development workflow, bug triage, root-cause proof | [SPEC_0033](https://github.com/CogniPilot/rumoca/blob/main/spec/SPEC_0033_DEVELOPMENT_PROCESS.md) | | Opening a PR | [SPEC_0025](https://github.com/CogniPilot/rumoca/blob/main/spec/SPEC_0025_PR_REVIEW_PROCESS.md) | | Scope/philosophy questions ("should this live in the compiler?") | [SPEC_0031](https://github.com/CogniPilot/rumoca/blob/main/spec/SPEC_0031_COMPILER_PHILOSOPHY.md) | diff --git a/docs/dev-guide/src/runtime/codegen.md b/docs/dev-guide/src/runtime/codegen.md index db9dce4d0..723a10595 100644 --- a/docs/dev-guide/src/runtime/codegen.md +++ b/docs/dev-guide/src/runtime/codegen.md @@ -32,7 +32,7 @@ user-facing contract. Solve-level tensor nodes, including `ComputeNode::AffineStencil`, arrive at templates with scalar fallback behavior already proven. Stencil-aware targets -such as `wgsl-solve` should render the native tensor node directly; scalar +such as `wgsl-ode` should render the native tensor node directly; scalar targets should use the fallback programs and must not infer stencil structure by scanning flattened row text. @@ -45,10 +45,12 @@ with such a test. ## Adding a Target -1. Start from a worked example: `examples/codegen/standalone_web/` is a - complete custom bundle; the built-in target directories show the full - manifest vocabulary. +1. Start from a worked example: `examples/codegen/checked_dae_report/` is a + complete custom bundle over the canonical checked DAE projection; the + built-in target directories show the full manifest vocabulary. 2. Declare the IR stage and capabilities in `target.toml`. -3. Write templates against the serialized IR (`--emit -json` shows - the exact shape). +3. Write templates against the target's documented semantic projection and + keep a rendering fixture beside the target. `--emit -json` exposes + the stage's wire representation; it is not a promise that template context + and wire storage have the same shape. 4. Wire a runtime regression test if the output executes. diff --git a/docs/dev-guide/src/runtime/simulation-runtime.md b/docs/dev-guide/src/runtime/simulation-runtime.md index cbc5b2a71..6a0ec6676 100644 --- a/docs/dev-guide/src/runtime/simulation-runtime.md +++ b/docs/dev-guide/src/runtime/simulation-runtime.md @@ -11,7 +11,7 @@ in the shared runtime layer, and solver backends are thin adapters. | `rumoca-sim` | Simulation orchestration over the compiled model | | `rumoca-solver` | Shared solver API, result types, report payloads | | `rumoca-solver-rk45` | Explicit Runge–Kutta-style backend (`rk-like`) | -| `rumoca-solver-diffsol` | Implicit backends via diffsol (`bdf`, `esdirk34`, `trbdf2`) | +| `rumoca-solver-diffsol` | Implicit backend via diffsol (`bdf`) | | `rumoca-input`, `rumoca-input-keyboard`, `rumoca-input-gamepad` | Interactive input devices | | `rumoca-signal-frame` | Signal payload types | | `rumoca-transport-udp`, `rumoca-transport-websocket` | External coupling and viewer transport | diff --git a/docs/dev-guide/src/tooling/msl-quality-gate.md b/docs/dev-guide/src/tooling/msl-quality-gate.md index d22d93373..112e20b95 100644 --- a/docs/dev-guide/src/tooling/msl-quality-gate.md +++ b/docs/dev-guide/src/tooling/msl-quality-gate.md @@ -88,6 +88,103 @@ cargo xtask repo msl parity-manifest \ Compare `msl_quality_current.json`, `parity_fail_manifest.json`, and the per-model `[sim_*]` log lines before inspecting emitted IR artifacts. +## Cohort pinning: the per-model band table + +Aggregate band counts (`agreement_high`, `agreement_minor`, +`agreement_deviation`) do not pin the population behind them. A model that stops +simulating disappears from the comparator's `models` map, and the headline count +can stay flat while the compared set moves underneath it — which is how +`DCPM_Start` went from strict-high to `sim_solver_fail` between two +certifications with neither run naming the departure. + +A run therefore writes `msl_band_table.json` next to `sim_trace_comparison.json`, +derived from that run's comparator output and stamped with the run's scope +(`full` for a cohort certification, `partial` for a focused or sharded run). The +table has one row per model in the run's `sim_target_models` roster — the cohort, +not the compared set — so a model that was never simulated is a row with a +reason, not a gap: + +| Field | Meaning | +|---|---| +| `band` | `high` / `near` / `deviation` for a compared model, `absent` otherwise | +| `exit_reason` | mandatory on `absent`: `sim_failed`, `not_attempted`, `rumoca_trace_missing`, `reference_missing`, `trace_missing_side_unrecorded`, `comparator_failed`, `no_comparable_samples`, `excluded`, `not_compared` | +| `exit_detail` | the solver status + error code, the phase the run stopped at, the OMC message, or that exclusion's own rationale | +| `run_scope` | `full` for a cohort run, `partial` for a focused one | +| `source.trace_comparison_digest` | content hash of the comparator output the rows came from — the table's run identity | +| `git_commit` | the certification's own commit, read from its `msl_results.json` | +| channel counts, `max_channel_bounded_normalized_l1`, `bounded_normalized_l1_score` | the per-model evidence behind the band | + +Each exit reason names the boundary that stopped the comparison, and the +comparator decides it where the knowledge is: `sim_trace_comparison.json` records +`{kind, detail}` per entry in `skipped` and `missing_trace`, so a comparator +crash is never filed as a policy exclusion and our own missing trace is never +filed as a missing OMC reference. Policy exclusions come from the tracked +`msl_trace_compare_exclusions.json`, where every entry carries its own reason. + +### Rotation and run scope + +Rotation is keyed on run identity, not on call count: persisting is idempotent, +so re-running the tool over an unchanged results directory rewrites the same +table and leaves `msl_band_table_previous.json` alone. Only a new comparator +output rotates. A focused (Tier 1) run derives its reading and writes nothing — +its 20-model table is not the cohort's — and `persist_band_table` refuses outright +to rotate a `full` table aside for a `partial` one. `cargo xtask verify msl-parity +--clean-results` preserves both tables for the same reason: wiping them would make +every run a first certification, with no diff and no departures. + +### Acceptance contract + +A certification artifact is **comparable** only when its band table carries: the +`msl_band_table` schema at a version this build reads; a comparator-output digest +binding it to a run; at least one row; at least one compared row; a unique +`model_name` per row; every banded row with channel counts and no exit reason; +and every `absent` row with a named exit reason. Anything else is rejected +outright — consumers report "not comparable" rather than diffing against a +partial table. + +The table is also checked against **itself**: its declared counts and its row +digest are recomputed from the rows, and the row count is held to the recorded +`cohort_roster_models`. A hand-edited band, a count edited upward, and a row set +that is not the cohort are all refused. + +Reading a table as a *directory's* evidence adds the binding check: its digests +must match the `sim_trace_comparison.json` and `msl_results.json` sitting beside +it. A well-formed table copied in from another run, or left behind when the +comparator re-ran, is refused rather than quoted as this run's band population. +The exclusion list that attributed the `excluded` rows is recorded by digest too, +so which policy list produced a reading is on the artifact rather than ambient. + +The quality gate reads the persisted table for its strict-high accounting and +refuses to quote a number when the table and the OMC reference disagree about the +compared, strict-high, near, or deviation counts (one of the two artifacts is +stale). Its summary states either the cohort movement or the named reason it +could not be computed — never zeros standing in for "we could not tell" — and +lists every model that left the compared set with its exit reason and every model +still compared over fewer channels than before, because a band is a *share* of +the compared channels and coverage can collapse under a band that never moves. + +Two movement readings fail the gate on a full-cohort run: a model that held the +strict-high band and is no longer compared, and a certification with no +predecessor table to diff against at all. The second is deliberate — the rules +that read movement are inert without a predecessor, and a run must not pass with +them silently switched off. CI restores the previous certification's table as +`msl_band_table_previous.json` before the gate; `--clean-results` preserves both +tables so a local re-run keeps its predecessor. + +```bash +# Persist / re-derive the table for a results directory (--check validates only, +# including that the table on disk belongs to that directory). With no +# --results-dir, the tool reads the directory the parity config names. +cargo xtask repo msl band-table --results-dir target/msl/results + +# ENTERED / LEFT / BAND-CHANGED / COVERAGE-DROPPED between two certifications. +# Directories written before the artifact existed are still diffable: the table is +# derived on the fly from sim_trace_comparison.json + msl_results.json. +cargo xtask repo msl transition-diff \ + --before target/msl/results-baseline \ + --after target/msl/results +``` + ## OMC reference pool and compile-speed comparison `cargo xtask repo msl omc-simulation-reference` generates the OMC simulation baseline diff --git a/docs/dev-guide/src/tooling/workspace-scenario-roadmap.md b/docs/dev-guide/src/tooling/workspace-scenario-roadmap.md deleted file mode 100644 index 84b92a826..000000000 --- a/docs/dev-guide/src/tooling/workspace-scenario-roadmap.md +++ /dev/null @@ -1,297 +0,0 @@ -# Workspace and Scenario Roadmap - -This roadmap tracks the migration to one browser/editor model shared by VS Code, -the playground, and mdBook live examples. The policy source of truth is -`spec/SPEC_0018_TOOL_CONFIG.md`; update that spec before treating any roadmap -item below as a permanent rule. - -## Target Shape - -Rumoca has two user-visible concepts: - -- **Workspace**: the open file tree and performance context, like a VS Code - workspace. It owns files, folders, active documents, mounted libraries, - parsed source-root caches, and editor session state. -- **Scenario**: one runnable TOML file, `rumoca-scenario.toml` or - `rumoca-scenario..toml`. - It owns model selection, task, source roots for that run, solver settings, - codegen settings, plots, viewer settings, input routing, and visualization - script paths. - -The GUI never owns configuration truth. It renders the selected scenario TOML -and writes back to that same scenario. - -## Workspace Settings - -Workspace configuration lives in visible `rumoca-workspace.toml` files. Rumoca -does not use hidden project directories for generated caches, results, or local -editor state. - -`rumoca-workspace.toml` is for workspace context only: - -- global and scoped source roots -- mounted libraries or package archives -- parsed source-root cache policy -- default scenario on open -- browser/book preload bundles -- editor layout defaults when they are portable - -It must not contain model-specific solver, plot, codegen, or viewer settings. -Those stay in scenario files. - -Nested workspace files cascade from parent to child. The nearest file may add -scoped source roots or override scalar workspace preferences. Scenario-local -`source_roots` are applied after matching workspace roots. - -Effective source roots for a scenario are: - -1. parent workspace roots -2. nested workspace roots -3. workspace scoped roots matching the scenario path -4. scenario `source_roots` -5. host-local overrides, only for local machine paths - -## Shared Surfaces - -All three hosts should use the same shared behavior: - -- VS Code reads real files from disk and calls the LSP. -- The playground reads an in-memory workspace and calls the WASM worker. -- The Rust book opens a small generated workspace per live example and calls - the same WASM/runtime helpers. - -`packages/rumoca-web` owns shared browser UI and transforms: - -- default scenario editor GUI for `rumoca-scenario.toml` and - `rumoca-scenario..toml` -- synchronized GUI/raw TOML mode switching for the same scenario file -- scenario settings GUI -- results viewer settings GUI -- shared Modelica language setup -- runtime helpers for parsed source-root caches and simulation -- host-neutral request/response shapes - -Host packages only adapt transport and file access. - -## Implementation Phases - -Current status: - -- Phase 1 is implemented for the browser command surface: playground code uses - workspace/scenario request names, and Rust WASM export names are treated as an - internal ABI boundary in the worker. -- Phase 2 has an initial Rust implementation for visible - `rumoca-workspace.toml` cascading, ordered source-root merging, and LSP - source-root loading. LSP reload paths now use the focused document/scenario - path when loading cascading workspace config, so child workspace files and - scoped source roots participate in completions, diagnostics, and simulation - preparation. The same Rust workspace-config semantics are now available to - browser hosts through a WASM/worker command that evaluates visible - `rumoca-workspace.toml` files from an in-memory workspace map for a focused - path. Review of that API exposure found no discrete correctness issues. The - playground now passes visible workspace config files through scenario - requests and uses the effective source-root command when building simulation - settings/defaults for the active document. Inherited workspace roots stay out - of editable scenario overrides, and simulation execution now loads the - resolved effective source-root contents before dispatching the solver. The - Rust book live-example harness now loads staged `rumoca-workspace.toml` files - through the same WASM workspace-config API, and generated book scenario TOMLs - keep inherited dependency roots out of editable scenario settings. The live - book harness now validates that repo examples resolve effective workspace - roots before simulation/DAE compilation, reports missing staged workspace or - source-root cache state explicitly, and surfaces the resolved root count in - the widget status. -- Phase 3 is implemented for the planned shared scenario surfaces: playground - scenario settings go through the - shared WASM scenario API instead of browser-local TOML rendering. The Rust - book live settings path now also uses the same WASM scenario config - round-trip API instead of a browser-local TOML parser/patcher. VS Code - scenario creation now renders TOML through a - shared Rust/LSP scenario config command instead of hand-built TOML strings. - The LSP/VS Code command surface now has full scenario config load/save - primitives for the shared scenario GUI, and open scenario files are saved - through the VS Code editor buffer so raw TOML state cannot go stale. VS Code - now contributes the shared scenario GUI as the default custom editor for - `rumoca-scenario.toml` and `rumoca-scenario..toml`, with a raw TOML - escape hatch back to the same file. The custom editor parses the live TOML buffer so unsaved raw edits - and parse errors are reflected immediately when switching back to the GUI, and - scenario creation/settings commands now route scenario files into that shared - editor instead of an older side-panel settings path. The scenario GUI refreshes - from live text when its custom editor becomes active, keeping GUI/raw TOML - switching synchronized without requiring an intermediate save. - The playground now opens - `rumoca-scenario.toml`/`rumoca-scenario..toml` in the shared - scenario GUI by default, uses the worker-backed full scenario config load/save - commands, and keeps raw TOML edits synchronized against the same in-memory - workspace file. The Rust book live settings panel now mounts the same shared - scenario GUI document as VS Code and the playground, with the book acting only - as a thin host for WASM load/save and raw TOML fallback. The shared GUI now - exposes a task-aware run action, with VS Code, playground, and mdBook hosts - all saving the scenario TOML before dispatching their existing scenario - execution path. The shared GUI can - now display resolved workspace source roots as read-only context while keeping - scenario-local `source_roots` as the only editable scenario field. The live - book Monaco editor follows mdBook theme changes and forces existing editors to - repaint when switching Light/Rust/Coal/Navy/Ayu. The shared scenario - GUI is now being upgraded from a generic - TOML leaf editor into the sole typed scenario authoring surface across VS - Code, playground, and mdBook: finite choices use dropdowns, simulation values - use validated numeric controls, plot panels use repeated structured controls - instead of JSON blobs, top-level sections have readable labels, summaries, - and visible disclosure affordances, advanced input routing is hidden behind a - summary disclosure, and normal saves validate data before writing TOML - through the shared Rust scenario renderer. Structured signal routes now - round-trip as typed TOML values instead of JSON strings, so input-enabled - native and browser runs share the same signal mapping shape. Inline mdBook - examples without a - scenario file now synthesize an in-memory `rumoca-scenario.toml` before - opening Settings, so the same GUI configures simulation, plot panels, and 3D - viewer script paths. 3D plot panels expose viewer script paths as typed - controls while script bodies stay in files or host-managed assets, and the - browser harness now renders scenario-configured plot/viewer panels instead of - treating sidecar JavaScript as a hardcoded viewer path. The canonical file - names are now - `rumoca-workspace.toml`, `rumoca-scenario.toml`, - `rumoca-scenario..toml`, `rumoca-result...json`, - and `rumoca-cache/`; old scenario/workspace names and hidden project-result - directories are being removed rather than aliased. VS Code source-file toolbar - actions now create/open a scenario file with sensible simulation defaults and - an output directory instead of prompting for task/model/name internals up - front. Playground editor toolbar actions now use the same shared scenario - command path: the lightning action creates/opens a codegen scenario file, - the run action dispatches from the active scenario task, scenario runs compile - the configured `[model].file` rather than editor chrome text, and generated - code respects `[codegen].output_dir`. Scenario-local source roots now use a - structured add/remove list in the shared GUI, with host-backed browsing where - the host can provide it, and confusing plot default knobs are hidden from the - main form in favor of concrete plot-panel configuration. Input routing is now - treated as a scenario-level simulation capability rather than an interactive - scenario type: the shared GUI has an explicit input-enable switch plus typed - local, keyboard key/integrator, gamepad axis/button/integrator, - and model-input mapping rows, while viewer surfaces remain separate. The - quadrotor interactive example now demonstrates that split: realtime - simulation plus input routing in the scenario, with the external 3D scene as - presentation rather than a distinct scenario kind. The Rust book live widget - now has the browser side of that path as well: input-enabled/realtime - scenarios load the shared web scheduled simulation, compile a WASM session with - staged workspace/source-root context, route keyboard/gamepad/local inputs into - Modelica inputs, and run the scenario's 3D scene inline instead of falling - back to batch plots or a native-only viewer. Interactive capture, HUD/camera - controls, stop handling, and state reset now live in the shared web runtime so - mdBook, VS Code, and the playground consume the same control semantics instead - of rebuilding them per host. Simulation speed and user input are separate - scenario concepts: `[sim].mode` controls pacing, `[input]` opts into the - input-capable session/viewer path, and viewer mode only chooses presentation. - The playground interactive-result editor now uses the same runtime failure - reporting path as the shared runtime: source-root loading, scene - initialization, startup rendering, and animation-frame errors update the tab - status and flow into the shared Errors panel instead of leaving `.input` - editor tabs stuck at "compiling session". -- Phase 5 has a packaging/runtime baseline: `xtask` owns Rust orchestration and - web asset freshness checks, while npm-owned package scripts may run npm, - refresh stale web dependencies, and rebuild shared web assets before copying - them into VS Code. The browser WASM package and GitHub Pages staging include - the worker runtime dependencies (`rumoca_runtime.js` and - `modelica_language.js`), and playground branding uses the shared staged brand - asset. -- Phase 6 has started in browser code: old browser-only config and generated - hidden side-data terminology have been removed from the playground - surface. Browser simulations with workspace sources keep RK/auto on the - workspace-source WASM path, while BDF syncs those workspace sources into the - runtime session before using the lazy diffsol addon path. Playground codegen - target selection now reads/writes `[codegen].target` through shared scenario - commands instead of persisting a duplicate browser editor-state setting, and - the same shared scenario command surface is now available through LSP/WASM for - hosts that need model-scoped codegen configuration. Codegen scenario settings - no longer save through simulation-preset commands: codegen target and - scenario-local source roots are task-aware scenario edits, and simulate and - codegen scenarios for the same model no longer collide in the shared scenario - config store. VS Code/shared settings helper APIs now use scenario names, and - the remaining browser dependency bundle generated by `packages/rumoca-web` - has been renamed away from project terminology. Playground editor-state now - uses an explicit session-state allowlist, so old hidden - `sim`, codegen-template, and selected-model values are not persisted as a - second scenario configuration store. Browser contract tests now pin removal - of `.rum` handling and generated/project side channels. Current progress is - no longer considered 100% because the generic scenario form exposed too much - raw TOML shape to users; remaining work is to finish typed coverage for - codegen and viewer/input routing, then rerun final cross-host done-criteria - verification. - -### Phase 1: Name the API - -- Add `rumoca-workspace.toml` to `SPEC_0018`. -- Define workspace cascade and source-root merge semantics there. -- Define that scenario files are the only run/config truth. -- Define that opening a scenario file defaults to the shared scenario GUI, with - a raw TOML toggle synchronized against the same file content. -- Rename browser/LSP command concepts away from project-specific config where - possible: - - `workspace.*` for file tree, model discovery, and source-root cache work - - `scenario.*` for reading/writing scenario TOML and derived run settings - -### Phase 2: Workspace-Driven Source Roots - -- Implement parent-to-child loading for `rumoca-workspace.toml`. -- Merge the nearest workspace file with its parents for the active document or - selected scenario. -- Use the merged workspace source roots for VS Code completion, hover, - diagnostics, go-to-definition, and model discovery. -- Apply the same effective workspace source roots in the playground and Rust - book through WASM. -- Rebuild or invalidate parsed source-root caches when the nearest effective - workspace context changes. - -### Phase 3: Centralize Scenario Logic - -- Keep TOML parsing/rendering in Rust shared APIs (`rumoca-compile` through LSP - and WASM bindings). -- Keep GUI rendering and host-neutral form transforms in `packages/rumoca-web`. -- Remove browser-only TOML parsers and config renderers from the playground. -- Make the playground apply writes returned by the WASM scenario API instead of - constructing scenario text itself. -- Make the GUI and raw TOML view round-trip through the same scenario file: - valid GUI edits render TOML; raw TOML edits reparse into the GUI; parse - errors stay visible without losing raw text. - -### Phase 4: Introduce Workspace Settings - -- Add a Rust `WorkspaceConfig` parser for `rumoca-workspace.toml`. -- Support parent-to-child cascading and scoped source roots. -- Expose the same effective workspace context through LSP and WASM. -- Store generated parsed source-root caches under visible `rumoca-cache/`. - -### Phase 5: Make Hosts Thin - -- VS Code maps filesystem events and webview messages to shared scenario and - workspace commands. -- VS Code contributes a custom editor for scenario files that opens the shared - GUI by default and can toggle to raw TOML. -- The playground maps in-memory files and workers to the same commands. -- The Rust book builds a small workspace for each live example, including the - selected scenario, model files, visualization assets, and prebuilt source-root - caches. - -### Phase 6: Delete Old Surfaces - -- Remove `.rum` handling. -- Remove project-specific config concepts from browser code. -- Remove separate playground settings state that duplicates scenario TOML. -- Remove hidden generated cache/result/session directories. Persisted - simulation results are standalone result JSON files written next to the - scenario TOML by default, or under the scenario's configured output directory. - -## Done Criteria - -- A scenario that works in VS Code works unchanged in the playground and Rust - book when the same workspace files are available. -- The settings GUI can be closed and reopened without losing fidelity because - `rumoca-scenario.toml` remains the source of truth. -- Source-root caches are preloaded per workspace/scenario context so editing, - autocomplete, diagnostics, and simulation startup stay responsive. -- VS Code autocomplete uses the merged nearest `rumoca-workspace.toml` context - plus scenario-local roots, matching playground and Rust book behavior. -- Clicking a scenario opens the shared GUI by default, and toggling raw TOML - edits the same file without drift. -- Browser, book, and VS Code tests exercise the same scenario/workspace command - shapes instead of three separate implementations. diff --git a/docs/user-guide/live/rumoca-live.js b/docs/user-guide/live/rumoca-live.js index ac5539403..84c077fcd 100644 --- a/docs/user-guide/live/rumoca-live.js +++ b/docs/user-guide/live/rumoca-live.js @@ -2715,7 +2715,7 @@ self.onmessage = async (event) => { gpuCheck.type = 'checkbox'; gpuCheck.checked = gpuDefault; gpuLabel.append(gpuCheck, document.createTextNode(' GPU')); - gpuLabel.title = 'Run on WebGPU (wgsl-solve backend; experimental)'; + gpuLabel.title = 'Run on WebGPU (wgsl-ode backend; experimental)'; const liveLabel = document.createElement('label'); liveLabel.className = 'rumoca-live-gpu'; const liveCheck = document.createElement('input'); @@ -3898,7 +3898,7 @@ html, body { margin: 0; width: 100%; height: 100%; overflow: hidden; background: variables: variableNames.length, }, requested: { - solver: 'wgsl-solve interactive', + solver: 'wgsl-ode interactive', t_start: t0, dt: outputDt, internal_dt: stepDt, @@ -4017,7 +4017,7 @@ html, body { margin: 0; width: 100%; height: 100%; overflow: hidden; background: const adapter = await gpu.probeGpu(); if (typeof wasm.prepare_gpu_simulation !== 'function') { throw new Error( - 'This WASM build predates the wgsl-solve backend; ' + 'This WASM build predates the wgsl-ode backend; ' + 'rebuild the package (cargo xtask playground build) or ' + 'uncheck GPU to simulate on the CPU (WASM) path.' ); diff --git a/docs/user-guide/src/codegen/custom-targets.md b/docs/user-guide/src/codegen/custom-targets.md index ccc678b5e..b5b0bea57 100644 --- a/docs/user-guide/src/codegen/custom-targets.md +++ b/docs/user-guide/src/codegen/custom-targets.md @@ -11,8 +11,9 @@ which IR the template receives (default `dae`): rumoca compile Model.mo --target my_template.jinja --phase flat -o out.txt ``` -The template gets the serialized IR as its context. The repository example -`examples/codegen/custom_casadi.jinja` shows this workflow. +The template gets the canonical stage projection as its context. The +repository example `examples/codegen/custom_checked_variables.jinja` shows +this workflow. To learn the available fields, dump the matching IR as JSON first: @@ -34,8 +35,8 @@ templates render which output files. The target — not individual templates — owns the IR choice, so a bundle stays consistent. The repository ships a complete worked example: -`examples/codegen/standalone_web/target.toml` renders a standalone HTML page -plus companion JavaScript from one model. +`examples/codegen/checked_dae_report/target.toml` renders a readable report +from the canonical checked DAE projection. ## Design Rule: Language Knowledge Lives in Targets diff --git a/docs/user-guide/src/codegen/targets.md b/docs/user-guide/src/codegen/targets.md index 293bca756..35ea78734 100644 --- a/docs/user-guide/src/codegen/targets.md +++ b/docs/user-guide/src/codegen/targets.md @@ -1,8 +1,8 @@ # Targets and Templates -Rumoca can render a compiled model into other languages and ecosystems: -symbolic math packages, compiled simulation kernels, FMUs, or Modelica -source at any pipeline stage. Code generation is *target-directory based*: a +Rumoca can render a compiled model into symbolic frameworks, compiled +simulation kernels, eFMI artifacts, or Modelica source. Code generation is +*target-directory based*: a target is a `target.toml` manifest plus Jinja templates, and each target declares which compiler IR stage it consumes. @@ -16,19 +16,23 @@ Built-in targets include: | Target | IR | Mode | Output | |---|---|---|---| -| `sympy` | dae | symbolic | SymPy model classes | -| `jax` | dae | symbolic | JAX functions | -| `casadi-sx` / `casadi-mx` | dae | symbolic | CasADi expressions | -| `julia-mtk` | dae | symbolic | ModelingToolkit.jl | -| `symforce` | dae | symbolic | SymForce, with native AD support | -| `onnx` | dae | symbolic | ONNX graph | -| `rust-fixed-solve` | solve | compiled | Fixed-size Rust derivative kernel with `State`, `Parameters`, `Derivative`, and `derivative_rhs_into` | -| `rust-solve` / `c-solve` / `embedded-c` | solve | compiled | Self-contained simulation kernels | -| `cuda-c` / `cuda-nvrtc-solve-jit` | solve | compiled/JIT | GPU kernels | -| `wgsl-solve` | solve | compiled | Experimental WebGPU kernels for browser runs | -| `cranelift-solve-jit` / `mlir` | solve | JIT/compiled | In-process execution backends | -| `fmi2` / `fmi3` | solve | packaged | FMU export | -| `modelica` / `flat-modelica` / `dae-modelica` / `base-modelica` | ast/flat/dae | source-transform | Modelica source at each stage | +| `casadi-ode` | solve | symbolic | Differentiable CasADi explicit RHS | +| `jax-ode` | solve | symbolic | JIT/AD-capable JAX explicit RHS | +| `rust-fixed-ode` | solve | compiled | Fixed-size, allocation-free Rust explicit-ODE derivative kernel | +| `rust-ode` / `c-ode` | solve | compiled | Checked explicit-ODE derivative kernels | +| `cuda-ode` | solve | compiled | Batched CUDA explicit-ODE derivative kernel | +| `wgsl-ode` | solve | JIT | Experimental WebGPU explicit-ODE kernels for browser execution | +| `mlir` | solve | source | Inspectible MLIR solve-kernel source with affine tensor loops | +| `flat-modelica` / `base-modelica` | flat | source-transform | Flattened Modelica-family interchange artifacts | +| `dae-modelica` | dae | source-transform | Modelica representation of the checked DAE | +| `fmi2` / `fmi3` | fmi | standards container | Source-code Model Exchange and Co-Simulation FMUs | +| `fmi-ls-wasm` | fmi | compiled component | Experimental FMI-LS WebAssembly component crate | +| `galec` / `galec-production` | algorithm-code | eFMI | Algorithm Code and Production Code eFMU containers | +| `embedded-c-galec` | algorithm-code | compiled | GALEC-derived embedded C without an eFMI container | + +Targets without a complete checked artifact and executable or independent +validation evidence are intentionally absent. Rumoca does not expose aliases +for removed target names or route those names through a weaker IR. The `rumoca targets` table also reports a readiness level (0 = experimental … 2 = validated) and per-feature support columns (scalarization, tensor @@ -41,8 +45,8 @@ truth. ```bash rumoca compile examples/models/SympyDecay.mo \ --model SympyDecay \ - --target sympy \ - --output /tmp/sympy_decay + --target c-ode \ + --output /tmp/decay_c_ode ``` `--output` may be a file or directory depending on what the target renders. @@ -53,10 +57,12 @@ Like simulations, generation jobs worth repeating belong in a `rumoca-scenario.t with `task = "codegen"`. Runnable examples live under `examples/codegen/` and write into `examples/codegen/gen/` (git-ignored): -- `examples/codegen/rumoca-scenario.ball_jax.toml` — built-in JAX target -- `examples/codegen/rumoca-scenario.sympy_decay_sympy.toml` — built-in SymPy target -- `examples/codegen/rumoca-scenario.sympy_decay_standalone_web.toml` — custom web target -- `examples/codegen/rumoca-scenario.sympy_decay_custom_casadi.toml` — raw Jinja template +- `examples/codegen/rumoca-scenario.ball_jax_ode.toml` — checked ODE RHS JAX target +- `examples/codegen/rumoca-scenario.sympy_decay_c_ode.toml` — checked ODE RHS C target +- `examples/codegen/rumoca-scenario.sympy_decay_checked_dae_report.toml` — + custom checked-DAE report target +- `examples/codegen/rumoca-scenario.sympy_decay_custom_checked_variables.toml` + — raw checked-DAE Jinja template ## IR Dumps vs Targets diff --git a/docs/user-guide/src/getting-started/quickstart.md b/docs/user-guide/src/getting-started/quickstart.md index aecf77e73..566105276 100644 --- a/docs/user-guide/src/getting-started/quickstart.md +++ b/docs/user-guide/src/getting-started/quickstart.md @@ -84,8 +84,8 @@ Render a target: ```bash rumoca compile examples/models/SympyDecay.mo \ --model SympyDecay \ - --target sympy \ - --output /tmp/sympy_decay + --target c-ode \ + --output /tmp/decay_c_ode ``` Dump an intermediate representation of the compiler instead: diff --git a/docs/user-guide/src/language/arrays-pde.md b/docs/user-guide/src/language/arrays-pde.md index 5119a24df..bcd59f872 100644 --- a/docs/user-guide/src/language/arrays-pde.md +++ b/docs/user-guide/src/language/arrays-pde.md @@ -456,7 +456,7 @@ the same slider feeds `aoa_cmd` during stepping, so the physical airfoil angle moves through the first-order lag. This example defaults the **GPU** checkbox -on: the compiler's experimental `wgsl-solve` backend lowers the system to +on: the compiler's experimental `wgsl-ode` backend lowers the system to WebGPU compute kernels and an in-page RK4 integrator runs them. Interior finite-volume loops are preserved as source-proven affine stencils, so the WebGPU path emits native row-parallel stencil kernels instead of rediscovering diff --git a/docs/user-guide/src/simulation/inspect.md b/docs/user-guide/src/simulation/inspect.md index 099182820..6a75ddfd4 100644 --- a/docs/user-guide/src/simulation/inspect.md +++ b/docs/user-guide/src/simulation/inspect.md @@ -10,7 +10,7 @@ pipeline. All of these work with both `rumoca compile` and `rumoca sim`. | Stage | What you see | |---|---| -| `ast-mo` / `ast-json` | The parsed, resolved syntax tree | +| `ast-json` | The parsed, resolved semantic tree (no lossy Modelica reconstruction) | | `flat-mo` / `flat-json` | The flattened model: hierarchy and `connect`s expanded | | `dae-mo` / `dae-json` | The DAE system: equations partitioned, ready for analysis | | `solve-json` | The solver IR: sorted, torn, scheduled for execution | @@ -78,7 +78,7 @@ rumoca cache status # compilation cache usage ## Verbose Compilation ```bash -rumoca compile Model.mo --target sympy -o out -v +rumoca compile Model.mo --target c-ode -o out -v ``` `-v` prints friendly `[rumoca] Phase ...` progress lines, which localizes diff --git a/docs/user-guide/src/simulation/scenario-tomls.md b/docs/user-guide/src/simulation/scenario-tomls.md index 3e3d117ec..a1c96a70c 100644 --- a/docs/user-guide/src/simulation/scenario-tomls.md +++ b/docs/user-guide/src/simulation/scenario-tomls.md @@ -81,7 +81,7 @@ dt = 0.01 # simulation timestep [s] t_end = 10.0 # batch/results-panel output horizon atol = 1e-6 # optional absolute solver tolerance rtol = 1e-6 # optional relative solver tolerance -solver = "auto" # auto | bdf | esdirk34 | trbdf2 | rk-like +solver = "auto" # auto | bdf | rk-like output = "results.html" mode = "realtime" # optional pacing, see below ``` diff --git a/docs/user-guide/src/simulation/solvers.md b/docs/user-guide/src/simulation/solvers.md index e3ec4fa7c..e7e77d847 100644 --- a/docs/user-guide/src/simulation/solvers.md +++ b/docs/user-guide/src/simulation/solvers.md @@ -3,14 +3,17 @@ Rumoca ships several integration methods behind one `--solver` flag (CLI), `solver` key (`[sim]` in scenarios), or `Solver` experiment annotation. +The table below is the whole set. A name that is not in it is an error naming +the valid ones — rumoca never falls back to a different solver than the one you +asked for, because a run that quietly changed integrator would report timings +and trajectories for a method you did not select. + ## Choosing a Solver | Solver | Kind | Use for | |---|---|---| | `auto` | — | Default; picks a method from the model's structure | | `bdf` | Implicit multistep (diffsol) | Stiff systems, smooth DAEs | -| `esdirk34` | Implicit SDIRK tableau (diffsol) | Stiff DAEs, one-step alternative to BDF | -| `trbdf2` | Implicit SDIRK tableau (diffsol) | Stiff DAEs | | `rk-like` | Explicit Runge–Kutta-style | Non-stiff systems, event-heavy models | Rules of thumb: diff --git a/docs/user-guide/src/tools/cli.md b/docs/user-guide/src/tools/cli.md index 6de63c536..66a8bd35e 100644 --- a/docs/user-guide/src/tools/cli.md +++ b/docs/user-guide/src/tools/cli.md @@ -38,7 +38,7 @@ rumoca sim -c path/to/rumoca-scenario.toml | `-c, --config ` | Run a `rumoca-scenario.toml` scenario instead of a direct sim | | `-m, --model ` | Main model/class to compile (auto-inferred when omitted) | | `--source-root ` | Add a source root (repeatable); `MODELICAPATH` entries are appended after these | -| `--solver ` | `auto`, `bdf`, `esdirk34`, `trbdf2`, or `rk-like` — see [Solvers and Accuracy](../simulation/solvers.md) | +| `--solver ` | `auto`, `bdf`, or `rk-like` — see [Solvers and Accuracy](../simulation/solvers.md) | | `--t-end ` | Batch end time. Direct runs default to 1.0; batch scenarios use `[sim].t_end`; interactive runs are user-terminated | | `--dt
` | Optional fixed output interval; chosen automatically if omitted | | `-o, --output ` | Simulation report path (default `_results.html`) | @@ -73,7 +73,7 @@ section of the scenario, not CLI flags. ```bash rumoca compile Model.mo --emit dae-mo # DAE as Modelica, to stdout rumoca compile Model.mo --emit solve-json -o out.json # solver IR as JSON -rumoca compile Model.mo --target sympy -o out/ # built-in target +rumoca compile Model.mo --target c-ode -o out/ # checked Solve target rumoca compile Model.mo --target my.jinja --phase flat ``` diff --git a/docs/user-guide/src/tools/python.md b/docs/user-guide/src/tools/python.md index 7f7e6b859..19b933f69 100644 --- a/docs/user-guide/src/tools/python.md +++ b/docs/user-guide/src/tools/python.md @@ -106,32 +106,18 @@ m6.simulate(t=(0, 10)) ## Live symbolic export -Turn a model into a live object in the symbolic framework of your choice — no -file dance: +CasADi and JAX exports consume the same checked, computable Solve program used +by compiled backends. A `SolveExport` exposes +`xdot = rhs(t, x, u, p)`, `state_names`, `input_names`, and `parameter_names`: ```python -cm = m.to_casadi() # CasadiModel (form="dae") -cm.ode # explicit RHS (CasADi's native DAE form) -cm.dae # {x, z, p, t, ode, alg} — feed ca.integrator -S = cm.jacobian("ode", "p") # exact AD parameter sensitivity - -sm = m.to_sympy() # SymPy model; sm.solve_explicit() -jm = m.to_jax() # JAX ode_fn + diffrax simulate -``` - -Pass `form="solve"` for the scalarized, causalized explicit form (the same -source the C/FMI/Rust backends use) — you get a `SolveExport` exposing the -explicit right-hand side `xdot = rhs(x, u, p)` plus `state_names`/ -`parameter_names`: - -```python -se = m.to_casadi(form="solve") # SolveExport; se.rhs is a ca.Function -se = m.to_jax(form="solve") # SolveExport; se.rhs is jit/grad/vmap-able +casadi_model = m.to_casadi() # rhs is a differentiable ca.Function +jax_model = m.to_jax() # rhs is jit/grad/vmap-able ``` These render the same tested codegen targets used by `m.codegen(target)`, so the live object and the generated files never drift. For writing files instead, use -`m.codegen("casadi-mx").save_all("out/")`. +`m.codegen("casadi-ode").save_all("out/")`. For build systems that need a single callable operation, use: diff --git a/docs/user-guide/src/troubleshooting.md b/docs/user-guide/src/troubleshooting.md index 04f4c1ca5..4fdac3dca 100644 --- a/docs/user-guide/src/troubleshooting.md +++ b/docs/user-guide/src/troubleshooting.md @@ -35,8 +35,7 @@ looks wrong. **"Step size is too small at time = ..."** — The implicit solver stalled, most often near a dense cascade of state events (rapid relay switching). Use the explicit solver: `--solver rk-like` or -`annotation(experiment(Solver = "rk-like"))`. For genuinely stiff smooth -systems, try `esdirk34` or `trbdf2` instead. +`annotation(experiment(Solver = "rk-like"))`. **NaN/Inf failures** — `rumoca sim` automatically re-runs with NaN tracing and names the offending variables. To investigate further, evaluate the diff --git a/examples/README.md b/examples/README.md index cefe82856..fcb5d2746 100644 --- a/examples/README.md +++ b/examples/README.md @@ -7,7 +7,8 @@ Examples are organized by workflow: - `interactive/`: browser or input-driven simulations with scene assets. - `benchmarks/`: reproducible cross-runtime comparisons for selected models. - `codegen/`: code generation scenarios and custom target bundles. -- `codegen/custom_casadi.jinja`: direct raw-template codegen example. +- `codegen/custom_checked_variables.jinja`: direct raw-template checked-DAE + codegen example. Generated code from `codegen/` scenarios goes under `codegen/gen/`, which is ignored by git. diff --git a/examples/codegen/README.md b/examples/codegen/README.md index bebcaf297..b339a5a73 100644 --- a/examples/codegen/README.md +++ b/examples/codegen/README.md @@ -7,20 +7,20 @@ Generated files go under `gen/`, which is ignored by git. cargo run -p rumoca -- \ compile examples/models/Ball.mo \ --model Ball \ - --target jax \ - --output examples/codegen/gen/ball_jax + --target jax-ode \ + --output examples/codegen/gen/ball_jax_ode cargo run -p rumoca -- \ compile examples/models/SympyDecay.mo \ --model SympyDecay \ - --target examples/codegen/standalone_web \ - --output examples/codegen/gen/sympy_decay_standalone_web + --target examples/codegen/checked_dae_report \ + --output examples/codegen/gen/sympy_decay_checked_dae_report cargo run -p rumoca -- \ compile examples/models/SympyDecay.mo \ --model SympyDecay \ - --target examples/codegen/custom_casadi.jinja \ - --output examples/codegen/gen/sympy_decay_custom_casadi.py + --target examples/codegen/custom_checked_variables.jinja \ + --output examples/codegen/gen/sympy_decay_custom_checked_variables.txt cargo run -p rumoca -- \ compile examples/models/GalecCounter.mo \ @@ -31,15 +31,17 @@ cargo run -p rumoca -- \ Scenarios: -- `rumoca-scenario.ball_jax.toml`: built-in JAX target. +- `rumoca-scenario.ball_jax_ode.toml`: checked ODE RHS JAX target. - `rumoca-scenario.galec_counter_production.toml`: GALEC/eFMI Production Code target (`.alg` plus generated C). -- `rumoca-scenario.sympy_decay_sympy.toml`: built-in SymPy target. -- `rumoca-scenario.sympy_decay_standalone_web.toml`: custom target directory that renders - standalone HTML and companion JavaScript. -- `rumoca-scenario.sympy_decay_custom_casadi.toml`: direct raw Jinja template example. +- `rumoca-scenario.sympy_decay_c_ode.toml`: checked ODE RHS C target. +- `rumoca-scenario.sympy_decay_checked_dae_report.toml`: custom target + directory that renders a readable report from the canonical checked DAE + projection. +- `rumoca-scenario.sympy_decay_custom_checked_variables.toml`: direct raw + Jinja template over the canonical checked DAE projection. Custom target directories and direct templates live beside scenarios: -- `standalone_web/` -- `custom_casadi.jinja` +- `checked_dae_report/` +- `custom_checked_variables.jinja` diff --git a/examples/codegen/checked_dae_report/README.md b/examples/codegen/checked_dae_report/README.md new file mode 100644 index 000000000..bf2e9dfa8 --- /dev/null +++ b/examples/codegen/checked_dae_report/README.md @@ -0,0 +1,18 @@ +# Checked DAE Report Target + +This worked custom-target example renders the canonical checked DAE template +projection as a readable report. It consumes typed variable identities, +attributes, expression counts, and every semantic system without simulating or +inventing a second runtime interface. + +```bash +cargo run -p rumoca -- \ + compile examples/models/SympyDecay.mo \ + --model SympyDecay \ + --target examples/codegen/checked_dae_report \ + --output examples/codegen/gen/sympy_decay_checked_dae_report +``` + +Browser-native simulation belongs on the planned FMI 3/Wasm execution path; +this target is deliberately an inspection/code-generation example rather than +an independent JavaScript solver. diff --git a/examples/codegen/checked_dae_report/checked_dae_report.txt.jinja b/examples/codegen/checked_dae_report/checked_dae_report.txt.jinja new file mode 100644 index 000000000..8adcfbbf8 --- /dev/null +++ b/examples/codegen/checked_dae_report/checked_dae_report.txt.jinja @@ -0,0 +1,23 @@ +checked-dae-report {{ dae.schema.version }} +model {{ model_name }} + +variables {{ dae.variables | length }} +{% for variable in dae.variables -%} +{{ variable.id }} {{ variable.role }} {{ variable.name }} {{ variable.value_type.scalar }}{% if variable.attributes.unit %} unit={{ variable.attributes.unit }}{% endif %} scalars={{ variable.scalar_count }} +{% endfor %} +expressions {{ dae.expressions | length }} +functions {{ dae.functions | length }} +domains {{ dae.domains | length }} +continuous_owners {{ dae.systems.continuous.owners | length }} +initialization_owners {{ dae.systems.initialization.owners | length }} +discrete_real_equations {{ dae.systems.discrete_real.residuals | length }} +b1c_owners {{ dae.systems.discrete_values.owners | length }} +relations {{ dae.systems.conditions.relations | length }} +conditions {{ dae.systems.conditions.conditions | length }} +roots {{ dae.systems.conditions.roots | length }} +time_events {{ dae.systems.events.time_events | length }} +event_actions {{ dae.systems.events.actions | length }} +clocks {{ dae.systems.clocks.clocks | length }} +previous_values {{ dae.systems.temporal.previous | length }} +terminals {{ dae.systems.temporal.terminals | length }} +delays {{ dae.systems.temporal.delays | length }} diff --git a/examples/codegen/checked_dae_report/target.toml b/examples/codegen/checked_dae_report/target.toml new file mode 100644 index 000000000..7c9369909 --- /dev/null +++ b/examples/codegen/checked_dae_report/target.toml @@ -0,0 +1,24 @@ +version = 1 +ir = "dae" +name = "checked-dae-report-example" +description = "Readable report over the canonical checked DAE template projection" +execution_mode = "source-transform" +deployment_class = "text" + +[capabilities] +continuous_states = true +residual_equations = true +structured_equation_families = true +external_functions = true +external_tables = true +random = true +initialization = true +events = true +runtime_events = true +clocks = true +dynamic_ranges = true +dynamic_derivative_subscripts = true + +[[files]] +path = "{{ model_name }}_checked_dae.txt" +template = "checked_dae_report.txt.jinja" diff --git a/examples/codegen/custom_casadi.jinja b/examples/codegen/custom_casadi.jinja deleted file mode 100644 index 3d756115e..000000000 --- a/examples/codegen/custom_casadi.jinja +++ /dev/null @@ -1,153 +0,0 @@ -{%- set cfg = {"prefix": "ca.", "power": "**", "power_fn": "ca.power", "if_style": "function", "mul_elem_fn": "ca.times", "and_op": "ca.logic_and", "or_op": "ca.logic_or", "not_op": "ca.logic_not"} -%} -{%- macro render_dae(dae) -%} - {%- set vars_vects = ['u', 'p', 'cp', 'x', 'y', 'z'] -%} -""" -Generated by Rumoca - rumoca pkg version : {{ dae.rumoca_version | default(value="unknown") }} - {{ dae.git_version | default(value="unknown") }} - model hash : {{ dae.model_hash | default(value="unknown") }} - template hash : {{ dae.template_hash | default(value="unknown") }} -""" - -import casadi as ca -import numpy as np - -cos = ca.cos -sin = ca.sin -tan = ca.tan - -class Model: - """ - Flattened Modelica Model - """ - - def __init__(self): - pass - - def __repr__(self): - return repr(self.__dict__) - - def simulate(self, t=None, u=None): - """ - Simulate the modelica model - """ - if t is None: - t = np.arange(0, 1, 0.01) - if u is None: - u = 0 - - # ============================================ - # Declare time - time = ca.SX.sym('time') - - {%- for var in vars_vects %} - - # ============================================ - # Declare {{ var }} - - {% for name, comp in dae[var] | default(value={}) | items -%} - {{ name }} = ca.SX.sym('{{ name }}') - {% endfor -%} - - self.{{var }} {{ "= ca.vertcat(" }}{%- for name, comp in dae[var] | default(value={}) | items %} - {{ name }} {%- if not loop.last -%}{{ ", " }}{%- endif -%} - {% endfor -%} {{ ")" }} - - self.{{ var }}0 = {{ "{" }} {% for name, comp in dae[var] | default(value={}) | items %} - '{{ name }}': {{ render_expression(comp.start) }} {%- if not loop.last -%}{{ ", " }}{%- endif -%} - {%- endfor -%}{{ "}" }} - {{ var }}0 = np.array([self.{{ var }}0[k] for k in self.{{ var }}0.keys()]) - - - {%- endfor %} - - # ============================================ - # Define Continous Update Function: fx - {% for eq in dae.f_x | default(value=dae.fx | default(value=[])) -%} - {{ render_equation(eq) }} - {% endfor%} - - # ============================================ - # Create Integrator - F = ca.integrator( - 'F', 'idas', - {'x': self.x, 'z': self.z, 'p': self.p, 'u': self.u, 'ode': self.ode, 'alg': self.alg}, - t[0], t) - - res = F(x0=x0, z0=z0, p=p0, u=u) - return { - 't': t, - 'x': res['xf'].T - } - - def linearize(self): - """ - Linearize the model - """ - A = ca.jacobian(self.ode, self.x) - B = ca.jacobian(self.ode, self.u) - C = ca.jacobian(self.y, self.x) - D = ca.jacobian(self.y, self.u) - return (A, B, C, D) - - -def cat(axis, *args): - return ca.vertcat(*args) -{%- endmacro -%} - -{%- macro render_expression(expr) -%} - {%- if expr is undefined or expr is none -%} - 0 - {%- else -%} - {{- render_expr(expr, cfg) -}} - {%- endif -%} -{%- endmacro -%} - -{%- macro render_equation(eq) -%} - {%- if eq.lhs is defined and eq.rhs is defined -%} - {{- render_expression(eq.lhs) -}} {{- " = " -}} - {{- render_expression(eq.rhs) -}} - {%- endif -%} -{%- endmacro -%} - -{%- macro render_terminal(term) -%} - {% if term.terimanal_type == "UnsignedInteger" %} - {{ term.token.text | float }} - {% elif term.terimanal_type == "UnsignedReal" %} - {{ term.token.text | float }} - {% endif %} -{%- endmacro -%} - -{%- macro render_binary(expr) -%} - {{- render_expression(expr.lhs) -}} {{- " " -}} - {%- if "Add" in expr.op -%} - {{ "+" }} - {%- elif "Sub" in expr.op -%} - {{ "-" }} - {%- elif "Mul" in expr.op -%} - {{ "*" }} - {%- elif "Div" in expr.op -%} - {{ "/" }} - {%- else -%} - UNHANDLED OP: {{ expr.op }} - {%- endif -%} - {{- " " -}} {{- render_expression(expr.rhs) -}} -{%- endmacro -%} - - -{%- macro render_unary(expr) -%} - {{ expr.op.text }} {{ render_expression(expr.rhs) }} -{%- endmacro -%} - -{%- macro render_component_reference(comp) -%} - {%- for part in comp.parts -%} - {{ part.ident.text }}{% if not loop.last %}.{% endif %} - {%- endfor -%} -{%- endmacro -%} - -{%- macro render_function(func) -%} - {{ render_component_reference(func.comp) }} {{- "(" -}}{%- for arg in func.args -%} - {{- render_expression(arg) -}} {%- if not loop.last -%}, {%- endif -%} - {%- endfor -%}{{ ")" }} -{%- endmacro -%} - -{{ render_dae(dae) }} diff --git a/examples/codegen/custom_checked_variables.jinja b/examples/codegen/custom_checked_variables.jinja new file mode 100644 index 000000000..7c1f0f041 --- /dev/null +++ b/examples/codegen/custom_checked_variables.jinja @@ -0,0 +1,5 @@ +checked-dae {{ dae.schema.version }} +{% for variable in dae.variables -%} +{{ variable.id }} {{ variable.role }} {{ variable.name }} {{ variable.value_type.scalar }}{% if variable.attributes.unit %} unit={{ variable.attributes.unit }}{% endif %} +{% endfor -%} +continuous_owners {{ dae.systems.continuous.owners | length }} diff --git a/examples/codegen/rumoca-scenario.ball_jax.toml b/examples/codegen/rumoca-scenario.ball_jax_ode.toml similarity index 87% rename from examples/codegen/rumoca-scenario.ball_jax.toml rename to examples/codegen/rumoca-scenario.ball_jax_ode.toml index fa5257821..61adceb2c 100644 --- a/examples/codegen/rumoca-scenario.ball_jax.toml +++ b/examples/codegen/rumoca-scenario.ball_jax_ode.toml @@ -7,5 +7,5 @@ file = "../models/Ball.mo" name = "Ball" [codegen] -target = "jax" +target = "jax-ode" output_dir = "gen/ball_jax" diff --git a/examples/codegen/rumoca-scenario.sympy_decay_sympy.toml b/examples/codegen/rumoca-scenario.sympy_decay_c_ode.toml similarity index 67% rename from examples/codegen/rumoca-scenario.sympy_decay_sympy.toml rename to examples/codegen/rumoca-scenario.sympy_decay_c_ode.toml index 35ef89c1d..4f95a1807 100644 --- a/examples/codegen/rumoca-scenario.sympy_decay_sympy.toml +++ b/examples/codegen/rumoca-scenario.sympy_decay_c_ode.toml @@ -7,5 +7,5 @@ file = "../models/SympyDecay.mo" name = "SympyDecay" [codegen] -target = "sympy" -output_dir = "gen/sympy_decay_sympy" +target = "c-ode" +output_dir = "gen/sympy_decay_c_ode" diff --git a/examples/codegen/rumoca-scenario.sympy_decay_custom_casadi.toml b/examples/codegen/rumoca-scenario.sympy_decay_checked_dae_report.toml similarity index 58% rename from examples/codegen/rumoca-scenario.sympy_decay_custom_casadi.toml rename to examples/codegen/rumoca-scenario.sympy_decay_checked_dae_report.toml index 00cfe3468..5974727a1 100644 --- a/examples/codegen/rumoca-scenario.sympy_decay_custom_casadi.toml +++ b/examples/codegen/rumoca-scenario.sympy_decay_checked_dae_report.toml @@ -7,5 +7,5 @@ file = "../models/SympyDecay.mo" name = "SympyDecay" [codegen] -target = "custom_casadi.jinja" -output_dir = "gen/sympy_decay_custom_casadi" +target = "checked_dae_report" +output_dir = "gen/sympy_decay_checked_dae_report" diff --git a/examples/codegen/rumoca-scenario.sympy_decay_standalone_web.toml b/examples/codegen/rumoca-scenario.sympy_decay_custom_checked_variables.toml similarity index 52% rename from examples/codegen/rumoca-scenario.sympy_decay_standalone_web.toml rename to examples/codegen/rumoca-scenario.sympy_decay_custom_checked_variables.toml index 7fc6a4484..f23e48940 100644 --- a/examples/codegen/rumoca-scenario.sympy_decay_standalone_web.toml +++ b/examples/codegen/rumoca-scenario.sympy_decay_custom_checked_variables.toml @@ -7,5 +7,5 @@ file = "../models/SympyDecay.mo" name = "SympyDecay" [codegen] -target = "standalone_web" -output_dir = "gen/sympy_decay_standalone_web" +target = "custom_checked_variables.jinja" +output_dir = "gen/sympy_decay_custom_checked_variables.txt" diff --git a/examples/codegen/standalone_web/README.md b/examples/codegen/standalone_web/README.md deleted file mode 100644 index 90b34bc6d..000000000 --- a/examples/codegen/standalone_web/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# Standalone Web Target - -This custom target renders a standalone HTML page plus companion JavaScript. -It demonstrates a target directory with `target.toml` and local template files. - -```bash -cargo run -p rumoca -- \ - compile examples/models/SympyDecay.mo \ - --model SympyDecay \ - --target examples/codegen/standalone_web \ - --output examples/codegen/gen/sympy_decay_standalone_web -``` - -Output is written to `examples/codegen/gen/sympy_decay_standalone_web/`. diff --git a/examples/codegen/standalone_web/javascript.jinja b/examples/codegen/standalone_web/javascript.jinja deleted file mode 100644 index 0922b02be..000000000 --- a/examples/codegen/standalone_web/javascript.jinja +++ /dev/null @@ -1,945 +0,0 @@ -{# ------------------------------------------------------------------------- - # JS generator for Rumoca DAE JSON. - # Supports current DAE field naming. - # ------------------------------------------------------------------------- #} - -{%- set model_name = dae.model_name if dae.model_name is defined else (model_name if model_name is defined else "GeneratedModel") -%} -{%- set p_map = dae.p if dae.p is defined else {} -%} -{%- set cp_map = dae.constants if dae.constants is defined else (dae.cp if dae.cp is defined else {}) -%} -{%- set x_map = dae.x if dae.x is defined else {} -%} -{%- set y_map = dae.y if dae.y is defined else {} -%} -{%- if dae.__rumoca_observables is defined -%} -{%- set obs_list = dae.__rumoca_observables -%} -{%- else -%} -{%- set obs_list = [] -%} -{%- endif -%} -{%- set u_map = dae.u if dae.u is defined else {} -%} -{%- set w_map = dae.w if dae.w is defined else {} -%} -{%- set z_map = dae.z if dae.z is defined else {} -%} -{%- set m_map = dae.m if dae.m is defined else {} -%} -{%- set x_dot_alias_map = dae.x_dot_alias if dae.x_dot_alias is defined else {} -%} -{%- set eqs = dae.f_x if dae.f_x is defined else (dae.fx if dae.fx is defined else []) -%} -{%- set relation_eqs = dae.relation if dae.relation is defined else [] -%} -{%- set synthetic_root_conditions = dae.synthetic_root_conditions if dae.synthetic_root_conditions is defined else [] -%} -{%- if dae.f_c is defined and dae.f_c|length > 0 -%} -{%- set cond_eqs = dae.f_c -%} -{%- elif relation_eqs|length > 0 -%} -{%- set cond_eqs = relation_eqs -%} -{%- else -%} -{%- set cond_eqs = synthetic_root_conditions -%} -{%- endif -%} -{%- set when_clauses = dae.when_clauses if dae.when_clauses is defined else [] -%} -{%- set reset_eqs = (dae.f_z if dae.f_z is defined else []) + (dae.f_m if dae.f_m is defined else []) -%} - -{%- macro render_cref(cr) -%} -{%- if cr.component_ref is defined -%} -{{ render_cref(cr.component_ref) }} -{%- elif cr.parts is defined -%} -{%- for part in cr.parts -%}{% if part.ident.text is defined %}{{ part.ident.text }}{% else %}{{ part.ident }}{% endif %}{{ "." if not loop.last }}{%- endfor -%} -{%- elif cr.ident is defined and cr.ident.text is defined -%} -{{ cr.ident.text }} -{%- elif cr.name is defined and cr.name is string -%} -{{ cr.name }} -{%- elif cr.name is defined and cr.name.name is defined and cr.name.name is string -%} -{{ cr.name.name }} -{%- elif cr.name is defined and cr.name.component_ref is defined -%} -{{ render_cref(cr.name.component_ref) }} -{%- else -%} -0 -{%- endif -%} -{%- endmacro -%} - -{%- macro render_ref_name(name) -%} -{%- if name is string -%} -{{ name }} -{%- elif name.name is defined and name.name is string -%} -{{ name.name }} -{%- elif name.component_ref is defined -%} -{{ render_cref(name.component_ref) }} -{%- else -%} -{{ name }} -{%- endif -%} -{%- endmacro -%} - -{%- macro js_ident(name) -%} -{{- ('v_' ~ (name|string) - |replace('.', '__') - |replace('[', '_') - |replace(']', '') - |replace('(', '_') - |replace(')', '') - |replace(',', '_') - |replace(' ', '_') - |replace('-', '_') - |replace('/', '_') - |replace(':', '_') - |replace(';', '_') - |replace('*', '_') - |replace('+', '_') - |replace('%', '_') - |replace('>', '_') - |replace('<', '_') - |replace('=', '_') - |replace('!', '_') - |replace('?', '_') - |replace('&', '_') - |replace('|', '_') - |replace('"', '_') - |replace("'", '_')) -}} -{%- endmacro -%} - -{%- macro render_static_literal(e) -%} -{%- if e is number -%} -{{ e }} -{%- elif e is boolean -%} -{{ "true" if e else "false" }} -{%- elif e is mapping and e.Literal is defined -%} - {%- set lit = e.Literal.value if e.Literal.value is defined else e.Literal -%} - {%- if lit.Integer is defined -%}{{ lit.Integer }} - {%- elif lit.Real is defined -%}{{ lit.Real }} - {%- elif lit.Bool is defined -%}{{ "true" if lit.Bool else "false" }} - {%- elif lit.String is defined -%}"{{ lit.String }}" - {%- else -%}0 - {%- endif -%} -{%- else -%} -0 -{%- endif -%} -{%- endmacro -%} - -{%- macro comp_start_or_value(comp) -%} -{%- if comp.value is defined -%} -{{ render_static_literal(comp.value) }} -{%- elif comp.start is defined -%} -{{ render_static_literal(comp.start) }} -{%- else -%} -0 -{%- endif -%} -{%- endmacro -%} - -{%- macro comp_expr_or_value(comp) -%} -{%- if comp.value is defined -%} -{{ render_expr(comp.value) }} -{%- elif comp.start is defined -%} -{{ render_expr(comp.start) }} -{%- else -%} -0 -{%- endif -%} -{%- endmacro -%} - -{%- macro inferred_unit_from_name(name) -%} -{%- set __n = (name|string)|trim -%} -{%- set __nl = __n|lower -%} -{%- if __nl == 'i' or __nl[-2:] == '.i' -%}A -{%- elif __nl == 'v' or __nl[-2:] == '.v' -%}V -{%- elif __nl[-7:] == 'q_flow' or __nl[-10:] == '.q_flow' -%}W -{%- elif __nl[-9:] == 'losspower' or __nl[-10:] == '.losspower' -%}W -{%- elif __nl[-9:] == 'r_actual' or __nl[-10:] == '.r_actual' or __nl == 'r' or __nl[-2:] == '.r' -%}Ohm -{%- elif __nl == 'g' or __nl[-2:] == '.g' -%}W/K -{%- elif __nl[-3:] == '.dt' or __nl == 'dt' -%}K -{%- elif __nl == 't' or __nl[-2:] == '.t' -%}K -{%- elif __nl == 'f' or __nl[-2:] == '.f' -%}Hz -{%- elif __nl[-6:] == '.phase' or __nl == 'phase' -%}rad -{%- elif __nl[-6:] == '.omega' or __nl == 'omega' -%}rad/s -{%- elif __nl[-6:] == '.alpha' or __nl == 'alpha' -%}1/K -{%- else -%} -{%- endif -%} -{%- endmacro -%} - -{%- macro comp_unit_prop(comp) -%} -{%- if comp.unit is defined and comp.unit is not none -%} - {%- set __u = (comp.unit|string)|trim -%} - {%- if __u != '' and __u|lower != 'none' and __u|lower != 'null' -%} -, unit: "{{ __u|replace('\\', '\\\\')|replace('"', '\\"') }}" - {%- endif -%} -{%- elif comp.displayUnit is defined and comp.displayUnit is not none -%} - {%- set __u = (comp.displayUnit|string)|trim -%} - {%- if __u != '' and __u|lower != 'none' and __u|lower != 'null' -%} -, unit: "{{ __u|replace('\\', '\\\\')|replace('"', '\\"') }}" - {%- endif -%} -{%- elif comp.display_unit is defined and comp.display_unit is not none -%} - {%- set __u = (comp.display_unit|string)|trim -%} - {%- if __u != '' and __u|lower != 'none' and __u|lower != 'null' -%} -, unit: "{{ __u|replace('\\', '\\\\')|replace('"', '\\"') }}" - {%- endif -%} -{%- else -%} - {%- set __u = inferred_unit_from_name(comp.name if comp.name is defined else '')|trim -%} - {%- if __u != '' -%} -, unit: "{{ __u|replace('\\', '\\\\')|replace('"', '\\"') }}" - {%- endif -%} -{%- endif -%} -{%- endmacro -%} - -{%- macro render_js_function_name(fname) -%} -{%- set f = (fname|string)|trim -%} -{%- set f_norm = f|replace('::', '.') -%} -{%- set f_leaf = f_norm - |replace('Modelica.Math.', '') - |replace('Modelica.Blocks.Math.', '') - |replace('Modelica.', '') - |replace('modelica.', '') - |replace('Units.', '') - |replace('units.', '') - |replace('SI.Conversions.', '') - |replace('si.conversions.', '') - |replace('Conversions.', '') - |replace('conversions.', '') - |replace('Math.', '') --%} -{%- set fl = f_leaf|lower -%} -{%- if fl == "sin" -%}Math.sin -{%- elif fl == "cos" -%}Math.cos -{%- elif fl == "tan" -%}Math.tan -{%- elif fl == "asin" -%}Math.asin -{%- elif fl == "acos" -%}Math.acos -{%- elif fl == "atan" -%}Math.atan -{%- elif fl == "atan2" -%}Math.atan2 -{%- elif fl == "sqrt" -%}Math.sqrt -{%- elif fl == "abs" -%}Math.abs -{%- elif fl == "exp" -%}Math.exp -{%- elif fl == "log" -%}Math.log -{%- elif fl == "log10" -%}Math.log10 -{%- elif fl == "sinh" -%}Math.sinh -{%- elif fl == "cosh" -%}Math.cosh -{%- elif fl == "tanh" -%}Math.tanh -{%- elif fl == "floor" -%}Math.floor -{%- elif fl == "ceil" -%}Math.ceil -{%- elif fl == "min" -%}Math.min -{%- elif fl == "max" -%}Math.max -{%- else -%}{{ f_leaf }} -{%- endif -%} -{%- endmacro -%} - -{%- macro normalize_expr_text(txt) -%} -{{- (txt|string) - |replace('::', '.') - |replace('Modelica.Blocks.Math.', 'Math.') - |replace('Modelica.Math.', 'Math.') - |replace('Modelica.Units.', '') - |replace('modelica.units.', '') - |replace('Units.', '') - |replace('units.', '') - |replace('modelica.blocks.math.', 'Math.') - |replace('modelica.math.', 'Math.') - |replace('Modelica.Constants.pi', 'Math.PI') - |replace('Modelica.Constants.e', 'Math.E') - |replace('modelica.constants.pi', 'Math.PI') - |replace('modelica.constants.e', 'Math.E') --}} -{%- endmacro -%} - -{%- macro safe_symbol_ref(name) -%} -{%- set id = js_ident(name) -%} -((typeof {{ id }} !== "undefined") ? {{ id }} : __rumocaResolveSymbol("{{ name|string|replace('\\', '\\\\')|replace('"', '\\"') }}")) -{%- endmacro -%} - -{%- macro render_symbol_expr(e) -%} -{%- if e is mapping and e.VarRef is defined and render_ref_name(e.VarRef.name)|trim == "time" -%} - t -{%- elif e is mapping and e.VarRef is defined -%} - {{- js_ident(render_ref_name(e.VarRef.name)|trim) -}} -{%- elif e is mapping and e.ComponentReference is defined and render_cref(e.ComponentReference) == "time" -%} - t -{%- elif e is mapping and e.ComponentReference is defined -%} - {{- js_ident(render_cref(e.ComponentReference)) -}} -{%- else -%} - {{- render_expr(e) -}} -{%- endif -%} -{%- endmacro -%} - -{%- macro render_expr(e) -%} -{%- if e is string -%} - {{- normalize_expr_text(e) -}} -{%- elif e is number -%} - {{- e -}} -{%- elif e is boolean -%} - {{- "true" if e else "false" -}} -{%- elif e is mapping and e.Literal is defined -%} - {%- set lit = e.Literal.value if e.Literal.value is defined else e.Literal -%} - {%- if lit.Integer is defined -%}{{ lit.Integer }} - {%- elif lit.Real is defined -%}{{ lit.Real }} - {%- elif lit.Bool is defined -%}{{ "true" if lit.Bool else "false" }} - {%- elif lit.String is defined -%}"{{ lit.String }}" - {%- else -%}0{%- endif -%} -{%- elif e is mapping and e.VarRef is defined and render_ref_name(e.VarRef.name)|trim == "time" -%} - t -{%- elif e is mapping and e.VarRef is defined and render_ref_name(e.VarRef.name)|trim == "Modelica.Constants.pi" -%} - Math.PI -{%- elif e is mapping and e.VarRef is defined and render_ref_name(e.VarRef.name)|trim == "Modelica.Constants.e" -%} - Math.E -{%- elif e is mapping and e.VarRef is defined -%} - {{- safe_symbol_ref(render_ref_name(e.VarRef.name)|trim) -}} -{%- elif e is mapping and e.BuiltinCall is defined -%} - {%- set bc = e.BuiltinCall -%} - {%- if bc.function|lower == "der" and bc.args|length == 1 -%} - der_{{ render_symbol_expr(bc.args[0]) }} - {%- elif bc.function|lower == "pre" and bc.args|length == 1 -%} - {{ render_expr(bc.args[0]) }} - {%- elif bc.function|lower == "noevent" and bc.args|length >= 1 -%} - {{ render_expr(bc.args[0]) }} - {%- elif bc.function|lower == "homotopy" and bc.args|length >= 1 -%} - {{ render_expr(bc.args[0]) }} - {%- elif render_js_function_name(bc.function)|trim|lower == "from_degc" and bc.args|length == 1 -%} - (({{ render_expr(bc.args[0]) }}) + 273.15) - {%- elif render_js_function_name(bc.function)|trim|lower == "to_degc" and bc.args|length == 1 -%} - (({{ render_expr(bc.args[0]) }}) - 273.15) - {%- elif render_js_function_name(bc.function)|trim|lower == "from_deg" and bc.args|length == 1 -%} - (({{ render_expr(bc.args[0]) }}) * Math.PI / 180) - {%- elif render_js_function_name(bc.function)|trim|lower == "to_deg" and bc.args|length == 1 -%} - (({{ render_expr(bc.args[0]) }}) * 180 / Math.PI) - {%- else -%} - {{ render_js_function_name(bc.function) }}({%- for a in bc.args -%}{{ render_expr(a) }}{{ ", " if not loop.last }}{%- endfor -%}) - {%- endif -%} -{%- elif e is mapping and e.Terminal is defined -%} - {{- normalize_expr_text(e.Terminal.token.text) -}} -{%- elif e is mapping and e.ComponentReference is defined and render_cref(e.ComponentReference) == "time" -%} - t -{%- elif e is mapping and e.ComponentReference is defined and render_cref(e.ComponentReference) == "Modelica.Constants.pi" -%} - Math.PI -{%- elif e is mapping and e.ComponentReference is defined and render_cref(e.ComponentReference) == "Modelica.Constants.e" -%} - Math.E -{%- elif e is mapping and e.ComponentReference is defined -%} - {{- safe_symbol_ref(render_cref(e.ComponentReference)) -}} -{%- elif e is mapping and e.Parenthesized is defined -%} - ({{ render_expr(e.Parenthesized.inner) }}) -{%- elif e is mapping and e.Unary is defined -%} - {%- set u = e.Unary -%} - {%- set op_text = u.op|lower if u.op is string else "" -%} - {%- if u.op.Minus is defined or op_text == "minus" or op_text == "-" -%} - -({{ render_expr(u.rhs) }}) - {%- elif u.op.Not is defined or op_text == "not" or op_text == "!" -%} - !({{ render_expr(u.rhs) }}) - {%- else -%} - {{ render_expr(u.rhs) }} - {%- endif -%} -{%- elif e is mapping and e.Binary is defined -%} - {%- set b = e.Binary -%} - {%- set op_text = b.op|lower if b.op is string else "" -%} - {%- set op -%} - {%- if b.op.Add is defined or op_text == "add" or op_text == "+" -%}+{% elif b.op.Sub is defined or op_text == "sub" or op_text == "-" -%}-{% elif b.op.Mul is defined or op_text == "mul" or op_text == "*" -%}*{% elif b.op.Div is defined or op_text == "div" or op_text == "/" -%}/{% elif b.op.Pow is defined or b.op.Power is defined or b.op.Caret is defined or b.op.Exp is defined or b.op.Circumflex is defined -%}** - {%- elif op_text == "^" or op_text == "**" or op_text == "pow" or op_text == "power" or op_text == "caret" or op_text == "exp" or op_text == "circumflex" -%}** - {%- elif b.op.token is defined and b.op.token.text is defined and (b.op.token.text == "^" or b.op.token.text == "**") -%}** - {%- elif b.op.Le is defined or b.op.LessEq is defined or op_text == "le" or op_text == "lesseq" or op_text == "<=" -%}<={% elif b.op.Lt is defined or b.op.Less is defined or op_text == "lt" or op_text == "less" or op_text == "<" -%}<{% elif b.op.Ge is defined or b.op.GreaterEq is defined or op_text == "ge" or op_text == "greatereq" or op_text == ">=" -%}>={% elif b.op.Gt is defined or b.op.Greater is defined or op_text == "gt" or op_text == "greater" or op_text == ">" -%}>{% elif b.op.Eq is defined or b.op.Equals is defined or op_text == "eq" or op_text == "equals" or op_text == "==" or op_text == "===" -%}==={% elif b.op.Ne is defined or b.op.NotEquals is defined or op_text == "ne" or op_text == "notequals" or op_text == "!=" or op_text == "!==" -%}!== - {%- elif b.op.And is defined or op_text == "and" or op_text == "&&" -%}&&{% elif b.op.Or is defined or op_text == "or" or op_text == "||" -%}||{% else -%}+{% endif -%} - {%- endset -%} - ({{ render_expr(b.lhs) }} {{ op }} {{ render_expr(b.rhs) }}) -{%- elif e is mapping and e.FunctionCall is defined -%} - {%- set f = e.FunctionCall -%} - {%- set args = f.args if f.args is defined else [] -%} - {%- set fname -%} - {%- if f.comp is defined and f.comp.parts is defined -%} - {%- for part in f.comp.parts -%}{{ part.ident.text }}{{ "." if not loop.last }}{%- endfor -%} - {%- elif f.function is defined -%} - {{- f.function -}} - {%- elif f.name is defined -%} - {{- f.name -}} - {%- elif f.comp is defined and f.comp.ident is defined and f.comp.ident.text is defined -%} - {{- f.comp.ident.text -}} - {%- else -%} - function_call - {%- endif -%} - {%- endset -%} - {%- if fname|lower == "der" and args|length == 1 -%} - der_{{ render_symbol_expr(args[0]) }} - {%- elif fname|lower == "pre" and args|length == 1 -%} - {{ render_expr(args[0]) }} - {%- elif fname|lower == "noevent" and args|length >= 1 -%} - {{ render_expr(args[0]) }} - {%- elif fname|lower == "homotopy" and args|length >= 1 -%} - {{ render_expr(args[0]) }} - {%- elif render_js_function_name(fname)|trim|lower == "from_degc" and args|length == 1 -%} - (({{ render_expr(args[0]) }}) + 273.15) - {%- elif render_js_function_name(fname)|trim|lower == "to_degc" and args|length == 1 -%} - (({{ render_expr(args[0]) }}) - 273.15) - {%- elif render_js_function_name(fname)|trim|lower == "from_deg" and args|length == 1 -%} - (({{ render_expr(args[0]) }}) * Math.PI / 180) - {%- elif render_js_function_name(fname)|trim|lower == "to_deg" and args|length == 1 -%} - (({{ render_expr(args[0]) }}) * 180 / Math.PI) - {%- else -%} - {{ render_js_function_name(fname) }}({%- for a in args -%}{{ render_expr(a) }}{{ ", " if not loop.last }}{%- endfor -%}) - {%- endif -%} -{%- elif e is mapping and e.If is defined -%} - {%- set branches = e.If.branches if e.If.branches is defined else [] -%} - {%- set else_expr = e.If.else_branch if e.If.else_branch is defined else 0 -%} - {%- if branches|length == 0 -%} - {{ render_expr(else_expr) }} - {%- else -%} - ( - {%- for branch in branches -%} - ({{ render_expr(branch[0]) }}) ? ({{ render_expr(branch[1]) }}) : - {%- endfor -%} - ({{ render_expr(else_expr) }}) - ) - {%- endif -%} -{%- elif e is mapping and e.FieldAccess is defined -%} - (({{ render_expr(e.FieldAccess.base) }})?.{{ e.FieldAccess.field }}) -{%- elif e is mapping and e.Index is defined -%} - {%- set subs = e.Index.subscripts if e.Index.subscripts is defined else [] -%} - {%- if subs|length > 0 and subs[0].Expr is defined -%} - (({{ render_expr(e.Index.base) }})[{{ render_expr(subs[0].Expr) }}]) - {%- else -%} - ({{ render_expr(e.Index.base) }}) - {%- endif -%} -{%- else -%} - 0 -{%- endif -%} -{%- endmacro -%} - -{%- macro residual_expr(eq) -%} - {%- if eq.lhs is defined and eq.rhs is defined -%} - ({{ render_expr(eq.lhs) }}) - ({{ render_expr(eq.rhs) }}) - {%- elif eq.rhs is defined -%} - {{ render_expr(eq.rhs) }} - {%- else -%} - 0 - {%- endif -%} -{%- endmacro -%} - -{%- macro event_indicator_expr(e) -%} -{%- if e is mapping and e.Binary is defined -%} - {%- set b = e.Binary -%} - {%- set op_text = b.op|lower if b.op is string else "" -%} - {%- if b.op.Le is defined or b.op.Lt is defined or b.op.LessEq is defined or b.op.Less is defined or op_text == "le" or op_text == "lt" or op_text == "lesseq" or op_text == "less" or op_text == "<=" or op_text == "<" -%} - (({{ render_expr(b.lhs) }}) - ({{ render_expr(b.rhs) }})) - {%- elif b.op.Ge is defined or b.op.Gt is defined or b.op.GreaterEq is defined or b.op.Greater is defined or op_text == "ge" or op_text == "gt" or op_text == "greatereq" or op_text == "greater" or op_text == ">=" or op_text == ">" -%} - (({{ render_expr(b.rhs) }}) - ({{ render_expr(b.lhs) }})) - {%- elif b.op.Eq is defined or b.op.Ne is defined or b.op.Equals is defined or b.op.NotEquals is defined or op_text == "eq" or op_text == "ne" or op_text == "equals" or op_text == "notequals" or op_text == "==" or op_text == "===" or op_text == "!=" or op_text == "!==" -%} - (({{ render_expr(b.lhs) }}) - ({{ render_expr(b.rhs) }})) - {%- else -%} - (({{ render_expr(e) }}) ? -1 : 1) - {%- endif -%} -{%- else -%} - (({{ render_expr(e) }}) ? -1 : 1) -{%- endif -%} -{%- endmacro -%} - -{%- macro condition_rhs_expr(ce) -%} -{%- if ce is mapping and ce.rhs is defined -%} - {{ render_expr(ce.rhs) }} -{%- else -%} - {{ render_expr(ce) }} -{%- endif -%} -{%- endmacro -%} - -function Model() { - const __rumocaHasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key); - - function __rumocaMissingRef(name) { - throw new Error("Generated residual references an unresolved symbol: " + String(name)); - } - - function __rumocaFiniteOr(value, fallback = 0) { - return typeof value === "number" && Number.isFinite(value) ? value : fallback; - } - - var __rumocaFallbackSymbols = {}; - - function __rumocaResolveSymbol(name) { - if (!__rumocaFallbackSymbols || typeof __rumocaFallbackSymbols !== "object") { - return __rumocaMissingRef(name); - } - const key = String(name); - if (__rumocaHasOwn(__rumocaFallbackSymbols, key)) return __rumocaFallbackSymbols[key]; - - const keyUnderscore = key.replaceAll(".", "__"); - if (__rumocaHasOwn(__rumocaFallbackSymbols, keyUnderscore)) { - return __rumocaFallbackSymbols[keyUnderscore]; - } - - const keyTail = key.includes(".") ? key.split(".").pop() : key; - if (keyTail && __rumocaHasOwn(__rumocaFallbackSymbols, keyTail)) { - return __rumocaFallbackSymbols[keyTail]; - } - - const norm = (s) => String(s).replaceAll("__", "."); - const fallbackKeys = Object.keys(__rumocaFallbackSymbols); - - const exactNormalized = fallbackKeys.find((k) => norm(k) === key); - if (exactNormalized) return __rumocaFallbackSymbols[exactNormalized]; - - const suffixMatches = fallbackKeys.filter((k) => { - const n = norm(k); - return key === n || key.endsWith("." + n) || n.endsWith("." + key); - }); - if (suffixMatches.length === 1) { - return __rumocaFallbackSymbols[suffixMatches[0]]; - } - if (suffixMatches.length > 1) { - throw new Error( - "Ambiguous symbol resolution for " + - key + - "; candidates: " + - suffixMatches.slice(0, 6).join(", "), - ); - } - return __rumocaMissingRef(name); - } - - const pDefaults = {}; - {%- for (name, comp) in p_map|items %} - pDefaults["{{ name }}"] = 0; - __rumocaFallbackSymbols["{{ name }}"] = 0; - {%- endfor %} - {%- for (name, comp) in p_map|items %} - { - const __rumocaP = {{ comp_expr_or_value(comp) }}; - pDefaults["{{ name }}"] = __rumocaFiniteOr(__rumocaP, pDefaults["{{ name }}"]); - __rumocaFallbackSymbols["{{ name }}"] = pDefaults["{{ name }}"]; - } - {%- endfor %} - - const constants = {}; - {%- for (name, comp) in cp_map|items %} - constants["{{ name }}"] = 0; - __rumocaFallbackSymbols["{{ name }}"] = 0; - {%- endfor %} - {%- for (name, comp) in cp_map|items %} - { - const __rumocaC = {{ comp_expr_or_value(comp) }}; - constants["{{ name }}"] = __rumocaFiniteOr(__rumocaC, constants["{{ name }}"]); - __rumocaFallbackSymbols["{{ name }}"] = constants["{{ name }}"]; - } - {%- endfor %} - - const x0 = []; - {%- for (name, comp) in x_map|items %} - { - const __rumocaX0 = {{ comp_expr_or_value(comp) }}; - x0.push(__rumocaFiniteOr(__rumocaX0, 0)); - __rumocaFallbackSymbols["{{ name }}"] = x0[{{ loop.index0 }}]; - } - {%- endfor %} - - const y0 = []; - {%- for (name, comp) in y_map|items %} - { - const __rumocaY0 = {{ comp_expr_or_value(comp) }}; - y0.push(__rumocaFiniteOr(__rumocaY0, 0)); - __rumocaFallbackSymbols["{{ name }}"] = y0[{{ loop.index0 }}]; - } - {%- endfor %} - {%- for (name, comp) in z_map|items %} - { - const __rumocaY0 = {{ comp_expr_or_value(comp) }}; - y0.push(__rumocaFiniteOr(__rumocaY0, 0)); - __rumocaFallbackSymbols["{{ name }}"] = y0[{{ y_map|length + loop.index0 }}]; - } - {%- endfor %} - {%- for (name, comp) in m_map|items %} - { - const __rumocaY0 = {{ comp_expr_or_value(comp) }}; - y0.push(__rumocaFiniteOr(__rumocaY0, 0)); - __rumocaFallbackSymbols["{{ name }}"] = y0[{{ y_map|length + z_map|length + loop.index0 }}]; - } - {%- endfor %} - {%- for (name, comp) in w_map|items %} - { - const __rumocaY0 = {{ comp_expr_or_value(comp) }}; - y0.push(__rumocaFiniteOr(__rumocaY0, 0)); - __rumocaFallbackSymbols["{{ name }}"] = y0[{{ y_map|length + z_map|length + m_map|length + loop.index0 }}]; - } - {%- endfor %} - {%- for (name, comp) in x_dot_alias_map|items %} - { - const __rumocaAlias = {{ comp_expr_or_value(comp) }}; - __rumocaFallbackSymbols["{{ name }}"] = __rumocaFiniteOr(__rumocaAlias, 0); - } - {%- endfor %} - {%- for obs in obs_list %} - { - const __rumocaObs0 = {% if obs.start is defined %}{{ render_expr(obs.start) }}{% else %}0{% endif %}; - __rumocaFallbackSymbols["{{ obs.name }}"] = __rumocaFiniteOr(__rumocaObs0, 0); - } - {%- endfor %} - - const c0 = [ - {%- if cond_eqs|length > 0 -%} - {%- for ce in cond_eqs %}false{% if not loop.last %},{% endif %}{%- endfor %} - {%- endif -%} - ]; - - const meta = { - name: "{{ model_name }}", - - parameters: [ - {%- for (name, comp) in p_map|items %} - { name: "{{ name }}", start: pDefaults["{{ name }}"]{{ comp_unit_prop(comp) }} }{% if not loop.last %},{% endif %} - {%- endfor %} - ], - - constants: [ - {%- for (name, comp) in cp_map|items %} - { name: "{{ name }}", value: constants["{{ name }}"]{{ comp_unit_prop(comp) }} }{% if not loop.last %},{% endif %} - {%- endfor %} - ], - - states: [ - {%- for (name, comp) in x_map|items %} - { name: "{{ name }}", start: x0[{{ loop.index0 }}]{{ comp_unit_prop(comp) }} }{% if not loop.last %},{% endif %} - {%- endfor %} - ], - - algebraics: [ - {%- for (name, comp) in y_map|items %} - { name: "{{ name }}", start: y0[{{ loop.index0 }}]{{ comp_unit_prop(comp) }} }{% if not loop.last or z_map|length > 0 or m_map|length > 0 or w_map|length > 0 or obs_list|length > 0 %},{% endif %} - {%- endfor %} - {%- for (name, comp) in z_map|items %} - { name: "{{ name }}", start: y0[{{ y_map|length + loop.index0 }}]{{ comp_unit_prop(comp) }} }{% if not loop.last or m_map|length > 0 or w_map|length > 0 or obs_list|length > 0 %},{% endif %} - {%- endfor %} - {%- for (name, comp) in m_map|items %} - { name: "{{ name }}", start: y0[{{ y_map|length + z_map|length + loop.index0 }}]{{ comp_unit_prop(comp) }} }{% if not loop.last or w_map|length > 0 or obs_list|length > 0 %},{% endif %} - {%- endfor %} - {%- for (name, comp) in w_map|items %} - { name: "{{ name }}", start: y0[{{ y_map|length + z_map|length + m_map|length + loop.index0 }}]{{ comp_unit_prop(comp) }} }{% if not loop.last or obs_list|length > 0 %},{% endif %} - {%- endfor %} - {%- for obs in obs_list %} - { name: "{{ obs.name }}", start: {% if obs.start is defined %}__rumocaFiniteOr({{ render_expr(obs.start) }}, 0){% else %}0{% endif %}{% if obs.unit is defined and obs.unit is not none and (obs.unit|string)|trim != '' and (obs.unit|string)|trim|lower != 'none' and (obs.unit|string)|trim|lower != 'null' %}, unit: "{{ (obs.unit|string)|trim|replace('\\', '\\\\')|replace('"', '\\"') }}"{% else %}{% set __u = inferred_unit_from_name(obs.name)|trim %}{% if __u != '' %}, unit: "{{ __u|replace('\\', '\\\\')|replace('"', '\\"') }}"{% endif %}{% endif %} }{% if not loop.last %},{% endif %} - {%- endfor %} - ], - - solverAlgebraics: [ - {%- for (name, comp) in y_map|items %} - "{{ name }}"{% if not loop.last or z_map|length > 0 or m_map|length > 0 or w_map|length > 0 %},{% endif %} - {%- endfor %} - {%- for (name, comp) in z_map|items %} - "{{ name }}"{% if not loop.last or m_map|length > 0 or w_map|length > 0 %},{% endif %} - {%- endfor %} - {%- for (name, comp) in m_map|items %} - "{{ name }}"{% if not loop.last or w_map|length > 0 %},{% endif %} - {%- endfor %} - {%- for (name, comp) in w_map|items %} - "{{ name }}"{% if not loop.last %},{% endif %} - {%- endfor %} - ], - - inputs: [ - {%- for (name, comp) in u_map|items %} - { name: "{{ name }}"{{ comp_unit_prop(comp) }} }{% if not loop.last %},{% endif %} - {%- endfor %} - ], - - conditions: [ - {%- if cond_eqs|length > 0 -%} - {%- for ce in cond_eqs %} - { name: "c[{{ loop.index }}]", start: false }{% if not loop.last %},{% endif %} - {%- endfor %} - {%- endif %} - ], - - summary: { - nx: {{ x_map|length }}, - ny: {{ y_map|length + z_map|length + m_map|length + w_map|length }}, - nc: {{ cond_eqs|length }}, - neqs: {{ eqs|length }} - } - }; - - const stateIndex = { - {%- for (name, comp) in x_map|items %} - "{{ name }}": {{ loop.index0 }}{% if not loop.last %},{% endif %} - {%- endfor %} - }; - - const algebraicIndex = { - {%- for (name, comp) in y_map|items %} - "{{ name }}": {{ loop.index0 }}{% if not loop.last or z_map|length > 0 or m_map|length > 0 or w_map|length > 0 %},{% endif %} - {%- endfor %} - {%- for (name, comp) in z_map|items %} - "{{ name }}": {{ y_map|length + loop.index0 }}{% if not loop.last or m_map|length > 0 or w_map|length > 0 %},{% endif %} - {%- endfor %} - {%- for (name, comp) in m_map|items %} - "{{ name }}": {{ y_map|length + z_map|length + loop.index0 }}{% if not loop.last or w_map|length > 0 %},{% endif %} - {%- endfor %} - {%- for (name, comp) in w_map|items %} - "{{ name }}": {{ y_map|length + z_map|length + m_map|length + loop.index0 }}{% if not loop.last %},{% endif %} - {%- endfor %} - }; - - function residual(t, xVec, xDotVec, yVec, uVec, pOverride) { - const p = Object.assign({}, pDefaults, pOverride || {}); - - {%- for (name, comp) in p_map|items %} - const {{ js_ident(name) }} = p["{{ name }}"]; - {%- endfor %} - - {%- for (name, comp) in cp_map|items %} - const {{ js_ident(name) }} = constants["{{ name }}"]; - {%- endfor %} - - {%- for (name, comp) in x_map|items %} - const {{ js_ident(name) }} = xVec[{{ loop.index0 }}]; - const der_{{ js_ident(name) }} = xDotVec[{{ loop.index0 }}]; - {%- endfor %} - - {%- for (name, comp) in y_map|items %} - const {{ js_ident(name) }} = yVec[{{ loop.index0 }}]; - {%- endfor %} - {%- for (name, comp) in z_map|items %} - const {{ js_ident(name) }} = yVec[{{ y_map|length + loop.index0 }}]; - {%- endfor %} - {%- for (name, comp) in m_map|items %} - const {{ js_ident(name) }} = yVec[{{ y_map|length + z_map|length + loop.index0 }}]; - {%- endfor %} - {%- for (name, comp) in w_map|items %} - const {{ js_ident(name) }} = yVec[{{ y_map|length + z_map|length + m_map|length + loop.index0 }}]; - {%- endfor %} - - {%- for (name, comp) in u_map|items %} - const {{ js_ident(name) }} = uVec[{{ loop.index0 }}]; - {%- endfor %} - - return [ - {%- for eq in eqs %} - {{ residual_expr(eq) }}{% if not loop.last %},{% endif %} - {%- endfor %} - ]; - } - - function evalConditions(t, xVec, yVec, uVec, pOverride) { - const p = Object.assign({}, pDefaults, pOverride || {}); - - {%- for (name, comp) in p_map|items %} - const {{ js_ident(name) }} = p["{{ name }}"]; - {%- endfor %} - - {%- for (name, comp) in cp_map|items %} - const {{ js_ident(name) }} = constants["{{ name }}"]; - {%- endfor %} - - {%- for (name, comp) in x_map|items %} - const {{ js_ident(name) }} = xVec[{{ loop.index0 }}]; - {%- endfor %} - - {%- for (name, comp) in y_map|items %} - const {{ js_ident(name) }} = yVec[{{ loop.index0 }}]; - {%- endfor %} - {%- for (name, comp) in z_map|items %} - const {{ js_ident(name) }} = yVec[{{ y_map|length + loop.index0 }}]; - {%- endfor %} - {%- for (name, comp) in m_map|items %} - const {{ js_ident(name) }} = yVec[{{ y_map|length + z_map|length + loop.index0 }}]; - {%- endfor %} - {%- for (name, comp) in w_map|items %} - const {{ js_ident(name) }} = yVec[{{ y_map|length + z_map|length + m_map|length + loop.index0 }}]; - {%- endfor %} - - {%- for (name, comp) in u_map|items %} - const {{ js_ident(name) }} = uVec[{{ loop.index0 }}]; - {%- endfor %} - - return [ - {%- for ce in cond_eqs %} - {{ condition_rhs_expr(ce) }}{% if not loop.last %},{% endif %} - {%- endfor %} - ]; - } - - function evalEventIndicators(t, xVec, yVec, uVec, pOverride) { - const p = Object.assign({}, pDefaults, pOverride || {}); - - {%- for (name, comp) in p_map|items %} - const {{ js_ident(name) }} = p["{{ name }}"]; - {%- endfor %} - - {%- for (name, comp) in cp_map|items %} - const {{ js_ident(name) }} = constants["{{ name }}"]; - {%- endfor %} - - {%- for (name, comp) in x_map|items %} - const {{ js_ident(name) }} = xVec[{{ loop.index0 }}]; - {%- endfor %} - - {%- for (name, comp) in y_map|items %} - const {{ js_ident(name) }} = yVec[{{ loop.index0 }}]; - {%- endfor %} - {%- for (name, comp) in z_map|items %} - const {{ js_ident(name) }} = yVec[{{ y_map|length + loop.index0 }}]; - {%- endfor %} - {%- for (name, comp) in m_map|items %} - const {{ js_ident(name) }} = yVec[{{ y_map|length + z_map|length + loop.index0 }}]; - {%- endfor %} - {%- for (name, comp) in w_map|items %} - const {{ js_ident(name) }} = yVec[{{ y_map|length + z_map|length + m_map|length + loop.index0 }}]; - {%- endfor %} - - {%- for (name, comp) in u_map|items %} - const {{ js_ident(name) }} = uVec[{{ loop.index0 }}]; - {%- endfor %} - - return [ - {%- for ce in cond_eqs %} - {{ event_indicator_expr(ce.rhs if ce is mapping and ce.rhs is defined else ce) }}{% if not loop.last %},{% endif %} - {%- endfor %} - ]; - } - - function evalAlgebraics(t, xVec, yVec, uVec, pOverride) { - var p = Object.assign({}, pDefaults, pOverride || {}); - - {%- for (name, comp) in p_map|items %} - var {{ js_ident(name) }} = p["{{ name }}"]; - {%- endfor %} - - {%- for (name, comp) in cp_map|items %} - var {{ js_ident(name) }} = constants["{{ name }}"]; - {%- endfor %} - - {%- for (name, comp) in x_map|items %} - var {{ js_ident(name) }} = xVec[{{ loop.index0 }}]; - var der_{{ js_ident(name) }} = 0; - {%- endfor %} - - {%- for (name, comp) in y_map|items %} - var {{ js_ident(name) }} = yVec[{{ loop.index0 }}]; - {%- endfor %} - {%- for (name, comp) in z_map|items %} - var {{ js_ident(name) }} = yVec[{{ y_map|length + loop.index0 }}]; - {%- endfor %} - {%- for (name, comp) in m_map|items %} - var {{ js_ident(name) }} = yVec[{{ y_map|length + z_map|length + loop.index0 }}]; - {%- endfor %} - {%- for (name, comp) in w_map|items %} - var {{ js_ident(name) }} = yVec[{{ y_map|length + z_map|length + m_map|length + loop.index0 }}]; - {%- endfor %} - - {%- for (name, comp) in u_map|items %} - var {{ js_ident(name) }} = uVec[{{ loop.index0 }}]; - {%- endfor %} - - var out = new Array(meta.algebraics.length).fill(0); - {%- for (name, comp) in y_map|items %} - out[{{ loop.index0 }}] = __rumocaFiniteOr({{ js_ident(name) }}, 0); - {%- endfor %} - {%- for (name, comp) in z_map|items %} - out[{{ y_map|length + loop.index0 }}] = __rumocaFiniteOr({{ js_ident(name) }}, 0); - {%- endfor %} - {%- for (name, comp) in m_map|items %} - out[{{ y_map|length + z_map|length + loop.index0 }}] = __rumocaFiniteOr({{ js_ident(name) }}, 0); - {%- endfor %} - {%- for (name, comp) in w_map|items %} - out[{{ y_map|length + z_map|length + m_map|length + loop.index0 }}] = __rumocaFiniteOr({{ js_ident(name) }}, 0); - {%- endfor %} - {%- for obs in obs_list %} - var {{ js_ident(obs.name) }} = __rumocaFiniteOr(__rumocaResolveSymbol("{{ obs.name|string|replace('\\', '\\\\')|replace('"', '\\"') }}"), 0); - {%- endfor %} - {%- if obs_list|length > 0 %} - for (let __rumocaObsPass = 0; __rumocaObsPass < {{ obs_list|length }}; __rumocaObsPass++) { - {%- for obs in obs_list %} - {{ js_ident(obs.name) }} = __rumocaFiniteOr({{ render_expr(obs.expr) }}, 0); - __rumocaFallbackSymbols["{{ obs.name }}"] = {{ js_ident(obs.name) }}; - {%- endfor %} - } - {%- endif %} - {%- for obs in obs_list %} - out[{{ y_map|length + z_map|length + m_map|length + w_map|length + loop.index0 }}] = {{ js_ident(obs.name) }}; - {%- endfor %} - return out; - } - - function applyResets(t, xVec, yVec, uVec, pOverride, cPrev, cCurr) { - const p = Object.assign({}, pDefaults, pOverride || {}); - - {%- for (name, comp) in p_map|items %} - const {{ js_ident(name) }} = p["{{ name }}"]; - {%- endfor %} - - {%- for (name, comp) in cp_map|items %} - const {{ js_ident(name) }} = constants["{{ name }}"]; - {%- endfor %} - - {%- for (name, comp) in x_map|items %} - const {{ js_ident(name) }} = xVec[{{ loop.index0 }}]; - {%- endfor %} - - {%- for (name, comp) in y_map|items %} - const {{ js_ident(name) }} = yVec[{{ loop.index0 }}]; - {%- endfor %} - {%- for (name, comp) in z_map|items %} - const {{ js_ident(name) }} = yVec[{{ y_map|length + loop.index0 }}]; - {%- endfor %} - {%- for (name, comp) in m_map|items %} - const {{ js_ident(name) }} = yVec[{{ y_map|length + z_map|length + loop.index0 }}]; - {%- endfor %} - {%- for (name, comp) in w_map|items %} - const {{ js_ident(name) }} = yVec[{{ y_map|length + z_map|length + m_map|length + loop.index0 }}]; - {%- endfor %} - - {%- for (name, comp) in u_map|items %} - const {{ js_ident(name) }} = uVec[{{ loop.index0 }}]; - {%- endfor %} - - let xNew = xVec.slice(); - let yNew = yVec.slice(); - let cNew = Array.isArray(cCurr) ? cCurr.slice() : []; - - {%- for wc in when_clauses %} - { - const idx = {{ loop.index0 }}; - const fired = !cPrev[idx] && cCurr[idx]; - if (fired) { - {%- for eq in wc.equations %} - {%- if eq.lhs is defined %} - if (stateIndex["{{ eq.lhs }}"] !== undefined) { - xNew[stateIndex["{{ eq.lhs }}"]] = {{ render_expr(eq.rhs) }}; - } else if (algebraicIndex["{{ eq.lhs }}"] !== undefined) { - yNew[algebraicIndex["{{ eq.lhs }}"]] = {{ render_expr(eq.rhs) }}; - } - {%- endif %} - {%- endfor %} - } - } - {%- endfor %} - - {%- if when_clauses|length == 0 and reset_eqs|length > 0 %} - const firedAny = Array.isArray(cPrev) && Array.isArray(cCurr) - ? cCurr.some((value, idx) => !Boolean(cPrev[idx]) && Boolean(value)) - : false; - if (firedAny) { - {%- for eq in reset_eqs %} - {%- if eq.lhs is defined %} - if (stateIndex["{{ eq.lhs }}"] !== undefined) { - xNew[stateIndex["{{ eq.lhs }}"]] = {{ render_expr(eq.rhs) }}; - } else if (algebraicIndex["{{ eq.lhs }}"] !== undefined) { - yNew[algebraicIndex["{{ eq.lhs }}"]] = {{ render_expr(eq.rhs) }}; - } - {%- endif %} - {%- endfor %} - } - {%- endif %} - - return { x: xNew, y: yNew, c: cNew }; - } - - const abiRequired = true; - const abi = { id: 'rumoca.model_runtime.residual.v1', version: 1 }; - const description = { - modelName: meta.name, - variables: [] - .concat(meta.parameters.map((v) => ({ name: v.name, kind: 'parameter', start: v.start, unit: v.unit }))) - .concat(meta.constants.map((v) => ({ name: v.name, kind: 'constant', start: v.value, unit: v.unit }))) - .concat(meta.states.map((v) => ({ name: v.name, kind: 'state', start: v.start, unit: v.unit }))) - .concat(meta.algebraics.map((v) => ({ name: v.name, kind: 'algebraic', start: v.start, unit: v.unit }))) - .concat(meta.inputs.map((v) => ({ name: v.name, kind: 'input', unit: v.unit }))), - nx: meta.summary.nx, - ny: meta.summary.ny, - nu: meta.inputs.length, - nz: {{ cond_eqs|length }} - }; - const capabilities = { events: {{ "true" if cond_eqs|length > 0 or when_clauses|length > 0 or reset_eqs|length > 0 else "false" }} }; - - return { - abiRequired, - abi, - description, - capabilities, - name: meta.name, - meta, - x0, - y0, - c0, - residual, - evalAlgebraics, - evalEventIndicators, - evalConditions, - applyResets - }; -} diff --git a/examples/codegen/standalone_web/standalone_html.jinja b/examples/codegen/standalone_web/standalone_html.jinja deleted file mode 100644 index a4e04f08d..000000000 --- a/examples/codegen/standalone_web/standalone_html.jinja +++ /dev/null @@ -1,3467 +0,0 @@ - - - - - - Rumoca Model UI - - - -
-
Rumoca Model UI
-
Model
-
- -
-
-
-
- - -
-
- - -
-
- - -
-
- -
- - -
- Options are merged into the solver defaults. Schema is provided by - simulateModel.optionsSchema if available. -
-
- -
- - -
- The function is compiled as new Function('t', BODY). It must - return an array of length 0. -
-
-
- - -
- -
-
Ready.
-
- -
- -
- - -
- -
- Time plot or custom X/Y plot using local model result series. -
-
-
- -
- - - - - - - -
-
- - - - - - - - - - - - diff --git a/examples/codegen/standalone_web/target.toml b/examples/codegen/standalone_web/target.toml deleted file mode 100644 index f81bc0adc..000000000 --- a/examples/codegen/standalone_web/target.toml +++ /dev/null @@ -1,14 +0,0 @@ -version = 1 -ir = "dae" -name = "standalone-web-example" -description = "Example target bundle that renders standalone HTML and companion JavaScript" -execution_mode = "symbolic" -deployment_class = "browser" - -[[files]] -path = "{{ model_name }}_standalone.html" -template = "standalone_html.jinja" - -[[files]] -path = "{{ model_name }}.js" -template = "javascript.jinja" diff --git a/examples/interactive/reusable_booster/rumoca-scenario.toml b/examples/interactive/reusable_booster/rumoca-scenario.toml index 3cee5b9a0..ec4bc3844 100644 --- a/examples/interactive/reusable_booster/rumoca-scenario.toml +++ b/examples/interactive/reusable_booster/rumoca-scenario.toml @@ -1,40 +1,13 @@ source_roots = ["../../../target/cmm/CMM-a642c381"] -[rumoca] -version = "1" -task = "simulate" - -[model] -file = "ReusableBoosterLanding.mo" -name = "ReusableBoosterLanding" - -[parameters] -launch_burn_duration = 6.0 -launch_thrust_fraction = 0.62 -landing_trigger_speed = 8.0 -landing_duration = 18.0 -rcs_authority = 1.0 -wind_speed = 5.0 -wind_direction_deg = 35.0 -wind_speed_variation = 0.0 -wind_direction_variation_deg = 15.0 -wind_variation_period = 10.0 -dryden_intensity = 4.0 -wave_heave_amplitude = 0.35 -wave_roll_amplitude_deg = 1.2 -wave_pitch_amplitude_deg = 0.8 -wave_period = 8.5 - -[sim] -dt = 0.05 -mode = "realtime" -solver = "rk-like" -atol = 1e-6 -rtol = 1e-6 - [input] mode = "keyboard" +[input.keyboard.keys.Space] +action = "toggle" +debounce_ms = 500 +state = "launch_command" + [input.keyboard.keys.q] action = "signal" signal = "quit" @@ -43,77 +16,104 @@ signal = "quit" action = "signal" signal = "reset" -[input.keyboard.keys.Space] -action = "toggle" -debounce_ms = 500 -state = "launch_command" - -[locals.launch_command] -default = false -type = "bool" +[locals.attitude_gain_setting] +default = 1 +type = "float" -[locals.wind_speed_setting] -default = 5.0 +[locals.deck_heave_setting] +default = 0.35 type = "float" -[locals.wind_direction_setting] -default = 35.0 +[locals.deck_pitch_setting] +default = 0.8 type = "float" -[locals.wind_swing_setting] -default = 15.0 +[locals.deck_roll_setting] +default = 1.2 type = "float" [locals.gust_setting] -default = 4.0 +default = 4 type = "float" -[locals.deck_heave_setting] -default = 0.35 -type = "float" +[locals.launch_command] +default = false +type = "bool" -[locals.deck_roll_setting] -default = 1.2 +[locals.position_gain_setting] +default = 1 type = "float" -[locals.deck_pitch_setting] -default = 0.8 +[locals.rate_gain_setting] +default = 1 type = "float" -[locals.position_gain_setting] -default = 1.0 +[locals.velocity_gain_setting] +default = 1 type = "float" -[locals.velocity_gain_setting] -default = 1.0 +[locals.wind_direction_setting] +default = 35 type = "float" -[locals.attitude_gain_setting] -default = 1.0 +[locals.wind_speed_setting] +default = 5 type = "float" -[locals.rate_gain_setting] -default = 1.0 +[locals.wind_swing_setting] +default = 15 type = "float" +[model] +file = "ReusableBoosterLanding.mo" +name = "ReusableBoosterLanding" + +[parameters] +dryden_intensity = 4 +landing_duration = 18 +landing_trigger_speed = 8 +launch_burn_duration = 6 +launch_thrust_fraction = 0.62 +rcs_authority = 1 +wave_heave_amplitude = 0.35 +wave_period = 8.5 +wave_pitch_amplitude_deg = 0.8 +wave_roll_amplitude_deg = 1.2 +wind_direction_deg = 35 +wind_direction_variation_deg = 15 +wind_speed = 5 +wind_speed_variation = 0 +wind_variation_period = 10 + +[plot] +views = [] + [reset] on_signal = "reset" -reset_session = true reset_locals = true +reset_session = true + +[rumoca] +task = "simulate" +version = "1" [signals.model_inputs] -launch_command = { from = "local:launch_command", when_false = 0, when_true = 1 } -wind_speed_input = "local:wind_speed_setting" -wind_direction_input = "local:wind_direction_setting" -wind_direction_variation_input = "local:wind_swing_setting" +attitude_gain_scale_input = "local:attitude_gain_setting" dryden_intensity_input = "local:gust_setting" -wave_heave_amplitude_input = "local:deck_heave_setting" -wave_roll_amplitude_input = "local:deck_roll_setting" -wave_pitch_amplitude_input = "local:deck_pitch_setting" position_gain_scale_input = "local:position_gain_setting" -velocity_gain_scale_input = "local:velocity_gain_setting" -attitude_gain_scale_input = "local:attitude_gain_setting" rate_gain_scale_input = "local:rate_gain_setting" +velocity_gain_scale_input = "local:velocity_gain_setting" +wave_heave_amplitude_input = "local:deck_heave_setting" +wave_pitch_amplitude_input = "local:deck_pitch_setting" +wave_roll_amplitude_input = "local:deck_roll_setting" +wind_direction_input = "local:wind_direction_setting" +wind_direction_variation_input = "local:wind_swing_setting" +wind_speed_input = "local:wind_speed_setting" + +[signals.model_inputs.launch_command] +from = "local:launch_command" +when_false = 0 +when_true = 1 [signals.viewer] altitude = "model:altitude" @@ -131,6 +131,13 @@ deck_q2 = "model:deck_quat[3]" deck_q3 = "model:deck_quat[4]" deck_roll = "model:deck_roll_display" deck_roll_setting = "local:deck_roll_setting" +est_px = "model:estimated_position[1]" +est_py = "model:estimated_position[2]" +est_pz = "model:estimated_position[3]" +est_var_x = "model:estimated_position_variance[1]" +est_var_y = "model:estimated_position_variance[2]" +est_var_z = "model:estimated_position_variance[3]" +estimator_error = "model:estimator_position_error" frame = "runtime:frame_num" gimbal_x = "model:gimbal_x" gimbal_y = "model:gimbal_y" @@ -138,10 +145,13 @@ gps_px = "model:gps_position_measurement[1]" gps_py = "model:gps_position_measurement[2]" gps_pz = "model:gps_position_measurement[3]" gps_update = "model:gps_update" +gps_var_x = "model:gps_measurement_variance[1]" +gps_var_y = "model:gps_measurement_variance[2]" +gps_var_z = "model:gps_measurement_variance[3]" +gust_setting = "local:gust_setting" gust_vx = "model:dryden_gust_velocity[1]" gust_vy = "model:dryden_gust_velocity[2]" gust_vz = "model:dryden_gust_velocity[3]" -gust_setting = "local:gust_setting" impact_speed = "model:impact_speed" impact_tilt = "model:impact_tilt_deg" leg_deploy = "model:leg_deploy" @@ -150,46 +160,37 @@ mission_phase = "model:mission_phase" omega_x = "model:omega[1]" omega_y = "model:omega[2]" omega_z = "model:omega[3]" -est_px = "model:estimated_position[1]" -est_py = "model:estimated_position[2]" -est_pz = "model:estimated_position[3]" -est_var_x = "model:estimated_position_variance[1]" -est_var_y = "model:estimated_position_variance[2]" -est_var_z = "model:estimated_position_variance[3]" -estimator_error = "model:estimator_position_error" -gps_var_x = "model:gps_measurement_variance[1]" -gps_var_y = "model:gps_measurement_variance[2]" -gps_var_z = "model:gps_measurement_variance[3]" -plan_p0x = "model:plan_initial_position[1]" -plan_p0y = "model:plan_initial_position[2]" -plan_p0z = "model:plan_initial_position[3]" plan_a0x = "model:plan_initial_acceleration[1]" plan_a0y = "model:plan_initial_acceleration[2]" plan_a0z = "model:plan_initial_acceleration[3]" -plan_pf_x = "model:plan_target_position[1]" -plan_pf_y = "model:plan_target_position[2]" -plan_pf_z = "model:plan_target_position[3]" -plan_vf_x = "model:plan_target_velocity[1]" -plan_vf_y = "model:plan_target_velocity[2]" -plan_vf_z = "model:plan_target_velocity[3]" plan_af_x = "model:plan_target_acceleration[1]" plan_af_y = "model:plan_target_acceleration[2]" plan_af_z = "model:plan_target_acceleration[3]" +plan_p0x = "model:plan_initial_position[1]" +plan_p0y = "model:plan_initial_position[2]" +plan_p0z = "model:plan_initial_position[3]" +plan_pf_x = "model:plan_target_position[1]" +plan_pf_y = "model:plan_target_position[2]" +plan_pf_z = "model:plan_target_position[3]" plan_tf = "model:plan_duration" plan_v0x = "model:plan_initial_velocity[1]" plan_v0y = "model:plan_initial_velocity[2]" plan_v0z = "model:plan_initial_velocity[3]" +plan_vf_x = "model:plan_target_velocity[1]" +plan_vf_y = "model:plan_target_velocity[2]" +plan_vf_z = "model:plan_target_velocity[3]" +position_gain_setting = "local:position_gain_setting" px = "model:position[1]" py = "model:position[2]" pz = "model:position[3]" -position_gain_setting = "local:position_gain_setting" q1 = "model:quat[2]" q2 = "model:quat[3]" q3 = "model:quat[4]" -rcs_roll_negative = "model:rcs_roll_negative" -rcs_roll_positive = "model:rcs_roll_positive" +rate_gain_setting = "local:rate_gain_setting" rcs_operating_fraction = "model:rcs_operating_fraction" rcs_propellant_used = "model:rcs_propellant_used" +rcs_roll_negative = "model:rcs_roll_negative" +rcs_roll_positive = "model:rcs_roll_positive" rcs_x_negative = "model:rcs_x_negative" rcs_x_positive = "model:rcs_x_positive" rcs_y_negative = "model:rcs_y_negative" @@ -198,23 +199,22 @@ ref_px = "model:reference_position[1]" ref_py = "model:reference_position[2]" ref_pz = "model:reference_position[3]" reference_feasible = "model:reference_feasible" +support_margin = "model:support_margin" +supporting_legs = "model:supporting_legs" t = "model:time" thrust = "model:thrust_fraction" -tracking_error = "model:tracking_error" -supporting_legs = "model:supporting_legs" -support_margin = "model:support_margin" touchdown_normal_speed = "model:touchdown_normal_speed" touchdown_tangential_speed = "model:touchdown_tangential_speed" -rate_gain_setting = "local:rate_gain_setting" +tracking_error = "model:tracking_error" +velocity_gain_setting = "local:velocity_gain_setting" vx = "model:velocity[1]" vy = "model:velocity[2]" vz = "model:velocity[3]" -velocity_gain_setting = "local:velocity_gain_setting" wall_ms = "runtime:wall_ms" -wind_direction_setting = "local:wind_direction_setting" wind_direction = "model:wind_direction_display" -wind_speed_setting = "local:wind_speed_setting" +wind_direction_setting = "local:wind_direction_setting" wind_speed = "model:wind_speed_display" +wind_speed_setting = "local:wind_speed_setting" wind_swing_setting = "local:wind_swing_setting" wind_vx = "model:wind_velocity[1]" wind_vy = "model:wind_velocity[2]" @@ -223,13 +223,17 @@ wind_vy = "model:wind_velocity[2]" default = 1 from = "model:quat[1]" -[plot] -views = [] +[sim] +atol = 0.000001 +dt = 0.05 +mode = "realtime" +rtol = 0.000001 +solver = "rk-like" [transport.http] +asset_dir = "../../assets" port = 8080 scene = "reusable_booster_scene.js" -asset_dir = "../../assets" [transport.websocket] port = 8081 @@ -238,11 +242,6 @@ port = 8081 mode = "external_web" status_title = "Reusable Booster Landing" -[[viewer.frame]] -name = "body" -position = ["px", "py", "pz"] -quaternion = ["q0", "q1", "q2", "q3"] - [[viewer.controls.keyboard]] action = "Launch" keys = "Space" @@ -262,3 +261,17 @@ keys = "T" [[viewer.controls.keyboard]] action = "Quit" keys = "Q" + +[[viewer.frame]] +name = "body" +position = [ + "px", + "py", + "pz", +] +quaternion = [ + "q0", + "q1", + "q2", + "q3", +] diff --git a/examples/models/FmiTensorDecay.mo b/examples/models/FmiTensorDecay.mo new file mode 100644 index 000000000..2560699f9 --- /dev/null +++ b/examples/models/FmiTensorDecay.mo @@ -0,0 +1,5 @@ +model FmiTensorDecay + output Real x[2](each start = 1.0); +equation + der(x) = {-0.5 * x[1], -x[2]}; +end FmiTensorDecay; diff --git a/examples/requirements.txt b/examples/requirements.txt index 79ccb2a98..aea433e70 100644 --- a/examples/requirements.txt +++ b/examples/requirements.txt @@ -12,6 +12,6 @@ rumoca[notebook] # the compiler + the %%modelica Jupyter magic (pulls IPython) ipykernel # lets VSCode/Jupyter launch this venv as a notebook kernel jupyterlab # run the example notebooks in a browser -sympy # `--target sympy` / `%%modelica --load` generated modules +sympy # symbolic analysis in notebooks and loaded generated modules numpy # post-processing simulation results matplotlib # plotting results diff --git a/flake.nix b/flake.nix index 79077ce39..19c50596a 100644 --- a/flake.nix +++ b/flake.nix @@ -24,7 +24,16 @@ flake-utils.lib.eachDefaultSystem ( system: let - pkgs = import nixpkgs { inherit system; }; + pkgs = import nixpkgs { + inherit system; + config.allowUnfreePredicate = + package: + builtins.elem (nixpkgs.lib.getName package) [ + "cuda_cccl" + "cuda_cudart" + "cuda_nvcc" + ]; + }; rumocaVersion = (builtins.fromTOML (builtins.readFile ./Cargo.toml)).workspace.package.version; # Pin the EXACT toolchain from rust-toolchain.toml (nightly-2026-02-27 + @@ -35,11 +44,75 @@ sha256 = "sha256-5twI9QsrPl0ryOZ4POGYAivSeI08jgmWnv0wVvzbjcE="; }; + # Kani is deliberately isolated from the ordinary development + # toolchain. Its compiler must exactly match the release bundle, while + # rumoca's default shell remains pinned by rust-toolchain.toml. + kaniVersion = "0.67.0"; + kaniSupported = system == "x86_64-linux"; + kaniRustToolchain = fenix.packages.${system}.fromToolchainFile { + file = ./rust-toolchain-kani.toml; + sha256 = "sha256-P39FCgpfDT04989+ZTNEdM/k/AE869JKSB4qjatYTSs="; + }; + kaniCli = pkgs.rustPlatform.buildRustPackage { + pname = "kani-verifier"; + version = kaniVersion; + src = pkgs.fetchCrate { + pname = "kani-verifier"; + version = kaniVersion; + hash = "sha256-m0khwmHJAiEtICN/f2IE70A2/0JNKwaL3so429YtdOY="; + }; + cargoHash = "sha256-KAFLA97yi74riDkBO3EJ9Uv6SdVQrJ1wLNJ68Jf9yWk="; + }; + kaniHome = pkgs.stdenv.mkDerivation { + pname = "kani-home"; + version = kaniVersion; + src = pkgs.fetchurl { + url = "https://github.com/model-checking/kani/releases/download/kani-${kaniVersion}/kani-${kaniVersion}-x86_64-unknown-linux-gnu.tar.gz"; + hash = "sha256-O196/TtRYD7nINt7wbxP5GtaT1022q2ZOcS0xli1GsA="; + }; + nativeBuildInputs = [ pkgs.autoPatchelfHook ]; + buildInputs = [ + kaniRustToolchain + pkgs.stdenv.cc.cc.lib + pkgs.zlib + ]; + installPhase = '' + runHook preInstall + mkdir -p "$out/kani-${kaniVersion}" + cp -R . "$out/kani-${kaniVersion}/" + ln -s ${kaniRustToolchain} "$out/kani-${kaniVersion}/toolchain" + runHook postInstall + ''; + }; + kani = pkgs.symlinkJoin { + name = "kani-${kaniVersion}"; + meta = { + mainProgram = "kani"; + platforms = [ "x86_64-linux" ]; + }; + paths = [ + kaniCli + kaniHome + kaniRustToolchain + ]; + nativeBuildInputs = [ pkgs.makeWrapper ]; + postBuild = '' + for proxy in kani cargo-kani; do + wrapProgram "$out/bin/$proxy" \ + --set KANI_HOME "$out" \ + --prefix PATH : "${kaniRustToolchain}/bin" + done + ''; + }; + craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain; ciJulia = pkgs.julia_111; ciPython = pkgs.python312.withPackages (ps: [ ps.casadi ps.ipython + (ps.jax.overridePythonAttrs (_: { + doCheck = false; + })) ps.numpy ps.pandas ps.pip @@ -47,6 +120,20 @@ ps.virtualenv ]); openModelicaCli = openmodelica.packages.${system}.default; + mlirCpuTools = pkgs.symlinkJoin { + name = "rumoca-mlir-cpu-tools-18"; + paths = [ + pkgs.llvmPackages_18.clang + pkgs.llvmPackages_18.llvm + pkgs.llvmPackages_18.mlir + ]; + postBuild = '' + ln -s "$out/bin/clang" "$out/bin/clang-18" + ln -s "$out/bin/llc" "$out/bin/llc-18" + ln -s "$out/bin/mlir-opt" "$out/bin/mlir-opt-18" + ln -s "$out/bin/mlir-translate" "$out/bin/mlir-translate-18" + ''; + }; # Native libs the workspace links against. libudev (systemd) for the # gamepad/input crates; clang/libclang for any bindgen-using dep. @@ -94,6 +181,36 @@ # Build the third-party dependency closure once; every package/check # below reuses it so a code change never recompiles dependencies. cargoArtifacts = craneLib.buildDepsOnly commonArgs; + mlirCpuTestArgs = builtins.concatStringsSep " " [ + "--package rumoca-exec-mlir" + "--features required-mlir-cpu" + "--test benchmark_matmul" + "--test compile_basic" + "--test implicit_euler" + "--test integrate" + "--test linsolve_mlir" + "--test multi_fn_mlir" + "--test options" + ]; + mlirCpuTests = + assert pkgs.lib.hasInfix ".#checks.x86_64-linux.mlir-cpu" ( + builtins.readFile ./.github/workflows/ci.yml + ); + craneLib.cargoTest ( + commonArgs + // { + inherit cargoArtifacts; + pname = "rumoca-mlir-cpu-tests"; + cargoExtraArgs = mlirCpuTestArgs; + nativeBuildInputs = commonArgs.nativeBuildInputs ++ [ mlirCpuTools ]; + preCheck = '' + for tool in clang-18 llc-18 mlir-opt-18 mlir-translate-18; do + command -v "$tool" + "$tool" --version | grep -Eq 'version 18(\.|$)|MLIR 18(\.|$)' + done + ''; + } + ); rumoca = craneLib.buildPackage ( commonArgs @@ -122,7 +239,7 @@ inherit src; cargoRoot = "."; name = "rumoca-${rumocaVersion}-cargo-vendor"; - hash = "sha256-Vk0Rcz/z16eQOfluUF3dpob9uFES0D1bM09X/AUg5KU="; + hash = "sha256-tS1YJQiDgGITMPLDj7d560HNZfLDyWwgFcYLNjVWy1k="; }; nativeBuildInputs = [ rustToolchain @@ -147,8 +264,8 @@ # Release-mode artifacts for the MSL parity gate, built as one Cargo # graph so the shard / merge / ModelicaTest / pinned-library consumers - # restore them via - # Cachix instead of recompiling + re-LTO'ing the workspace. A single + # restore them from the CI producer instead of recompiling + re-LTO'ing + # the workspace. A single # derivation keeps rumoca-worker, rumoca-sim-worker, rumoca-msl-tools, # the focused profile runner, and the libtest harness in one target # directory; separate derivations @@ -163,7 +280,7 @@ cargo build --release \ -p rumoca-worker \ -p rumoca-test-msl \ - --features rumoca-test-msl/msl-full-test \ + --features rumoca-test-msl/msl-full-test,rumoca-test-msl/msl-profile-bin \ --bin rumoca-worker \ --bin rumoca-sim-worker \ --bin rumoca-msl-tools \ @@ -185,18 +302,89 @@ ''; } ); - templateRuntimeShell = - extraPackages: - craneLib.devShell { - inputsFrom = [ rumoca ]; - packages = extraPackages; - LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib"; - LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ - pkgs.gfortran.cc.lib + commonDevShellArgs = { + buildInputs = commonArgs.buildInputs; + shellHook = '' + export PATH="''${CARGO_HOME:-$HOME/.cargo}/bin:$PATH" + ''; + LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib"; + LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath ( + [ pkgs.stdenv.cc.cc.lib pkgs.zlib - ]; - }; + ] + ++ pkgs.lib.optionals pkgs.stdenv.isLinux [ pkgs.udev ] + ); + }; + mkDevShell = + extraPackages: + craneLib.devShell (commonDevShellArgs // { packages = [ pkgs.pkg-config ] ++ extraPackages; }); + templateRuntimeShell = + extraPackages: + craneLib.devShell ( + commonDevShellArgs + // { + packages = [ pkgs.pkg-config ] ++ extraPackages; + LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath ( + [ + pkgs.gfortran.cc.lib + pkgs.stdenv.cc.cc.lib + pkgs.zlib + ] + ++ pkgs.lib.optionals pkgs.stdenv.isLinux [ pkgs.udev ] + ); + } + ); + wasmShell = mkDevShell [ + pkgs.binaryen + pkgs.nodejs_22 + pkgs.wasm-pack + ]; + pythonShell = templateRuntimeShell [ + ciPython + pkgs.maturin + ]; + juliaShell = templateRuntimeShell (pkgs.lib.optionals pkgs.stdenv.isLinux [ ciJulia ]); + modelicaShell = templateRuntimeShell (pkgs.lib.optionals pkgs.stdenv.isLinux [ openModelicaCli ]); + fmiShell = templateRuntimeShell ( + [ + ciPython + pkgs.cmake + pkgs.curl + pkgs.jre_headless + pkgs.libxml2 + pkgs.nodejs_22 + pkgs.unzip + ] + ++ pkgs.lib.optionals pkgs.stdenv.isLinux [ openModelicaCli ] + ); + docsShell = mkDevShell [ + pkgs.binaryen + pkgs.mdbook + pkgs.nodejs_22 + pkgs.wasm-pack + ]; + fullShell = templateRuntimeShell ( + pkgs.lib.optionals pkgs.stdenv.isLinux [ + ciJulia + openModelicaCli + ] + ++ [ + ciPython + pkgs.binaryen + pkgs.cargo-expand + pkgs.cargo-llvm-cov + pkgs.cargo-nextest + pkgs.hyperfine + pkgs.jq + pkgs.libxml2 + pkgs.maturin + pkgs.mdbook + pkgs.nodejs_22 + pkgs.ripgrep + pkgs.wasm-pack + ] + ); # xtask itself is NOT built here: after the light-xtask split it carries no # compiler deps and compiles per-job in seconds, so build-once buys nothing. # The MSL merge and ModelicaTest jobs run reporting through the @@ -215,6 +403,11 @@ rumoca-python-env = rumocaPythonEnv; msl-artifacts = msl-artifacts; } + // pkgs.lib.optionalAttrs kaniSupported { + kani = kani; + kani-cli = kaniCli; + kani-home = kaniHome; + } // pkgs.lib.optionalAttrs pkgs.stdenv.isLinux { openmodelica-cli = openModelicaCli; }; @@ -231,46 +424,56 @@ fmt = craneLib.cargoFmt { src = ./.; }; } // pkgs.lib.optionalAttrs pkgs.stdenv.isLinux { + mlir-cpu = mlirCpuTests; openmodelica-cli = openModelicaCli; }; - devShells.default = craneLib.devShell { - inputsFrom = [ rumoca ]; - packages = - pkgs.lib.optionals pkgs.stdenv.isLinux [ - ciJulia - ] - ++ pkgs.lib.optionals pkgs.stdenv.isLinux [ - openModelicaCli - ] - ++ [ - ciPython - pkgs.binaryen - pkgs.cargo-llvm-cov - pkgs.libxml2 - pkgs.maturin - pkgs.mdbook - pkgs.nodejs_22 - pkgs.wasm-pack + # Keep the default shell source-independent: it supplies only the + # pinned Rust/native build environment. Optional runtimes are exposed + # through named shells, and the packaged compiler remains an explicit + # `nix build .#rumoca` operation. + devShells.default = mkDevShell [ ]; + devShells.full = fullShell; + devShells.wasm = wasmShell; + devShells.python = pythonShell; + devShells.julia = juliaShell; + devShells.modelica = modelicaShell; + devShells.fmi = fmiShell; + devShells.docs = docsShell; + # Python wheel packaging must depend only on the build and smoke-test + # toolchain. In particular, it must not inherit optional template + # runtimes such as JAX, whose platform support is narrower than the + # wheel matrix (currently excluding x86_64-darwin). + devShells.ci-python-wheel = mkDevShell [ + pkgs.maturin + pkgs.python312 + ]; + # WASM packaging needs the workspace build inputs plus the JavaScript + # and optimization tools. Keep the interactive shell's Rumoca, OMC, + # Julia, Python, and documentation closures out of this CI boundary. + devShells.ci-wasm = wasmShell; + devShells.${if kaniSupported then "kani" else null} = pkgs.mkShell ( + commonDevShellArgs + // { + packages = [ + pkgs.pkg-config + kani ]; - shellHook = '' - export PATH="''${CARGO_HOME:-$HOME/.cargo}/bin:$PATH" - ''; - LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib"; - LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath ( - [ - pkgs.gfortran.cc.lib - pkgs.stdenv.cc.cc.lib - pkgs.zlib - ] - ++ pkgs.lib.optionals pkgs.stdenv.isLinux [ pkgs.udev ] - ); - }; + KANI_HOME = kaniHome; + } + ); devShells.ci-template-core = templateRuntimeShell [ ]; - devShells.ci-template-python = templateRuntimeShell [ ciPython ]; - devShells.ci-template-julia = templateRuntimeShell ( - pkgs.lib.optionals pkgs.stdenv.isLinux [ ciJulia ] + devShells.ci-template-cuda = templateRuntimeShell ( + pkgs.lib.optionals pkgs.stdenv.isLinux [ + pkgs.cudaPackages.cuda_cudart + pkgs.cudaPackages.cuda_nvcc + ] ); + devShells.ci-template-fmi = fmiShell; + devShells.ci-template-modelica = modelicaShell; + devShells.ci-template-wasm = templateRuntimeShell [ pkgs.wasm-tools ]; + devShells.ci-template-python = templateRuntimeShell [ ciPython ]; + devShells.ci-template-julia = juliaShell; } ); } diff --git a/packages/playground/src/main.js b/packages/playground/src/main.js index 424a67097..d9be98aea 100644 --- a/packages/playground/src/main.js +++ b/packages/playground/src/main.js @@ -180,7 +180,7 @@ let outlineBranchKeys = []; let selectedExplorerPath = ''; let sidebarContextActions = []; const EXPLORER_ROOT_SELECTION = '.'; -const DEFAULT_CODEGEN_TARGET_ID = 'sympy'; +const DEFAULT_CODEGEN_TARGET_ID = 'c-ode'; const PLAYGROUND_THEME_STORAGE_KEY = 'rumoca-playground-theme'; const PLAYGROUND_THEME_IDS = new Set(['system', 'light', 'rust', 'coal', 'navy', 'ayu']); const systemThemeMedia = typeof window.matchMedia === 'function' @@ -3846,234 +3846,6 @@ function navigateProblems(step) { diagnosticsController.navigateProblems(step); } -// Pretty print DAE IR for human-readable display -function formatExpr(expr) { - if (!expr) return '?'; - if (typeof expr === 'number') return String(expr); - if (typeof expr === 'string') return expr; - if (expr.Real !== undefined) return String(expr.Real); - if (expr.Integer !== undefined) return String(expr.Integer); - if (expr.Boolean !== undefined) return expr.Boolean ? 'true' : 'false'; - if (expr.Ref) return expr.Ref; - if (expr.Neg) return `-${formatExpr(expr.Neg)}`; - if (expr.Add) return `(${formatExpr(expr.Add[0])} + ${formatExpr(expr.Add[1])})`; - if (expr.Sub) return `(${formatExpr(expr.Sub[0])} - ${formatExpr(expr.Sub[1])})`; - if (expr.Mul) return `(${formatExpr(expr.Mul[0])} * ${formatExpr(expr.Mul[1])})`; - if (expr.Div) return `(${formatExpr(expr.Div[0])} / ${formatExpr(expr.Div[1])})`; - if (expr.Pow) return `(${formatExpr(expr.Pow[0])} ^ ${formatExpr(expr.Pow[1])})`; - if (expr.Der) return `der(${formatExpr(expr.Der)})`; - if (expr.Sin) return `sin(${formatExpr(expr.Sin)})`; - if (expr.Cos) return `cos(${formatExpr(expr.Cos)})`; - if (expr.Sqrt) return `sqrt(${formatExpr(expr.Sqrt)})`; - if (expr.Exp) return `exp(${formatExpr(expr.Exp)})`; - if (expr.Log) return `log(${formatExpr(expr.Log)})`; - if (expr.Abs) return `abs(${formatExpr(expr.Abs)})`; - if (expr.Sign) return `sign(${formatExpr(expr.Sign)})`; - if (expr.Gt) return `(${formatExpr(expr.Gt[0])} > ${formatExpr(expr.Gt[1])})`; - if (expr.Lt) return `(${formatExpr(expr.Lt[0])} < ${formatExpr(expr.Lt[1])})`; - if (expr.Ge) return `(${formatExpr(expr.Ge[0])} >= ${formatExpr(expr.Ge[1])})`; - if (expr.Le) return `(${formatExpr(expr.Le[0])} <= ${formatExpr(expr.Le[1])})`; - if (expr.Eq) return `(${formatExpr(expr.Eq[0])} == ${formatExpr(expr.Eq[1])})`; - if (expr.And) return `(${formatExpr(expr.And[0])} and ${formatExpr(expr.And[1])})`; - if (expr.Or) return `(${formatExpr(expr.Or[0])} or ${formatExpr(expr.Or[1])})`; - if (expr.Not) return `not ${formatExpr(expr.Not)}`; - if (expr.IfExpr) return `if ${formatExpr(expr.IfExpr.cond)} then ${formatExpr(expr.IfExpr.then_expr)} else ${formatExpr(expr.IfExpr.else_expr)}`; - if (expr.Pre) return `pre(${formatExpr(expr.Pre)})`; - if (expr.call) return `${expr.call}(${(expr.args || []).map(formatExpr).join(', ')})`; - return JSON.stringify(expr); -} - -// Format equation to string -function formatEq(eq) { - if (!eq) return '?'; - if (eq.lhs !== undefined && eq.rhs !== undefined) { - return `${formatExpr(eq.lhs)} = ${formatExpr(eq.rhs)}`; - } - // Handle For equation variant - if (eq.For) { - const indices = eq.For.indices || []; - const idxStr = indices.map(i => `${i.ident?.text || '?'} in ${formatExpr(i.range)}`).join(', '); - const eqsStr = (eq.For.equations || []).map(formatEq).join('; '); - return `for ${idxStr} loop ${eqsStr} end for`; - } - // Handle Connect equation variant - if (eq.Connect) { - return `connect(${formatExpr(eq.Connect.from)}, ${formatExpr(eq.Connect.to)})`; - } - return JSON.stringify(eq); -} - -// Format component to string -function formatComp(name, comp) { - if (!comp) return ` ${name}: ?`; - const type = comp.type_name || 'Real'; - const shape = comp.shape && comp.shape.length > 0 ? `[${comp.shape.join(', ')}]` : ''; - const start = comp.start && comp.start !== 'Empty' ? ` = ${formatExpr(comp.start)}` : ''; - return ` ${name}: ${type}${shape}${start}`; -} - -// Format statement to string -function formatStmt(stmt) { - if (!stmt) return '?'; - if (stmt.Assignment) { - return `${formatExpr(stmt.Assignment.comp)} := ${formatExpr(stmt.Assignment.value)}`; - } - if (stmt.Return) return 'return'; - if (stmt.Break) return 'break'; - if (stmt.For) { - const indices = stmt.For.indices || []; - const idxStr = indices.map(i => `${i.ident?.text || '?'} in ${formatExpr(i.range)}`).join(', '); - return `for ${idxStr} loop ... end for`; - } - if (stmt.When) { - return 'when ... end when'; - } - if (stmt.If) { - return 'if ... end if'; - } - return JSON.stringify(stmt); -} - -function prettyPrintDae(dae) { - if (!dae) return 'No DAE'; - let out = []; - - out.push(`=== ${dae.model_name || 'Model'} ===`); - if (dae.rumoca_version) out.push(`Rumoca: ${dae.rumoca_version}`); - out.push(''); - - // Parameters (p) - if (dae.p && Object.keys(dae.p).length > 0) { - out.push('Parameters:'); - for (const [name, comp] of Object.entries(dae.p)) { - out.push(formatComp(name, comp)); - } - out.push(''); - } - - // Constant parameters (cp) - if (dae.cp && Object.keys(dae.cp).length > 0) { - out.push('Constants:'); - for (const [name, comp] of Object.entries(dae.cp)) { - out.push(formatComp(name, comp)); - } - out.push(''); - } - - // Inputs (u) - if (dae.u && Object.keys(dae.u).length > 0) { - out.push('Inputs:'); - for (const [name, comp] of Object.entries(dae.u)) { - out.push(formatComp(name, comp)); - } - out.push(''); - } - - // States (x) - if (dae.x && Object.keys(dae.x).length > 0) { - out.push('States (x):'); - for (const [name, comp] of Object.entries(dae.x)) { - out.push(formatComp(name, comp)); - } - out.push(''); - } - - // Algebraic variables (y) - if (dae.y && Object.keys(dae.y).length > 0) { - out.push('Algebraics (y):'); - for (const [name, comp] of Object.entries(dae.y)) { - out.push(formatComp(name, comp)); - } - out.push(''); - } - - // Discrete Real (z) - if (dae.z && Object.keys(dae.z).length > 0) { - out.push('Discrete Real (z):'); - for (const [name, comp] of Object.entries(dae.z)) { - out.push(formatComp(name, comp)); - } - out.push(''); - } - - // Discrete-valued (m) - if (dae.m && Object.keys(dae.m).length > 0) { - out.push('Discrete (m):'); - for (const [name, comp] of Object.entries(dae.m)) { - out.push(formatComp(name, comp)); - } - out.push(''); - } - - // Conditions (c) - if (dae.c && Object.keys(dae.c).length > 0) { - out.push('Conditions (c):'); - for (const [name, comp] of Object.entries(dae.c)) { - out.push(formatComp(name, comp)); - } - out.push(''); - } - - // Continuous equations (fx) - if (dae.fx && dae.fx.length > 0) { - out.push('Equations (fx):'); - dae.fx.forEach(eq => out.push(` ${formatEq(eq)};`)); - out.push(''); - } - - // Initial equations (fx_init) - if (dae.fx_init && dae.fx_init.length > 0) { - out.push('Initial Equations (fx_init):'); - dae.fx_init.forEach(eq => out.push(` ${formatEq(eq)};`)); - out.push(''); - } - - // Algebraic equations (fz) - if (dae.fz && dae.fz.length > 0) { - out.push('Algebraic Equations (fz):'); - dae.fz.forEach(eq => out.push(` ${formatEq(eq)};`)); - out.push(''); - } - - // Discrete update equations (fm) - if (dae.fm && dae.fm.length > 0) { - out.push('Discrete Equations (fm):'); - dae.fm.forEach(eq => out.push(` ${formatEq(eq)};`)); - out.push(''); - } - - // Reset statements (fr) - if (dae.fr && Object.keys(dae.fr).length > 0) { - out.push('Reset Statements (fr):'); - for (const [cond, stmt] of Object.entries(dae.fr)) { - out.push(` when ${cond}: ${formatStmt(stmt)}`); - } - out.push(''); - } - - // Condition updates (fc) - if (dae.fc && Object.keys(dae.fc).length > 0) { - out.push('Condition Updates (fc):'); - for (const [cond, expr] of Object.entries(dae.fc)) { - out.push(` ${cond} := ${formatExpr(expr)}`); - } - out.push(''); - } - - // Summary - const numStates = dae.x ? Object.keys(dae.x).length : 0; - const numAlg = dae.y ? Object.keys(dae.y).length : 0; - const numFx = dae.fx ? dae.fx.length : 0; - const numFz = dae.fz ? dae.fz.length : 0; - - out.push('Summary:'); - out.push(` States: ${numStates}`); - out.push(` Algebraics: ${numAlg}`); - out.push(` Equations: ${numFx} (continuous) + ${numFz} (algebraic)`); - - return out.join('\n'); -} - const packageArchiveController = createPackageArchiveController({ sendLanguageCommand, sendWorkspaceCommand, @@ -4170,12 +3942,8 @@ window.updateSelectedModel = function() { const modelName = document.getElementById('modelSelect').value; window.selectedModel = modelName; scenarioInterface.execute('rumoca.scenario.setSelectedSimulationModel', { model: modelName }); - if (modelName && window.compiledModels[modelName]) { - const result = window.compiledModels[modelName]; - // Update DAE for codegen completions - if (result.dae_native) { - window.currentDaeForCompletions = result.dae_native; - } + if (modelName && window.compiledModels[modelName]?.dae_native) { + window.currentDaeForCompletions = window.compiledModels[modelName].dae_native; } updateSourceBreadcrumbs(); scenarioConfigEditorController.syncAll(); @@ -5408,7 +5176,6 @@ require(['vs/editor/editor.main'], function() { balance: result.balance, pretty: result.pretty, }; - // Update DAE for codegen completions (use dae_native for actual field names) if (result.dae_native && (modelName === modelSelect.value || modelSelect.value === '')) { window.currentDaeForCompletions = result.dae_native; } diff --git a/packages/playground/src/modules/default_workspace.js b/packages/playground/src/modules/default_workspace.js index 2c616f32c..01af7c3cb 100644 --- a/packages/playground/src/modules/default_workspace.js +++ b/packages/playground/src/modules/default_workspace.js @@ -19,16 +19,15 @@ const DEFAULT_EXAMPLE_FILE_PATHS = [ 'examples/assets/skybox/arid2_rt.jpg', 'examples/assets/skybox/arid2_up.jpg', 'examples/codegen/README.md', - 'examples/codegen/custom_casadi.jinja', + 'examples/codegen/custom_checked_variables.jinja', 'examples/codegen/rumoca-scenario.galec_counter_production.toml', - 'examples/codegen/rumoca-scenario.ball_jax.toml', - 'examples/codegen/rumoca-scenario.sympy_decay_custom_casadi.toml', - 'examples/codegen/rumoca-scenario.sympy_decay_standalone_web.toml', - 'examples/codegen/rumoca-scenario.sympy_decay_sympy.toml', - 'examples/codegen/standalone_web/README.md', - 'examples/codegen/standalone_web/javascript.jinja', - 'examples/codegen/standalone_web/standalone_html.jinja', - 'examples/codegen/standalone_web/target.toml', + 'examples/codegen/rumoca-scenario.ball_jax_ode.toml', + 'examples/codegen/rumoca-scenario.sympy_decay_custom_checked_variables.toml', + 'examples/codegen/rumoca-scenario.sympy_decay_checked_dae_report.toml', + 'examples/codegen/rumoca-scenario.sympy_decay_c_ode.toml', + 'examples/codegen/checked_dae_report/README.md', + 'examples/codegen/checked_dae_report/checked_dae_report.txt.jinja', + 'examples/codegen/checked_dae_report/target.toml', 'examples/interactive/README.md', 'examples/interactive/reusable_booster/ReusableBoosterLanding.mo', 'examples/interactive/reusable_booster/README.md', diff --git a/packages/playground/src/modules/monaco_setup.js b/packages/playground/src/modules/monaco_setup.js index 4ce198cee..b67de3a6e 100644 --- a/packages/playground/src/modules/monaco_setup.js +++ b/packages/playground/src/modules/monaco_setup.js @@ -256,106 +256,67 @@ monaco.languages.setMonarchTokensProvider('jinja2', { } }); -// Store current DAE for dynamic completions window.currentDaeForCompletions = null; -// Extract dynamic completions from the current DAE -function getDynamicDaeCompletions(dae) { - if (!dae) return []; - const completions = []; - - // Component fields that can be accessed on variables - const componentFields = [ - { name: 'type_name', detail: 'Type name (e.g., Real, Integer)' }, - { name: 'shape', detail: 'Array shape' }, - { name: 'start', detail: 'Start value' }, - { name: 'variability', detail: 'Variability (parameter, constant, etc.)' }, - { name: 'causality', detail: 'Causality (input, output, etc.)' }, - { name: 'description', detail: 'Description string' }, +function checkedVariableCompletions(dae) { + const variables = dae?.storage?.variables; + if (!Array.isArray(variables)) return []; + const fields = [ + ['name', 'Source variable name'], + ['role', 'Checked DAE variable role'], + ['variability', 'Expression variability'], + ['value_type.scalar', 'Primitive scalar type'], + ['value_type.dimensions', 'Checked array dimensions'], + ['scalar_count', 'Derived scalar cardinality'], + ['scalar_names', 'Canonical scalar element names'], + ['attributes.unit', 'Declared unit'], + ['attributes.start_values', 'Checked numeric start values'], + ['attributes.binding_values', 'Checked static binding values'], ]; - - // Variable maps to iterate over - const varMaps = [ - { key: 'x', name: 'State', desc: 'state variable' }, - { key: 'y', name: 'Algebraic', desc: 'algebraic variable' }, - { key: 'p', name: 'Parameter', desc: 'parameter' }, - { key: 'cp', name: 'Constant', desc: 'constant parameter' }, - { key: 'u', name: 'Input', desc: 'input variable' }, - { key: 'z', name: 'Discrete Real', desc: 'discrete Real variable' }, - { key: 'm', name: 'Discrete', desc: 'discrete-valued variable' }, - { key: 'c', name: 'Condition', desc: 'condition variable' }, - ]; - - // Add variable name completions for each map - for (const { key, name, desc } of varMaps) { - const varMap = dae[key]; - if (varMap && typeof varMap === 'object') { - for (const varName of Object.keys(varMap)) { - // Add dae.x.varname style completion - completions.push({ - label: `dae.${key}.${varName}`, - kind: monaco.languages.CompletionItemKind.Variable, - insertText: `dae.${key}.${varName}`, - detail: `${name}: ${varName} (${desc})`, - sortText: `0_${key}_${varName}` // Sort model vars first - }); - - // Add component field completions (dae.x.varname.start, etc.) - for (const field of componentFields) { - completions.push({ - label: `dae.${key}.${varName}.${field.name}`, - kind: monaco.languages.CompletionItemKind.Property, - insertText: `dae.${key}.${varName}.${field.name}`, - detail: `${varName}.${field.name} - ${field.detail}`, - sortText: `1_${key}_${varName}_${field.name}` - }); - } - } - } - } - - return completions; + const suggestions = []; + variables.forEach((variable, ordinal) => { + if (!variable || typeof variable.name !== 'string') return; + const base = `dae.variables[${ordinal}]`; + suggestions.push({ + label: `${base} (${variable.name})`, + kind: monaco.languages.CompletionItemKind.Variable, + insertText: base, + detail: `${variable.role || 'variable'} ${variable.name}`, + sortText: `0_${String(ordinal).padStart(8, '0')}`, + }); + fields.forEach(([field, detail], fieldOrdinal) => { + suggestions.push({ + label: `${base}.${field} (${variable.name})`, + kind: monaco.languages.CompletionItemKind.Property, + insertText: `${base}.${field}`, + detail: `${variable.name}: ${detail}`, + sortText: `1_${String(ordinal).padStart(8, '0')}_${String(fieldOrdinal).padStart(2, '0')}`, + }); + }); + }); + return suggestions; } -// Jinja2 completion provider for DAE fields +// Jinja2 completion provider for the canonical checked DAE template schema. monaco.languages.registerCompletionItemProvider('jinja2', { triggerCharacters: ['.', '{'], - provideCompletionItems: (model, position) => { - const textUntilPosition = model.getValueInRange({ - startLineNumber: 1, - startColumn: 1, - endLineNumber: position.lineNumber, - endColumn: position.column - }); - - // Get the word being typed - const lineText = model.getLineContent(position.lineNumber); - const textBeforeCursor = lineText.substring(0, position.column - 1); - - // Check for context-specific completions - // Match patterns like "dae.", "dae.x.", "dae.x.varname." - const daePathMatch = textBeforeCursor.match(/dae\.(\w*)$/); - const daeVarMatch = textBeforeCursor.match(/dae\.(\w+)\.(\w*)$/); - const daeFieldMatch = textBeforeCursor.match(/dae\.(\w+)\.(\w+)\.(\w*)$/); - - // Static DAE field suggestions (always available) + provideCompletionItems: () => { const daeFields = [ - { label: 'dae.model_name', kind: monaco.languages.CompletionItemKind.Field, insertText: 'dae.model_name', detail: 'Model name', sortText: '2_model_name' }, - { label: 'dae.rumoca_version', kind: monaco.languages.CompletionItemKind.Field, insertText: 'dae.rumoca_version', detail: 'Rumoca version', sortText: '2_rumoca_version' }, - { label: 'dae.x', kind: monaco.languages.CompletionItemKind.Field, insertText: 'dae.x', detail: 'State variables (IndexMap)', sortText: '2_x' }, - { label: 'dae.y', kind: monaco.languages.CompletionItemKind.Field, insertText: 'dae.y', detail: 'Algebraic variables (IndexMap)', sortText: '2_y' }, - { label: 'dae.p', kind: monaco.languages.CompletionItemKind.Field, insertText: 'dae.p', detail: 'Parameters (IndexMap)', sortText: '2_p' }, - { label: 'dae.cp', kind: monaco.languages.CompletionItemKind.Field, insertText: 'dae.cp', detail: 'Constant parameters (IndexMap)', sortText: '2_cp' }, - { label: 'dae.u', kind: monaco.languages.CompletionItemKind.Field, insertText: 'dae.u', detail: 'Inputs (IndexMap)', sortText: '2_u' }, - { label: 'dae.z', kind: monaco.languages.CompletionItemKind.Field, insertText: 'dae.z', detail: 'Discrete Real variables (IndexMap)', sortText: '2_z' }, - { label: 'dae.m', kind: monaco.languages.CompletionItemKind.Field, insertText: 'dae.m', detail: 'Discrete-valued variables (IndexMap)', sortText: '2_m' }, - { label: 'dae.c', kind: monaco.languages.CompletionItemKind.Field, insertText: 'dae.c', detail: 'Conditions (IndexMap)', sortText: '2_c' }, - { label: 'dae.fx', kind: monaco.languages.CompletionItemKind.Field, insertText: 'dae.fx', detail: 'Continuous equations (Vec)', sortText: '2_fx' }, - { label: 'dae.fx_init', kind: monaco.languages.CompletionItemKind.Field, insertText: 'dae.fx_init', detail: 'Initial equations (Vec)', sortText: '2_fx_init' }, - { label: 'dae.fz', kind: monaco.languages.CompletionItemKind.Field, insertText: 'dae.fz', detail: 'Algebraic equations (Vec)', sortText: '2_fz' }, - { label: 'dae.fm', kind: monaco.languages.CompletionItemKind.Field, insertText: 'dae.fm', detail: 'Discrete equations (Vec)', sortText: '2_fm' }, - { label: 'dae.fr', kind: monaco.languages.CompletionItemKind.Field, insertText: 'dae.fr', detail: 'Reset statements (IndexMap)', sortText: '2_fr' }, - { label: 'dae.fc', kind: monaco.languages.CompletionItemKind.Field, insertText: 'dae.fc', detail: 'Condition updates (IndexMap)', sortText: '2_fc' }, + { label: 'dae.schema', kind: monaco.languages.CompletionItemKind.Field, insertText: 'dae.schema', detail: 'Checked template schema identity', sortText: '2_schema' }, + { label: 'dae.value_types', kind: monaco.languages.CompletionItemKind.Field, insertText: 'dae.value_types', detail: 'Dense checked value types', sortText: '2_value_types' }, + { label: 'dae.variables', kind: monaco.languages.CompletionItemKind.Field, insertText: 'dae.variables', detail: 'Dense typed variable catalog', sortText: '2_variables' }, + { label: 'dae.functions', kind: monaco.languages.CompletionItemKind.Field, insertText: 'dae.functions', detail: 'Checked function owners', sortText: '2_functions' }, + { label: 'dae.domains', kind: monaco.languages.CompletionItemKind.Field, insertText: 'dae.domains', detail: 'Compact structured domains', sortText: '2_domains' }, + { label: 'dae.expressions', kind: monaco.languages.CompletionItemKind.Field, insertText: 'dae.expressions', detail: 'Dense expression arena', sortText: '2_expressions' }, + { label: 'dae.modelica', kind: monaco.languages.CompletionItemKind.Field, insertText: 'dae.modelica', detail: 'Checked Modelica display projection', sortText: '2_modelica' }, + { label: 'dae.systems', kind: monaco.languages.CompletionItemKind.Field, insertText: 'dae.systems', detail: 'DAE semantic owner systems', sortText: '2_systems' }, + { label: 'dae.systems.continuous', kind: monaco.languages.CompletionItemKind.Field, insertText: 'dae.systems.continuous', detail: 'Continuous equation owners', sortText: '2_systems_continuous' }, + { label: 'dae.systems.initialization', kind: monaco.languages.CompletionItemKind.Field, insertText: 'dae.systems.initialization', detail: 'Initialization owners', sortText: '2_systems_initialization' }, + { label: 'dae.systems.discrete_real', kind: monaco.languages.CompletionItemKind.Field, insertText: 'dae.systems.discrete_real', detail: 'B.1b residual owners', sortText: '2_systems_discrete_real' }, + { label: 'dae.systems.conditions', kind: monaco.languages.CompletionItemKind.Field, insertText: 'dae.systems.conditions', detail: 'Relations, conditions, and roots', sortText: '2_systems_conditions' }, + { label: 'dae.systems.events', kind: monaco.languages.CompletionItemKind.Field, insertText: 'dae.systems.events', detail: 'Time events and actions', sortText: '2_systems_events' }, + { label: 'dae.systems.clocks', kind: monaco.languages.CompletionItemKind.Field, insertText: 'dae.systems.clocks', detail: 'Clock owners and assignments', sortText: '2_systems_clocks' }, + { label: 'dae.systems.temporal', kind: monaco.languages.CompletionItemKind.Field, insertText: 'dae.systems.temporal', detail: 'History, terminal, and delay owners', sortText: '2_systems_temporal' }, ]; // Jinja2 snippets @@ -374,10 +335,13 @@ monaco.languages.registerCompletionItemProvider('jinja2', { { label: 'loop.length', kind: monaco.languages.CompletionItemKind.Property, insertText: 'loop.length', detail: 'Total number of items', sortText: '4_loop_length' }, ]; - // Get dynamic completions from current DAE - const dynamicCompletions = getDynamicDaeCompletions(window.currentDaeForCompletions); - - return { suggestions: [...dynamicCompletions, ...daeFields, ...snippets] }; + return { + suggestions: [ + ...checkedVariableCompletions(window.currentDaeForCompletions), + ...daeFields, + ...snippets, + ], + }; } }); diff --git a/packages/playground/tests/gpu_schedule.test.mjs b/packages/playground/tests/gpu_schedule.test.mjs index a4bf6672b..c43fa4f8b 100644 --- a/packages/playground/tests/gpu_schedule.test.mjs +++ b/packages/playground/tests/gpu_schedule.test.mjs @@ -4,9 +4,6 @@ import assert from "node:assert/strict"; import { derivativeKernelSchedule as rawDerivativeKernelSchedule, gpuKernelDispatchPlan, - gpuKernelSchedules as rawGpuKernelSchedules, - gpuKernelWorkgroupBudget, - implicitKernelSchedule as rawImplicitKernelSchedule, } from "../../rumoca-web/runtime/rumoca_gpu.js"; const derivativeEntryPrefixes = { @@ -14,37 +11,14 @@ const derivativeEntryPrefixes = { scalar: "derivative_rhs_chunk", }; -const implicitEntryPrefixes = { - native: ["implicit_rhs_map", "implicit_rhs_stencil"], - scalar: "implicit_rhs_chunk", -}; - function withDerivativeEntryPrefixes(layout) { return { entry_prefixes: derivativeEntryPrefixes, ...layout }; } -function withImplicitEntryPrefixes(block) { - return { entry_prefixes: implicitEntryPrefixes, ...block }; -} - function derivativeKernelSchedule(layout) { return rawDerivativeKernelSchedule(withDerivativeEntryPrefixes(layout)); } -function implicitKernelSchedule(layout) { - return rawImplicitKernelSchedule({ - ...layout, - implicit_rhs: withImplicitEntryPrefixes(layout.implicit_rhs), - }); -} - -function gpuKernelSchedules(layout) { - return rawGpuKernelSchedules({ - ...withDerivativeEntryPrefixes(layout), - implicit_rhs: withImplicitEntryPrefixes(layout.implicit_rhs), - }); -} - function nativeKernel(overrides = {}) { return { entry: "derivative_rhs_map0", @@ -93,7 +67,6 @@ test("derivativeKernelSchedule accepts native and scalar derivative kernels", () ], ); }); - test("derivativeKernelSchedule rejects missing entry prefix metadata", () => { assert.throws( () => rawDerivativeKernelSchedule({ @@ -648,382 +621,6 @@ test("derivativeKernelSchedule rejects mismatched chunk size", () => { ); }); -test("implicitKernelSchedule accepts native and scalar implicit kernels", () => { - assert.deepEqual( - implicitKernelSchedule({ - implicit_rhs: { - rows: 5, - workgroup_size: 64, - chunk_size: 64, - chunks: 1, - kernel_count: 2, - kernels: [ - nativeKernel({ entry: "implicit_rhs_map0" }), - scalarKernel({ - entry: "implicit_rhs_chunk0", - start_slot: 3, - output_indices: [3, 4], - }), - ], - native_families: [nativeFamily()], - }, - }), - [ - { entry: "implicit_rhs_map0", rows: 3, workgroupSize: 64 }, - { entry: "implicit_rhs_chunk0", rows: 2, workgroupSize: 64 }, - ], - ); -}); - -test("implicitKernelSchedule accepts empty zero-row implicit inventory", () => { - assert.deepEqual( - implicitKernelSchedule({ - implicit_rhs: { - rows: 0, - workgroup_size: 64, - chunk_size: 64, - chunks: 0, - kernel_count: 0, - kernels: [], - native_families: [], - }, - }), - [], - ); -}); - -test("implicitKernelSchedule rejects empty nonzero-row implicit inventory", () => { - assert.throws( - () => implicitKernelSchedule({ - implicit_rhs: { - rows: 1, - workgroup_size: 64, - chunk_size: 64, - chunks: 0, - kernel_count: 0, - kernels: [], - native_families: [], - }, - }), - /manifest has no kernel inventory/, - ); -}); - -test("implicitKernelSchedule rejects empty implicit inventory with family counts", () => { - assert.throws( - () => implicitKernelSchedule({ - implicit_rhs: { - rows: 0, - workgroup_size: 64, - chunk_size: 64, - chunks: 1, - kernel_count: 0, - kernels: [], - native_families: [], - }, - }), - /empty implicit RHS inventory must not report scalar chunks or native families/, - ); -}); - -test("implicitKernelSchedule rejects missing entry prefix metadata", () => { - assert.throws( - () => rawImplicitKernelSchedule({ - implicit_rhs: { - rows: 3, - workgroup_size: 64, - chunk_size: 64, - chunks: 0, - kernel_count: 1, - kernels: [nativeKernel({ entry: "implicit_rhs_map0" })], - native_families: [nativeFamily()], - }, - }), - /invalid entry_prefixes metadata/, - ); -}); - -test("implicitKernelSchedule rejects mismatched entry prefix metadata", () => { - assert.throws( - () => rawImplicitKernelSchedule({ - implicit_rhs: { - rows: 3, - entry_prefixes: { - native: ["derivative_rhs_map", "derivative_rhs_stencil"], - scalar: "implicit_rhs_chunk", - }, - workgroup_size: 64, - chunk_size: 64, - chunks: 0, - kernel_count: 1, - kernels: [nativeKernel({ entry: "implicit_rhs_map0" })], - native_families: [nativeFamily()], - }, - }), - /native entry_prefixes must be implicit_rhs_map, implicit_rhs_stencil/, - ); -}); - -test("implicitKernelSchedule rejects stale kernel prefix metadata", () => { - assert.throws( - () => rawImplicitKernelSchedule({ - implicit_rhs: { - rows: 3, - kernel_prefix: "implicit_rhs_chunk", - entry_prefixes: implicitEntryPrefixes, - workgroup_size: 64, - chunk_size: 64, - chunks: 0, - kernel_count: 1, - kernels: [nativeKernel({ entry: "implicit_rhs_map0" })], - native_families: [nativeFamily()], - }, - }), - /stale kernel_prefix metadata/, - ); -}); - -test("implicitKernelSchedule accepts sparse scalar implicit output coverage", () => { - assert.deepEqual( - implicitKernelSchedule({ - implicit_rhs: { - rows: 5, - workgroup_size: 64, - chunk_size: 64, - chunks: 1, - kernel_count: 1, - kernels: [scalarKernel({ - entry: "implicit_rhs_chunk0", - start_slot: 0, - output_indices: [1, 4], - })], - }, - }), - [{ entry: "implicit_rhs_chunk0", rows: 2, workgroupSize: 64 }], - ); -}); - -test("implicitKernelSchedule allows sparse implicit output coverage", () => { - assert.deepEqual( - implicitKernelSchedule({ - implicit_rhs: { - rows: 5, - workgroup_size: 64, - chunk_size: 64, - chunks: 0, - kernel_count: 1, - kernels: [nativeKernel({ - entry: "implicit_rhs_map0", - output_map: { start: 0, strides: [{ dimension: 0, stride: 2 }] }, - })], - native_families: [ - nativeFamily({ output_map: { start: 0, strides: [{ dimension: 0, stride: 2 }] } }), - ], - }, - }), - [{ entry: "implicit_rhs_map0", rows: 3, workgroupSize: 64 }], - ); -}); - -test("implicitKernelSchedule rejects mismatched implicit scalar chunk metadata", () => { - assert.throws( - () => implicitKernelSchedule({ - implicit_rhs: { - rows: 5, - workgroup_size: 64, - chunk_size: 64, - chunks: 0, - kernel_count: 2, - kernels: [ - nativeKernel({ entry: "implicit_rhs_map0" }), - scalarKernel({ - entry: "implicit_rhs_chunk0", - start_slot: 3, - output_indices: [3, 4], - }), - ], - native_families: [nativeFamily()], - }, - }), - /chunks=0 does not match 1 scalar implicit RHS kernel entries/, - ); -}); - -test("implicitKernelSchedule rejects duplicate kernel entries", () => { - assert.throws( - () => implicitKernelSchedule({ - implicit_rhs: { - rows: 5, - workgroup_size: 64, - chunk_size: 64, - chunks: 2, - kernel_count: 2, - kernels: [ - scalarKernel({ - entry: "implicit_rhs_chunk0", - output_indices: [0, 1], - }), - scalarKernel({ - entry: "implicit_rhs_chunk0", - start_slot: 2, - output_indices: [2, 3], - }), - ], - }, - }), - /duplicates implicit RHS kernel entry implicit_rhs_chunk0/, - ); -}); - -test("implicitKernelSchedule rejects invalid native family list metadata", () => { - assert.throws( - () => implicitKernelSchedule({ - implicit_rhs: { - rows: 2, - workgroup_size: 64, - chunk_size: 64, - chunks: 1, - kernel_count: 1, - kernels: [scalarKernel({ entry: "implicit_rhs_chunk0" })], - native_families: {}, - }, - }), - /invalid native_families metadata/, - ); -}); - -test("implicitKernelSchedule rejects mixed native and scalar kernel metadata", () => { - assert.throws( - () => implicitKernelSchedule({ - implicit_rhs: { - rows: 3, - workgroup_size: 64, - chunk_size: 64, - chunks: 0, - kernel_count: 1, - kernels: [nativeKernel({ - entry: "implicit_rhs_map0", - start_slot: 0, - output_indices: [0, 1, 2], - })], - native_families: [nativeFamily()], - }, - }), - /mixes native tensor output metadata with scalar chunk metadata/, - ); -}); - -test("implicitKernelSchedule rejects derivative entry names", () => { - assert.throws( - () => implicitKernelSchedule({ - implicit_rhs: { - rows: 3, - workgroup_size: 64, - chunk_size: 64, - chunks: 0, - kernel_count: 1, - kernels: [nativeKernel({ entry: "derivative_rhs_map0" })], - native_families: [nativeFamily()], - }, - }), - /native entry must start with one of implicit_rhs_map, implicit_rhs_stencil/, - ); -}); - -test("implicitKernelSchedule rejects native chunk entry names", () => { - assert.throws( - () => implicitKernelSchedule({ - implicit_rhs: { - rows: 3, - workgroup_size: 64, - chunk_size: 64, - chunks: 0, - kernel_count: 1, - kernels: [nativeKernel({ entry: "implicit_rhs_chunk0" })], - native_families: [nativeFamily()], - }, - }), - /native entry must start with one of implicit_rhs_map, implicit_rhs_stencil/, - ); -}); - -test("implicitKernelSchedule rejects scalar native entry names", () => { - assert.throws( - () => implicitKernelSchedule({ - implicit_rhs: { - rows: 2, - workgroup_size: 64, - chunk_size: 64, - chunks: 1, - kernel_count: 1, - kernels: [scalarKernel({ entry: "implicit_rhs_map0" })], - }, - }), - /scalar chunk entry must start with implicit_rhs_chunk/, - ); -}); - -test("implicitKernelSchedule rejects missing implicit layout metadata", () => { - assert.throws( - () => implicitKernelSchedule({}), - /invalid rows metadata/, - ); -}); - -test("gpuKernelSchedules validates derivative and implicit schedules together", () => { - assert.deepEqual( - gpuKernelSchedules({ - rows: 3, - workgroup_size: 64, - chunk_size: 64, - chunks: 0, - kernel_count: 1, - kernels: [nativeKernel()], - native_families: [nativeFamily()], - implicit_rhs: { - rows: 3, - workgroup_size: 64, - chunk_size: 64, - chunks: 0, - kernel_count: 1, - kernels: [nativeKernel({ entry: "implicit_rhs_map0" })], - native_families: [nativeFamily()], - }, - }), - { - derivative: [{ entry: "derivative_rhs_map0", rows: 3, workgroupSize: 64 }], - implicit: [{ entry: "implicit_rhs_map0", rows: 3, workgroupSize: 64 }], - }, - ); -}); - -test("gpuKernelSchedules accepts empty zero-row implicit schedule", () => { - assert.deepEqual( - gpuKernelSchedules({ - rows: 3, - workgroup_size: 64, - chunk_size: 64, - chunks: 0, - kernel_count: 1, - kernels: [nativeKernel()], - native_families: [nativeFamily()], - implicit_rhs: { - rows: 0, - workgroup_size: 64, - chunk_size: 64, - chunks: 0, - kernel_count: 0, - kernels: [], - native_families: [], - }, - }), - { - derivative: [{ entry: "derivative_rhs_map0", rows: 3, workgroupSize: 64 }], - implicit: [], - }, - ); -}); - test("gpuKernelDispatchPlan computes per-kernel workgroups", () => { assert.deepEqual( gpuKernelDispatchPlan([ @@ -1048,48 +645,6 @@ test("gpuKernelDispatchPlan rejects dispatches beyond the device limit", () => { ); }); -test("gpuKernelWorkgroupBudget accepts empty implicit schedules", () => { - assert.equal( - gpuKernelWorkgroupBudget([], "test implicit budget", 64), - 0, - ); -}); - -test("gpuKernelWorkgroupBudget computes validated implicit schedule budget", () => { - const { implicit } = gpuKernelSchedules({ - rows: 3, - workgroup_size: 64, - chunk_size: 64, - chunks: 0, - kernel_count: 1, - kernels: [nativeKernel()], - native_families: [nativeFamily()], - implicit_rhs: { - rows: 3, - workgroup_size: 64, - chunk_size: 64, - chunks: 0, - kernel_count: 1, - kernels: [nativeKernel({ entry: "implicit_rhs_map0" })], - native_families: [nativeFamily()], - }, - }); - - assert.equal( - gpuKernelWorkgroupBudget(implicit, "test implicit budget", 64), - 1, - ); -}); - -test("gpuKernelWorkgroupBudget rejects implicit schedules beyond the device limit", () => { - assert.throws( - () => gpuKernelWorkgroupBudget([ - { entry: "implicit_rhs_map0", rows: 65, workgroupSize: 64 }, - ], "test implicit budget", 1), - /budget needs 2 workgroups, exceeding device limit 1/, - ); -}); - test("gpuKernelDispatchPlan rejects empty dispatch schedules", () => { assert.throws( () => gpuKernelDispatchPlan([], "test derivative dispatch"), @@ -1105,27 +660,3 @@ test("gpuKernelDispatchPlan rejects malformed dispatch metadata", () => { /invalid workgroupSize metadata/, ); }); - -test("gpuKernelSchedules rejects invalid implicit schedule before GPU build", () => { - assert.throws( - () => gpuKernelSchedules({ - rows: 3, - workgroup_size: 64, - chunk_size: 64, - chunks: 0, - kernel_count: 1, - kernels: [nativeKernel()], - native_families: [nativeFamily()], - implicit_rhs: { - rows: 3, - workgroup_size: 64, - chunk_size: 64, - chunks: 1, - kernel_count: 1, - kernels: [nativeKernel({ entry: "implicit_rhs_map0" })], - native_families: [nativeFamily()], - }, - }), - /chunks=1 does not match 0 scalar implicit RHS kernel entries/, - ); -}); diff --git a/packages/playground/tests/monaco_setup.test.mjs b/packages/playground/tests/monaco_setup.test.mjs index ae1b643e8..e44281eea 100644 --- a/packages/playground/tests/monaco_setup.test.mjs +++ b/packages/playground/tests/monaco_setup.test.mjs @@ -45,6 +45,7 @@ function createFakeMonaco() { const registeredLanguages = []; const tokenProviders = []; const semanticTokenProviders = []; + const completionProviders = []; return { captured: { @@ -52,6 +53,7 @@ function createFakeMonaco() { registeredLanguages, tokenProviders, semanticTokenProviders, + completionProviders, }, Emitter: FakeEmitter, MarkerSeverity: { @@ -101,7 +103,8 @@ function createFakeMonaco() { setMonarchTokensProvider(languageId, provider) { tokenProviders.push([languageId, provider]); }, - registerCompletionItemProvider() { + registerCompletionItemProvider(languageId, provider) { + completionProviders.push([languageId, provider]); return { dispose() {} }; }, registerDocumentSemanticTokensProvider(languageId, provider) { @@ -190,6 +193,57 @@ test("setupMonacoWorkspace wires comment metadata for editable languages", async } }); +test("jinja completion uses checked dense variable ordinals", async () => { + const fakeMonaco = createFakeMonaco(); + const originalWindow = globalThis.window; + const originalDocument = globalThis.document; + globalThis.window = { + currentDaeForCompletions: { + storage: { + variables: [ + { name: "gain", role: "parameter" }, + { name: "x", role: "state" }, + ], + }, + }, + }; + globalThis.document = { + getElementById() { + return {}; + }, + }; + + try { + setupMonacoWorkspace({ + monaco: fakeMonaco, + async sendLanguageCommand() { + return JSON.stringify({}); + }, + layoutAllEditors() {}, + }); + window.currentDaeForCompletions = { + storage: { + variables: [ + { name: "gain", role: "parameter" }, + { name: "x", role: "state" }, + ], + }, + }; + + const provider = fakeMonaco.captured.completionProviders + .find(([languageId]) => languageId === "jinja2")?.[1]; + assert(provider, "expected checked Jinja completion provider"); + const labels = provider.provideCompletionItems().suggestions + .map((suggestion) => suggestion.label); + assert(labels.includes("dae.variables[0] (gain)")); + assert(labels.includes("dae.variables[1].attributes.start_values (x)")); + assert(!labels.some((label) => label.startsWith("dae.x."))); + } finally { + globalThis.window = originalWindow; + globalThis.document = originalDocument; + } +}); + test("modelica semantic token cache refreshes after model edits", async () => { const fakeMonaco = createFakeMonaco(); const originalWindow = globalThis.window; diff --git a/packages/playground/tests/scenario_interface_smoke.mjs b/packages/playground/tests/scenario_interface_smoke.mjs index c2556138f..c8b87376b 100644 --- a/packages/playground/tests/scenario_interface_smoke.mjs +++ b/packages/playground/tests/scenario_interface_smoke.mjs @@ -297,7 +297,7 @@ async function codegenConfigCommandsUseRuntimeBridgeAndApplyWrites() { 'name = "Ball"', "", "[codegen]", - 'target = "sympy"', + 'target = "c-ode"', "", ].join("\n"), }, @@ -310,14 +310,14 @@ async function codegenConfigCommandsUseRuntimeBridgeAndApplyWrites() { const sources = JSON.parse(payload?.payload?.workspaceSources || "{}"); if (payload?.command === "rumoca.scenario.getCodegenConfig") { assert( - sources["rumoca-scenario.ball.toml"]?.includes('target = "sympy"'), + sources["rumoca-scenario.ball.toml"]?.includes('target = "c-ode"'), "expected getCodegenConfig to receive initial scenario TOML", ); - return JSON.stringify({ target: "sympy", outputDir: null }); + return JSON.stringify({ target: "c-ode", outputDir: null }); } if (payload?.command === "rumoca.scenario.setCodegenConfig") { assert( - sources["rumoca-scenario.ball.toml"]?.includes('target = "sympy"'), + sources["rumoca-scenario.ball.toml"]?.includes('target = "c-ode"'), "expected setCodegenConfig to receive live scenario TOML", ); return JSON.stringify({ @@ -343,7 +343,7 @@ async function codegenConfigCommandsUseRuntimeBridgeAndApplyWrites() { const current = await scenarioInterface.execute("rumoca.scenario.getCodegenConfig", { model: "Ball", }); - assert(current?.target === "sympy", "expected codegen target from scenario"); + assert(current?.target === "c-ode", "expected codegen target from scenario"); const saved = await scenarioInterface.execute("rumoca.scenario.setCodegenConfig", { model: "Ball", diff --git a/packages/rumoca-web/runtime/rumoca_gpu.js b/packages/rumoca-web/runtime/rumoca_gpu.js index 17fbf036f..405c5bf7e 100644 --- a/packages/rumoca-web/runtime/rumoca_gpu.js +++ b/packages/rumoca-web/runtime/rumoca_gpu.js @@ -1,9 +1,8 @@ // Rumoca WebGPU RK4 driver. // // Canonical WGSL/WebGPU execution adapter for the GPU simulation path. The -// compiler emits per-state derivative kernels via the `wgsl-solve` target -// (WASM `prepare_gpu_simulation`); the target also exposes implicit residual -// kernels in the layout for future implicit GPU solvers. This module wraps a +// compiler emits per-state derivative kernels via the `wgsl-ode` target +// (WASM `prepare_gpu_simulation`). This module wraps a // fixed-step classic RK4 integrator around the derivative kernels. The RK4 // stage/combine algebra runs in the two small hand-written kernels below. // @@ -627,7 +626,7 @@ function validatedKernelSchedule(block, options) { return schedule; } -// Normalize and validate the derivative kernel schedule in the wgsl-solve +// Normalize and validate the derivative kernel schedule in the wgsl-ode // layout. Native kernels write through generated WGSL output maps, so the host // only dispatches them; it still validates the maps before building pipelines // because the RK4 path assumes a dense derivative vector matching state order. @@ -645,31 +644,6 @@ export function derivativeKernelSchedule(layout) { }); } -// Validate the implicit RHS kernel inventory exposed by wgsl-solve. The -// browser RK4 path does not dispatch these kernels yet; this keeps the manifest -// contract executable for future implicit GPU solvers. -export function implicitKernelSchedule(layout) { - if (layout === null || typeof layout !== 'object') { - throw new Error('GPU layout has invalid implicit_rhs metadata.'); - } - return validatedKernelSchedule(layout.implicit_rhs, { - layoutLabel: 'GPU implicit_rhs layout', - kernelEntryLabel: 'GPU implicit kernel', - outputName: 'implicit RHS', - nativeEntryPrefixes: ['implicit_rhs_map', 'implicit_rhs_stencil'], - scalarEntryPrefix: 'implicit_rhs_chunk', - denseOutputRequired: false, - allowEmptySchedule: true, - }); -} - -export function gpuKernelSchedules(layout) { - return { - derivative: derivativeKernelSchedule(layout), - implicit: implicitKernelSchedule(layout), - }; -} - export function gpuKernelDispatchPlan( schedule, label = 'GPU kernel schedule', @@ -697,34 +671,6 @@ export function gpuKernelDispatchPlan( }); } -export function gpuKernelWorkgroupBudget( - schedule, - label = 'GPU kernel schedule', - maxWorkgroups = Number.MAX_SAFE_INTEGER, -) { - if (!Array.isArray(schedule)) { - throw new Error(`${label} metadata is invalid.`); - } - return schedule.reduce((total, kernel, index) => { - const entry = stringField(kernel, 'entry', `${label}[${index}]`); - const rows = integerField(kernel, 'rows', `${label}[${index}]`, 1); - const workgroupSize = integerField( - kernel, 'workgroupSize', `${label}[${index}]`, 1); - const workgroups = checkedWorkgroupCount( - rows, - workgroupSize, - `${label}[${index}] ${entry}`, - maxWorkgroups, - 'budget', - ); - return checkedMetadataAdd( - total, - workgroups, - `${label}[${index}] workgroup budget`, - ); - }, 0); -} - // Acquire a WebGPU adapter, throwing actionable errors when WebGPU is // unavailable. Returns a GPUAdapter suitable for `runGpuSimulation`. export async function probeGpu() { @@ -782,16 +728,14 @@ export async function buildGpuProgram(adapter, prep, onPhase = () => {}) { + `states=${nStates}); this model is not supported yet.` ); } - const schedules = gpuKernelSchedules(layout); + const derivativeSchedule = derivativeKernelSchedule(layout); const device = await adapter.requestDevice(); const maxWorkgroups = deviceWorkgroupLimit(device); const kernelList = gpuKernelDispatchPlan( - schedules.derivative, 'GPU derivative kernel schedule', maxWorkgroups); - const implicitWorkgroups = gpuKernelWorkgroupBudget( - schedules.implicit, 'GPU implicit kernel schedule', maxWorkgroups); + derivativeSchedule, 'GPU derivative kernel schedule', maxWorkgroups); onPhase('Parsing GPU kernels (WGSL)', null); - const derModule = await compileGpuModule(device, prep.wgsl, 'wgsl-solve'); + const derModule = await compileGpuModule(device, prep.wgsl, 'wgsl-ode'); const stageModule = await compileGpuModule(device, GPU_STAGE_WGSL, 'rk4-stage'); const combineModule = await compileGpuModule(device, GPU_COMBINE_WGSL, 'rk4-combine'); @@ -1123,7 +1067,7 @@ export async function buildGpuProgram(adapter, prep, onPhase = () => {}) { simDetails: { actual: { t_start: tStart, t_end: times[times.length - 1], points: times.length, variables: names.length }, requested: { - solver: `wgsl-solve RK4 (f32)${eventNote}`, + solver: `wgsl-ode RK4 (f32)${eventNote}`, t_start: tStart, t_end: tEnd, dt: outputDt, @@ -1136,8 +1080,6 @@ export async function buildGpuProgram(adapter, prep, onPhase = () => {}) { derivativeKernels: kernelList.length, derivativeWorkgroups: workgroupTotal( kernelList, 'GPU derivative kernel schedule'), - implicitKernels: schedules.implicit.length, - implicitWorkgroups, profile, }, }; diff --git a/packages/rumoca-web/viz/visualization_shared.js b/packages/rumoca-web/viz/visualization_shared.js index 4742ca26c..df0c99078 100644 --- a/packages/rumoca-web/viz/visualization_shared.js +++ b/packages/rumoca-web/viz/visualization_shared.js @@ -1351,8 +1351,6 @@ ctx.onFrame = (api) => { const SCENARIO_SOLVER_OPTIONS = [ ['auto', 'Auto'], ['bdf', 'BDF (stiff systems)'], - ['esdirk34', 'ESDIRK34 (implicit)'], - ['trbdf2', 'TR-BDF2 (implicit)'], ['rk-like', 'RK-like (explicit)'], ]; const SCENARIO_SIM_MODE_OPTIONS = [ @@ -1375,13 +1373,13 @@ ctx.onFrame = (api) => { ['3d', '3d'], ]; const SCENARIO_CODEGEN_TARGET_OPTIONS = [ - ['sympy', 'sympy'], - ['jax', 'jax'], - ['casadi-sx', 'casadi-sx'], - ['casadi-mx', 'casadi-mx'], - ['onnx', 'onnx'], - ['fmi2', 'fmi2'], - ['fmi3', 'fmi3'], + ['c-ode', 'c-ode'], + ['casadi-ode', 'casadi-ode'], + ['jax-ode', 'jax-ode'], + ['rust-ode', 'rust-ode'], + ['rust-fixed-ode', 'rust-fixed-ode'], + ['wgsl-ode', 'wgsl-ode'], + ['embedded-c', 'embedded-c'], ['galec', 'galec (eFMI Algorithm Code)'], ['galec-production', 'galec-production (eFMI Production Code)'], ['embedded-c-galec', 'embedded-c-galec (embedded C)'], @@ -1616,7 +1614,7 @@ ctx.onFrame = (api) => { label: 'Target', path: ['codegen', 'target'], kind: 'select', - value: scenarioFieldValue(config, ['codegen', 'target'], 'sympy'), + value: scenarioFieldValue(config, ['codegen', 'target'], 'c-ode'), options: SCENARIO_CODEGEN_TARGET_OPTIONS, hint: 'Built-in renderer used when task is codegen.', }, @@ -2145,7 +2143,7 @@ ctx.onFrame = (api) => { return scenarioOptionLabel(SCENARIO_VIEWER_MODE_OPTIONS, scenarioFieldByPath(fields, ['viewer', 'mode'])?.value || 'results_panel'); } if (section === 'codegen') { - return scenarioFieldByPath(fields, ['codegen', 'target'])?.value || 'sympy'; + return scenarioFieldByPath(fields, ['codegen', 'target'])?.value || 'c-ode'; } if (section === 'source_roots') { const roots = normalizeStringArray(scenarioFieldByPath(fields, ['source_roots'])?.value); diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 83cbe6cdc..ad32ab07f 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -2,7 +2,7 @@ "name": "rumoca-modelica", "displayName": "Rumoca Modelica", "description": "Modelica language support powered by rumoca-lsp", - "version": "0.9.20", + "version": "0.10.0", "publisher": "JamesGoppert", "repository": { "type": "git", @@ -107,7 +107,7 @@ "rumoca.galecServerPath": { "type": "string", "default": "", - "description": "Path to the rumoca-galec-lsp executable (GALEC .alg language server). If empty, uses the bundled binary, then searches PATH." + "description": "Path to the rumoca-lsp-galec executable (GALEC .alg language server). If empty, uses the bundled binary, then searches PATH." }, "rumoca.trace.server": { "type": "string", diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 5a29ab4a8..768025e31 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -1108,7 +1108,7 @@ function scenarioNeedsInputRunner(scenario: ScenarioConfigResponse): boolean { return scenario.viewerMode === 'external_web'; } -const DEFAULT_CODEGEN_TARGET_ID = 'sympy'; +const DEFAULT_CODEGEN_TARGET_ID = 'c-ode'; const SELECTED_SIMULATION_MODELS_STATE_KEY = 'rumoca.selectedSimulationModelsByDocument'; const LIVE_VIEWER_READY_PREFIX = 'rumoca-viewer-ready '; const MAX_INTERACTIVE_FAILURE_LINES = 30; diff --git a/packages/vscode/src/galec_client.ts b/packages/vscode/src/galec_client.ts index b2b3a5110..df40590f3 100644 --- a/packages/vscode/src/galec_client.ts +++ b/packages/vscode/src/galec_client.ts @@ -13,7 +13,7 @@ import { TransportKind, } from 'vscode-languageclient/node'; -const SERVER_BINARY = 'rumoca-galec-lsp'; +const SERVER_BINARY = 'rumoca-lsp-galec'; function serverExeName(): string { return process.platform === 'win32' ? `${SERVER_BINARY}.exe` : SERVER_BINARY; @@ -80,7 +80,7 @@ function serverExecutes(serverPath: string): boolean { /** * Build the GALEC `.alg` language client, or return `undefined` (having logged - * why) when no usable `rumoca-galec-lsp` is available — GALEC features are then + * why) when no usable `rumoca-lsp-galec` is available — GALEC features are then * simply absent, without disturbing the Modelica server. */ export function createGalecLanguageClient( @@ -91,14 +91,14 @@ export function createGalecLanguageClient( const serverPath = resolveServerPath(context, config); if (!serverPath || !fs.existsSync(serverPath)) { log( - 'rumoca-galec-lsp not found; GALEC (.alg) language features are disabled. ' + + 'rumoca-lsp-galec not found; GALEC (.alg) language features are disabled. ' + 'Install it with `cargo install rumoca` or set "rumoca.galecServerPath".' ); return undefined; } if (!serverExecutes(serverPath)) { log( - `rumoca-galec-lsp at ${serverPath} could not execute (--version probe failed); ` + + `rumoca-lsp-galec at ${serverPath} could not execute (--version probe failed); ` + 'GALEC (.alg) language features are disabled.' ); return undefined; diff --git a/rust-toolchain-kani.toml b/rust-toolchain-kani.toml new file mode 100644 index 000000000..c14fae817 --- /dev/null +++ b/rust-toolchain-kani.toml @@ -0,0 +1,7 @@ +# Exact compiler used to build Kani 0.67.0's release bundle. +# Kept separate from rust-toolchain.toml so bounded verification cannot change +# the compiler used by ordinary Rumoca development and WASM builds. +[toolchain] +channel = "nightly-2025-11-21" +profile = "minimal" +components = ["cargo", "rust-src", "rustc"] diff --git a/rust-toolchain.toml b/rust-toolchain.toml index c8d7e2b31..2c8bbee73 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -3,4 +3,4 @@ [toolchain] channel = "nightly-2026-02-27" components = ["llvm-tools-preview", "rust-src"] -targets = ["wasm32-unknown-unknown"] +targets = ["wasm32-unknown-unknown", "wasm32-wasip2"] diff --git a/spec/README.md b/spec/README.md index 5d07d7ed9..beb737fbf 100644 --- a/spec/README.md +++ b/spec/README.md @@ -13,19 +13,50 @@ For setup and day-to-day usage, see [CONTRIBUTING.md](../CONTRIBUTING.md). | Spec | Title | Domain | Lines | Status | |------|-------|--------|-------|--------| -| [SPEC_0000](SPEC_0000_SPEC_GUIDELINES.md) | Specification Writing Guidelines | process | ~220 | ACCEPTED | -| [SPEC_0001](SPEC_0001_DEFID.md) | DefId for Stable References | IR | ~50 | ACCEPTED | -| [SPEC_0002](SPEC_0002_SCOPE_TREE.md) | Scope Tree for Name Lookup | IR | ~95 | ACCEPTED | -| [SPEC_0007](SPEC_0007_IR_PIPELINE.md) | Compiler Pipeline and IR Contracts | architecture | ~350 | ACCEPTED | -| [SPEC_0008](SPEC_0008_PHASE_ERRORS.md) | Diagnostics, Traceability, and Phase-Local Errors | error | ~260 | ACCEPTED | -| [SPEC_0018](SPEC_0018_TOOL_CONFIG.md) | Tool Configuration Loading | tooling | ~155 | ACCEPTED | -| [SPEC_0021](SPEC_0021_CODE_COMPLEXITY.md) | Maintainability and Determinism Guidelines | convention | ~210 | ACCEPTED | -| [SPEC_0022](SPEC_0022_MLS_COMPILER_COMPLIANCE.md) | MLS Compiler Compliance (431 contracts) | MLS | ~960 | REFERENCE | -| [SPEC_0025](SPEC_0025_PR_REVIEW_PROCESS.md) | Change Review Process | process | ~350 | ACCEPTED | -| [SPEC_0029](SPEC_0029_CRATE_BOUNDARIES.md) | Crate Boundaries as Collaboration Guardrails | architecture | ~340 | ACCEPTED | -| [SPEC_0031](SPEC_0031_COMPILER_PHILOSOPHY.md) | Compiler Scope and Philosophy | architecture | ~150 | ACCEPTED | -| [SPEC_0032](SPEC_0032_RANGE_PRESERVING_TENSORS.md) | Range-Preserving Tensor IR | IR | ~85 | ACCEPTED | -| [SPEC_0034](SPEC_0034_GALEC_EFMI_EXPORT.md) | eFMI/GALEC Algorithm Code Export | target/codegen | ~180 | DRAFT | +| [SPEC_0000](SPEC_0000_SPEC_GUIDELINES.md) | Specification Writing Guidelines | process | ~258 | ACCEPTED | +| [SPEC_0001](SPEC_0001_DEFID.md) | DefId for Stable References | IR | ~128 | ACCEPTED | +| [SPEC_0002](SPEC_0002_SCOPE_TREE.md) | Scope Tree for Name Lookup | IR | ~119 | ACCEPTED | +| [SPEC_0007](SPEC_0007_IR_PIPELINE.md) | Compiler Pipeline and IR Contracts | architecture | ~313 | ACCEPTED | +| [SPEC_0008](SPEC_0008_PHASE_ERRORS.md) | Diagnostics, Traceability, and Phase-Local Errors | error | ~328 | ACCEPTED | +| [SPEC_0018](SPEC_0018_TOOL_CONFIG.md) | Tool Configuration Loading | tooling | ~329 | ACCEPTED | +| [SPEC_0021](SPEC_0021_CODE_COMPLEXITY.md) | Maintainability and Determinism Guidelines | convention | ~248 | ACCEPTED | +| [SPEC_0022](SPEC_0022_MLS_COMPILER_COMPLIANCE.md) | MLS Compiler Compliance (438 contracts) | MLS | ~1010 | REFERENCE | +| [SPEC_0025](SPEC_0025_PR_REVIEW_PROCESS.md) | Change Review Process | process | ~236 | ACCEPTED | +| [SPEC_0029](SPEC_0029_CRATE_BOUNDARIES.md) | Crate Boundaries as Collaboration Guardrails | architecture | ~345 | ACCEPTED | +| [SPEC_0031](SPEC_0031_COMPILER_PHILOSOPHY.md) | Compiler Scope and Philosophy | architecture | ~159 | REFERENCE | +| [SPEC_0032](SPEC_0032_RANGE_PRESERVING_TENSORS.md) | Range-Preserving Tensor IR | IR | ~191 | ACCEPTED | +| [SPEC_0033](SPEC_0033_DEVELOPMENT_PROCESS.md) | Development Process | process | ~175 | ACCEPTED | +| [SPEC_0034](SPEC_0034_GALEC_EFMI_EXPORT.md) | eFMI/GALEC Algorithm Code Export | target/codegen | ~189 | DRAFT | +| [SPEC_0035](SPEC_0035_COMPLEX_NUMERIC_TYPES.md) | Complex Numeric Types in Solve IR | IR | ~196 | DRAFT | +| [SPEC_0036](SPEC_0036_VALID_BY_CONSTRUCTION_IR.md) | Valid-by-Construction Compiler IR | IR | ~319 | DRAFT | +| [SPEC_0037](SPEC_0037_FORMALLY_VERIFIED_COMPILER.md) | Formally Verified Compiler | verification | ~311 | DRAFT | +| [SPEC_0038](SPEC_0038_UNIFIED_FMI_EXECUTION.md) | Unified FMI Execution | target/runtime | ~241 | DRAFT | +| [SPEC_0039](SPEC_0039_PROOF_CARRYING_SPARSITY.md) | Proof-Carrying Structural Sparsity | IR | ~153 | DRAFT | +| [SPEC_0040](SPEC_0040_IR_STAGE_CONTRACT_CATALOG.md) | IR Stage Contract Catalog | architecture | ~130 | REFERENCE | +| [SPEC_0041](SPEC_0041_CRATE_OWNERSHIP_CATALOG.md) | Crate Ownership Catalog | architecture | ~142 | REFERENCE | +| [SPEC_0042](SPEC_0042_GALEC_LANGUAGE_CATALOG.md) | GALEC Language and Decision Catalog | target/codegen | ~67 | REFERENCE | +| [SPEC_0043](SPEC_0043_CONSTRUCTION_CATALOG.md) | Valid-by-Construction Catalog | IR | ~272 | REFERENCE | +| [SPEC_0044](SPEC_0044_FMI_EXECUTION_CATALOG.md) | FMI Execution Contract Catalog | runtime/verification | ~477 | REFERENCE | +| [SPEC_0045](SPEC_0045_SOLVE_EXECUTABLE_VOCABULARY_AND_PROFILES.md) | Solve Executable Vocabulary and Profiles | IR | ~165 | DRAFT | +| [SPEC_0046](SPEC_0046_SCHEDULED_DISCRETE_OWNERSHIP.md) | Scheduled Discrete Ownership | IR/runtime | ~187 | DRAFT | +| [SPEC_0047](SPEC_0047_SOLVE_EXECUTABLE_VOCABULARY_CATALOG.md) | Solve Vocabulary and Target Refinement Catalog | IR/target | ~552 | REFERENCE | +| [SPEC_0048](SPEC_0048_TARGET_REFINEMENT_AND_PREPARED_PRODUCTS.md) | Target Refinement and Prepared Products | target/codegen | ~114 | DRAFT | +| [SPEC_0049](SPEC_0049_SOLVE_GRAMMAR_CATALOG.md) | Solve Grammar and Effect Catalog | IR | ~214 | REFERENCE | +| [SPEC_0050](SPEC_0050_TRACE_EVIDENCE_CATALOG.md) | Trace Evidence Catalog | process/verification | ~26 | REFERENCE | + +### Reference annexes + +`SPEC_0040`–`SPEC_0044`, `SPEC_0047`, `SPEC_0049`, and `SPEC_0050` are REFERENCE annexes: +they carry the lookup catalogs split out of their parent spec under SPEC_0000 +§3/§3a size budgets. Every row in an annex is normative by reference from the +parent section that links it (SPEC_0007→0040, SPEC_0029→0041, SPEC_0034→0042, +SPEC_0036→0043, SPEC_0038→0044, SPEC_0033→0050). `SPEC_0047` serves three +parents — SPEC_0045, SPEC_0046, and SPEC_0048 — and each gate row names the +parent rule it covers. `SPEC_0049` serves SPEC_0045 and SPEC_0048. It is bound +by SEV-001/002/007 (grammar and coverage), SEV-011/024 (contract +classes and element-kind splits), and TRP-042 (the capability profile keyed to +its rows). Annexes add no rules of their own; edit the parent when the +requirement itself changes. ## Deferred Specifications @@ -35,7 +66,7 @@ useful after the 0.9 stabilization work. | Spec | Title | Domain | Lines | Status | |------|-------|--------|-------|--------| -| [SPEC_0012](archive/deferred/SPEC_0012_CST_AST.md) | CST vs AST Distinction | parser/tooling | ~170 | DEFERRED | -| [SPEC_0014](archive/deferred/SPEC_0014_EVAL_MEMO.md) | Eval Memoization at Phase Boundaries | performance | ~200 | DEFERRED | -| [SPEC_0015](archive/deferred/SPEC_0015_FORMATTER.md) | Token-Based Formatter | tooling | ~250 | DEFERRED | -| [SPEC_0028](archive/deferred/SPEC_0028_CERTIFICATION_CODEGEN.md) | Safety-Oriented Code Generation | codegen | ~100 | DEFERRED | +| [SPEC_0012](archive/deferred/SPEC_0012_CST_AST.md) | CST vs AST Distinction | parser/tooling | ~167 | DEFERRED | +| [SPEC_0014](archive/deferred/SPEC_0014_EVAL_MEMO.md) | Eval Memoization at Phase Boundaries | performance | ~189 | DEFERRED | +| [SPEC_0015](archive/deferred/SPEC_0015_FORMATTER.md) | Token-Based Formatter | tooling | ~249 | DEFERRED | +| [SPEC_0028](archive/deferred/SPEC_0028_CERTIFICATION_CODEGEN.md) | Safety-Oriented Code Generation | codegen | ~97 | DEFERRED | diff --git a/spec/SPEC_0000_SPEC_GUIDELINES.md b/spec/SPEC_0000_SPEC_GUIDELINES.md index 784d2aefc..1b864bd54 100644 --- a/spec/SPEC_0000_SPEC_GUIDELINES.md +++ b/spec/SPEC_0000_SPEC_GUIDELINES.md @@ -114,15 +114,15 @@ applied. | Status | Cap | Rationale | |---|---|---| -| ACCEPTED + DRAFT | 15 specs total | ~3000 lines of normative content at cap; fits 2-3 AI context loads | +| ACCEPTED + DRAFT | 20 specs total | ~4000 lines of normative content at cap; fits 2-3 AI context loads | | REFERENCE | uncapped | Lookup catalogs (e.g. SPEC_0022) are not rules; size doesn't burden rule comprehension | -Adding a 16th ACCEPTED/DRAFT spec requires either: +Adding a 21st ACCEPTED/DRAFT spec requires either: - merging into an existing spec, or - moving inactive future-work proposals to `archive/deferred/`, or - deleting inactive proposals that are not worth preserving. -Enforced by `crates/rumoca/tests/spec_budget_test.rs::test_active_spec_count_under_cap`. +Enforced by `crates/rumoca/tests/suite_gates/spec_budget_test.rs::test_active_spec_count_under_cap`. ### 3a. Hard Word and Line Budgets Per Spec @@ -138,8 +138,21 @@ skimmed; skimmed specs get misapplied. **Hard cap:** an ACCEPTED design spec MUST NOT exceed 2500 words / 350 lines without an explicit `REFERENCE` status. Going over requires moving the spec -to REFERENCE (lookup catalogs only, like SPEC_0022) or splitting it into -multiple ACCEPTED specs. +to REFERENCE (lookup catalogs only, like SPEC_0022), splitting it into +multiple ACCEPTED specs, or splitting off a REFERENCE annex. + +**REFERENCE annexes** (e.g. SPEC_0040–SPEC_0043) carry a spec's lookup catalogs +so the parent stays inside budget without losing rules: + +| Rule | Where | Why | +|---|---|---| +| The parent keeps its ACCEPTED/DRAFT status and every governing requirement | parent spec | Splitting must not downgrade a rule | +| An annex holds only lookup catalogs, evidence, and duplicated rationale | annex | An annex is not a place to hide requirements | +| The parent section states the rule and links the annex rows it binds | parent spec | Every row stays reachable and cited | +| Annex rows are normative by reference from that link | annex header | Catalog rows still bind | +| Annexes are `REFERENCE` and need no vote | SPEC_0000 §5 | They add no new rule | + +Enforced by `spec_budget_test.rs::test_specs_respect_size_budgets`. **What to cut:** - Implementation code that duplicates what's in the source (link to the file instead) diff --git a/spec/SPEC_0001_DEFID.md b/spec/SPEC_0001_DEFID.md index c4292b6b8..687c7a734 100644 --- a/spec/SPEC_0001_DEFID.md +++ b/spec/SPEC_0001_DEFID.md @@ -110,9 +110,14 @@ DAE/Solve/Sim code must key or compare such variables by the instance identity, not by the reused source declaration DefId. If downstream code needs to answer "does this instance originate from this -source declaration?", it must use explicit ancestry metadata such as -`symbol_ancestry`, not string prefix checks or equality on reused source -declaration ids. +source declaration?", it must use explicit ancestry metadata such as the +declaration chain from `ClassDefIndex::def_ancestry` or the occurrence owner +chain in `flat::Model::instance_relations`, not string prefix checks or equality +on reused source declaration ids. + +`InstanceId(0)` is reserved as `InstanceId::UNSET`: instantiation allocates +occurrence identities from one, so a defaulted occurrence field is provably +unset and stage contracts reject it instead of accepting it as an instance. ## Rationale - Inspired by Rust compiler's `DefId` which provides stable cross-crate references diff --git a/spec/SPEC_0002_SCOPE_TREE.md b/spec/SPEC_0002_SCOPE_TREE.md index 91edec98d..117717529 100644 --- a/spec/SPEC_0002_SCOPE_TREE.md +++ b/spec/SPEC_0002_SCOPE_TREE.md @@ -96,11 +96,20 @@ splitting, or hashing names that were already resolved earlier in the pipeline. 1. Check direct members of the current scope 2. Check imports in the current scope -3. Move to parent scope and repeat -4. Stop at global scope (return None if not found) +3. Check effective inherited members of the current class scope; semantically + equivalent declarations inherited from multiple bases share the first + deterministic identity, while conflicting inherited names stop lookup + without selecting an arbitrary declaration +4. Move to parent scope and repeat +5. Stop at global scope (return None if not found) Encapsulated scopes (`ScopeKind::Encapsulated`) block upward lookup — names must be found locally or via imports. +Imports are not inherited. Effective inherited-member entries are populated +from resolved `extends` edges before class contents are resolved. Direct +members and current-scope imports therefore retain their precedence, while an +unrelated declaration in an enclosing scope cannot shadow an inherited member. + ## Rationale - Tree structure handles Modelica's extends semantics - IndexMap for members preserves insertion order (SPEC_0021) diff --git a/spec/SPEC_0007_IR_PIPELINE.md b/spec/SPEC_0007_IR_PIPELINE.md index 9701bdd30..7e8344e4f 100644 --- a/spec/SPEC_0007_IR_PIPELINE.md +++ b/spec/SPEC_0007_IR_PIPELINE.md @@ -5,54 +5,109 @@ ACCEPTED ## Summary -Rumoca transforms Modelica through AST → Flat → DAE → Solve IRs. Each -stage has a contract: contents, ownership, boundary leaks. +Rumoca transforms Modelica through AST → Flat → DAE → Solve IRs. Each stage +defines its contents, ownership, and boundary. -## The Four IR Stages +Per-stage contract rows and the structural-lowering transformation list are +catalogued in [SPEC_0040](SPEC_0040_IR_STAGE_CONTRACT_CATALOG.md). Every row is +normative by reference from the stage section linking it. + +## Specification ``` Modelica source (.mo) │ ▼ rumoca-phase-parse ┌──────────┐ - │ AST │ rumoca-ir-ast ◄─ codegen: formatters, pretty-printers, - └────┬─────┘ documentation generators - │ rumoca-phase-resolve, rumoca-phase-typecheck, - │ rumoca-phase-instantiate + │ AST │ rumoca-ir-ast ◄─ consumers: formatters, source-aware + └────┬─────┘ documentation tools + │ rumoca-phase-resolve, rumoca-phase-instantiate, + │ rumoca-phase-typecheck, rumoca-phase-flatten ▼ ┌──────────┐ │ Flat │ rumoca-ir-flat ◄─ codegen: flat Modelica export └────┬─────┘ - │ rumoca-phase-flatten, rumoca-phase-dae + │ rumoca-phase-dae ▼ ┌──────────┐ - │ DAE │ rumoca-ir-dae ◄─ codegen: FMI export, DAE-level - └────┬─────┘ symbolic/array backends + │ DAE │ rumoca-ir-dae ◄─ codegen: DAE-level symbolic/array + └────┬─────┘ backends │ rumoca-phase-solve (CasADi, SymPy, JAX) ▼ ┌──────────┐ │ Solve │ rumoca-ir-solve ◄─ codegen/JIT: numeric C/Rust, - └──────────┘ CUDA C/NVRTC, MLIR/LLVM, kernels + └──────────┘ MLIR/LLVM, CUDA C and WGSL kernels ``` -**Codegen targets the lowest IR it needs — no lower.** +**Codegen targets the lowest proven-valid IR it needs — no lower.** | Backend | IR level | Why | |---|---|---| -| Formatter, doc generator | AST | Needs syntax + spans | +| Formatter, doc generator | AST | Needs syntax + spans; it is a target only when it preserves every supported construct or fails closed | | Flat Modelica export | Flat | Original expression structure | -| FMI export, DAE-readable C/Fortran, CasADi, SymPy, JAX-style symbolic/array targets | DAE | MLS B.1 form, source traceability | -| Numeric sim, C/Rust kernels, JIT, MLIR, CUDA/GPU | Solve | Register-machine plus tensor bytecode | +| DAE residual and symbolic-analysis targets | DAE | MLS B.1 form, residual ownership, source traceability | +| Numeric simulation and explicit-ODE products | `SolveProblem` | Register-machine plus tensor bytecode | +| eFMI Algorithm Code | checked `AlgorithmCodePackage` derived from DAE | Causal GALEC lifecycle and language semantics | +| eFMI Production Code and GALEC-derived embedded execution | checked `SolveAlgorithmBlock` derived from `AlgorithmCodePackage` (pending: 2026-08-08 plan, M3-4) | Typed executable lifecycle, storage, effects, and ABI obligations | +| FMI 2/3 components | checked FMI component export IR derived from DAE + Solve | DAE metadata and tensor shape plus one executable checked kernel | + +`rumoca-phase-codegen` renders text; execution adapters wrap toolchains and +runtimes without owning compiler semantics. + +Every IR that crosses the code-generation boundary MUST already satisfy its +stage invariants by construction. A target manifest selects the exact canonical +or checked export IR it consumes; the compiler supplies a typed, read-only +semantic view of that artifact to MiniJinja. Rendering MUST NOT resolve names, +infer types or shapes, lower to another IR, mutate its input, or repair an +invalid artifact. + +Code-generation architecture: + +```text +proven-valid IR -> typed semantic template view -> target.toml + MiniJinja -> artifacts +``` + +This boundary applies uniformly to syntax, Flat, DAE, Solve, and checked export +IRs. Adding a target for an already-supported IR requires only a target +directory. Supporting a new IR requires one target-neutral semantic view and +capability vocabulary, never a target-language renderer in Rust. Export IRs +remain projections, never canonical pipeline stages. + +The checked FMI component export is the single deployment projection for FMI 2 +and FMI 3. Its constructor binds DAE-owned variable identity, causality, type, +shape, units, and provenance to the executable Solve kernel. FMI-version +adapters may scalarize only the external value-reference view required by that +version; they MUST NOT repeat equation lowering, initialization, event, or +state-machine semantics. A raw derivative-only C kernel is not an FMI component +and MUST NOT be advertised as an FMI deployment substitute. + +### Built-in Target Product Contract + +A built-in target is an executable or inspectable compiler product, not a +roadmap marker. Every directory registered below +`rumoca-phase-codegen/src/templates/` MUST satisfy all of these rules: -**Template/codegen ownership:** `rumoca-phase-codegen` renders text. Execution -adapters wrap toolchains, packaging, runtime calls, or JIT APIs, not semantics, -DAE lowering, structural rewrites, or template policy. +| Rule | Required evidence | +|---|---| +| Public names describe artifacts or interface profiles | Target IDs remain meaningful without IR knowledge | +| Consumed IR is a separate manifest dimension | `target.toml` declares `ir`; `rumoca targets` reports it | +| The target has a concrete present-day user workflow | `README.md` names the intended user, input IR, produced artifact, invocation, and the decision or deployment task the artifact supports | +| The target states its semantic boundary honestly | `README.md` and `target.toml` name non-goals, unsupported semantics, readiness, and whether the artifact is source, analysis output, a runtime component, or a standards container | +| The target emits a non-empty artifact | At least one `[[files]]` entry renders through the checked target path; manifest-only future placeholders are prohibited | +| Unsupported input fails closed | Focused negative tests prove that unsupported semantic operations cannot become comments, stubs, zero values, omitted sections, or successful-looking artifacts | +| The artifact is checked at the strongest practical boundary | Unit tests always cover manifest parsing and real rendering; language targets parse or compile; executable targets run a numerical fixture; package/standard targets validate metadata, lifecycle, and execution against the exact claimed revision | +| Documentation and tests are target-local and discoverable | The target `README.md` lists the exact focused tests and external gates that support its readiness claim | +| Experimental status narrows claims, not evidence | A readiness-zero target may expose a pinned experimental interface, but still emits and validates a useful artifact; readiness zero cannot excuse a non-product | + +Proposed-future-use targets stay in specs or notes until an artifact and +evidence exist. Templates MUST fail with a span-bearing error on an unsupported +checked construct; lossy placeholder text is never acceptable. --- ### Stage 1 — AST (`rumoca-ir-ast`) -**What it is:** The parser output: concrete syntax with comments and spans. +**What it is:** Parser output: concrete syntax, comments, and spans. **Contract:** - Represents source text structure, not language semantics. @@ -60,32 +115,29 @@ DAE lowering, structural rewrites, or template policy. - Every node carries a source `Span`; later AST merges must preserve parser provenance instead of rewriting source ids. -**What to do here:** Parsing, formatting, early syntax diagnostics. +**Do here:** Parsing, formatting, early syntax diagnostics. -**What NOT to do here:** Name lookup, class instantiation, type inference, -equation manipulation. +**Do not:** Name lookup, class instantiation, type inference, equation +manipulation. --- ### Stage 2 — Flat (`rumoca-ir-flat`) -**What it is:** The instantiated, modified class hierarchy: variables, -equations, and algorithms with fully-qualified names. +**What it is:** The instantiated class hierarchy with fully-qualified names. **Contract:** - No unresolved class references. - No modification chains; all modifications have been applied. +- Virtual connection graphs satisfy MLS §9.4 forest and root invariants. - Arrays remain symbolic (not scalarized). - Function bodies remain structured in `functions`. - `pre()`, `der()`, `initial()`, and other Modelica built-ins are still present as expression nodes — semantic lowering has not occurred. -**What to do here:** Name resolution, instantiation, post-instantiation -type checking, flattening, structural transformations that preserve Modelica -expression form. - -**What NOT to do here:** Solving equations, eliminating Modelica-specific -operators, generating simulation code. +**Do here:** Resolution, instantiation, post-instantiation type checking, and +flattening. **Do not:** solve equations, eliminate Modelica operators, or +generate simulation code. **Cross-cutting rules (Flat through DAE):** @@ -93,18 +145,20 @@ operators, generating simulation code. |---|---| | Instantiation and flattening are separate logical phases | Instantiation applies modifications + builds `InstanceOverlay`/`InstancedTree`; production then runs `typecheck_instanced` before flattening traverses the overlay, expands connections, and produces `flat::Model`. | | Arrays stay symbolic through Flat and DAE | Backends requesting scalar form call scalarization in structural/solver layers with shape metadata, not via display-string parsing | -| Function algorithms remain structured in `Flat.functions`/`Dae.functions` | Function bodies are not lowered into solver equation buckets | +| Function algorithms remain structured; conditional joins retain checked shared-branch correlation | Downstream projections preserve call cardinality without reconstructing control flow | +| A function-algorithm `assert` is a flow action, not an ordinary call or a value expression | A value-proven function specialization may erase the statement only when its exact specialization environment proves the condition `true`. An unsettled condition may lower only through the call-specialized guarded root/action schedule in SOLVE-C25; a proven-false or otherwise unrepresentable schedule is typed-rejected. The action is never silently discarded or routed through multi-result-call lowering. | | Model algorithms lower to DAE only when they fit the declarative subset | Unsupported forms fail explicitly with `ED013` | -| Post-resolution compiler identity is keyed by `DefId`, not strings | Hashing rendered names, `VarName`, flat names, cached display strings, rendered `ComponentPath`, or rendered `ComponentReference` after resolution is a phase-boundary bug. Carry `DefId`; semantic keys may be `DefId` or structured keys whose identity fields are all `DefId` values. | +| Initial sections use declarative owners: sequential scalar assignments and `if` conditionals in an `initial algorithm` determine a `parameter` declared `fixed = false` or a discrete coordinate; an explicit initial equation `m = value` or `pre(m) = value` determines the same typed discrete initial-value owner; and `assert` becomes an assertion owner carrying its enclosing branch conditions | A discrete initial value is a checked definition, not a numeric residual: its constructor proves exact scalar type, initialization-settled reads, and unique target ownership, and Solve initializes both current and `pre` storage from it. Replayed calculated-parameter values read only parameters and constants. Where each dependency is settled at parameter-set time, the parameter set computes exactly the initialization value; where one is a `fixed = false` parameter, Solve re-applies the binding after the initialization projection, so the parameter-set value is an iteration seed. Algebraic, state, output, and input algorithm targets and every loop, `when`, or non-`assert` call statement keep `ED013` because no checked initialization owner determines them | +| Post-resolution declaration identity is keyed by `DefId`, not strings | Hashing rendered names, `VarName`, flat names, cached display strings, rendered `ComponentPath`, or rendered `ComponentReference` after resolution is a phase-boundary bug. Carry `DefId` for declarations and structured instance identity where one declaration has multiple instantiated meanings. | +| Flat `TypeId` is the resolved effective type of that concrete instance | Two instances originating from one `DefId` may have different effective types after redeclare or modification. DAE type catalogs key by this identity and retain `DefId` only as declaration provenance. | | Semantic phases do not recover name hierarchy by tokenizing flattened strings | The AST, `QualifiedName`, `ComponentReference`, `DefId`, scope tree, and phase metadata carry name structure. Splitting `a.b.c` text inside compiler/evaluator/lowering logic means structure was lost too early. Textual path parsing is allowed only at source/protocol/config/display boundaries while structured IR replaces it. | --- ### Stage 3 — DAE (`rumoca-ir-dae`) -**What it is:** A computable mathematical representation matching the MLS -Appendix B canonical DAE. Modelica-specific operators have been eliminated. -The result is a set of pure functions over the variable vector +**What it is:** The computable MLS Appendix B canonical DAE after eliminating +Modelica-specific operators: pure functions over `v := [p; t; ẋ; x; y; z; m; pre(z); pre(m)]`. **The four MLS B.1 functions:** @@ -112,53 +166,52 @@ The result is a set of pure functions over the variable vector | ID | Function | Role | |------|--------------------|-----------------------------| | B.1a | `fx(v, c) = 0` | Continuous DAE residual | -| B.1b | `fz(v, c) = 0` | Discrete real update | -| B.1c | `fm(v, c) = 0` | Discrete-valued update | +| B.1b | `fz(v, c) = 0` | Coupled discrete Real residual | +| B.1c | `m := fm(v, c)` | Solved discrete-valued assignment | | B.1d | `fc(relation(v))` | Event conditions | -**DAE representation rule:** DAE is the lean canonical MLS Appendix B model, -not a solver work cache. Variables stay partitioned by kind under -`DaeVariables`; event behavior lives in `discrete`, `conditions`, `events`, and -`clocks`. Serialized DAE exposes MLS/root template keys through serde -flattening; Rust stays partitioned. The root `schema_version` is mandatory and -unsupported versions are rejected. - -DAE fields represent Modelica semantics, source identity, or stable MLS Appendix -B partitions. Backend products such as mass matrices, Jacobians, BLT orderings, -tearing choices, state-selection reports, and scalarized variants belong in -structural analysis results or Solve artifacts. - -`conditions.relations` owns MLS Appendix B relation surfaces. Runtime metadata -passes must not rediscover roots from continuous equations. Non-Appendix-B -runtime surfaces, such as numeric roots from `abs(...)` or `sign(...)`, belong -in `events.synthetic_root_conditions`. - -Optional same-version DAE fields may use `#[serde(default)]` only when absence -has the same meaning as the default. Incompatible schema changes bump -`schema_version`. - -**Contract:** - -| Rule | Where | Why | -|---|---|---| -| No source temporal operators (`pre`, `edge`, `change`, `sample`, `previous`) survive in f_x, f_z, f_m, f_c, relations, or initialization equations | DAE lowering rewrites them into Appendix B constructs: explicit `__pre__.*` inputs, relation/c variables, scheduled events, clock metadata, and ordinary equations over `v` | MLS Appendix B states the DAE as functions over `v` and `relation(v)`; source temporal operators are not computable DAE/Solve graph nodes | -| No `der()` on RHS | derivatives flow via `dae.states` + equation structure | Inline `der()` would hide state identity | -| No `initial()` in f_x/f_z/f_m/f_c | initial phase is handled separately | Avoids mixing initialization into runtime equations | -| `pre(z)` / `pre(m)` are `__pre__.*` entries in `dae.parameters` | runtime writes slots at event entry per Stage 4 (`SolveLayout::pre_param_bindings`) | The Modelica `pre()` operator exists only in AST and Flat | -| `edge(b)` and `change(v)` are equations over current values and `__pre__.*` inputs | DAE lowering expands source operators before validation | Leaves no event operator for Solve lowering to interpret | -| `sample(...)` and clocked `previous(...)` are represented by DAE event/clock metadata plus ordinary equations over current/pre slots | Runtime scheduling data is explicit DAE metadata; sampled values are `__pre__.*` reads where needed | Keeps clock semantics at DAE level while keeping compute functions ordinary | -| `reinit(x, expr)` is lowered into guarded discrete state-update equations before DAE validation | DAE lowering converts state resets into ordinary Appendix B update equations over current/pre slots | Keeps state reset semantics in the numeric update system instead of exposing a source operator to runtimes | -| `assert(...)` and `terminate(...)` are represented as `events.event_actions`, not as residual/value expressions | DAE lowering converts integration-flow statements into guarded event actions with source spans | Keeps Appendix B compute graphs pure while preserving solver-visible runtime actions | -| `appendix_b_validation` rejects any surviving source temporal operator | `phase-dae/src/appendix_b_validation.rs::validate_no_source_temporal_operator_survives` | Positive enforcement gate, not defensive code | - -**What to do here:** DAE-level passes such as pre-lowering and alias -elimination; structural lowering/transformation such as index reduction and -state selection; and structural analysis products that are returned separately -from the DAE value. - -**What NOT to do here:** Register allocation, lowering expression trees to -bytecode, FMI/MLIR template emission, or storing backend-specific solver -artifacts in DAE. +**DAE representation rule:** DAE is the canonical MLS Appendix B model, not a +solver cache. One canonical variable catalog owns stable variable identity; +typed views classify `p`, `x`, `y`, `z`, and `m`, while input/output causality +is orthogonal metadata. Dedicated continuous, initialization, discrete, +condition, event, and clock systems own their respective behavior. The current +`DAE_SCHEMA_VERSION` wire schema is the only supported version; every other +version is rejected without superseded readers or adapters. + +Finalized DAE is valid by construction. Invariant-bearing fields are private, +checked child constructors establish local expression/type/shape/domain +contracts, and root construction establishes catalog membership and +cross-object contracts. Production phases do not receive a weaker DAE-shaped +draft and do not run a whole-root validation pass. + +`Dae::construct` lends sequential semantic-owner closures one generatively +branded aggregate. All expressions use one DAE-wide dense arena with parallel +node, provenance, and type columns plus packed variadic operands. Every source +node carries its exact occurrence span; generated nodes carry typed generation +and the nearest responsible source span. + +DAE fields represent Modelica semantics, source identity, or stable Appendix B +partitions. Mass matrices, Jacobians, BLT orderings, tearing choices, +state-selection reports, and scalarized variants belong in structural results +or Solve artifacts. + +The condition system independently owns typed relation and condition catalogs. +Conditions refer to relation leaves by typed identity; relation and condition +counts are not required to match. Runtime metadata passes must not rediscover +roots from continuous equations. Non-Appendix-B event-generating numeric +surfaces introduced by lowering belong to the event system as synthetic roots. +The event-free MLS `abs(...)` and `sign(...)` functions do not create roots. + +Only private current-version wire records derive `Deserialize`. Decoding +constructs checked children and then the checked root; derived counts and +indexes are recomputed rather than accepted as wire inputs. + +**Contract:** rows `DAE-C01`–`DAE-C21` in +[SPEC_0040 §1](SPEC_0040_IR_STAGE_CONTRACT_CATALOG.md#1-dae-stage-contract-catalog-spec_0007-stage-3). + +**Do here:** DAE lowering, structural transformation, and separately returned +structural analysis. **Do not:** allocate registers, lower bytecode, emit +templates, or store backend artifacts in DAE. **Prohibited:** mutable cache fields, merged variable-kind maps, solver row bytecode/layout, model-level `when_clauses`, and unlowered synchronous @@ -168,14 +221,8 @@ operators in solver equation partitions. ### Stage 4 — Solve (`rumoca-ir-solve`) -**What it is:** A register-machine representation of the DAE-IR functions. -MLS B.1 functions lower into `ComputeBlock` graphs of scalar programs and -tensor program nodes. -Solve-IR does not add new mathematical content; it changes format. Tensor -structure (matrix multiply, linear solve, affine stencils, future -reductions/maps/broadcasts) is preserved as `ComputeNode` variants above the -scalar layer so backends can choose scalar expansion or native tensor ops -(BLAS/faer, Cranelift/LLVM kernels, CUDA, MLIR `linalg`). +**What it is:** Typed programs with DAE and Algorithm Code roots over +shared scalar/tensor vocabulary. Canonical terminology: @@ -184,137 +231,85 @@ Canonical terminology: | `ScalarProgram` | `Vec` | A flat register program that produces one scalar output | | `ScalarProgramBlock` | `ScalarProgramBlock` | A group of scalar programs with one output per program | | `TensorProgramNode` | `ComputeNode::{MatMul, LinSolve, AffineStencil, ...}` | A tensor-level kernel with explicit shape/layout metadata and scalar fallback | +| `FunctionFoldProgram` | `FunctionFoldProgram` | A finite-domain loop with an explicit loop-carried tuple and compact typed body | | `ComputeBlock` | `ComputeBlock` | Ordered mix of scalar program blocks and tensor program nodes | +| `SolveAlgorithmBlock` | (pending: 2026-08-08 plan, M3-4) | Checked Algorithm Code execution root | -`ScalarProgramBlock` and `ComputeNode::ScalarPrograms` are the public source-code -names. New Solve-IR APIs must use `ScalarProgram` / `ScalarProgramBlock` -terminology and must not reintroduce `RowBlock` / `ScalarRows` naming. +New Solve-IR APIs use `ScalarProgram` / `ScalarProgramBlock` terminology, not +`RowBlock` / `ScalarRows`. `ComputeNode::AffineStencil` is source-proven: it comes from preserved DAE structured-family domains plus affine operand proofs. It carries the compact iteration domain and strides; Solve lowering must not recover stencils by scanning unstructured scalar rows after structured-family metadata is discarded. -The root `schema_version` field is mandatory on serialized Solve payloads. -Deserializers reject unsupported versions and the Solve wire format does not -accept pre-versioned `ComputeBlock` row payloads. - -`SolveProblem` is the base lowered problem. Backend products that are expensive -or not part of the canonical MLS DAE, such as mass-matrix form and -Jacobian-vector scalar-program blocks, live in `SolveArtifacts` and are materialized by -`rumoca-phase-solve` only when a backend/template/runtime boundary asks for -them. Ordinary `lower_solve_problem` must not eagerly populate backend-specific -Jacobian products. - -**Contract:** +Structured B.1c definitions follow the same boundary: Solve preserves their +authoritative DAE domain as a compact map plus a compact target map, and phase +lowering creates no parallel scalar owner (SOLVE-C20). -| Rule | Why | -|---|---| -| All ops are pure functions of `(y[], p[], t)`; only `LoadY`, `LoadP`, `Const`, math ops | No Modelica-specific ops remain | -| No source temporal operators (`pre`, `edge`, `change`, `sample`, `previous`) in Solve-IR | Eliminated or represented as explicit DAE metadata before Solve lowering; surviving source temporal operators are upstream bugs | -| No flow-action calls (`assert`, `terminate`, `reinit`) in Solve-IR scalar programs | `reinit` is already a guarded discrete update; `assert` and `terminate` lower from DAE `events.event_actions` into action metadata plus pure action-condition scalar programs | -| `__pre__.*` parameters in `p[]` hold discrete/continuous pre-values | Runtime writes slots at event entry via `SolveLayout::pre_param_bindings` | -| Event timing is partitioned into root conditions, static arbitrary time instants, dynamic time-event rows, and periodic clock schedules | `events` owns zero-crossing and one-shot/dynamic time events; `clocks` owns periodic schedules derived from `sample`/clock metadata | -| Valid `ComputeBlock`s scalarize via fallible `rumoca-eval-solve::to_scalar_program_block(&block)` | Tensor-agnostic adapters call it and propagate span-bearing metadata errors | -| Scalarization is a backend/evaluator choice, not an IR or lowering choice | Do not flatten tensor nodes in `rumoca-ir-solve` / `rumoca-phase-solve`; IR crates must not define scalarization helpers | -| Forward and reverse AD products are Solve artifacts, not base Solve IR fields | Keeps base Solve payloads lean while allowing Rumoca-owned JVP/VJP/adjoint paths for runtime and generated targets | -| Jacobian products live in `SolveArtifacts`, not base `SolveProblem` | Avoids unconditional AD materialization for codegen/IDE paths that do not consume them | -| Mass-matrix form lives in `ContinuousSolveArtifacts`, not DAE | It is solver-facing derived metadata, not canonical Modelica DAE semantics | -| BLT orderings from DAE-IR MAY drive `ComputeBlock` layout | Reuses upstream structural analysis | - -Steady-state objectives, adjoints, parameter sensitivities, and -optimizer-facing projections are runtime or generated-target products layered -over Solve artifacts, not canonical `SolveProblem` payload fields. - -**Do here:** lower DAE-IR expression trees + for-loops to `LinearOp` sequences -and preserve tensor nodes/sparsity metadata for downstream consumers. -**Do NOT do here:** DAE-level structural transformations, MLS semantics changes, -expression-level symbolic rewrites, concrete JIT/toolchain invocation, CUDA -runtime compilation, native object loading, or Jinja/minijinja template -rendering (those live in DAE-IR/upstream lowering, `rumoca-exec-*`, or -`rumoca-phase-codegen`, respectively). +Each scalar and structured discrete update owns a typed integrator-history +effect derived by Solve lowering, never recovered by a runtime from model +names, row positions, or observed numerical behavior (SOLVE-C21). ---- +One clocked partition has one equation-shaped owner: producers proved total on +that tick exchange same-tick values through construction-issued intermediates, +guarded producers lacking that proof remain hold-fallback members under the +checked hold rows, and event-transaction, `sample`, and causally unowned rows +keep their existing owners (SOLVE-C57; pending design +`dev/2026-08-11-clock-partition-transaction-design.md`). -## Key Invariants for Agents +Serialized Solve roots carry a mandatory schema version; unsupported and +pre-versioned payloads are rejected. -1. **Source temporal operators are eliminated at the DAE boundary.** Any `pre`, - `edge`, `change`, `sample`, or `previous` callable expression past - `phase-dae` is a bug; Solve-IR is also free of these operators. +`SolveProblem` is the numerical DAE root. Backend products that are expensive +or outside the canonical MLS DAE (mass-matrix form, Jacobian-vector +scalar-program blocks) live in `SolveArtifacts`, materialized by +`rumoca-phase-solve` only when a backend/template/runtime boundary asks. +`lower_solve_problem` must not eagerly populate them. -1. **Runtime flow actions are not expression graph nodes.** DAE-IR lowers - `reinit` into guarded updates and stores `assert`/`terminate` as guarded - event actions. Numeric DAE or Solve compute graphs must never contain these - source calls. +`SolveAlgorithmBlock` is constructed only from checked Algorithm Code under an +explicit arithmetic profile (pending: 2026-08-08 plan, M3-4). It is not a mode +of `SolveProblem`; rows SOLVE-C32–C38 define its complete obligations. -2. **IR crates are pure data.** No evaluation logic, phase logic, or side - effects in `rumoca-ir-ast`, `rumoca-ir-flat`, `rumoca-ir-dae`, or - `rumoca-ir-solve`. See `SPEC_0029`. +**Contract:** rows `SOLVE-C01`–`SOLVE-C57` in +[SPEC_0040 §2](SPEC_0040_IR_STAGE_CONTRACT_CATALOG.md#2-solve-stage-contract-catalog-spec_0007-stage-4). -3. **Scalarization happens at the backend/evaluator boundary.** Call - `rumoca_eval_solve::to_scalar_program_block(&compute_block)` from the backend - or evaluator crate that needs scalar programs, and propagate its `Result`. - Do not define scalarization helpers in IR crates, and do not - flatten tensor nodes in `rumoca-phase-solve` lowering. +Objectives, adjoints, sensitivities, and optimizer projections are derived +products, not canonical root fields. -4. **Each stage's output is serializable.** All IR types implement - `serde::Serialize` / `Deserialize`. Serialized DAE and Solve root payloads - carry a mandatory `schema_version`; deserializers reject unsupported - versions. `#[serde(default)]` is allowed only for documented optional - same-version fields where the default is semantically valid. +**Do here:** construct either checked root and preserve typed programs, +provenance, and its execution contract. -5. **The dependency direction is strictly downward.** AST → Flat → DAE → Solve. - No stage imports from a later stage. +Sparsity follows [SPEC_0039](SPEC_0039_PROOF_CARRYING_SPARSITY.md); compact +affine patterns originate from SPEC_0032 owners, never scalar-row recovery. -6. **DAE-IR owns symbolic math; Solve-IR lowers format only.** Do not add - expression rewrites or new mathematical content in Solve-IR or solve - lowering; add them to DAE-IR first. +**Do not:** work assigned to DAE/structural phases, concrete execution crates, +or `rumoca-phase-codegen` by SPEC_0029. -7. **Optional IR fields are explicit same-version omissions, not hidden work - caches.** DAE optional fields must still be canonical MLS/diagnostic data. - Solver-facing optional products belong in structural analysis results or - Solve artifacts. If a field changes the meaning of existing payloads, bump - `schema_version` instead of adding a defaulted field. +--- -## Structural Lowering Scope +### Structural Lowering Scope Rumoca performs OpenModelica-class structural lowering between DAE and Solve. -Structural lowering is DAE-to-DAE: it rewrites or annotates mathematical -structure for downstream lowering without changing IR stage. The supported -transformations are listed here to keep scope and ownership clear. - -**In scope:** +Structural lowering is DAE-to-DAE: each pass consumes a finalized DAE and +returns another finalized DAE through root-owned checked changes. Partial +mutation, independently replayable proof receipts, and mutable partition +callbacks are prohibited. -| Transformation | Owning module | Notes | -|---|---|---| -| Pre-lowering (`pre(v)` → `__pre__.v`) | `rumoca-phase-dae::pre_lowering` | Runs at DAE entry; applies to every partition (f_x, f_z, f_m, f_c). See Stage 3 Contract. | -| Alias elimination | `rumoca-phase-dae` | Folds trivial equalities into the variable graph. | -| Structural index reduction (Pantelides-style) | `rumoca-phase-structural` | For states without a `der(state)` equation, differentiate a non-ODE constraint referencing that state and substitute. Index-1 lift is supported; higher-index lifts are an explicit subset of Pantelides. | -| State demotion | `rumoca-phase-structural` | Demote over-classified states whose derivative is structurally unreachable. | -| BLT ordering | `rumoca-phase-structural` | Block-lower-triangular ordering of equations for sequential solve. | -| Algebraic-loop tearing (Greedy Cellier) | `rumoca-phase-structural::tearing` | Identifies tear variables for cyclic algebraic blocks. | -| State selection | `rumoca-phase-structural` | Pick a consistent state set. | - -**Out of scope (require an explicit spec update before adding):** - -- Full dummy-derivative method (Mattsson-Söderlind). The current - Pantelides-style approach may add dummy derivatives in restricted forms, - but a general dummy-derivative pass is not implemented. -- Higher-order symbolic simplification beyond what serves index reduction - and alias elimination. -- Symbolic linearization for control-design output (codegen-level concern, - not pipeline-level). +**In scope:** exactly rows `STRUCT-T01`–`STRUCT-T07` in +[SPEC_0040 §3](SPEC_0040_IR_STAGE_CONTRACT_CATALOG.md#3-structural-lowering-transformation-catalog-spec_0007-structural-lowering-scope). +A transformation absent from that catalog is out of scope until this spec is +amended. **Placement requirement:** -All DAE structural lowering/transformation MUST live in -`rumoca-phase-structural` per SPEC_0029 §12. A structural lowering pass's IR -output is another finalized DAE. Separate structural analysis products may -accompany that DAE, but they are not stored as backend convenience fields on -`ir-dae::Dae`. `rumoca-phase-solve` only lowers a finalized DAE to Solve-IR; it -does not mutate DAE mathematical structure. +DAE structural transformations live in `rumoca-phase-structural`, return a +finalized DAE, and keep analysis products outside DAE. `rumoca-phase-solve` +only lowers finalized DAE. General dummy derivatives, unrelated symbolic +simplification, and control-design linearization require a spec update. -## Relevant Specs +## References +- [SPEC_0040](SPEC_0040_IR_STAGE_CONTRACT_CATALOG.md) — stage contract catalog - `SPEC_0029` — Crate boundary rules - `SPEC_0021` — Maintainability and deterministic collection rules diff --git a/spec/SPEC_0008_PHASE_ERRORS.md b/spec/SPEC_0008_PHASE_ERRORS.md index 095140abc..33ee6a2b7 100644 --- a/spec/SPEC_0008_PHASE_ERRORS.md +++ b/spec/SPEC_0008_PHASE_ERRORS.md @@ -49,6 +49,7 @@ must carry the original span through AST -> Flat -> DAE -> Solve. | Missing semantic data MUST NOT be synthesized | All semantic passes | Garbage in produces garbage out | | MLS-defined defaults are allowed only when explicitly modeled | Type/instantiate/sim semantics | Language defaults are not recovery | | Optional serialization defaults require valid absent-field meaning | IR serde boundaries | Compatibility must stay semantic | +| A file-backed compile invocation MUST invalidate its recognized prior output before semantic compilation; failure leaves no consumable artifact at the named product paths, and foreign paths MUST NOT be deleted | CLI + package writer | A stale successful artifact is not output from the failed source | Compiler phases MUST fail immediately when required semantic data is missing, malformed, or unresolved. The phase MUST return a phase-local error carrying @@ -137,132 +138,122 @@ Error codes use mnemonic prefixes for readability: | EF0xx | flatten | **F**latten | Connection errors | | ED0xx | todae | **D**AE | Equation errors | | EC0xx | codegen | **C**odegen | Code generation errors | +| EM0xx | class merge | **M**erge | Class-tree merge errors | +| ES0xx | structural | **S**tructural | Matching/BLT/singularity (`ES001`-`ES002` warnings, `ES01x` errors) | +| EL0xx | solve lowering | so**L**ve | DAE → Solve-IR lowering (`EL001`-`EL011` rows, `EL02x` assembly, `EL03x` overrides) | +| EX0xx | sim runtime | e**X**ecution | Solver, runtime-preparation, parameter-override | +| EG0xx | GALEC IR | **G**ALEC | GALEC IR parse/validation errors | +| EGT0xx | GALEC target projection | **G**ALEC **T**arget | DAE-to-GALEC projection/export errors | +| EFM0xx | eFMI packaging | e**FM**I | eFMI manifest/packaging errors | | WP/WR/WT/etc | (same) | | Warnings per phase | +The leading letter is the severity: a warning MUST NOT be minted in an `E` +range, nor an error in a `W` range. The stable identity of a diagnostic is its +bare mnemonic (`ED001`); `miette` phases render it as +`rumoca::::` and others emit the bare form, so consumers MUST +match by mnemonic **suffix**. Contract tests implement this comparison locally +in `crates/rumoca-contracts/src/test_support.rs`. A shipped code is stable: +retire it rather than renumber or reuse. + +The former GALEC-target meanings of `ET001`–`ET023` are retired because they +collided with typecheck. GALEC target projection now emits `EGT001`–`EGT023`; +the typecheck meanings of `ET0xx` are unchanged. + +**Known drift**, tracked separately: `rumoca-phase-structural` emits +`ES001`/`ES002` at warning severity. For these, severity MUST be read from the +diagnostic's `severity` field, never inferred. + +### Acceptance Contract Before Rejection + +A rejection is only as good as the acceptance it bounds. A **typed rejection +path** is a new error variant, a newly minted code, or an `Err`/`emit()` on +input a phase previously accepted. Every new one MUST land in the same change as +a written **acceptance contract**: which inputs stay legal, and which owner +handles them. + +| Rule | Owner/Where | Brief Justification | +|---|---|---| +| A new typed rejection ships with its acceptance contract | Author, same change | Unbounded rejection is over-reach | +| The contract names the legal shapes *and* their handling owner | Same change | "Not rejected" is not a design | +| Discharge it as a test asserting the accepted shape, or a checklist/spec bullet | Test suite, or PR notes per SPEC_0025 §3 | Executable evidence preferred, written evidence required | +| Widening an existing rejection to new input is a new rejection path | Author | Scope creep needs the same contract | +| Never discharge it by relaxing an existing fixture or assertion | Test suite | Weakening hides the over-reach it should expose | + +**Why:** `EF025` over-reached onto callables that legally select no +implementation — MLS §3.7 predefined operators, record constructors, `type` +conversions — because nothing stated which callables stay legal; the `EI012` +partial-class rejection was absorbed by weakening a fixture instead of naming +the accepted deferred-declaration shape. Rejections whose accepted shape was +designed first landed clean. + +**Checklist-item template** — one per new rejection path: + +```markdown +- [ ] rejects ; + accepts , + owned by ; + evidence +``` + ### PhaseError Trait -The `PhaseError` trait in `rumoca-core` provides a common interface for all phase errors: +`PhaseError` in `crates/rumoca-core/src/lib.rs` is the common interface: ```rust -//! rumoca-core/src/diag.rs - pub trait PhaseError { /// Convert this error to a diagnostic. fn to_diagnostic(&self) -> Diagnostic; - - /// Emit this error to a diagnostics collection. - fn emit_to(&self, diags: &mut Diagnostics) { - diags.emit(self.to_diagnostic()); - } } ``` ### Phase Error Pattern -Each phase defines errors in a local `errors.rs` and implements `PhaseError`: +Each phase defines errors in a local `errors.rs`, derives `thiserror::Error` and +`miette::Diagnostic` for the message/code/label, and implements `PhaseError` by +handing the retained spans to `miette_phase_error_to_diagnostic`: ```rust //! rumoca-phase-resolve/src/errors.rs -use rumoca_core::{Diagnostic, Label, PhaseError, Span}; - +#[derive(Debug, Clone, Error, MietteDiagnostic)] pub enum ResolveError { - UnresolvedName { name: String, span: Span }, - DuplicateDefinition { name: String, first: Span, second: Span }, - CyclicInheritance { class: String, span: Span }, - InvalidExtends { name: String, span: Span }, + #[error("undefined reference: `{name}` not found")] + #[diagnostic( + code(rumoca::resolve::ER002), + help("check that the name is declared before use") + )] + UndefinedReference { + name: String, + #[label("not found in scope")] + span: Span, + }, + // ER001, ER003, ER004 follow the same shape. } impl PhaseError for ResolveError { fn to_diagnostic(&self) -> Diagnostic { - match self { - Self::UnresolvedName { name, span } => { - Diagnostic::error(format!("cannot find `{name}` in this scope")) - .with_code("ER001") - .with_label(Label::primary(*span)) - } - Self::DuplicateDefinition { name, first, second } => { - Diagnostic::error(format!("`{name}` is defined multiple times")) - .with_code("ER002") - .with_label(Label::primary(*second).with_message("duplicate")) - .with_label(Label::secondary(*first).with_message("first defined here")) - } - Self::CyclicInheritance { class, span } => { - Diagnostic::error(format!("cyclic inheritance involving `{class}`")) - .with_code("ER003") - .with_label(Label::primary(*span)) - } - Self::InvalidExtends { name, span } => { - Diagnostic::error(format!("`{name}` cannot be extended")) - .with_code("ER004") - .with_label(Label::primary(*span)) - } - } - } -} -``` - -### Typecheck Phase Example - -```rust -//! rumoca-phase-typecheck/src/errors.rs - -use rumoca_core::{Diagnostic, Label, PhaseError, Span}; - -pub enum TypecheckError { - TypeMismatch { expected: String, found: String, span: Span }, - VariabilityViolation { msg: String, span: Span }, - DimensionMismatch { expected: String, found: String, span: Span }, - UnknownType { name: String, span: Span }, -} - -impl PhaseError for TypecheckError { - fn to_diagnostic(&self) -> Diagnostic { - match self { - Self::TypeMismatch { expected, found, span } => { - Diagnostic::error(format!("expected `{expected}`, found `{found}`")) - .with_code("ET001") - .with_label(Label::primary(*span)) - } - // ... - } + let span = match self { + Self::UndefinedReference { span, .. } => span, + // ... one arm per variant + }; + miette_phase_error_to_diagnostic(self, std::slice::from_ref(span)) } } ``` -### Usage in Phase Implementation - -```rust -//! rumoca-phase-resolve/src/lib.rs - -mod errors; -use errors::ResolveError; - -pub fn resolve(ast: &Ast, ctx: &mut ResolveContext) -> Result { - let resolver = Resolver::new(ctx); - - // Error emission - if let Some(existing) = resolver.lookup_local(&name) { - ctx.diags.emit(ResolveError::DuplicateDefinition { - name: name.clone(), - first: existing.span, - second: span, - }.to_diagnostic()); - } - - // ... -} -``` +Miette labels carry byte offsets but not source identity, so the phase error +retains the original `Span` values and passes them in `#[label]` order. Callers +emit with `ctx.diags.emit(err.to_diagnostic())` or bubble the `Result`, per the +propagation table above. Typecheck defines `TypeCheckError` in +`crates/rumoca-phase-typecheck/src/lib.rs` rather than in an `errors.rs`. ### Common Diagnostic Infrastructure -The shared `rumoca-core` crate provides the base types: +`crates/rumoca-core/src/lib.rs` provides the base types: ```rust -//! rumoca-core/src/diag.rs - pub struct Diagnostic { - pub severity: Severity, + pub severity: DiagnosticSeverity, pub code: Option, pub message: String, pub labels: Vec