diff --git a/.github/workflows/semver.yml b/.github/workflows/semver.yml index ca8caa9c..98cc2235 100644 --- a/.github/workflows/semver.yml +++ b/.github/workflows/semver.yml @@ -33,4 +33,4 @@ jobs: uses: obi1kenobi/cargo-semver-checks-action@v2 with: feature-group: default-features - exclude: benchmarks_aterm, benchmarks_aterm, benchmarks_sharedmutex, benchmarks_unsafety, benchmarks_utilities, benchmarks_lts, merc-rewrite, merc-lts, merc-pbes, merc-lps, merc-sym, merc_rec-tests, merc_tools, merc_sabre-compiling, merc_sabre-ffi, merc_symbolic, merc_vpg, merc_typecheck + exclude: benchmarks_aterm, benchmarks_aterm, benchmarks_sharedmutex, benchmarks_unsafety, benchmarks_utilities, merc-rewrite, merc-lts, merc-pbes, merc-lps, merc-stark, merc-sym, merc_rec-tests, merc_tools, merc_sabre-compiling, merc_sabre-ffi, merc_symbolic, merc_vpg, merc_typecheck diff --git a/Cargo.lock b/Cargo.lock index 9f89ee39..9ab1066a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1060,6 +1060,18 @@ dependencies = [ "merc_utilities", ] +[[package]] +name = "merc-stark" +version = "2.0.0" +dependencies = [ + "clap", + "env_logger", + "log", + "merc_stark", + "merc_tools", + "merc_utilities", +] + [[package]] name = "merc-sym" version = "2.0.0" @@ -1381,6 +1393,21 @@ dependencies = [ "rand", ] +[[package]] +name = "merc_stark" +version = "1.0.0" +dependencies = [ + "log", + "merc_pest_consume", + "merc_utilities", + "pest", + "pest_derive", + "rand", + "test-case", + "test-log", + "thiserror", +] + [[package]] name = "merc_symbolic" version = "3.0.0" diff --git a/Cargo.toml b/Cargo.toml index 66dabed5..35f1c693 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ members = [ "crates/sabre/benchmarks", "crates/sharedmutex", "crates/sharedmutex/benchmarks", + "crates/stark", "crates/symbolic", "crates/syntax", "crates/tools", @@ -50,6 +51,7 @@ members = [ "crates/xtask", "tools/lts", "tools/rewrite", + "tools/stark", "tools/sym", "tools/vpg", ] @@ -153,6 +155,7 @@ merc_sabre = { version = "3.0", path = "crates/sabre" } merc_sabre-compiling = { path = "crates/sabre_compiling" } merc_sabre-ffi = { path = "crates/sabre_compiling/sabre_ffi" } merc_sharedmutex = { version = "3.0", path = "crates/sharedmutex" } +merc_stark = { version = "1.0", path = "crates/stark" } merc_symbolic = { version = "3.0",path = "crates/symbolic", features = ["clap"] } merc_syntax = { version = "3.0", path = "crates/syntax" } merc_tools = { path = "crates/tools" } diff --git a/README.md b/README.md index ee60a376..5e3afc9e 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Bugs and issues can be reported in the [issue tracker](https://github.com/MERCor Various tools have been implemented so far: - `merc-lts` implements various algorithms for labelled transition systems in the mCRL2 binary [`.lts`](https://www.mcrl2.org/web/user_manual/tools/lts.html) format and the AUTomaton (or ALDEBARAN) [`.aut`](https://cadp.inria.fr/man/aut.html) format. Using [CADP](https://cadp.inria.fr/) it can also read and write the [`.bcg`](https://cadp.inria.fr/man/bcg.html) format. It can do (signature-based) bisimulation algorithms for reduction and comparison, and also supports various refinement preorders. Furthermore, it can now also compute compositions of LTSs. - `merc-rewrite` allows rewriting of Rewrite Engine Competition specifications ([REC](https://doi.org/10.1007/978-3-030-17502-3_6)) using [Sabre](https://arxiv.org/abs/2202.08687) (**S**et **A**utomaton **B**ased **RE**writing). + - `merc-stark` parses, resolves and type checks specifications written in the STARK specification language, reporting every problem it finds in one pass. - `merc-vpg` can be used to solve (variability) parity games in the [PGSolver](https://github.com/tcsprojects/pgsolver) `.pg` format, and a slightly extended variability parity game `.vpg` format. Furthermore, it can generate variability parity games for model checking modal mu-calculus on LTSs. - `merc-lps` can be used to explore linear process specifications of mCRL2, located in the `tools/mcrl2` workspace. - `merc-pbes` can identify symmetries in parameterised boolean equation systems [PBES](https://doi.org/10.1016%2Fj.tcs.2005.06.016), located in the `tools/mcrl2` workspace. diff --git a/crates/stark/Cargo.toml b/crates/stark/Cargo.toml new file mode 100644 index 00000000..cd070ff6 --- /dev/null +++ b/crates/stark/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "merc_stark" +license = "APACHE-2.0" +version = "1.0.0" +readme = "README.md" +edition.workspace = true +rust-version.workspace = true + +[dependencies] +merc_utilities.workspace = true + +log.workspace = true +pest.workspace = true +pest_derive.workspace = true +rand.workspace = true +thiserror.workspace = true +merc_pest_consume.workspace = true + +[dev-dependencies] +test-log.workspace = true +test-case.workspace = true \ No newline at end of file diff --git a/crates/stark/LICENSE b/crates/stark/LICENSE new file mode 100644 index 00000000..8f3d380f --- /dev/null +++ b/crates/stark/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly aiState otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/crates/stark/README.md b/crates/stark/README.md new file mode 100644 index 00000000..971f1bfa --- /dev/null +++ b/crates/stark/README.md @@ -0,0 +1,128 @@ +# Overview + +STARK is a specification language for *robustness analysis* of stochastic, +discrete-time systems: a specification describes a system as a set of state +variables driven by component controllers and an environment, and then asks how +much the system's behaviour changes when that environment is perturbed. + +This crate is a Rust port of the original Java STARK tool. It contains the +whole front end — parser, name resolution, type checker and a lowering pass to +an evaluation IR — together with an evaluator that both simulates a +specification and verifies its robustness properties. + +## Usage + +A specification travels through a fixed pipeline, one type per stage, so a +stage can never be skipped by accident: + +```text +&str -> UntypedStarkSpecification -> StarkSpecification -> IrProgram -> [ evaluate ] + parse from_untyped from_spec +``` + +[`UntypedStarkSpecification::parse`] yields a faithful syntax tree whose +references are unresolved and whose expressions have no types yet. +[`StarkSpecification::from_untyped`] runs name resolution followed by type +checking, and either reports *every* problem at once through a `Diagnostics` or +produces a [`StarkSpecification`]. Only that constructor can produce the type, +so anything holding one knows resolution and type checking already succeeded +and never has to re-derive or re-validate it. [`IrProgram::from_spec`] then +flattens it into an [`IrProgram`], the arena the evaluator walks. + +```rust +use merc_stark::UntypedStarkSpecification; +use merc_stark::StarkSpecification; +use merc_stark::IrProgram; +use merc_stark::eval::RecordingObserver; +use merc_stark::eval::Simulation; + +let source = r#" + variables { + real x range [0, 100] = 50; + real y range [0, 100] = 50; + } + + environment { + x' = x + U[-1,0,1]; + y' = y + U[-1,0,1]; + } +"#; + +let untyped = UntypedStarkSpecification::parse(source).expect("should parse"); +let specification = StarkSpecification::from_untyped(untyped) + .unwrap_or_else(|diagnostics| panic!("{}", diagnostics.render(source))); +let program = IrProgram::from_spec(&specification) + .unwrap_or_else(|diagnostics| panic!("{}", diagnostics.render(source))); + +// Run one trajectory of twenty macro-steps, recording every state. +let mut simulation = Simulation::new(&program, 42).expect("should initialise"); +let mut observer = RecordingObserver::default(); +simulation.run(20, &mut observer).expect("should run"); + +assert_eq!(observer.trajectory.len(), 20); +``` + +There are two entry points into the evaluator, one per thing you can ask of a +specification: + +- [`eval::Simulation`] — *run* it. One trajectory, stepped on demand, with + states pushed to an [`eval::Observer`]. +- [`eval::Analysis`] — *verify* it. Checks the specification's `formula` and + `distance` declarations by comparing an ensemble of trajectories against a + perturbed copy of itself, yielding a [`eval::TruthValue`] (or a raw + distance). + +Both are seeded explicitly, so a whole run or analysis is reproducible from its +seed. The random stream is deliberately **not** bit-compatible with the +original Java tool's; only the distributions match. + +Every entry point is fallible: evaluation returns `Result<_, EvalError>` rather +than propagating an absorbing error *value* the way the original does — see the +[`value`] module for why. + +## Crate layout + +The front end is a sequence of passes, each in its own module. Those modules +are private and their contents are re-exported flat from the crate root, but +each carries the design rationale for its pass in its module documentation — +build the documentation with `--document-private-items` to read it. + +| Module | Pass | +| ---------------- | ------------------------------------------------------------------------- | +| `parse` | `pest` grammar entry point (`stark_grammar.pest`). | +| `consume` | Turns the `pest` parse tree into the AST. | +| `precedence` | Pratt parsers for the expression and robustness sub-languages. | +| `ast` | The syntax tree the two above produce. | +| `resolve` | Name resolution: assigns every declaration a stable id. | +| `typecheck` | Type inference over the resolved tree. | +| `types` | The STARK type lattice. | +| `diagnostics` | What resolution and type checking can complain about. | +| `specification` | The `check` entry point and the checked-specification type. | +| `lower` | Lowers a checked specification to the evaluation IR. | + +Three modules are public rather than flattened into the crate root: + +- [`ir`] — the evaluation IR. Kept separate because [`ir::BinaryOp`] + deliberately collides in name (not in meaning) with `ast::BinaryOp`; + flattening both would be an ambiguous glob re-export. +- [`value`] — runtime values and evaluation errors, for the same reason. +- [`eval`] — the evaluator, whose own submodules are private. + +## Related work + +This crate is a port of the Java [STARK +tool](https://github.com/the-stark-tool/STARK), which is also where the +specifications in `examples/stark` come from. Where this port deviates from the +reference semantics, the module documentation of the pass in question says so +and why. + +## Minimum Supported Rust Version + +We do not maintain an official minimum supported rust version (MSRV), and it +may be upgraded at any time when necessary. + +## License + +All MERC crates are licensed under the `BSL-1.0` license. See the +[LICENSE](https://raw.githubusercontent.com/MERCorg/merc/refs/heads/main/LICENSE) +file in the repository root for more information. diff --git a/crates/stark/src/ast.rs b/crates/stark/src/ast.rs new file mode 100644 index 00000000..17d1a519 --- /dev/null +++ b/crates/stark/src/ast.rs @@ -0,0 +1,534 @@ +pub use merc_utilities::Span; +pub use merc_utilities::Spanned; +use merc_utilities::TagIndex; + +/// A unique tag for top-level declarations. +pub struct DefTag; +/// The index type assigned to a top-level declaration during name resolution. +pub type DefId = TagIndex; + +/// A unique tag for controller states, which are scoped to their component. +pub struct StateTag; +/// The index type assigned to a controller state during name resolution. +pub type StateId = TagIndex; + +/// A unique tag for local bindings: function arguments, `let` bindings, and +/// the `it` iterator parameter. +pub struct LocalTag; +/// The index type assigned to a local binding during name resolution. +pub type LocalId = TagIndex; + +/// An expression node together with the source span it was parsed from. +pub type Expression = Spanned; + +/// A reference to a top-level declaration, resolved to a [DefId] by name +/// resolution. `id` is `None` before that step. +#[derive(Clone, Debug)] +pub struct DefRef { + pub id: Option, + pub name: Identifier, +} + +impl DefRef { + pub fn new(name: Identifier) -> Self { + DefRef { id: None, name } + } +} + +/// A reference to a controller state (`step`/`exec` target, or a component's +/// `init` expression), resolved to a [StateId] within its enclosing +/// component. `id` is `None` until then. +#[derive(Clone, Debug)] +pub struct StateRef { + pub id: Option, + pub name: Identifier, +} + +impl StateRef { + pub fn new(name: Identifier) -> Self { + StateRef { id: None, name } + } +} + +/// What an expression-level name reference resolves to: either a top-level +/// declaration or a local binding (function argument, `let` binding, or the +/// `it` iterator parameter). +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Binding { + Def(DefId), + Local(LocalId), +} + +/// A complete parsed STARK specification: the ordered list of every top-level +/// declaration in the source. +#[derive(Clone, Debug, Default)] +pub struct UntypedStarkSpecification { + pub constants: Vec, + pub parameters: Vec, + pub variables: Vec, + pub types: Vec, + pub functions: Vec, + pub components: Vec, + pub environment: Option, + pub penalties: Vec, + pub perturbations: Vec, + pub distances: Vec, + pub formulas: Vec, +} + +impl UntypedStarkSpecification { + pub fn new() -> Self { + Self::default() + } +} + +// --------------------------------------------------------------------------- +// Top-level declarations +// --------------------------------------------------------------------------- + +/// `const name = value;` +#[derive(Clone, Debug)] +pub struct Constant { + pub id: Option, + pub name: Identifier, + pub value: Expression, +} + +/// `param name = value;` +#[derive(Clone, Debug)] +pub struct Parameter { + pub id: Option, + pub name: Identifier, + pub value: Expression, +} + +/// A single variable in a (`global`) `variables { ... }` block, or in a +/// component's local `variables { ... }` block. +#[derive(Clone, Debug)] +pub struct Variable { + pub id: Option, + pub global: bool, + pub ty: Ty, + pub name: Identifier, + pub range: Option, + pub initial_value: Expression, +} + +/// `type name = A | B | C;` +#[derive(Clone, Debug)] +pub struct TypeDeclaration { + pub id: Option, + pub name: Identifier, + pub elements: Vec, +} + +/// `penalty name = expr` +#[derive(Clone, Debug)] +pub struct Penalty { + pub id: Option, + pub name: Identifier, + pub value: Expression, +} + +/// `function name(args) { body }` +#[derive(Clone, Debug)] +pub struct Function { + pub id: Option, + pub name: Identifier, + pub arguments: Vec, + pub body: FunctionStatement, +} + +#[derive(Clone, Debug)] +pub struct FunctionArgument { + pub id: Option, + pub ty: Ty, + pub name: Identifier, +} + +#[derive(Clone, Debug)] +pub enum FunctionStatement { + Return(Expression), + IfThenElse { + guard: Expression, + then_branch: Box, + else_branch: Option>, + }, + Let { + id: Option, + name: Identifier, + value: Expression, + body: Box, + }, + Block(Box), +} + +// --------------------------------------------------------------------------- +// Components and controllers +// --------------------------------------------------------------------------- + +/// `component name { variables { .. } controller { .. } init .. }` +#[derive(Clone, Debug)] +pub struct Component { + pub id: Option, + pub name: Identifier, + pub variables: Vec, + pub states: Vec, + /// The `init` expression: the parallel composition of state references. + pub init: Vec, +} + +/// `state name { .. }` +#[derive(Clone, Debug)] +pub struct ControllerState { + pub id: Option, + pub name: Identifier, + pub body: Vec, +} + +#[derive(Clone, Debug)] +pub enum ControllerCommand { + /// `[steps #] step target;` + Step { + steps: Option, + target: StateRef, + }, + /// `exec target;` + Exec(StateRef), + /// `let id = value in body` + Let { + id: Option, + name: Identifier, + value: Expression, + body: Vec, + }, + /// `[when guard] target' = value;` + Assignment(Update), + /// `if (guard) { .. } else { .. }` + IfThenElse { + guard: Expression, + then_branch: Vec, + else_branch: Option>, + }, + /// A nested `{ .. }` block. + Block(Vec), +} + +// --------------------------------------------------------------------------- +// Environment +// --------------------------------------------------------------------------- + +/// `environment { .. }` +#[derive(Clone, Debug)] +pub struct Environment { + pub commands: Vec, +} + +#[derive(Clone, Debug)] +pub enum EnvironmentCommand { + /// `[when guard] target' = value;` + Assignment(Update), + /// `if (guard) cmd [else cmd]` + IfThenElse { + guard: Expression, + then_branch: Box, + else_branch: Option>, + }, + /// `let a = e1 and b = e2 in cmd` + Let { + bindings: Vec, + body: Box, + }, + /// A nested `{ .. }` block. + Block(Vec), +} + +#[derive(Clone, Debug)] +pub struct LocalVariable { + pub id: Option, + pub name: Identifier, + pub value: Expression, +} + +/// A `[when guard] target' = value;` assignment shared by controllers and the +/// environment. `target` is the primed variable name (without the trailing +/// `'`), resolved to the [DefId] of the variable it updates. +#[derive(Clone, Debug)] +pub struct Update { + pub guard: Option, + pub target: DefRef, + pub value: Expression, +} + +// --------------------------------------------------------------------------- +// Robustness sub-languages (perturbation / distance / ROBTL) +// --------------------------------------------------------------------------- + +/// `perturbation name = expr;` +#[derive(Clone, Debug)] +pub struct Perturbation { + pub id: Option, + pub name: Identifier, + pub value: PerturbationExpression, +} + +#[derive(Clone, Debug)] +pub enum PerturbationExpression { + Nil, + Reference(DefRef), + /// `[ v1 <- e1, v2 <- e2 ] @ time` + Atomic { + assignments: Vec, + time: Expression, + }, + /// `left ; right` + Sequence(Box, Box), + /// `argument ^ iterations` + Iteration { + argument: Box, + iterations: Expression, + }, +} + +#[derive(Clone, Debug)] +pub struct PerturbationAssignment { + pub target: DefRef, + pub value: Expression, +} + +/// `distance name = expr;` +#[derive(Clone, Debug)] +pub struct Distance { + pub id: Option, + pub name: Identifier, + pub value: DistanceExpression, +} + +#[derive(Clone, Debug)] +pub enum DistanceExpression { + /// A reference to another named `distance` declaration. + Reference(DefRef), + /// `< penalty` + AtomicLeft(DefRef), + /// `> penalty` + AtomicRight(DefRef), + /// `\F[from,to] argument` + Eventually { + from: Expression, + to: Expression, + argument: Box, + }, + /// `\G[from,to] argument` + Globally { + from: Expression, + to: Expression, + argument: Box, + }, + /// `left \U[from,to] right` + Until { + from: Expression, + to: Expression, + left: Box, + right: Box, + }, + /// `left op threshold` + Threshold { + op: ComparisonOp, + left: Box, + threshold: Expression, + }, + Min(Box, Box), + Max(Box, Box), + /// `w1 * d1 + w2 * d2 + ...` + LinearCombination(Vec<(Expression, DistanceExpression)>), +} + +/// `formula name = formula;` +#[derive(Clone, Debug)] +pub struct Formula { + pub id: Option, + pub name: Identifier, + pub value: RobtlFormula, +} + +#[derive(Clone, Debug)] +pub enum RobtlFormula { + True, + False, + /// A reference to another named `formula` declaration. + Reference(DefRef), + /// `\D[distance, perturbation] op value` + Distance { + distance: DefRef, + perturbation: DefRef, + op: ComparisonOp, + value: Expression, + }, + Not(Box), + Globally { + from: Expression, + to: Expression, + argument: Box, + }, + Eventually { + from: Expression, + to: Expression, + argument: Box, + }, + And(Box, Box), + Or(Box, Box), + Until { + from: Expression, + to: Expression, + left: Box, + right: Box, + }, +} + +#[derive(Clone, Debug)] +pub enum ExpressionKind { + // Literals + False, + True, + Integer(i64), + Real(f64), + /// A name reference. + Reference { + name: String, + binding: Option, + }, + /// The `it` lambda parameter used inside aggregate/perturbation contexts. + Iterator, + + // Distributions / random values + Normal { + mean: Box, + std_dev: Box, + }, + Uniform { + values: Vec, + }, + /// `R` or `R[min,max]`. + Range { + min: Option>, + max: Option>, + }, + + // Prefix operators + Not(Box), + UnaryPlus(Box), + UnaryMinus(Box), + + // Binary operators + Binary(BinaryOp, Box, Box), + + // `guard ? then : else` + Ternary { + guard: Box, + then_branch: Box, + else_branch: Box, + }, + + /// A user-defined function application `name(args)`. + Call { + function: DefRef, + arguments: Vec, + }, + + /// A built-in math function application, e.g. `abs(x)`, `max(a, b)`. + MathCall { + function: MathFunction, + arguments: Vec, + }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BinaryOp { + Pow, + Mult, + Div, + IntDiv, + Add, + Subtract, + Mod, + Less, + Leq, + Eq, + Geq, + Greater, + BitAnd, + And, + BitOr, + Or, +} + +/// Comparison operators used as thresholds in distance expressions and ROBTL +/// formulas. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ComparisonOp { + Less, + Leq, + Eq, + Geq, + Greater, +} + +/// Built-in mathematical functions (both unary and binary arities). +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MathFunction { + // Unary + Abs, + Acos, + Asin, + Atan, + Cbrt, + Ceil, + Cos, + Cosh, + Exp, + Expm1, + Floor, + Log, + Log10, + Log1p, + Signum, + Sin, + Sinh, + Sqrt, + Tan, + // Binary + Atan2, + Hypot, + Max, + Min, + Pow, +} + +/// A `range [min, max]` bound on a variable declaration. +#[derive(Clone, Debug)] +pub struct Range { + pub min: Expression, + pub max: Expression, +} + +#[derive(Clone, Debug)] +pub enum Ty { + Real, + Integer, + Boolean, + /// A user-defined type referenced by name. + Named(String), +} + +/// An identifier together with its source location. +#[derive(Clone, Debug)] +pub struct Identifier { + pub name: String, + pub span: Span, +} + +impl Identifier { + pub fn new(name: String, span: Span) -> Self { + Identifier { name, span } + } +} diff --git a/crates/stark/src/consume.rs b/crates/stark/src/consume.rs new file mode 100644 index 00000000..e340b4c5 --- /dev/null +++ b/crates/stark/src/consume.rs @@ -0,0 +1,481 @@ +#![allow(clippy::result_large_err)] + +use merc_pest_consume::Error; +use merc_pest_consume::match_nodes; + +use crate::StarkParser; +use crate::ast::Component; +use crate::ast::Constant; +use crate::ast::ControllerCommand; +use crate::ast::ControllerState; +use crate::ast::DefRef; +use crate::ast::Distance; +use crate::ast::Environment; +use crate::ast::EnvironmentCommand; +use crate::ast::Expression; +use crate::ast::Formula; +use crate::ast::Function; +use crate::ast::FunctionArgument; +use crate::ast::FunctionStatement; +use crate::ast::Identifier; +use crate::ast::LocalVariable; +use crate::ast::Parameter; +use crate::ast::Penalty; +use crate::ast::Perturbation; +use crate::ast::Range; +use crate::ast::StateRef; +use crate::ast::Ty; +use crate::ast::TypeDeclaration; +use crate::ast::UntypedStarkSpecification; +use crate::ast::Update; +use crate::ast::Variable; +use crate::parse::Rule; +use crate::precedence::parse_distance_expression; +use crate::precedence::parse_expression_node; +use crate::precedence::parse_perturbation_expression; +use crate::precedence::parse_robtl_formula; + +/// Type alias for Errors resulting from parsing. +pub(crate) type ParseResult = std::result::Result>; +pub(crate) type ParseNode<'i> = merc_pest_consume::Node<'i, Rule, ()>; + +/// Routes one variant node of the silent `FunctionStatement` rule to its +/// consumer. +fn function_statement(node: ParseNode) -> ParseResult { + match node.as_rule() { + Rule::FunctionReturn => StarkParser::FunctionReturn(node), + Rule::FunctionIfThenElse => StarkParser::FunctionIfThenElse(node), + Rule::FunctionBlock => StarkParser::FunctionBlock(node), + Rule::FunctionLet => StarkParser::FunctionLet(node), + rule => unreachable!("unexpected function statement: {rule:?}"), + } +} + +/// Routes one variant node of the silent `ControllerCommand` rule to its +/// consumer. +fn controller_command(node: ParseNode) -> ParseResult { + match node.as_rule() { + Rule::ControllerStep => StarkParser::ControllerStep(node), + Rule::ControllerExec => StarkParser::ControllerExec(node), + Rule::ControllerLet => StarkParser::ControllerLet(node), + Rule::ControllerAssignment => StarkParser::ControllerAssignment(node), + Rule::ControllerIfThenElse => StarkParser::ControllerIfThenElse(node), + Rule::ControllerBlock => Ok(ControllerCommand::Block(StarkParser::ControllerBlock(node)?)), + rule => unreachable!("unexpected controller command: {rule:?}"), + } +} + +/// Routes one variant node of the silent `EnvironmentCommand` rule to its +/// consumer. +fn environment_command(node: ParseNode) -> ParseResult { + match node.as_rule() { + Rule::EnvironmentAssignment => StarkParser::EnvironmentAssignment(node), + Rule::EnvironmentIfThenElse => StarkParser::EnvironmentIfThenElse(node), + Rule::EnvironmentLet => StarkParser::EnvironmentLet(node), + Rule::EnvironmentBlock => Ok(EnvironmentCommand::Block(StarkParser::EnvironmentBlock(node)?)), + rule => unreachable!("unexpected environment command: {rule:?}"), + } +} + +/// Consume a `[when guard] target' = value;` assignment shared by controllers and +/// the environment. +fn assignment_update(node: ParseNode) -> ParseResult { + let mut guard = None; + let mut target = None; + let mut value = None; + + for child in node.into_children() { + match child.as_rule() { + Rule::WhenGuard => guard = Some(StarkParser::WhenGuard(child)?), + Rule::NEXT_ID => target = Some(DefRef::new(StarkParser::NEXT_ID(child)?)), + Rule::Expression => value = Some(StarkParser::Expression(child)?), + rule => unreachable!("unexpected assignment child: {rule:?}"), + } + } + + Ok(Update { + guard, + target: target.expect("assignment requires a target"), + value: value.expect("assignment requires a value"), + }) +} + +#[merc_pest_consume::parser] +impl StarkParser { + /// Turns the `pest` parse tree produced by `parse.rs` into the [crate::ast] + /// tree, one `merc_pest_consume` consumer per grammar rule. + pub fn UntypedStarkSpecification(input: ParseNode) -> ParseResult { + let mut spec = UntypedStarkSpecification::new(); + + for child in input.into_children() { + match child.as_rule() { + Rule::DeclarationConstant => spec.constants.push(Self::DeclarationConstant(child)?), + Rule::DeclarationParameter => spec.parameters.push(Self::DeclarationParameter(child)?), + Rule::DeclarationVariables => spec.variables.extend(Self::DeclarationVariables(child)?), + Rule::DeclarationType => spec.types.push(Self::DeclarationType(child)?), + Rule::DeclarationFunction => spec.functions.push(Self::DeclarationFunction(child)?), + Rule::DeclarationComponent => spec.components.push(Self::DeclarationComponent(child)?), + Rule::DeclarationEnvironment => spec.environment = Some(Self::DeclarationEnvironment(child)?), + Rule::DeclarationPenalty => spec.penalties.push(Self::DeclarationPenalty(child)?), + Rule::DeclarationPerturbation => spec.perturbations.push(Self::DeclarationPerturbation(child)?), + Rule::DeclarationDistance => spec.distances.push(Self::DeclarationDistance(child)?), + Rule::DeclarationFormula => spec.formulas.push(Self::DeclarationFormula(child)?), + Rule::EOI => {} + rule => unreachable!("unexpected top-level declaration: {rule:?}"), + } + } + + Ok(spec) + } + + // --- Leaf tokens ------------------------------------------------------- + + fn ID(input: ParseNode) -> ParseResult { + let span = input.as_span(); + Ok(Identifier::new(input.as_str().to_string(), span.into())) + } + + fn NEXT_ID(input: ParseNode) -> ParseResult { + let span = input.as_span(); + // Strip the trailing `'` from the primed variable name. + let name = input.as_str().trim_end_matches('\'').to_string(); + Ok(Identifier::new(name, span.into())) + } + + fn Ty(input: ParseNode) -> ParseResult { + let child = input.into_children().next().expect("Ty has a single variant child"); + Ok(match child.as_rule() { + Rule::TyInt => Ty::Integer, + Rule::TyReal => Ty::Real, + Rule::TyBool => Ty::Boolean, + Rule::TyCustom => Ty::Named(child.as_str().to_string()), + rule => unreachable!("unexpected type: {rule:?}"), + }) + } + + pub(crate) fn Expression(input: ParseNode) -> ParseResult { + parse_expression_node(input.into_pair()) + } + + fn WhenGuard(input: ParseNode) -> ParseResult { + match_nodes!(input.into_children(); + [Expression(guard)] => Ok(guard) + ) + } + + // --- Simple declarations ---------------------------------------------- + + fn DeclarationConstant(input: ParseNode) -> ParseResult { + match_nodes!(input.into_children(); + [ID(name), Expression(value)] => Ok(Constant { id: None, name, value }) + ) + } + + fn DeclarationParameter(input: ParseNode) -> ParseResult { + match_nodes!(input.into_children(); + [ID(name), Expression(value)] => Ok(Parameter { id: None, name, value }) + ) + } + + fn DeclarationPenalty(input: ParseNode) -> ParseResult { + match_nodes!(input.into_children(); + [ID(name), Expression(value)] => Ok(Penalty { id: None, name, value }) + ) + } + + fn DeclarationType(input: ParseNode) -> ParseResult { + match_nodes!(input.into_children(); + [ID(name), TypeElement(elements)..] => Ok(TypeDeclaration { id: None, name, elements: elements.collect() }) + ) + } + + fn TypeElement(input: ParseNode) -> ParseResult { + match_nodes!(input.into_children(); + [ID(id)] => Ok(id) + ) + } + + // --- Variables --------------------------------------------------------- + + fn DeclarationVariables(input: ParseNode) -> ParseResult> { + let mut global = false; + let mut variables = Vec::new(); + + for child in input.into_children() { + match child.as_rule() { + Rule::GlobalMarker => global = true, + Rule::VariableDeclaration => variables.push(Self::VariableDeclaration(child)?), + rule => unreachable!("unexpected variables child: {rule:?}"), + } + } + + for variable in &mut variables { + variable.global = global; + } + + Ok(variables) + } + + fn VariableDeclaration(input: ParseNode) -> ParseResult { + match_nodes!(input.into_children(); + [Ty(ty), ID(name), VariableRange(range), Expression(initial_value)] => { + Ok(Variable { id: None, global: false, ty, name, range: Some(range), initial_value }) + }, + [Ty(ty), ID(name), Expression(initial_value)] => { + Ok(Variable { id: None, global: false, ty, name, range: None, initial_value }) + } + ) + } + + fn VariableRange(input: ParseNode) -> ParseResult { + match_nodes!(input.into_children(); + [Expression(min), Expression(max)] => Ok(Range { min, max }) + ) + } + + // --- Functions --------------------------------------------------------- + + fn DeclarationFunction(input: ParseNode) -> ParseResult { + let mut name = None; + let mut arguments = Vec::new(); + let mut body = None; + + for child in input.into_children() { + match child.as_rule() { + Rule::ID => name = Some(Self::ID(child)?), + Rule::FunctionArgument => arguments.push(Self::FunctionArgument(child)?), + Rule::FunctionBlock => body = Some(Self::FunctionBlock(child)?), + rule => unreachable!("unexpected function child: {rule:?}"), + } + } + + Ok(Function { + id: None, + name: name.expect("function requires a name"), + arguments, + body: body.expect("function requires a body"), + }) + } + + fn FunctionArgument(input: ParseNode) -> ParseResult { + match_nodes!(input.into_children(); + [Ty(ty), ID(name)] => Ok(FunctionArgument { id: None, ty, name }) + ) + } + + fn FunctionReturn(input: ParseNode) -> ParseResult { + match_nodes!(input.into_children(); + [Expression(value)] => Ok(FunctionStatement::Return(value)) + ) + } + + fn FunctionLet(input: ParseNode) -> ParseResult { + let mut children = input.into_children(); + let name = Self::ID(children.next().expect("let name"))?; + let value = Self::Expression(children.next().expect("let value"))?; + let body = function_statement(children.next().expect("let body"))?; + Ok(FunctionStatement::Let { + id: None, + name, + value, + body: Box::new(body), + }) + } + + fn FunctionIfThenElse(input: ParseNode) -> ParseResult { + let mut children = input.into_children(); + let guard = Self::Expression(children.next().expect("if guard"))?; + let then_branch = Box::new(function_statement(children.next().expect("then branch"))?); + let else_branch = children.next().map(function_statement).transpose()?.map(Box::new); + Ok(FunctionStatement::IfThenElse { + guard, + then_branch, + else_branch, + }) + } + + fn FunctionBlock(input: ParseNode) -> ParseResult { + let mut children = input.into_children(); + let inner = function_statement(children.next().expect("block body"))?; + Ok(FunctionStatement::Block(Box::new(inner))) + } + + // --- Components and controllers --------------------------------------- + + fn DeclarationComponent(input: ParseNode) -> ParseResult { + let mut name = None; + let mut variables = Vec::new(); + let mut states = Vec::new(); + let mut init = Vec::new(); + + for child in input.into_children() { + match child.as_rule() { + Rule::ID => name = Some(Self::ID(child)?), + Rule::VariableDeclaration => variables.push(Self::VariableDeclaration(child)?), + Rule::ControllerState => states.push(Self::ControllerState(child)?), + Rule::ControllerExpression => init = Self::ControllerExpression(child)?, + rule => unreachable!("unexpected component child: {rule:?}"), + } + } + + Ok(Component { + id: None, + name: name.expect("component requires a name"), + variables, + states, + init, + }) + } + + fn ControllerExpression(input: ParseNode) -> ParseResult> { + match_nodes!(input.into_children(); + [ID(states)..] => Ok(states.map(StateRef::new).collect()) + ) + } + + fn ControllerState(input: ParseNode) -> ParseResult { + let mut children = input.into_children(); + let name = Self::ID(children.next().expect("state name"))?; + let body = Self::ControllerBlock(children.next().expect("state body"))?; + Ok(ControllerState { id: None, name, body }) + } + + fn ControllerBlock(input: ParseNode) -> ParseResult> { + input.into_children().map(controller_command).collect() + } + + fn ControllerStep(input: ParseNode) -> ParseResult { + let mut steps = None; + let mut target = None; + for child in input.into_children() { + match child.as_rule() { + Rule::Expression => steps = Some(Self::Expression(child)?), + Rule::ID => target = Some(StateRef::new(Self::ID(child)?)), + rule => unreachable!("unexpected step child: {rule:?}"), + } + } + Ok(ControllerCommand::Step { + steps, + target: target.expect("step requires a target"), + }) + } + + fn ControllerExec(input: ParseNode) -> ParseResult { + match_nodes!(input.into_children(); + [ID(target)] => Ok(ControllerCommand::Exec(StateRef::new(target))) + ) + } + + fn ControllerLet(input: ParseNode) -> ParseResult { + let mut children = input.into_children(); + let name = Self::ID(children.next().expect("let name"))?; + let value = Self::Expression(children.next().expect("let value"))?; + let body = Self::ControllerBlock(children.next().expect("let body"))?; + Ok(ControllerCommand::Let { + id: None, + name, + value, + body, + }) + } + + fn ControllerAssignment(input: ParseNode) -> ParseResult { + Ok(ControllerCommand::Assignment(assignment_update(input)?)) + } + + fn ControllerIfThenElse(input: ParseNode) -> ParseResult { + let mut children = input.into_children(); + let guard = Self::Expression(children.next().expect("if guard"))?; + let then_branch = Self::ControllerBlock(children.next().expect("then branch"))?; + let else_branch = children.next().map(Self::ControllerBlock).transpose()?; + Ok(ControllerCommand::IfThenElse { + guard, + then_branch, + else_branch, + }) + } + + // --- Environment ------------------------------------------------------- + + fn DeclarationEnvironment(input: ParseNode) -> ParseResult { + let mut children = input.into_children(); + let commands = Self::EnvironmentBlock(children.next().expect("environment block"))?; + Ok(Environment { commands }) + } + + fn EnvironmentBlock(input: ParseNode) -> ParseResult> { + input.into_children().map(environment_command).collect() + } + + fn EnvironmentAssignment(input: ParseNode) -> ParseResult { + Ok(EnvironmentCommand::Assignment(assignment_update(input)?)) + } + + fn EnvironmentIfThenElse(input: ParseNode) -> ParseResult { + let mut children = input.into_children(); + let guard = Self::Expression(children.next().expect("if guard"))?; + let then_branch = Box::new(environment_command(children.next().expect("then branch"))?); + let else_branch = children.next().map(environment_command).transpose()?.map(Box::new); + Ok(EnvironmentCommand::IfThenElse { + guard, + then_branch, + else_branch, + }) + } + + fn EnvironmentLet(input: ParseNode) -> ParseResult { + let mut bindings = Vec::new(); + let mut body = None; + for child in input.into_children() { + match child.as_rule() { + Rule::LocalVariable => bindings.push(Self::LocalVariable(child)?), + _ => body = Some(environment_command(child)?), + } + } + Ok(EnvironmentCommand::Let { + bindings, + body: Box::new(body.expect("let requires a body")), + }) + } + + fn LocalVariable(input: ParseNode) -> ParseResult { + match_nodes!(input.into_children(); + [ID(name), Expression(value)] => Ok(LocalVariable { id: None, name, value }) + ) + } + + // --- Robustness sub-languages ----------------------------------------- + + fn DeclarationPerturbation(input: ParseNode) -> ParseResult { + let mut children = input.into_children(); + let name = Self::ID(children.next().expect("perturbation name"))?; + let value = Self::PerturbationExpression(children.next().expect("perturbation value"))?; + Ok(Perturbation { id: None, name, value }) + } + + fn PerturbationExpression(input: ParseNode) -> ParseResult { + parse_perturbation_expression(input.children().as_pairs().clone()) + } + + fn DeclarationDistance(input: ParseNode) -> ParseResult { + let mut children = input.into_children(); + let name = Self::ID(children.next().expect("distance name"))?; + let value = Self::DistanceExpression(children.next().expect("distance value"))?; + Ok(Distance { id: None, name, value }) + } + + fn DistanceExpression(input: ParseNode) -> ParseResult { + parse_distance_expression(input.children().as_pairs().clone()) + } + + fn DeclarationFormula(input: ParseNode) -> ParseResult { + let mut children = input.into_children(); + let name = Self::ID(children.next().expect("formula name"))?; + let value = Self::RobtlFormula(children.next().expect("formula value"))?; + Ok(Formula { id: None, name, value }) + } + + fn RobtlFormula(input: ParseNode) -> ParseResult { + parse_robtl_formula(input.children().as_pairs().clone()) + } +} diff --git a/crates/stark/src/diagnostics.rs b/crates/stark/src/diagnostics.rs new file mode 100644 index 00000000..a0a53c71 --- /dev/null +++ b/crates/stark/src/diagnostics.rs @@ -0,0 +1,311 @@ +use std::error::Error; +use std::fmt; + +use merc_utilities::Span; +use thiserror::Error as ThisError; + +use crate::types::StarkType; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Severity { + Error, +} + +impl fmt::Display for Severity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Severity::Error => write!(f, "error"), + } + } +} + +/// Everything `resolve.rs` and `typecheck.rs` can complain about. +/// +/// The `#[error]` messages are the single source of truth for the wording; +/// nothing else in the crate formats a diagnostic message. +#[derive(Clone, Debug, ThisError)] +pub enum DiagnosticKind { + /// Two top-level declarations share a name. STARK has a single flat + /// namespace, so this covers a constant clashing with a function just as + /// much as two constants clashing. + #[error("duplicate definition of `{name}`")] + DuplicateDefinition { name: String, first: Span }, + + /// Two `state`s in the same component share a name. + #[error("duplicate controller state `{name}`")] + DuplicateControllerState { name: String, first: Span }, + + /// Two bindings in the *same* `let`/argument frame share a name. + /// Shadowing an outer scope is legal and never reported. + #[error("duplicate binding `{name}`")] + DuplicateBinding { name: String, first: Span }, + + #[error("unknown symbol `{name}`")] + UnknownSymbol { name: String }, + + #[error("unknown controller state `{name}`")] + UnknownControllerState { name: String }, + + /// The name resolves, but to the wrong kind of thing — calling a + /// variable, referencing a function as a value, assigning to a constant. + #[error("`{name}` is {found}, expected {expected}")] + IllegalUseOfName { + name: String, + found: &'static str, + expected: &'static str, + }, + + /// `type X = X | Y;`. Needs its own variant because neither name is + /// registered yet at the point the general duplicate check would run. + #[error("type `{name}` cannot declare an element with the same name")] + TypeElementSharesTypeName { name: String }, + + /// A state variable was read from an expression evaluated once at load + /// time, before any variable store exists. + #[error("`{context}` cannot read state variable `{name}`{}", .via.as_ref().map(|f| format!(" (via function `{f}`)")).unwrap_or_default())] + StateVariableInStaticExpression { + name: String, + context: &'static str, + via: Option, + }, + + /// A `Ty::Named` annotation that names no declared `type`. + #[error("unknown type `{name}`")] + UnknownType { name: String }, + + /// `expected.is_compatible_with(actual)` failed. Note this relation is + /// asymmetric: `real` accepts `int`, but `int` does not accept `real`. + #[error("expected {expected}, found {found}")] + TypeMismatch { expected: StarkType, found: StarkType }, + + #[error("expected a numerical type, found {found}")] + NotNumerical { found: StarkType }, + + /// Two types that have to meet at a join point have no common supertype. + /// For example ternary operands must match. + #[error("cannot merge {left} with {right}")] + IncompatibleTypes { left: StarkType, right: StarkType }, + + /// `R`/`N[..]`/`U[..]` used somewhere randomness is not permitted. + #[error("random expressions are not allowed here")] + RandomNotAllowed, + + #[error("`{name}` expects {expected} argument(s), found {found}")] + ArityMismatch { + name: String, + expected: usize, + found: usize, + }, + + /// A construct that resolves and type-checks but has no IR + /// representation yet. + #[error("{construct} is not yet supported by lowering")] + NotYetSupported { construct: &'static str }, +} + +impl DiagnosticKind { + /// A second source location worth showing alongside the primary one, with + /// the label to introduce it by. For example to render a duplicated + /// definition. + pub fn related(&self) -> Option<(&Span, &'static str)> { + match self { + DiagnosticKind::DuplicateDefinition { first, .. } + | DiagnosticKind::DuplicateControllerState { first, .. } => Some((first, "first defined here")), + DiagnosticKind::DuplicateBinding { first, .. } => Some((first, "first bound here")), + _ => None, + } + } +} + +/// A single diagnostic anchored to a source [Span]. +/// +/// Collecting rather than failing fast: instead of stopping at the first +/// problem, `resolve.rs` and `typecheck.rs` record every diagnostic they find +/// into one [Diagnostics] and only fail at the end, so a single +/// `UntypedStarkSpecification` check reports everything wrong with it in one +/// pass. +/// +/// Every diagnostic is a concrete [DiagnosticKind] variant rather than a +/// pre-formatted string, so the message is written once (in the `#[error]` +/// attribute) and callers can still match on *what* went wrong — which the +/// tests do, instead of asserting on message substrings. Each variant carries +/// the data the message interpolates, and the few that reference a second +/// location (a duplicate's original declaration) carry that [Span] too, so +/// [Diagnostic::render] can point at both. +#[derive(Clone, Debug, ThisError)] +#[error("{kind}")] +pub struct Diagnostic { + pub span: Span, + pub severity: Severity, + #[source] + pub kind: DiagnosticKind, +} + +impl Diagnostic { + pub fn error(span: Span, kind: DiagnosticKind) -> Self { + Diagnostic { + span, + severity: Severity::Error, + kind, + } + } + + /// Renders this diagnostic against its `source` text, in the same style as + /// [Span::render], followed by a second annotated snippet when + /// [DiagnosticKind::related] gives one. + pub fn render(&self, source: &str) -> String { + let mut rendered = format!("{}: {}\n{}", self.severity, self.kind, self.span.render(source)); + if let Some((span, label)) = self.kind.related() { + rendered.push_str(&format!("\nnote: {label}\n{}", span.render(source))); + } + rendered + } +} + +/// An accumulator for every [Diagnostic] found while resolving or +/// type-checking a [crate::UntypedStarkSpecification]. +#[derive(Clone, Debug, Default)] +pub struct Diagnostics { + items: Vec, +} + +impl Diagnostics { + pub fn new() -> Self { + Self::default() + } + + /// Records an error diagnostic at `span`. + pub fn error(&mut self, span: Span, kind: DiagnosticKind) { + log::trace!("diagnostic at {}..{}: {}", span.start, span.end, kind); + self.items.push(Diagnostic::error(span, kind)); + } + + pub fn has_errors(&self) -> bool { + self.items.iter().any(|d| d.severity == Severity::Error) + } + + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } + + pub fn items(&self) -> &[Diagnostic] { + &self.items + } + + /// Whether any recorded diagnostic matches `predicate` — the way tests + /// assert on *which* problem was found without depending on wording. + pub fn any(&self, predicate: impl Fn(&DiagnosticKind) -> bool) -> bool { + self.items.iter().any(|d| predicate(&d.kind)) + } + + /// Merges another collector's diagnostics into this one. + pub fn extend(&mut self, other: Diagnostics) { + self.items.extend(other.items); + } + + /// `Ok(value)` if nothing errored, otherwise `Err(self)`. + pub fn into_result(self, value: T) -> Result { + if self.has_errors() { Err(self) } else { Ok(value) } + } + + /// Renders every diagnostic against `source`, separated by blank lines. + pub fn render(&self, source: &str) -> String { + self.items + .iter() + .map(|d| d.render(source)) + .collect::>() + .join("\n\n") + } +} + +impl fmt::Display for Diagnostics { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // Without the source text there is nothing to underline, so this + // prints messages only. Prefer `render(source)` wherever the source + // is still in hand. + for (index, item) in self.items.iter().enumerate() { + if index > 0 { + writeln!(f)?; + } + writeln!(f, "{}: {}", item.severity, item.kind)?; + } + Ok(()) + } +} + +/// Implemented so that a [Diagnostics] converts into `MercError` for free, via +/// that type's blanket `From` impl. +impl Error for Diagnostics {} + +#[cfg(test)] +mod tests { + use super::DiagnosticKind; + use super::Diagnostics; + use merc_utilities::Span; + + fn unknown(name: &str) -> DiagnosticKind { + DiagnosticKind::UnknownSymbol { name: name.to_string() } + } + + #[test] + fn empty_collector_has_no_errors() { + let diagnostics = Diagnostics::new(); + assert!(!diagnostics.has_errors()); + assert!(diagnostics.into_result(()).is_ok()); + } + + #[test] + fn recorded_error_fails_into_result() { + let mut diagnostics = Diagnostics::new(); + diagnostics.error(Span { start: 0, end: 1 }, unknown("boom")); + assert!(diagnostics.has_errors()); + assert!(diagnostics.into_result(()).is_err()); + } + + #[test] + fn collects_every_error_not_just_the_first() { + let mut diagnostics = Diagnostics::new(); + diagnostics.error(Span { start: 0, end: 1 }, unknown("first")); + diagnostics.error(Span { start: 2, end: 3 }, unknown("second")); + assert_eq!(diagnostics.items().len(), 2); + } + + #[test] + fn render_includes_message_and_caret() { + let mut diagnostics = Diagnostics::new(); + diagnostics.error(Span { start: 4, end: 5 }, unknown("x")); + let rendered = diagnostics.render("eqn f = x;"); + assert!(rendered.contains("unknown symbol `x`")); + assert!(rendered.contains("^")); + } + + #[test] + fn render_includes_the_related_span_for_duplicates() { + let source = "const a = 1;\nconst a = 2;"; + let first = Span { start: 6, end: 7 }; + let second = Span { start: 19, end: 20 }; + let mut diagnostics = Diagnostics::new(); + diagnostics.error( + second, + DiagnosticKind::DuplicateDefinition { + name: "a".to_string(), + first, + }, + ); + + let rendered = diagnostics.render(source); + assert!(rendered.contains("duplicate definition of `a`"), "{rendered}"); + assert!(rendered.contains("note: first defined here"), "{rendered}"); + // Both the offending line (2) and the original one (1) are shown. + assert!(rendered.contains("--> 2:7"), "{rendered}"); + assert!(rendered.contains("--> 1:7"), "{rendered}"); + } + + #[test] + fn any_matches_on_the_kind_not_the_message() { + let mut diagnostics = Diagnostics::new(); + diagnostics.error(Span { start: 0, end: 1 }, DiagnosticKind::RandomNotAllowed); + assert!(diagnostics.any(|kind| matches!(kind, DiagnosticKind::RandomNotAllowed))); + assert!(!diagnostics.any(|kind| matches!(kind, DiagnosticKind::UnknownSymbol { .. }))); + } +} diff --git a/crates/stark/src/eval/distance.rs b/crates/stark/src/eval/distance.rs new file mode 100644 index 00000000..1956d97e --- /dev/null +++ b/crates/stark/src/eval/distance.rs @@ -0,0 +1,409 @@ +//! Distance expressions: how far apart two evolution sequences are, as a +//! single `f64` per time step. +//! +//! Every node reduces, eventually, to an *atomic* distance — a penalty +//! function lifted to the two sampled distributions by [wasserstein] — with +//! the temporal and lattice operators (`\F`, `\G`, `\U`, `min`, `max`, +//! thresholds, convex combinations) combining those pointwise values over an +//! interval. +//! +//! Two things about the original semantics are preserved deliberately and +//! are easy to get wrong: +//! +//! - **`\F` is a minimum and `\G` is a maximum.** A distance measures +//! *dissimilarity*, so "eventually close" is the best (smallest) distance +//! over the interval and "always close" is the worst (largest) one. This +//! inverts the intuition from the formula layer, where `\F` is a +//! disjunction. +//! - **A distance interval `[from, to]` excludes `to`**, whereas the +//! *formula* layer (see [super::formula]) includes it. That inconsistency +//! is the original's, not this port's, and is preserved so results match. +//! +//! An empty interval yields `NaN` rather than being an error, again matching +//! the original. +//! +//! # Confidence intervals +//! +//! Each node has two evaluations: [Analysis::distance], the plain value, and +//! [Analysis::distance_ci], which additionally carries an empirical-bootstrap +//! confidence interval. Only the three-valued formula semantics needs the +//! latter — it is what lets a verdict be `Unknown` when the threshold falls +//! *inside* the interval, i.e. when the sample size cannot decide the +//! question. See [super::formula]. + +use rand::Rng; +use rand::RngExt; + +use crate::ir::ComparisonOp; +use crate::ir::DistanceId; +use crate::ir::DistanceIr; +use crate::ir::ExprRef; +use crate::value::EvalError; + +use super::expr::eval; +use super::robust::Analysis; +use super::sequence::EvolutionSequence; +use super::sequence::ground_geq; +use super::sequence::ground_leq; +use super::sequence::wasserstein; + +/// A distance value together with the empirical-bootstrap confidence +/// interval around it — the original's bare `{value, lower, upper}` triple, +/// named. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Ci { + pub value: f64, + pub lower: f64, + pub upper: f64, +} + +impl Ci { + /// Combines two intervals component-wise, as every `min`/`max` node + /// does: the same operator is applied to the value and to both bounds + /// independently, rather than propagating the bounds of whichever + /// operand won. + fn zip(self, other: Ci, combine: fn(f64, f64) -> f64) -> Ci { + Ci { + value: combine(self.value, other.value), + lower: combine(self.lower, other.lower), + upper: combine(self.upper, other.upper), + } + } + + /// A degenerate interval, for a value known exactly. + fn exact(value: f64) -> Ci { + Ci { + value, + lower: value, + upper: value, + } + } +} + +impl ComparisonOp { + /// Applies this operator to two distance values. + pub(crate) fn compare(self, left: f64, right: f64) -> bool { + match self { + ComparisonOp::Less => left < right, + ComparisonOp::Leq => left <= right, + ComparisonOp::Eq => left == right, + ComparisonOp::Geq => left >= right, + ComparisonOp::Greater => left > right, + } + } +} + +/// `NaN`-propagating `min`/`max`, unlike [f64::min]/[f64::max], which return +/// the non-`NaN` operand. An empty interval produces `NaN`, and it must stay +/// `NaN` through the enclosing operators rather than being silently absorbed +/// — which is also what the original does. +fn nan_min(a: f64, b: f64) -> f64 { + if a.is_nan() || b.is_nan() { f64::NAN } else { a.min(b) } +} + +fn nan_max(a: f64, b: f64) -> f64 { + if a.is_nan() || b.is_nan() { f64::NAN } else { a.max(b) } +} + +impl Analysis<'_, R> { + /// Evaluates a distance expression between `reference` and `perturbed` at + /// time `step`. + pub(crate) fn distance( + &mut self, + reference: &mut EvolutionSequence, + perturbed: &mut EvolutionSequence, + step: usize, + id: DistanceId, + ) -> Result { + match self.program.distance(id).clone() { + DistanceIr::Reference(target) => self.distance(reference, perturbed, step, target), + DistanceIr::AtomicLeft(penalty) => { + let (left, right) = self.penalties(reference, perturbed, step, penalty)?; + wasserstein(ground_leq, &left, &right) + } + DistanceIr::AtomicRight(penalty) => { + let (left, right) = self.penalties(reference, perturbed, step, penalty)?; + wasserstein(ground_geq, &left, &right) + } + // `\F` is the *minimum* over the interval; see the module doc. + DistanceIr::Eventually { from, to, argument } => { + self.fold_interval(reference, perturbed, step, from, to, argument, nan_min) + } + DistanceIr::Globally { from, to, argument } => { + self.fold_interval(reference, perturbed, step, from, to, argument, nan_max) + } + DistanceIr::Until { from, to, left, right } => { + let (from, to) = self.interval(from, to, step)?; + // For each `i`, the worse of "the right expression at `i`" + // and "the worst the left expression has been strictly + // before `i`"; then the best such `i`. `running_left` + // accumulates across iterations, which is equivalent to + // recomputing the running maximum each time. + let mut result = 1.0; + let mut running_left = 0.0; + for i in from..to { + let right_value = self.distance(reference, perturbed, i, right)?; + for j in from..i { + running_left = nan_max(running_left, self.distance(reference, perturbed, j, left)?); + } + result = nan_min(result, nan_max(right_value, running_left)); + } + Ok(result) + } + DistanceIr::Threshold { op, left, threshold } => { + let value = self.distance(reference, perturbed, step, left)?; + let threshold = self.constant(threshold)?; + // Note the polarity: *satisfying* the threshold is distance + // `0.0` (no dissimilarity), violating it is `1.0`. + Ok(if op.compare(value, threshold) { 0.0 } else { 1.0 }) + } + DistanceIr::Min(left, right) => { + let left = self.distance(reference, perturbed, step, left)?; + let right = self.distance(reference, perturbed, step, right)?; + Ok(nan_min(left, right)) + } + DistanceIr::Max(left, right) => { + let left = self.distance(reference, perturbed, step, left)?; + let right = self.distance(reference, perturbed, step, right)?; + Ok(nan_max(left, right)) + } + DistanceIr::LinearCombination(terms) => { + let mut total = 0.0; + for (weight, term) in terms { + total += self.constant(weight)? * self.distance(reference, perturbed, step, term)?; + } + Ok(total) + } + } + } + + /// [Analysis::distance], plus a bootstrap confidence interval around it. + pub(crate) fn distance_ci( + &mut self, + reference: &mut EvolutionSequence, + perturbed: &mut EvolutionSequence, + step: usize, + id: DistanceId, + ) -> Result { + match self.program.distance(id).clone() { + DistanceIr::Reference(target) => self.distance_ci(reference, perturbed, step, target), + DistanceIr::AtomicLeft(penalty) => self.atomic_ci(reference, perturbed, step, penalty, ground_leq), + DistanceIr::AtomicRight(penalty) => self.atomic_ci(reference, perturbed, step, penalty, ground_geq), + DistanceIr::Eventually { from, to, argument } => { + self.fold_interval_ci(reference, perturbed, step, from, to, argument, nan_min) + } + DistanceIr::Globally { from, to, argument } => { + self.fold_interval_ci(reference, perturbed, step, from, to, argument, nan_max) + } + DistanceIr::Until { from, to, left, right } => { + let (from, to) = self.interval(from, to, step)?; + let mut result = Ci::exact(1.0); + for i in from..to { + let right_value = self.distance_ci(reference, perturbed, i, right)?; + // Unlike the plain evaluation above, the original + // re-seeds the running left maximum from the left + // expression *at `i`* on every iteration before folding + // in `[from, i)`. Preserved as written; see `plan.md`. + let mut running_left = self.distance_ci(reference, perturbed, i, left)?; + for j in from..i { + running_left = running_left.zip(self.distance_ci(reference, perturbed, j, left)?, nan_max); + } + result = result.zip(right_value.zip(running_left, nan_max), nan_min); + } + Ok(result) + } + DistanceIr::Threshold { op, left, threshold } => { + let value = self.distance_ci(reference, perturbed, step, left)?; + let threshold = self.constant(threshold)?; + let decided = if op.compare(value.value, threshold) { 0.0 } else { 1.0 }; + // If the threshold falls strictly inside the confidence + // interval, the sample cannot tell which side of it the true + // distance is on, so the *thresholded* interval spans both + // outcomes — which is exactly what makes the enclosing + // formula `Unknown`. + Ok(if value.lower < threshold && threshold < value.upper { + Ci { + value: decided, + lower: 0.0, + upper: 1.0, + } + } else { + Ci::exact(decided) + }) + } + DistanceIr::Min(left, right) => { + let left = self.distance_ci(reference, perturbed, step, left)?; + let right = self.distance_ci(reference, perturbed, step, right)?; + Ok(left.zip(right, nan_min)) + } + DistanceIr::Max(left, right) => { + let left = self.distance_ci(reference, perturbed, step, left)?; + let right = self.distance_ci(reference, perturbed, step, right)?; + Ok(left.zip(right, nan_max)) + } + DistanceIr::LinearCombination(terms) => { + let mut total = Ci::exact(0.0); + for (weight, term) in terms { + let weight = self.constant(weight)?; + let term = self.distance_ci(reference, perturbed, step, term)?; + total = Ci { + value: total.value + weight * term.value, + lower: total.lower + weight * term.lower, + upper: total.upper + weight * term.upper, + }; + } + Ok(total) + } + } + } + + /// The two sorted penalty-value distributions an atomic distance compares. + fn penalties( + &mut self, + reference: &mut EvolutionSequence, + perturbed: &mut EvolutionSequence, + step: usize, + penalty: crate::ir::PenaltyId, + ) -> Result<(Vec, Vec), EvalError> { + let left = reference.eval_penalty(self.program, &mut self.rng, penalty, step)?; + let right = perturbed.eval_penalty(self.program, &mut self.rng, penalty, step)?; + Ok((left, right)) + } + + /// An atomic distance with its bootstrap interval. + fn atomic_ci( + &mut self, + reference: &mut EvolutionSequence, + perturbed: &mut EvolutionSequence, + step: usize, + penalty: crate::ir::PenaltyId, + ground: fn(f64, f64) -> f64, + ) -> Result { + let (left, right) = self.penalties(reference, perturbed, step, penalty)?; + let value = wasserstein(ground, &left, &right)?; + let (lower, upper) = self.bootstrap(&left, &right, ground)?; + Ok(Ci { value, lower, upper }) + } + + /// The empirical bootstrap: resample both distributions with replacement + /// `m` times, and take a `z`-quantile normal interval around the mean of + /// the resulting distances. + /// + /// The interval is clamped to `[0, 1]` exactly as the original clamps + /// it, which assumes penalty values are normalised to that range. + fn bootstrap(&mut self, left: &[f64], right: &[f64], ground: fn(f64, f64) -> f64) -> Result<(f64, f64), EvalError> { + let m = self.options.bootstrap_replicas; + if m < 2 { + // The standard error divides by `m - 1`; with fewer than two + // replicas there is no spread to estimate, so report the point + // value as exact rather than dividing by zero. + let value = wasserstein(ground, left, right)?; + return Ok((value, value)); + } + let mut distances = Vec::with_capacity(m); + let mut total = 0.0; + for _ in 0..m { + let left_sample = self.resample(left); + let right_sample = self.resample(right); + let distance = wasserstein(ground, &left_sample, &right_sample)?; + distances.push(distance); + total += distance; + } + let mean = total / m as f64; + let variance = distances.iter().map(|d| (d - mean).powi(2)).sum::() / (m - 1) as f64; + let error = self.options.quantile * variance.sqrt(); + Ok(((mean - error).max(0.0), (mean + error).min(1.0))) + } + + /// One bootstrap resample: `len` draws with replacement, sorted — the + /// sort is required because [wasserstein] pairs by rank. + fn resample(&mut self, data: &[f64]) -> Vec { + let mut sample: Vec = (0..data.len()) + .map(|_| data[self.rng.random_range(0..data.len())]) + .collect(); + sample.sort_by(f64::total_cmp); + sample + } + + /// The `\F`/`\G` fold: `argument` over `[from + step, to + step)`, `NaN` + /// if empty. + #[expect(clippy::too_many_arguments, reason = "one argument per IR field, plus the fold")] + fn fold_interval( + &mut self, + reference: &mut EvolutionSequence, + perturbed: &mut EvolutionSequence, + step: usize, + from: ExprRef, + to: ExprRef, + argument: DistanceId, + combine: fn(f64, f64) -> f64, + ) -> Result { + let (from, to) = self.interval(from, to, step)?; + let mut folded: Option = None; + for i in from..to { + let value = self.distance(reference, perturbed, i, argument)?; + folded = Some(match folded { + Some(previous) => combine(previous, value), + None => value, + }); + } + Ok(folded.unwrap_or(f64::NAN)) + } + + /// [Analysis::fold_interval] for confidence intervals: the value and + /// both bounds are folded independently. + #[expect(clippy::too_many_arguments, reason = "one argument per IR field, plus the fold")] + fn fold_interval_ci( + &mut self, + reference: &mut EvolutionSequence, + perturbed: &mut EvolutionSequence, + step: usize, + from: ExprRef, + to: ExprRef, + argument: DistanceId, + combine: fn(f64, f64) -> f64, + ) -> Result { + let (from, to) = self.interval(from, to, step)?; + let mut folded: Option = None; + for i in from..to { + let value = self.distance_ci(reference, perturbed, i, argument)?; + folded = Some(match folded { + Some(previous) => previous.zip(value, combine), + None => value, + }); + } + Ok(folded.unwrap_or(Ci::exact(f64::NAN))) + } + + /// Evaluates an interval's bounds and shifts them by `step`, as a Rust + /// range. Both bounds are [ExprRef]s in the IR rather than folded + /// constants, so they are evaluated here with the ordinary expression + /// evaluator, against the program's `const`/`param` slots. + /// + /// A negative bound, or `to <= from`, gives an empty range rather than + /// an error. The original rejects such bounds at construction time, + /// which this port has no equivalent of since bounds are evaluated on + /// demand, and the never-panic contract rules out failing here. + pub(crate) fn interval(&mut self, from: ExprRef, to: ExprRef, step: usize) -> Result<(usize, usize), EvalError> { + let from = self.constant_integer(from, "the lower bound of an interval")?; + let to = self.constant_integer(to, "the upper bound of an interval")?; + let from = (from.max(0) as usize).saturating_add(step); + let to = (to.max(0) as usize).saturating_add(step); + Ok((from, to.max(from))) + } + + /// Evaluates a program-level constant expression — an interval bound, a + /// threshold, a combination weight. These may only refer to `const`/ + /// `param` slots, which is what [Analysis::globals] holds. + pub(crate) fn constant(&mut self, id: ExprRef) -> Result { + eval(self.program, &mut self.globals, &mut self.rng, id)? + .as_f64("a distance or formula constant") + .map_err(|kind| EvalError::from(kind).or_span(self.program.expr_span(id))) + } + + fn constant_integer(&mut self, id: ExprRef, context: &'static str) -> Result { + eval(self.program, &mut self.globals, &mut self.rng, id)? + .as_integer(context) + .map_err(|kind| EvalError::from(kind).or_span(self.program.expr_span(id))) + } +} diff --git a/crates/stark/src/eval/expr.rs b/crates/stark/src/eval/expr.rs new file mode 100644 index 00000000..a4247f40 --- /dev/null +++ b/crates/stark/src/eval/expr.rs @@ -0,0 +1,431 @@ +//! Expression and function-body evaluation over [IrProgram]'s arena: a +//! straight post-order walk of `ExprRef`/`StmtRef` indices rather than the +//! original's tree of lambda closures, since lowering already collapsed the +//! AST into that arena. +//! +//! Every function here returns a `Result` and never panics. +//! A malformed runtime state (which shouldn't arise against a checked + +//! lowered [IrProgram]) is an `Err` naming what went wrong, rather than the +//! absorbing error *value* the original propagates. The `Result` honours the +//! same contract more strictly, since the error cannot be silently dropped. + +use rand::Rng; +use rand::RngExt; + +use crate::ir::BinaryOp; +use crate::ir::ExprNode; +use crate::ir::ExprRef; +use crate::ir::IrProgram; +use crate::ir::MathBinaryFunction; +use crate::ir::MathUnaryFunction; +use crate::ir::StmtNode; +use crate::ir::StmtRef; +use crate::value::EvalError; +use crate::value::EvalErrorKind; +use crate::value::Value; + +use super::store::Store; + +/// Evaluates one expression against `store`, sampling from `rng` wherever +/// the expression does. +/// +/// This is a thin wrapper over [eval_inner] that pins the offending source +/// location: on failure it attaches `id`'s [Span](merc_utilities::Span) to the error +/// unless a more specific inner one was already recorded (see +/// [EvalError::or_span]). Because every recursive sub-evaluation goes through +/// this wrapper too, the span that survives is the innermost failing +/// expression's — so `1 / 0` deep inside a larger expression is reported +/// against `1 / 0`, not the whole expression. +pub(crate) fn eval( + program: &IrProgram, + store: &mut Store, + rng: &mut R, + id: ExprRef, +) -> Result { + eval_inner(program, store, rng, id).map_err(|error| error.or_span(program.expr_span(id))) +} + +fn eval_inner( + program: &IrProgram, + store: &mut Store, + rng: &mut R, + id: ExprRef, +) -> Result { + match *program.expr(id) { + ExprNode::Literal(value) => Ok(value), + ExprNode::Unreachable(what) => Err(EvalErrorKind::Unreachable(what).into()), + ExprNode::Load(slot) => Ok(store.load(slot)), + ExprNode::Not(inner) => Ok(Value::Boolean((!eval(program, store, rng, inner)?)?)), + // Both always widen to `Real`, matching the original — see + // `ExprNode::Negate` and `ExprNode::Widen`'s doc comments in `ir.rs`. + // The `Value` operations produce a bare [EvalErrorKind] (they have no + // [crate::ir::Span] to give); `EvalError::from` lifts it, and the + // outer `eval` wrapper then anchors it to this node's span. + ExprNode::Negate(inner) => eval(program, store, rng, inner)? + .apply_unary("-", |x| -x) + .map_err(EvalError::from), + ExprNode::Widen(inner) => eval(program, store, rng, inner)? + .apply_unary("+", |x| x) + .map_err(EvalError::from), + ExprNode::Binary(op, left, right) => { + let left = eval(program, store, rng, left)?; + let right = eval(program, store, rng, right)?; + apply_binary_op(op, left, right).map_err(EvalError::from) + } + ExprNode::MathUnary(function, inner) => { + let value = eval(program, store, rng, inner)?; + let (name, f) = math_unary_fn(function); + value.apply_unary(name, f).map_err(EvalError::from) + } + ExprNode::MathBinary(function, left, right) => { + let left = eval(program, store, rng, left)?; + let right = eval(program, store, rng, right)?; + let (name, f) = math_binary_fn(function); + left.apply_binary(right, name, f).map_err(EvalError::from) + } + ExprNode::Select { + guard, + then_branch, + else_branch, + } => { + // Lazy, as in the original: only the taken branch is evaluated, + // since the untaken one may sample (advancing `rng`) or divide + // by zero. + if eval(program, store, rng, guard)?.as_boolean("the condition of a `?:` expression")? { + eval(program, store, rng, then_branch) + } else { + eval(program, store, rng, else_branch) + } + } + ExprNode::Call { function, arguments } => { + let function_ir = program.function(function); + // Evaluate every argument against the *caller's* slots first... + let mut values = Vec::with_capacity(function_ir.arguments.len()); + for &argument in program.expr_list(arguments) { + values.push(eval(program, store, rng, argument)?); + } + // ...then write them into the callee's fixed argument slots. + // No frame save/restore: `resolve.rs` forbids recursion, so + // every function's argument/`let` slots are disjoint from every + // other function's and no function is ever live twice at once. + for (&slot, value) in function_ir.arguments.iter().zip(values) { + store.set(slot, value); + } + eval_stmt(program, store, rng, function_ir.body) + } + ExprNode::SampleUnit => Ok(Value::Real(rng.random::())), + ExprNode::SampleRange { min, max } => { + let min = eval(program, store, rng, min)?; + let max = eval(program, store, rng, max)?; + sample_range(rng, min, max) + } + ExprNode::SampleNormal { mean, variance } => { + let mean = eval(program, store, rng, mean)?; + let variance = eval(program, store, rng, variance)?; + sample_normal(rng, mean, variance) + } + ExprNode::SampleChoice(list) => { + let elements = program.expr_list(list); + // Lazy like `Select`: the original picks an index and evaluates + // only that one element. + let selected = rng.random_range(0..elements.len()); + eval(program, store, rng, elements[selected]) + } + } +} + +/// Evaluates a function body statement, returning the value of whichever +/// `Return` is reached. +pub(crate) fn eval_stmt( + program: &IrProgram, + store: &mut Store, + rng: &mut R, + id: StmtRef, +) -> Result { + match *program.stmt(id) { + StmtNode::Return(value) => eval(program, store, rng, value), + StmtNode::IfThenElse { + guard, + then_branch, + else_branch, + } => { + if eval(program, store, rng, guard)?.as_boolean("the condition of an `if` statement")? { + eval_stmt(program, store, rng, then_branch) + } else { + match else_branch { + Some(else_branch) => eval_stmt(program, store, rng, else_branch), + // `typecheck.rs` requires a function to return on every + // path, so a false guard with no `else` is unreachable + // against a checked program. + None => Err(EvalErrorKind::MissingReturn.into()), + } + } + } + StmtNode::Let { slot, value, body } => { + let value = eval(program, store, rng, value)?; + store.set(slot, value); + eval_stmt(program, store, rng, body) + } + } +} + +fn apply_binary_op(op: BinaryOp, left: Value, right: Value) -> Result { + // The comparisons and the boolean connectives return a bare `bool` (see + // `Value::is_less_than`); an `ExprNode::Binary` is an expression, so they + // are wrapped back into a `Value` here. + match op { + BinaryOp::Add => left.sum(right), + BinaryOp::Subtract => left.subtraction(right), + BinaryOp::Mult => left.product(right), + BinaryOp::Div => left.division(right), + BinaryOp::IntDiv => left.int_div(right), + BinaryOp::Mod => left.modulo(right), + BinaryOp::Less => left.is_less_than(right).map(Value::Boolean), + BinaryOp::Leq => left.is_less_or_equal_than(right).map(Value::Boolean), + BinaryOp::Eq => left.is_equal_to(right).map(Value::Boolean), + BinaryOp::Geq => left.is_greater_or_equal_than(right).map(Value::Boolean), + BinaryOp::Greater => left.is_greater_than(right).map(Value::Boolean), + // `&&`/`&` and `||`/`|` are one operation each, two spellings — see + // `Value::and`/`Value::or`'s doc comments. + BinaryOp::And | BinaryOp::BitAnd => left.and(right).map(Value::Boolean), + BinaryOp::Or | BinaryOp::BitOr => left.or(right).map(Value::Boolean), + } +} + +/// The `f64` implementation of each unary math function, paired with its +/// source-level name so a non-numeric argument can be reported against the +/// name the user actually wrote. +fn math_unary_fn(function: MathUnaryFunction) -> (&'static str, fn(f64) -> f64) { + match function { + MathUnaryFunction::Abs => ("abs", f64::abs), + MathUnaryFunction::Acos => ("acos", f64::acos), + MathUnaryFunction::Asin => ("asin", f64::asin), + MathUnaryFunction::Atan => ("atan", f64::atan), + MathUnaryFunction::Cbrt => ("cbrt", f64::cbrt), + MathUnaryFunction::Ceil => ("ceil", f64::ceil), + MathUnaryFunction::Cos => ("cos", f64::cos), + MathUnaryFunction::Cosh => ("cosh", f64::cosh), + MathUnaryFunction::Exp => ("exp", f64::exp), + MathUnaryFunction::Expm1 => ("expm1", f64::exp_m1), + MathUnaryFunction::Floor => ("floor", f64::floor), + MathUnaryFunction::Log => ("log", f64::ln), + MathUnaryFunction::Log10 => ("log10", f64::log10), + MathUnaryFunction::Log1p => ("log1p", f64::ln_1p), + MathUnaryFunction::Signum => ("signum", signum), + MathUnaryFunction::Sin => ("sin", f64::sin), + MathUnaryFunction::Sinh => ("sinh", f64::sinh), + MathUnaryFunction::Sqrt => ("sqrt", f64::sqrt), + MathUnaryFunction::Tan => ("tan", f64::tan), + } +} + +/// The binary counterpart of [math_unary_fn]. +fn math_binary_fn(function: MathBinaryFunction) -> (&'static str, fn(f64, f64) -> f64) { + match function { + MathBinaryFunction::Atan2 => ("atan2", f64::atan2), + MathBinaryFunction::Hypot => ("hypot", f64::hypot), + MathBinaryFunction::Max => ("max", nan_max), + MathBinaryFunction::Min => ("min", nan_min), + MathBinaryFunction::Pow => ("pow", f64::powf), + } +} + +/// `signum` as the language defines it: unlike [f64::signum] (which returns +/// `±1.0` for `±0.0` and never `0.0`), this returns the zero itself (`0.0` or +/// `-0.0`) unchanged, and propagates `NaN`. +fn signum(x: f64) -> f64 { + if x == 0.0 || x.is_nan() { x } else { x.signum() } +} + +/// `max` as the language defines it: propagates `NaN` if *either* argument +/// is `NaN`. [f64::max] instead returns the non-`NaN` argument, so it can't +/// be used directly. +fn nan_max(a: f64, b: f64) -> f64 { + if a.is_nan() || b.is_nan() { f64::NAN } else { a.max(b) } +} + +/// `min`, see [nan_max]. +fn nan_min(a: f64, b: f64) -> f64 { + if a.is_nan() || b.is_nan() { f64::NAN } else { a.min(b) } +} + +/// `R[a,b]`: a uniform sample, `from + u * (to - from)` for `u` in `[0, 1)`. +fn sample_range(rng: &mut R, min: Value, max: Value) -> Result { + let from = min.as_f64("the lower bound of an `R[a,b]` sample")?; + let to = max.as_f64("the upper bound of an `R[a,b]` sample")?; + Ok(Value::Real(from + rng.random::() * (to - from))) +} + +/// `N[mean, variance]`. **Not actually Gaussian** — despite the name and the +/// syntax, the original computes `u * mean + variance` for `u` in `[0, 1)`, +/// a scaled-and-shifted uniform sample rather than a normal distribution. +/// This is ported *exactly*, not "fixed", so behaviour matches the original +/// tool: it reads as a bug there, but silently correcting it is not this +/// port's place. +fn sample_normal(rng: &mut R, mean: Value, variance: Value) -> Result { + // Both bounds are already guaranteed numeric by `typecheck.rs` (`R[a,b]`'s + // bounds and `N[m,v]`'s mean/variance are all checked against `real`), so + // these errors only fire against an otherwise-unreachable malformed IR. + let mean = mean.as_f64("the mean of an `N[m,v]` sample")?; + let variance = variance.as_f64("the variance of an `N[m,v]` sample")?; + Ok(Value::Real(rng.random::() * mean + variance)) +} + +#[cfg(test)] +mod tests { + use rand::SeedableRng; + use rand::rngs::StdRng; + use test_case::test_case; + use test_log::test; + + use super::*; + use crate::StarkSpecification; + use crate::UntypedStarkSpecification; + + fn eval_expression(source: &str) -> Value { + let full_source = format!("const result = {source};"); + let untyped = UntypedStarkSpecification::parse(&full_source).expect("should parse"); + let spec = StarkSpecification::from_untyped(untyped).expect("should check"); + let program = IrProgram::from_spec(&spec).expect("should lower"); + let mut rng = StdRng::seed_from_u64(0); + let store = Store::new(&program, &mut rng).expect("should initialise"); + store.load(program.globals()[0].slot) + } + + #[test_case("1 + 2", Value::Integer(3) ; "integer addition stays integer")] + #[test_case("1 + 2.0", Value::Real(3.0) ; "integer plus real widens")] + #[test_case("7 / 2", Value::Integer(3) ; "integer division truncates")] + #[test_case("7 % 2", Value::Integer(1) ; "integer modulo")] + #[test_case("max(1, 2)", Value::Real(2.0) ; "math functions always widen to real")] + #[test_case("true && false", Value::Boolean(false) ; "double ampersand and")] + #[test_case("true & false", Value::Boolean(false) ; "single ampersand and")] + #[test_case("!true", Value::Boolean(false) ; "boolean not")] + #[test_case("-3", Value::Real(-3.0) ; "arithmetic negate widens to real")] + #[test_case("+3", Value::Real(3.0) ; "unary plus widens to real")] + #[test_case("2 < 3", Value::Boolean(true) ; "less than")] + #[test_case("2 == 2.0", Value::Boolean(true) ; "equality widens")] + #[test_case("2 < 3 ? 10 : 20", Value::Integer(10) ; "select ternary")] + fn evaluates_literal_expressions(source: &str, expected: Value) { + assert_eq!(eval_expression(source), expected); + } + + #[test] + fn a_runtime_error_is_anchored_to_the_innermost_offending_expression() { + // The `1 / 0` is nested inside `4 + …`; the reported span must + // underline the division that actually failed, not the whole + // initializer — this is what `EvalError::or_span`'s innermost-wins + // rule buys, and what makes the message read as "division by zero at + // ". + let source = "const result = 4 + 1 / 0;"; + let untyped = UntypedStarkSpecification::parse(source).expect("should parse"); + let spec = StarkSpecification::from_untyped(untyped).expect("should check"); + let program = IrProgram::from_spec(&spec).expect("should lower"); + let mut rng = StdRng::seed_from_u64(0); + + let error = Store::new(&program, &mut rng).expect_err("initialisation divides by zero"); + assert_eq!(error.kind, EvalErrorKind::DivisionByZero); + + let span = error.span.clone().expect("the failure carries a source span"); + let underlined = &source[span.start..span.end]; + assert!( + underlined.contains('/') && !underlined.contains('4'), + "expected the span to point at the inner division, got {underlined:?}" + ); + // And it renders in the shared `-->`/`^^^` diagnostic style. + let rendered = error.render(source); + assert!(rendered.starts_with("error: division by zero"), "got: {rendered}"); + assert!(rendered.contains("--> 1:"), "got: {rendered}"); + } + + #[test] + fn select_only_evaluates_the_taken_branch() { + // The untaken branch divides by zero; if `Select` weren't lazy this + // would fail with `EvalError::DivisionByZero` (and, before errors + // became a `Result`, would have silently yielded `Value::Error`) + // instead of `Value::Integer(1)`. + assert_eq!(eval_expression("true ? 1 : 1/0"), Value::Integer(1)); + assert_eq!(eval_expression("false ? 1/0 : 1"), Value::Integer(1)); + } + + #[test] + fn function_calls_write_into_callee_slots_and_return() { + // Constants are resolved *before* functions (`resolve.rs`), so a + // function call can't appear in a `const` initializer — a variable's + // initial value is resolved after functions, so it can. + let source = r" + function add(int a, int b) { + return a + b; + } + global variables { + int result = add(3, 4); + } + "; + let untyped = UntypedStarkSpecification::parse(source).expect("should parse"); + let spec = StarkSpecification::from_untyped(untyped).expect("should check"); + let program = IrProgram::from_spec(&spec).expect("should lower"); + let mut rng = StdRng::seed_from_u64(0); + let store = Store::new(&program, &mut rng).expect("should initialise"); + let result_slot = program.variables()[0].slot; + assert_eq!(store.load(result_slot), Value::Integer(7)); + } + + #[test] + fn let_binding_shadows_within_its_body() { + let source = r" + function with_let(int x) { + let y = x + 1 in + return y + 1; + } + global variables { + int result = with_let(1); + } + "; + let untyped = UntypedStarkSpecification::parse(source).expect("should parse"); + let spec = StarkSpecification::from_untyped(untyped).expect("should check"); + let program = IrProgram::from_spec(&spec).expect("should lower"); + let mut rng = StdRng::seed_from_u64(0); + let store = Store::new(&program, &mut rng).expect("should initialise"); + assert_eq!(store.load(program.variables()[0].slot), Value::Integer(3)); + } + + #[test] + fn sample_unit_is_seeded_and_reproducible() { + let mut rng_a = StdRng::seed_from_u64(42); + let mut rng_b = StdRng::seed_from_u64(42); + let value_a = rng_a.random::(); + let value_b = rng_b.random::(); + assert_eq!(value_a, value_b); + assert!((0.0..1.0).contains(&value_a)); + } + + #[test] + fn sample_range_stays_in_bounds() { + let mut rng = StdRng::seed_from_u64(7); + for _ in 0..100 { + match sample_range(&mut rng, Value::Real(2.0), Value::Real(5.0)) { + Ok(Value::Real(v)) => assert!((2.0..5.0).contains(&v)), + other => panic!("expected a Real, got {other:?}"), + } + } + } + + #[test] + fn sample_normal_matches_the_non_gaussian_quirk() { + // Pin the `u * mean + variance` quirk exactly. + let mut rng = StdRng::seed_from_u64(3); + let uniform = rng.random::(); + let mut rng = StdRng::seed_from_u64(3); + let sampled = sample_normal(&mut rng, Value::Real(10.0), Value::Real(1.0)); + assert_eq!(sampled, Ok(Value::Real(uniform * 10.0 + 1.0))); + } + + #[test] + fn sample_choice_selects_each_element() { + let mut rng = StdRng::seed_from_u64(1); + let mut seen = std::collections::HashSet::new(); + for _ in 0..200 { + seen.insert(rng.random_range(0..3usize)); + } + assert_eq!(seen, std::collections::HashSet::from([0, 1, 2])); + } +} diff --git a/crates/stark/src/eval/formula.rs b/crates/stark/src/eval/formula.rs new file mode 100644 index 00000000..d594fccf --- /dev/null +++ b/crates/stark/src/eval/formula.rs @@ -0,0 +1,252 @@ +//! ROBTL formulas: the top of the verification stack. A formula is checked +//! against *one* evolution sequence — the reference behaviour — and each +//! atomic proposition compares that sequence against a perturbed copy of +//! itself. +//! +//! Two semantics, both from the original: +//! +//! - [Analysis::check] — the **three-valued** semantics, the one the tool +//! uses by default. A verdict may be [TruthValue::Unknown] when the sample +//! size is too small to place the true distance on one side of the +//! threshold; this is statistical honesty, not a modelling gap, and it is +//! the reason the distance layer computes confidence intervals at all. +//! - [Analysis::check_boolean] — the **two-valued** semantics, which compares +//! point estimates only. It is cheaper (no bootstrap) and is what you want +//! when you have already decided the sample is large enough. +//! +//! Note the interval convention differs from [super::distance]'s: a formula's +//! `[from, to]` **includes** `to`, whereas a distance's excludes it. +//! Preserved as-is; see the distance module doc. + +use rand::Rng; + +use crate::ir::FormulaId; +use crate::ir::FormulaIr; +use crate::value::EvalError; + +use super::robust::Analysis; +use super::sequence::EvolutionSequence; + +/// A three-valued verdict. [TruthValue::Unknown] means the +/// samples were not conclusive, not that the formula is undefined. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TruthValue { + True, + False, + Unknown, +} + +impl TruthValue { + /// Kleene conjunction: `false` is absorbing, so an `Unknown` operand only + /// matters when the other one is not already decisive. + pub fn and(self, other: TruthValue) -> TruthValue { + match (self, other) { + (TruthValue::False, _) | (_, TruthValue::False) => TruthValue::False, + (TruthValue::True, TruthValue::True) => TruthValue::True, + _ => TruthValue::Unknown, + } + } + + /// Kleene disjunction — the dual of [TruthValue::and]. + pub fn or(self, other: TruthValue) -> TruthValue { + match (self, other) { + (TruthValue::True, _) | (_, TruthValue::True) => TruthValue::True, + (TruthValue::False, TruthValue::False) => TruthValue::False, + _ => TruthValue::Unknown, + } + } + + /// Kleene negation: `Unknown` is its own negation. Named to complete the + /// `and`/`or`/`not` trio rather than to mirror `std::ops::Not`, which + /// would force a `Not` impl for one call site. + #[expect(clippy::should_implement_trait, reason = "reads as the Kleene-logic trio and/or/not")] + pub fn not(self) -> TruthValue { + match self { + TruthValue::True => TruthValue::False, + TruthValue::False => TruthValue::True, + TruthValue::Unknown => TruthValue::Unknown, + } + } + + /// `1.0`/`0.0`/`-1.0`, for callers that want a numeric verdict. + pub fn as_f64(self) -> f64 { + match self { + TruthValue::True => 1.0, + TruthValue::Unknown => 0.0, + TruthValue::False => -1.0, + } + } +} + +impl From for TruthValue { + fn from(value: bool) -> TruthValue { + if value { TruthValue::True } else { TruthValue::False } + } +} + +impl Analysis<'_, R> { + /// Checks a formula against `sequence` at time `step`, under the + /// three-valued semantics. + pub fn check( + &mut self, + sequence: &mut EvolutionSequence, + step: usize, + id: FormulaId, + ) -> Result { + match self.program.formula(id).clone() { + FormulaIr::True => Ok(TruthValue::True), + FormulaIr::False => Ok(TruthValue::False), + FormulaIr::Reference(target) => self.check(sequence, step, target), + FormulaIr::Distance { + distance, + perturbation, + op, + value, + } => { + let threshold = self.constant(value)?; + let mut perturbed = self.perturb(sequence, perturbation, step)?; + let result = self.distance_ci(sequence, &mut perturbed, step, distance)?; + log::debug!( + "\\D at step {step}: distance {} in [{}, {}] against threshold {threshold}", + result.value, + result.lower, + result.upper + ); + // Undecidable exactly when the threshold falls strictly + // inside the confidence interval: the samples are consistent + // with the true distance being on either side of it. + if result.lower < threshold && threshold < result.upper { + Ok(TruthValue::Unknown) + } else { + Ok(TruthValue::from(op.compare(result.value, threshold))) + } + } + FormulaIr::Not(inner) => Ok(self.check(sequence, step, inner)?.not()), + FormulaIr::Globally { from, to, argument } => { + let (from, to) = self.interval(from, to, step)?; + let mut value = TruthValue::True; + // `to` is inclusive here, unlike a distance interval. + for i in from..=to { + value = value.and(self.check(sequence, i, argument)?); + // `false` is absorbing, so nothing later can change the + // verdict — the original short-circuits here too. + if value == TruthValue::False { + break; + } + } + Ok(value) + } + FormulaIr::Eventually { from, to, argument } => { + let (from, to) = self.interval(from, to, step)?; + let mut value = TruthValue::False; + for i in from..=to { + value = value.or(self.check(sequence, i, argument)?); + if value == TruthValue::True { + break; + } + } + Ok(value) + } + FormulaIr::And(left, right) => { + let left = self.check(sequence, step, left)?; + let right = self.check(sequence, step, right)?; + Ok(left.and(right)) + } + FormulaIr::Or(left, right) => { + let left = self.check(sequence, step, left)?; + let right = self.check(sequence, step, right)?; + Ok(left.or(right)) + } + FormulaIr::Until { from, to, left, right } => { + let (from, to) = self.interval(from, to, step)?; + // Walk forward while the left side still holds, looking for + // a point where the right side does. + // `left_value` accumulates the conjunction of the left side + // over everything seen so far. + let mut value = TruthValue::False; + let mut left_value = TruthValue::True; + for i in from..=to { + if value == TruthValue::True || left_value == TruthValue::False { + break; + } + value = left_value.and(self.check(sequence, i, right)?); + if value != TruthValue::True { + left_value = left_value.and(self.check(sequence, i, left)?); + } + } + Ok(value) + } + } + } + + /// Checks a formula under the two-valued semantics. Compares point + /// estimates, so it never needs the bootstrap and never answers + /// "unknown". + pub fn check_boolean( + &mut self, + sequence: &mut EvolutionSequence, + step: usize, + id: FormulaId, + ) -> Result { + match self.program.formula(id).clone() { + FormulaIr::True => Ok(true), + FormulaIr::False => Ok(false), + FormulaIr::Reference(target) => self.check_boolean(sequence, step, target), + FormulaIr::Distance { + distance, + perturbation, + op, + value, + } => { + let threshold = self.constant(value)?; + let mut perturbed = self.perturb(sequence, perturbation, step)?; + let result = self.distance(sequence, &mut perturbed, step, distance)?; + Ok(op.compare(result, threshold)) + } + FormulaIr::Not(inner) => Ok(!self.check_boolean(sequence, step, inner)?), + FormulaIr::Globally { from, to, argument } => { + let (from, to) = self.interval(from, to, step)?; + for i in from..=to { + if !self.check_boolean(sequence, i, argument)? { + return Ok(false); + } + } + Ok(true) + } + FormulaIr::Eventually { from, to, argument } => { + let (from, to) = self.interval(from, to, step)?; + for i in from..=to { + if self.check_boolean(sequence, i, argument)? { + return Ok(true); + } + } + Ok(false) + } + FormulaIr::And(left, right) => { + // Short-circuiting, as in the original. + Ok(self.check_boolean(sequence, step, left)? && self.check_boolean(sequence, step, right)?) + } + FormulaIr::Or(left, right) => { + Ok(self.check_boolean(sequence, step, left)? || self.check_boolean(sequence, step, right)?) + } + FormulaIr::Until { from, to, left, right } => { + let (from, to) = self.interval(from, to, step)?; + for i in from..=to { + if self.check_boolean(sequence, i, right)? { + let mut holds = true; + for j in from..i { + if !self.check_boolean(sequence, j, left)? { + holds = false; + break; + } + } + if holds { + return Ok(true); + } + } + } + Ok(false) + } + } + } +} diff --git a/crates/stark/src/eval/mod.rs b/crates/stark/src/eval/mod.rs new file mode 100644 index 00000000..36a1da13 --- /dev/null +++ b/crates/stark/src/eval/mod.rs @@ -0,0 +1,45 @@ +//! The evaluator: executes a checked, lowered [crate::ir::IrProgram] — +//! expression evaluation, function calls, sampling, and simulation stepping. +//! +//! ```text +//! parse -> resolve -> typecheck -> lower -> IrProgram -> [ evaluate ] +//! ``` +//! +//! The store (`Store`, one flat `Vec` indexed by [crate::ir::SlotId]), +//! the per-component controller cursor (`Cursor`), the sampled `SystemState` +//! and the `EvolutionSequence` of sample sets are internal implementation +//! details, not part of this module's public surface. +//! +//! There are two entry points, one per thing you can ask of a specification: +//! +//! - [Simulation] — *run* it. One trajectory, stepped on demand, states +//! pushed to an [Observer]. +//! - [Analysis] — *verify* it. Checks the specification's `formula` and +//! `distance` declarations by comparing an ensemble of trajectories against +//! a perturbed copy of itself, yielding a [TruthValue] (or a raw distance). +//! +//! Every entry point is fallible: evaluation returns `Result<_, EvalError>` +//! rather than propagating an absorbing error *value* the way the original +//! does — see `value.rs` for why. + +mod distance; +mod expr; +mod formula; +mod perturbation; +mod robust; +mod sequence; +mod sim; +mod step; +mod store; +mod system; + +pub use crate::value::EvalError; +pub use crate::value::EvalErrorKind; +pub use distance::Ci; +pub use formula::TruthValue; +pub use robust::Analysis; +pub use robust::AnalysisOptions; +pub use sequence::EvolutionSequence; +pub use sim::Observer; +pub use sim::RecordingObserver; +pub use sim::Simulation; diff --git a/crates/stark/src/eval/perturbation.rs b/crates/stark/src/eval/perturbation.rs new file mode 100644 index 00000000..7088041e --- /dev/null +++ b/crates/stark/src/eval/perturbation.rs @@ -0,0 +1,301 @@ +//! The perturbation coroutine: a value that, tick by tick, decides whether +//! the state it is attached to gets rewritten and how. The original's +//! three-method interface — "what is your effect *now*", "what are you +//! *next*", "are you done" — carries over verbatim, since that is the whole +//! semantics of a perturbation. +//! +//! Like [super::step]'s [Cursor](super::step::Cursor) replacing a recursive +//! tree of controller objects, [PerturbationState] replaces an object graph +//! of atomic, sequential, iterative and empty perturbations with one plain +//! enum: an atomic perturbation's *static* part (which slots, which value +//! expressions) stays in the [PerturbationIr] arena and is referenced by +//! [PerturbationId], so this value only carries what actually changes over +//! time — the countdowns. +//! +//! The original has two further cases, a delay wrapping a whole +//! sub-perturbation and an indefinitely repeating one, which the grammar +//! cannot produce; [PerturbationIr] matches the reachable subset exactly. +//! See `plan.md`. + +use rand::Rng; + +use crate::ir::IrProgram; +use crate::ir::PerturbationId; +use crate::ir::PerturbationIr; +use crate::value::EvalError; +use crate::value::EvalErrorKind; + +use super::expr::eval; +use super::store::Store; + +/// A perturbation's remaining schedule. Immutable: [PerturbationState::step] +/// returns the successor rather than mutating, as in the original. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum PerturbationState { + /// `nil`: no effect, self-loop, already done. + None, + /// `[..]@time`: fires `node`'s assignments once `after_steps` ticks have + /// elapsed. `node` is always a [PerturbationIr::Atomic]. + Atomic { after_steps: i64, node: PerturbationId }, + /// `a ; b`: `first` runs until it is done, then `second`. + Sequence(Box, Box), + /// `a ^ n`: `body`, repeated `replica` times. `body` is kept *pristine* + /// (never stepped), because each repetition is seeded from the original + /// body rather than from the previous repetition's remainder. + Iterative { replica: i64, body: Box }, +} + +impl PerturbationState { + /// Builds the initial schedule for a perturbation declaration. + /// + /// The `@time` and `^iterations` counts are [crate::ir::ExprRef]s in the + /// IR rather than folded constants, so they are evaluated here, once, at + /// construction — the same point the original evaluates them while + /// building the perturbation. `globals` is the store holding the + /// program's `const`/`param` slots, which is all such a bound can legally + /// refer to. + pub(crate) fn build( + program: &IrProgram, + globals: &mut Store, + rng: &mut R, + id: PerturbationId, + ) -> Result { + Ok(match program.perturbation(id) { + PerturbationIr::Nil => PerturbationState::None, + // A `Reference` is already resolved to the referent's root node, + // so following it is a plain recursion. The original shares one + // perturbation object between every reference to a declaration; + // building a fresh (equal) value per reference is equivalent, + // because a perturbation is immutable — stepping it returns a new + // one rather than mutating the shared instance. + PerturbationIr::Reference(target) => PerturbationState::build(program, globals, rng, *target)?, + PerturbationIr::Atomic { time, .. } => PerturbationState::Atomic { + after_steps: eval(program, globals, rng, *time)?.as_integer("the `@` time of a perturbation")?, + node: id, + }, + PerturbationIr::Sequence(left, right) => PerturbationState::Sequence( + Box::new(PerturbationState::build(program, globals, rng, *left)?), + Box::new(PerturbationState::build(program, globals, rng, *right)?), + ), + PerturbationIr::Iteration { argument, iterations } => PerturbationState::Iterative { + replica: eval(program, globals, rng, *iterations)? + .as_integer("the `^` iteration count of a perturbation")?, + body: Box::new(PerturbationState::build(program, globals, rng, *argument)?), + }, + }) + } + + /// The atomic node whose assignments fire on *this* tick, if any — + /// `Perturbation.effect()`. + pub(crate) fn effect(&self) -> Option { + match self { + PerturbationState::None => None, + PerturbationState::Atomic { after_steps, node } => (*after_steps <= 0).then_some(*node), + PerturbationState::Sequence(first, second) => { + if first.is_done() { + second.effect() + } else { + first.effect() + } + } + PerturbationState::Iterative { replica, body } => { + if *replica > 0 { + body.effect() + } else { + None + } + } + } + } + + /// The schedule for the next tick — `Perturbation.step()`. + pub(crate) fn step(self) -> PerturbationState { + match self { + PerturbationState::None => PerturbationState::None, + PerturbationState::Atomic { after_steps, node } => { + if after_steps <= 0 { + // An atomic perturbation fires exactly once. + PerturbationState::None + } else { + PerturbationState::Atomic { + after_steps: after_steps - 1, + node, + } + } + } + PerturbationState::Sequence(first, second) => { + if first.is_done() { + second.step() + } else { + PerturbationState::Sequence(Box::new(first.step()), second) + } + } + PerturbationState::Iterative { replica, body } => { + if replica > 0 { + // The current repetition advances one tick, with the + // remaining `replica - 1` repetitions queued behind it — + // each seeded from the *pristine* body, which is why + // `body` is never stepped in place. + PerturbationState::Sequence( + Box::new(body.as_ref().clone().step()), + Box::new(PerturbationState::Iterative { + replica: replica - 1, + body, + }), + ) + } else { + PerturbationState::None + } + } + } + } + + /// Whether this schedule can still produce an effect. Note an `Atomic` + /// is *never* done in the original, even once it has fired: only + /// stepping retires it (to `None`), and only a `Sequence` ever asks. + pub(crate) fn is_done(&self) -> bool { + match self { + PerturbationState::None => true, + PerturbationState::Atomic { .. } => false, + PerturbationState::Sequence(first, second) => first.is_done() && second.is_done(), + PerturbationState::Iterative { replica, .. } => *replica <= 0, + } + } +} + +/// Applies one atomic perturbation node's assignments to `store`. +/// +/// **Buffered**, like a controller assignment: every right-hand side is +/// evaluated against the pre-perturbation store before any of them is +/// written, matching the original, which materialises the whole update list +/// against the pre-perturbation state before applying it. +pub(crate) fn apply_effect( + program: &IrProgram, + store: &mut Store, + rng: &mut R, + node: PerturbationId, +) -> Result<(), EvalError> { + let PerturbationIr::Atomic { assignments, .. } = program.perturbation(node) else { + // `effect()` only ever returns the id of an `Atomic` node. + return Err(EvalErrorKind::Unreachable("a non-atomic perturbation produced an effect").into()); + }; + let mut values = Vec::with_capacity(assignments.len()); + for assignment in assignments { + values.push((assignment.target, eval(program, store, rng, assignment.value)?)); + } + for (target, value) in values { + store.set(target, value); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use rand::SeedableRng; + use rand::rngs::StdRng; + use test_log::test; + + use super::*; + use crate::StarkSpecification; + use crate::UntypedStarkSpecification; + + /// Builds the program's single perturbation declaration's initial state. + fn build(source: &str) -> (IrProgram, PerturbationState) { + let untyped = UntypedStarkSpecification::parse(source).expect("should parse"); + let spec = StarkSpecification::from_untyped(untyped).expect("should check"); + let program = IrProgram::from_spec(&spec).expect("should lower"); + let mut rng = StdRng::seed_from_u64(0); + let mut globals = Store::new(&program, &mut rng).expect("should initialise"); + let root = program.perturbation_decls().last().expect("a perturbation").root; + let state = PerturbationState::build(&program, &mut globals, &mut rng, root).expect("should build"); + (program, state) + } + + /// The sequence of ticks at which `state` produces an effect, over + /// `ticks` ticks — the observable behaviour of a schedule. + fn firing_ticks(mut state: PerturbationState, ticks: usize) -> Vec { + let mut fired = Vec::new(); + for tick in 0..ticks { + if state.effect().is_some() { + fired.push(tick); + } + state = state.step(); + } + fired + } + + const PREAMBLE: &str = r" + global variables { + int x = 0; + } + "; + + #[test] + fn an_atomic_perturbation_fires_once_at_its_time() { + let (_, state) = build(&format!("{PREAMBLE} perturbation p = [x <- 1]@3;")); + assert_eq!(firing_ticks(state, 8), vec![3]); + } + + #[test] + fn an_atomic_perturbation_at_time_zero_fires_immediately() { + let (_, state) = build(&format!("{PREAMBLE} perturbation p = [x <- 1]@0;")); + assert_eq!(firing_ticks(state, 5), vec![0]); + } + + #[test] + fn an_iteration_repeats_the_body_once_per_tick() { + // `[x <- 1]@0` fires immediately, so iterating it `3` times fires on + // three consecutive ticks — each repetition is queued behind the + // previous one's remainder. + let (_, state) = build(&format!("{PREAMBLE} perturbation p = ([x <- 1]@0)^3;")); + assert_eq!(firing_ticks(state, 8), vec![0, 1, 2]); + } + + #[test] + fn a_sequence_runs_the_second_only_after_the_first_is_done() { + let (_, state) = build(&format!("{PREAMBLE} perturbation p = [x <- 1]@1;[x <- 2]@2;")); + // The first fires at tick 1 and retires; the second's own `@2` + // countdown then starts from *there*, not from tick 0. + assert_eq!(firing_ticks(state, 10), vec![1, 4]); + } + + #[test] + fn nil_never_fires() { + let (_, state) = build(&format!("{PREAMBLE} perturbation p = nil;")); + assert_eq!(firing_ticks(state, 5), Vec::::new()); + assert!(PerturbationState::None.is_done()); + } + + #[test] + fn a_reference_behaves_like_the_declaration_it_names() { + let (_, referenced) = build(&format!( + "{PREAMBLE} perturbation base = [x <- 1]@2; perturbation p = base;" + )); + let (_, direct) = build(&format!("{PREAMBLE} perturbation p = [x <- 1]@2;")); + assert_eq!(firing_ticks(referenced, 6), firing_ticks(direct, 6)); + } + + #[test] + fn assignments_are_buffered_so_they_read_the_pre_perturbation_state() { + let source = r" + global variables { + int x = 1; + int y = 2; + } + perturbation swap = [x <- y, y <- x]@0; + "; + let untyped = UntypedStarkSpecification::parse(source).expect("should parse"); + let spec = StarkSpecification::from_untyped(untyped).expect("should check"); + let program = IrProgram::from_spec(&spec).expect("should lower"); + let mut rng = StdRng::seed_from_u64(0); + let mut store = Store::new(&program, &mut rng).expect("should initialise"); + let root = program.perturbation_decls()[0].root; + let state = PerturbationState::build(&program, &mut store.clone(), &mut rng, root).expect("should build"); + + let node = state.effect().expect("should fire at tick 0"); + apply_effect(&program, &mut store, &mut rng, node).expect("should apply"); + + use crate::value::Value; + assert_eq!(store.state_prefix(&program), &[Value::Integer(2), Value::Integer(1)]); + } +} diff --git a/crates/stark/src/eval/robust.rs b/crates/stark/src/eval/robust.rs new file mode 100644 index 00000000..84bea6dd --- /dev/null +++ b/crates/stark/src/eval/robust.rs @@ -0,0 +1,310 @@ +//! The public entry point for **robustness analysis**: checking a lowered +//! specification's `formula` and `distance` declarations against the system +//! it describes. +//! +//! Where [Simulation](super::Simulation) answers "what does one run of this +//! specification look like", [Analysis] answers the question the language +//! actually exists for: *how much does the system's behaviour change when the +//! environment is perturbed, and is that change within tolerance?* Concretely +//! it drives the three layers below it — +//! +//! ```text +//! formula \D[d, p] >= eta, \G, \F, \U, &&, ||, ! (super::formula) +//! | compares a distance against a threshold +//! distance < rho, \F, \G, \U, min, max, weights (super::distance) +//! | lifts a penalty to a pair of distributions +//! sequence a sample set per step, perturbed copies (super::sequence) +//! ``` +//! +//! # Why the sequence is a separate argument +//! +//! Every check takes the [EvolutionSequence] it runs against as an explicit +//! `&mut` parameter rather than [Analysis] owning it. That mirrors the +//! original, which passes the sequence into each check, and it is what lets +//! one analysis — one RNG stream, one set of options — be reused +//! across several sequences, and lets a sequence be reused across several +//! formulas without regenerating it. Generation is the expensive part, so +//! keeping it out of the analysis object is deliberate: checking five +//! formulas against one sequence samples the system once, not five times. +//! +//! # Reproducibility +//! +//! Everything stochastic — initial sampling, stepping, perturbation values, +//! and the bootstrap resampling — draws from the single RNG this object +//! owns, so a whole analysis is reproducible from its seed. As with +//! [Simulation](super::Simulation), the stream is **not** bit-compatible with +//! the original's; only the distributions match. + +use rand::Rng; +use rand::SeedableRng; +use rand::rngs::StdRng; + +use crate::ir::IrProgram; +use crate::ir::PerturbationId; +use crate::value::EvalError; + +use super::sequence::EvolutionSequence; +use super::store::Store; + +/// The statistical knobs of an analysis. The defaults are the original's: +/// 50 bootstrap replicas at a quantile of 1.96 (a 95% normal interval). +#[derive(Clone, Copy, Debug)] +pub struct AnalysisOptions { + /// Samples per step in the reference evolution sequence. + pub sample_size: usize, + /// How many perturbed samples are drawn per reference sample. The + /// perturbed sequence therefore holds `sample_size * scale` samples. + pub scale: usize, + /// Bootstrap replicas (`m`). Below `2` the confidence interval collapses + /// to the point estimate, which makes the three-valued semantics behave + /// like the two-valued one. + pub bootstrap_replicas: usize, + /// The standard-normal quantile (`z`) the confidence interval spans. + pub quantile: f64, +} + +impl Default for AnalysisOptions { + fn default() -> AnalysisOptions { + AnalysisOptions { + sample_size: 100, + scale: 1, + bootstrap_replicas: 50, + quantile: 1.96, + } + } +} + +/// A robustness analysis over one lowered program: the RNG stream, the +/// options, and the `const`/`param` store that interval bounds, thresholds +/// and perturbation timings are evaluated against. +/// +/// The interesting methods live in the sibling modules — [Analysis::check] +/// and [Analysis::check_boolean] in [super::formula], [Analysis::distance] in +/// [super::distance] — since each is a faithful port of one reference file +/// and reads better next to the doc comment explaining that file. +pub struct Analysis<'a, R: Rng> { + pub(crate) program: &'a IrProgram, + /// A store used only for program-level constants. + pub(crate) globals: Store, + pub(crate) rng: R, + pub(crate) options: AnalysisOptions, +} + +impl<'a> Analysis<'a, StdRng> { + /// Builds an analysis seeded from a `u64`, for reproducibility. + pub fn new(program: &'a IrProgram, seed: u64, options: AnalysisOptions) -> Result, EvalError> { + Analysis::with_rng(program, StdRng::seed_from_u64(seed), options) + } +} + +impl<'a, R: Rng> Analysis<'a, R> { + /// Builds an analysis from an already-constructed RNG — the seam a test + /// uses to inject a deterministic generator. + pub fn with_rng( + program: &'a IrProgram, + mut rng: R, + options: AnalysisOptions, + ) -> Result, EvalError> { + let globals = Store::new(program, &mut rng)?; + Ok(Analysis { + program, + globals, + rng, + options, + }) + } + + /// Samples a fresh reference evolution sequence of + /// [AnalysisOptions::sample_size] trajectories, to check formulas + /// against. + pub fn sample(&mut self) -> Result { + EvolutionSequence::generate(self.program, &mut self.rng, self.options.sample_size) + } + + /// The distance between `sequence` and a copy of it perturbed from + /// `step` onwards — the value an atomic formula compares against its + /// threshold, exposed on its own so a caller can report *how far off* a + /// system is rather than only whether it passed. + pub fn distance_under( + &mut self, + sequence: &mut EvolutionSequence, + step: usize, + distance: crate::ir::DistanceId, + perturbation: PerturbationId, + ) -> Result { + let mut perturbed = self.perturb(sequence, perturbation, step)?; + self.distance(sequence, &mut perturbed, step, distance) + } + + /// Builds the perturbed counterpart of `sequence`. + pub(crate) fn perturb( + &mut self, + sequence: &mut EvolutionSequence, + perturbation: PerturbationId, + step: usize, + ) -> Result { + sequence.perturbed( + self.program, + &mut self.globals, + &mut self.rng, + perturbation, + step, + self.options.scale, + ) + } +} + +#[cfg(test)] +mod tests { + use test_log::test; + + use super::*; + use crate::StarkSpecification; + use crate::UntypedStarkSpecification; + use crate::eval::TruthValue; + + fn build(source: &str) -> IrProgram { + let spec = UntypedStarkSpecification::parse(source).expect("should parse"); + + let spec = StarkSpecification::from_untyped(spec).expect("should check"); + IrProgram::from_spec(&spec).expect("should lower") + } + + /// A deterministic system whose single variable holds still, with a + /// perturbation that shifts it by a known amount. Because nothing + /// samples, every trajectory is identical and the distance is exactly + /// the shift — which makes the expected values below arithmetic rather + /// than statistical. + const SHIFT: &str = r" + global variables { + real x = 1.0; + } + environment { + x' = x; + } + penalty rho = x + distance d = < rho; + perturbation shift = [x <- x + 0.25]@0; + "; + + fn analysis(program: &IrProgram) -> Analysis<'_, StdRng> { + Analysis::new( + program, + 0, + AnalysisOptions { + sample_size: 4, + ..AnalysisOptions::default() + }, + ) + .expect("should initialise") + } + + #[test] + fn an_atomic_distance_measures_the_perturbations_shift() { + let program = build(SHIFT); + let mut analysis = analysis(&program); + let mut sequence = analysis.sample().expect("should sample"); + + let distance = analysis + .distance_under( + &mut sequence, + 0, + program.distance_decls()[0].root, + program.perturbation_decls()[0].root, + ) + .expect("should compute"); + assert_eq!(distance, 0.25); + } + + #[test] + fn an_unperturbed_sequence_is_at_distance_zero_from_itself() { + let program = build(&format!("{SHIFT} perturbation nothing = nil;")); + let mut analysis = analysis(&program); + let mut sequence = analysis.sample().expect("should sample"); + + let distance = analysis + .distance_under( + &mut sequence, + 0, + program.distance_decls()[0].root, + program.perturbation_decls()[1].root, + ) + .expect("should compute"); + assert_eq!(distance, 0.0); + } + + #[test] + fn a_threshold_formula_is_decided_when_the_system_is_deterministic() { + // Deterministic system => every bootstrap resample gives the same + // distance => a zero-width confidence interval => never `Unknown`. + let program = build(&format!("{SHIFT} formula within = \\D[d,shift] <= 0.5;")); + let mut analysis = analysis(&program); + let mut sequence = analysis.sample().expect("should sample"); + + let verdict = analysis + .check(&mut sequence, 0, program.formula_decls()[0].root) + .expect("should check"); + assert_eq!(verdict, TruthValue::True); + } + + #[test] + fn a_threshold_formula_fails_when_the_shift_exceeds_it() { + let program = build(&format!("{SHIFT} formula within = \\D[d,shift] <= 0.1;")); + let mut analysis = analysis(&program); + let mut sequence = analysis.sample().expect("should sample"); + + assert_eq!( + analysis.check(&mut sequence, 0, program.formula_decls()[0].root), + Ok(TruthValue::False) + ); + assert_eq!( + analysis.check_boolean(&mut sequence, 0, program.formula_decls()[0].root), + Ok(false) + ); + } + + #[test] + fn both_semantics_agree_on_a_deterministic_system() { + let program = build(&format!("{SHIFT} formula within = \\D[d,shift] <= 0.5;")); + let mut analysis = analysis(&program); + let mut sequence = analysis.sample().expect("should sample"); + let root = program.formula_decls()[0].root; + + let three_valued = analysis.check(&mut sequence, 0, root).expect("should check"); + let boolean = analysis.check_boolean(&mut sequence, 0, root).expect("should check"); + assert_eq!(three_valued, TruthValue::from(boolean)); + } + + #[test] + fn the_same_seed_reproduces_the_same_verdict() { + // A genuinely stochastic system, so the verdict depends on the RNG + // stream — including the bootstrap resampling — end to end. Checked + // at step 1, since the samples only diverge after a step has been + // taken (`typecheck.rs` forbids sampling in an initializer). + let program = build( + r" + global variables { + real x = 0.0; + } + environment { + x' = R[0,1]; + } + penalty rho = x + distance d = < rho; + perturbation shift = [x <- x + 0.25]@0; + formula within = \D[d,shift] <= 0.5; + ", + ); + + let verdicts: Vec<_> = (0..2) + .map(|_| { + let mut analysis = analysis(&program); + let mut sequence = analysis.sample().expect("should sample"); + analysis + .check(&mut sequence, 1, program.formula_decls()[0].root) + .expect("should check") + }) + .collect(); + assert_eq!(verdicts[0], verdicts[1]); + } +} diff --git a/crates/stark/src/eval/sequence.rs b/crates/stark/src/eval/sequence.rs new file mode 100644 index 00000000..0a59f3d4 --- /dev/null +++ b/crates/stark/src/eval/sequence.rs @@ -0,0 +1,389 @@ +use rand::Rng; + +use crate::ir::IrProgram; +use crate::ir::PenaltyId; +use crate::ir::PerturbationId; +use crate::value::EvalError; +use crate::value::EvalErrorKind; +use crate::value::Value; + +use super::expr::eval; +use super::perturbation::PerturbationState; +use super::perturbation::apply_effect; +use super::store::Store; +use super::system::SystemState; + +/// A sequence of sample sets, one per time step, extended on demand. +/// +/// A *perturbed* sequence additionally carries the [PerturbationState] it is +/// being rewritten by. The original models this as a subclass; keeping it as +/// an `Option` field instead means there is only one generation path (see +/// [EvolutionSequence::generate_next]). +/// +/// Evolution sequences and sample sets — the stochastic counterpart of +/// [super::sim]'s single trajectory, and what every distance and ROBTL +/// formula is actually evaluated over. +/// +/// Because the language is stochastic, "the state at time `t`" is not one +/// state but a *distribution*, approximated by `size` independently sampled +/// [SystemState]s — a *sample set*. An [EvolutionSequence] is the sequence of +/// those sample sets, generated lazily: [EvolutionSequence::generate_up_to] +/// extends it on demand. +/// +/// Two sequences (a reference one and a perturbed one) are compared by +/// lifting a *penalty function* — a `real`-valued expression over a state — +/// to distributions. The lifting is the Wasserstein distance between the two +/// sampled distributions of penalty values, computed from the sorted arrays +/// by [wasserstein]. +#[derive(Clone, Debug)] +pub struct EvolutionSequence { + /// `steps[t]` is the sample set at time `t`; always non-empty (`steps[0]` + /// is the initial distribution). + steps: Vec>, + /// `None` for an unperturbed sequence. + perturbation: Option, +} + +impl EvolutionSequence { + /// Samples `size` independent initial states. + pub(crate) fn generate( + program: &IrProgram, + rng: &mut R, + size: usize, + ) -> Result { + if size == 0 { + return Err(EvalErrorKind::EmptySampleSet.into()); + } + let mut initial = Vec::with_capacity(size); + for _ in 0..size { + initial.push(SystemState::new(program, rng)?); + } + log::debug!("generated an initial sample set of {size} states"); + Ok(EvolutionSequence { + steps: vec![initial], + perturbation: None, + }) + } + + /// The number of samples in the initial sample set. A perturbed + /// sequence's sample sets are `scale` times larger + /// *from the perturbed step onwards*, but its shared history (including + /// step 0, which this reads) keeps the original size. + pub fn size(&self) -> usize { + self.steps[0].len() + } + + /// The `[0, n_variables)` state of every sample at step `t`, generating + /// the sequence that far if necessary — the sampled distribution itself, + /// for a caller that wants to plot or export it rather than only ask a + /// formula about it. + pub fn states( + &mut self, + program: &IrProgram, + rng: &mut R, + t: usize, + ) -> Result, EvalError> { + self.generate_up_to(program, rng, t)?; + Ok(self.steps[t].iter().map(|state| state.variables(program)).collect()) + } + + /// The last time step generated so far. + fn last_generated_step(&self) -> usize { + self.steps.len() - 1 + } + + /// Extends the sequence so that step `n` exists. + pub(crate) fn generate_up_to( + &mut self, + program: &IrProgram, + rng: &mut R, + n: usize, + ) -> Result<(), EvalError> { + while self.last_generated_step() < n { + let next = self.generate_next(program, rng)?; + self.steps.push(next); + } + Ok(()) + } + + /// One step of every sample. For a perturbed sequence the perturbation + /// advances *before* generating and its resulting effect is applied + /// *after*, as in the original. + fn generate_next( + &mut self, + program: &IrProgram, + rng: &mut R, + ) -> Result, EvalError> { + if let Some(perturbation) = self.perturbation.take() { + self.perturbation = Some(perturbation.step()); + } + let mut next = self.steps[self.last_generated_step()].clone(); + for state in &mut next { + state.sample_next(program, rng)?; + } + self.apply_perturbation_effect(program, rng, &mut next)?; + log::trace!("generated sample set for step {}", self.steps.len()); + Ok(next) + } + + /// Rewrites every sample with the perturbation's current effect, if it + /// has one this tick. + fn apply_perturbation_effect( + &self, + program: &IrProgram, + rng: &mut R, + sample: &mut [SystemState], + ) -> Result<(), EvalError> { + let Some(node) = self.perturbation.as_ref().and_then(PerturbationState::effect) else { + return Ok(()); + }; + for state in sample { + apply_effect(program, &mut state.store, rng, node)?; + } + Ok(()) + } + + /// The sequence obtained by perturbing this one from step `step` + /// onwards. + /// + /// The result **shares this sequence's history** up to `step - 1` (a copy + /// here, where the original shares immutable sample sets) and re-samples + /// from there: at `step` itself it holds this sequence's sample set + /// replicated `scale` times, already perturbed. Replication is what makes + /// the perturbed distribution `scale` times finer-grained than the + /// reference one while still being paired with it sample-for-sample — + /// which is the pairing [wasserstein] relies on. + pub(crate) fn perturbed( + &mut self, + program: &IrProgram, + globals: &mut Store, + rng: &mut R, + id: PerturbationId, + step: usize, + scale: usize, + ) -> Result { + self.generate_up_to(program, rng, step)?; + let perturbation = PerturbationState::build(program, globals, rng, id)?; + + // The history strictly before the perturbed step, empty when + // `step == 0`. + let mut steps: Vec> = self.steps[0..step].to_vec(); + + let mut perturbed = EvolutionSequence { + // Placeholder: `apply_perturbation_effect` only reads + // `self.perturbation`, and `steps` is filled in just below. + steps: Vec::new(), + perturbation: Some(perturbation), + }; + let mut sample: Vec = self.steps[step] + .iter() + .flat_map(|state| std::iter::repeat_n(state, scale)) + .cloned() + .collect(); + perturbed.apply_perturbation_effect(program, rng, &mut sample)?; + + steps.push(sample); + perturbed.steps = steps; + Ok(perturbed) + } + + /// Evaluates a penalty function on every sample at step `t`, returning + /// the values **sorted ascending**. + /// The sort is what makes the two arrays comparable index-by-index in + /// [wasserstein]: pairing the `i`-th smallest with the `i`-th smallest is + /// the optimal transport plan on the real line. + pub(crate) fn eval_penalty( + &mut self, + program: &IrProgram, + rng: &mut R, + penalty: PenaltyId, + t: usize, + ) -> Result, EvalError> { + self.generate_up_to(program, rng, t)?; + let expression = program.penalty(penalty).value; + let mut values = Vec::with_capacity(self.steps[t].len()); + for state in &mut self.steps[t] { + // `eval` takes the store mutably because a call or a `let` writes + // its scratch slots; those are outside the `[0, n_variables)` + // state prefix, so evaluating a penalty cannot disturb the sample. + values.push(eval(program, &mut state.store, rng, expression)?.as_f64("a penalty function")?); + } + values.sort_by(f64::total_cmp); + Ok(values) + } +} + +/// The Wasserstein lifting of a ground distance on reals to the two sampled +/// distributions `reference` and `perturbed`. +/// +/// Both arrays must be sorted, and `perturbed.len()` must be a multiple `k` +/// of `reference.len()` (it is `k = scale` replicas, by construction in +/// [EvolutionSequence::perturbed]): the `i`-th reference sample is paired +/// with the `k` perturbed samples that descend from it, and the ground +/// distance is averaged over all `perturbed.len()` pairs. +pub(crate) fn wasserstein(ground: fn(f64, f64) -> f64, reference: &[f64], perturbed: &[f64]) -> Result { + if reference.is_empty() || !perturbed.len().is_multiple_of(reference.len()) { + return Err(EvalErrorKind::IncompatibleSampleSizes { + reference: reference.len(), + perturbed: perturbed.len(), + } + .into()); + } + let k = perturbed.len() / reference.len(); + let mut total = 0.0; + for (i, &left) in reference.iter().enumerate() { + for &right in &perturbed[i * k..(i + 1) * k] { + total += ground(left, right); + } + } + Ok(total / perturbed.len() as f64) +} + +/// The ground distance behind `< penalty` — asymmetric, penalising only +/// the perturbed value being *larger*. This is what `< penalty` (an +/// [crate::ir::DistanceIr::AtomicLeft]) asks for: "how much does perturbing +/// push the penalty up". +pub(crate) fn ground_leq(reference: f64, perturbed: f64) -> f64 { + (perturbed - reference).max(0.0) +} + +/// The mirror of [ground_leq], behind `> penalty`. +pub(crate) fn ground_geq(reference: f64, perturbed: f64) -> f64 { + (reference - perturbed).max(0.0) +} + +#[cfg(test)] +mod tests { + use rand::SeedableRng; + use rand::rngs::StdRng; + use test_log::test; + + use super::*; + use crate::StarkSpecification; + use crate::UntypedStarkSpecification; + + fn build(source: &str) -> IrProgram { + let spec = UntypedStarkSpecification::parse(source) + .expect("should parse"); + + let typed_spec = StarkSpecification::from_untyped(spec) + .expect("should check"); + + IrProgram::from_spec(&typed_spec).expect("should lower") + } + + const COUNTER: &str = r" + global variables { + int x = 0; + } + environment { + x' = x + 1; + } + "; + + #[test] + fn a_sequence_generates_lazily_and_advances_every_sample() { + let program = build(COUNTER); + let mut rng = StdRng::seed_from_u64(0); + let mut sequence = EvolutionSequence::generate(&program, &mut rng, 4).expect("should generate"); + assert_eq!(sequence.last_generated_step(), 0); + + let sample = sequence.states(&program, &mut rng, 3).expect("should generate up to 3"); + assert_eq!(sample.len(), 4); + for state in sample { + assert_eq!(state, &[Value::Integer(3)]); + } + assert_eq!(sequence.last_generated_step(), 3); + } + + #[test] + fn a_perturbed_sequence_shares_history_and_diverges_from_the_perturbed_step() { + let program = build(&format!("{COUNTER} perturbation bump = [x <- x + 100]@0;")); + let mut rng = StdRng::seed_from_u64(0); + let mut globals = Store::new(&program, &mut rng).expect("should initialise"); + let mut reference = EvolutionSequence::generate(&program, &mut rng, 2).expect("should generate"); + let root = program.perturbation_decls()[0].root; + + let mut perturbed = reference + .perturbed(&program, &mut globals, &mut rng, root, 2, 3) + .expect("should perturb"); + + // Shared history: step 1 is identical, and the perturbation only + // takes effect from step 2. + assert_eq!( + perturbed.states(&program, &mut rng, 1).expect("step 1")[0], + &[Value::Integer(1)] + ); + // Replicated `scale = 3` times, and perturbed at the step itself. + let at_step = perturbed.states(&program, &mut rng, 2).expect("step 2"); + assert_eq!(at_step.len(), 6); + assert_eq!(at_step[0], &[Value::Integer(102)]); + // Step 0 is shared history at the original, unreplicated size. + assert_eq!(perturbed.size(), 2); + // The atomic perturbation fires once, so the offset persists but is + // not re-applied. + assert_eq!( + perturbed.states(&program, &mut rng, 3).expect("step 3")[0], + &[Value::Integer(103)] + ); + assert_eq!( + reference.states(&program, &mut rng, 3).expect("step 3")[0], + &[Value::Integer(3)] + ); + } + + #[test] + fn wasserstein_averages_the_ground_distance_over_every_pair() { + // 1 reference sample against 2 perturbed replicas: (|3-1| + |5-1|)/2. + let distance = wasserstein(|a, b| (b - a).abs(), &[1.0], &[3.0, 5.0]).expect("should compute"); + assert_eq!(distance, 3.0); + } + + #[test] + fn wasserstein_rejects_incommensurable_sample_sizes() { + assert_eq!( + wasserstein(|a, b| (b - a).abs(), &[1.0, 2.0], &[3.0, 4.0, 5.0]), + Err(EvalErrorKind::IncompatibleSampleSizes { + reference: 2, + perturbed: 3 + } + .into()) + ); + } + + #[test] + fn the_asymmetric_ground_distances_only_penalise_one_direction() { + assert_eq!(ground_leq(1.0, 3.0), 2.0); + assert_eq!(ground_leq(3.0, 1.0), 0.0); + assert_eq!(ground_geq(1.0, 3.0), 0.0); + assert_eq!(ground_geq(3.0, 1.0), 2.0); + } + + #[test] + fn a_penalty_is_evaluated_on_every_sample_and_returned_sorted() { + // `typecheck.rs` forbids sampling in a variable's initializer, so the + // samples only diverge once a step has been taken — hence step `1` + // rather than `0`. + let program = build( + r" + global variables { + real x = 0.0; + } + environment { + x' = R[0,10]; + } + penalty rho = x + ", + ); + let mut rng = StdRng::seed_from_u64(7); + let mut sequence = EvolutionSequence::generate(&program, &mut rng, 5).expect("should generate"); + let values = sequence + .eval_penalty(&program, &mut rng, PenaltyId::new(0), 1) + .expect("should evaluate"); + + assert_eq!(values.len(), 5); + assert!(values.is_sorted()); + // The samples are genuinely independent draws, not one value copied. + assert!(values[0] < values[4]); + } +} diff --git a/crates/stark/src/eval/sim.rs b/crates/stark/src/eval/sim.rs new file mode 100644 index 00000000..6d22ed7d --- /dev/null +++ b/crates/stark/src/eval/sim.rs @@ -0,0 +1,236 @@ +//! The public entry point for running a specification. [Simulation] owns the +//! store and every component's controller cursor and steps the whole system +//! one macro-step at a time (see `eval::step`'s doc comment for the exact +//! per-step ordering). +//! +//! Deliberately **push-based**: [Simulation::run] takes an [Observer] and +//! calls it after every step, rather than building an eager +//! `Vec>` trajectory. A caller can stop early, aggregate on the +//! fly, or drive an ensemble of independently-seeded [Simulation]s without +//! [Simulation] itself needing to change: an ensemble driver is just "N +//! `Simulation`s, one `Observer` that collects across them", built on top of +//! this rather than into it. + +use rand::Rng; +use rand::SeedableRng; +use rand::rngs::StdRng; + +use crate::ir::IrProgram; +use crate::value::EvalError; +use crate::value::Value; + +use super::system::SystemState; + +/// Notified after every macro-step (see [Simulation::run]). +pub trait Observer { + /// `step` is the number of macro-steps taken so far (`1` after the + /// first); `state` is the `[0, n_variables)` state prefix — exactly what + /// an evolution sequence checkpoints per sample. + fn on_step(&mut self, step: u64, state: &[Value]); +} + +/// An [Observer] that records every state it's given — the eager +/// `Vec>` trajectory, for callers that do want the whole thing +/// materialised (most tests, small examples) rather than driving the push +/// callback themselves. +#[derive(Default)] +pub struct RecordingObserver { + pub trajectory: Vec>, +} + +impl Observer for RecordingObserver { + fn on_step(&mut self, _step: u64, state: &[Value]) { + self.trajectory.push(state.to_vec()); + } +} + +/// A running instance of a checked, lowered specification: the store, every +/// component's controller cursor, the step counter, and the RNG stream — +/// minus the controller/environment indirection that lowering already +/// collapsed into `program`. +pub struct Simulation<'a, R: Rng> { + program: &'a IrProgram, + state: SystemState, + rng: R, + step: u64, +} + +impl<'a> Simulation<'a, StdRng> { + /// Builds a simulation seeded from a `u64`, for reproducibility. + /// **Not** bit-compatible with the original's random stream — a + /// different PRNG makes that infeasible, so only the *distributions* + /// match. This port's own stream is reproducible from this seed, which + /// is what matters for regression tests and for building an ensemble + /// from independent substreams later. + pub fn new(program: &'a IrProgram, seed: u64) -> Result, EvalError> { + Simulation::with_rng(program, StdRng::seed_from_u64(seed)) + } +} + +impl<'a, R: Rng> Simulation<'a, R> { + /// Builds a simulation from an already-constructed RNG — the seam a test + /// uses to inject a deterministic/scripted generator. + /// + /// Fails if evaluating a `const`/`param` or a variable's initial value + /// fails (e.g. a `const` that divides by zero), since there is no valid + /// initial state to run from in that case. + pub fn with_rng(program: &'a IrProgram, mut rng: R) -> Result, EvalError> { + let state = SystemState::new(program, &mut rng)?; + log::debug!( + "initialised a simulation over {} slots, {} of them variables", + program.n_slots(), + program.n_variables() + ); + Ok(Simulation { + program, + state, + rng, + step: 0, + }) + } + + /// The current `[0, n_variables)` state prefix. + pub fn state(&self) -> &[Value] { + self.state.variables(self.program) + } + + /// The number of macro-steps taken so far. + pub fn step_count(&self) -> u64 { + self.step + } + + /// Runs one macro-step — see `eval::step`'s doc comment for the exact + /// controller-then-environment ordering. + /// + /// On an [EvalError] the step counter does not advance and the state is + /// left as it was before the step, so a caller that wants to report the + /// failure can still inspect [Simulation::state] for the state that + /// triggered it. + pub fn step(&mut self) -> Result<(), EvalError> { + self.state.sample_next(self.program, &mut self.rng)?; + self.step += 1; + log::trace!("step {}: {:?}", self.step, self.state()); + Ok(()) + } + + /// Runs `steps` macro-steps, calling `observer.on_step` after each one. + /// Push-based rather than returning a trajectory, so a caller can stop + /// early or aggregate incrementally instead of paying for an eagerly + /// collected `Vec` it may not fully need — see the module doc comment. + /// + /// Stops at the first failing step and returns its [EvalError]; the + /// observer has already been called for every step that did succeed. + pub fn run(&mut self, steps: u64, observer: &mut impl Observer) -> Result<(), EvalError> { + for _ in 0..steps { + self.step()?; + observer.on_step(self.step, self.state()); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use test_log::test; + + use super::*; + use crate::UntypedStarkSpecification; + + fn build(source: &str) -> IrProgram { + let spec = UntypedStarkSpecification::parse(source) + .expect("should parse"); + + let typed_spec = crate::StarkSpecification::from_untyped(spec) + .expect("should check"); + IrProgram::from_spec(&typed_spec).expect("should lower") + } + + #[test] + fn run_pushes_one_state_per_step_to_the_observer() { + let program = build( + r" + global variables { + int x = 0; + } + environment { + x' = x + 1; + } + ", + ); + let mut simulation = Simulation::new(&program, 0).expect("should initialise"); + let mut observer = RecordingObserver::default(); + simulation.run(5, &mut observer).expect("should run"); + + assert_eq!( + observer.trajectory, + vec![ + vec![Value::Integer(1)], + vec![Value::Integer(2)], + vec![Value::Integer(3)], + vec![Value::Integer(4)], + vec![Value::Integer(5)], + ] + ); + assert_eq!(simulation.step_count(), 5); + assert_eq!(simulation.state(), &[Value::Integer(5)]); + } + + #[test] + fn same_seed_is_deterministic() { + let program = build( + r" + global variables { + real x = 0.0; + } + environment { + x' = R; + } + ", + ); + let mut a = Simulation::new(&program, 123).expect("should initialise"); + let mut b = Simulation::new(&program, 123).expect("should initialise"); + for _ in 0..10 { + a.step().expect("should step"); + b.step().expect("should step"); + } + assert_eq!(a.state(), b.state()); + } + + #[test] + fn different_seeds_diverge() { + let program = build( + r" + global variables { + real x = 0.0; + } + environment { + x' = R; + } + ", + ); + let mut a = Simulation::new(&program, 1).expect("should initialise"); + let mut b = Simulation::new(&program, 2).expect("should initialise"); + a.step().expect("should step"); + b.step().expect("should step"); + assert_ne!(a.state(), b.state()); + } + + #[test] + fn observer_can_stop_early_by_running_fewer_steps() { + let program = build( + r" + global variables { + int x = 0; + } + environment { + x' = x + 1; + } + ", + ); + let mut simulation = Simulation::new(&program, 0).expect("should initialise"); + let mut observer = RecordingObserver::default(); + simulation.run(2, &mut observer).expect("should run"); + assert_eq!(observer.trajectory.len(), 2); + assert_eq!(simulation.state(), &[Value::Integer(2)]); + } +} diff --git a/crates/stark/src/eval/step.rs b/crates/stark/src/eval/step.rs new file mode 100644 index 00000000..f24b4aa8 --- /dev/null +++ b/crates/stark/src/eval/step.rs @@ -0,0 +1,482 @@ +//! One macro-step for the whole system: every component's controller cursor +//! advances, its buffered updates are applied, then the environment runs +//! against the post-controller state and its own updates are applied. +//! That ordering — controller, then apply, then environment, then apply — is +//! the original's, and the two apply points are what make assignments read +//! the pre-phase state (see [PendingUpdate]). +//! +//! [Cursor] replaces the original's recursive tree of controller objects with +//! a plain value: a controller's entire "next state" is exactly "which named +//! state, and how many ticks left before it's live", the idle count being the +//! only thing that tree actually threads from one tick to the next. Walking a +//! state's body is one flat recursion over [CommandNode], since lowering +//! already collapsed the controller AST into that arena. + +use rand::Rng; + +use crate::ir::CommandNode; +use crate::ir::CommandRef; +use crate::ir::IrProgram; +use crate::ir::IrStateId; +use crate::ir::SlotId; +use crate::value::EvalError; +use crate::value::Value; + +use super::expr::eval; +use super::store::Store; + +/// One component's continuation between macro-steps. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Cursor { + /// The component ran off the end of a body with no `step`/`exec`: no + /// effect, self-loop, forever. + Nil, + /// Live in `state` this tick; walk its body now. + Run(IrStateId), + /// Idling: `remaining` more ticks with no effect, then `Run(target)`. + /// `remaining` is always `>= 1` by construction (see [Walk::Transitioned] + /// at `CommandNode::Step`, which produces `Cursor::Run` directly for a + /// zero-or-negative step count). + Idle { remaining: u32, target: IrStateId }, +} + +/// A buffered `target' = value` reached while walking a command tree. +/// Pushed into a list during the walk and applied only once the whole step +/// (controller or environment) has run — see [CommandNode]'s doc comment on +/// why updates must not write through immediately. +#[derive(Clone, Copy, Debug)] +struct PendingUpdate { + target: SlotId, + value: Value, +} + +/// The outcome of walking one command subtree for the current tick. +enum Walk { + /// Fell off the end with no `step`/`exec` — an enclosing `Sequence` + /// should keep walking its next sibling, if any. + FellThrough, + /// Hit a transition; this component's tick is over. Carries the cursor + /// to use starting the *next* tick. + Transitioned(Cursor), +} + +/// Runs one macro-step: every component's cursor advances (all reading the +/// same pre-step state — their updates are buffered and only applied once +/// every cursor has run, matching the original's parallel composition, which +/// concatenates both sides' effects before the single apply), then the +/// environment runs against the post-controller state. +/// +/// A failing evaluation anywhere in the step aborts the whole step with that +/// [EvalError] — the buffered updates from the failed phase are dropped rather +/// than half-applied, so `store` is left holding the last state that was +/// computed successfully. +pub(crate) fn macro_step( + program: &IrProgram, + store: &mut Store, + rng: &mut R, + cursors: &mut [Cursor], +) -> Result<(), EvalError> { + let budget = total_states(program); + let mut updates = Vec::new(); + for cursor in cursors.iter_mut() { + let mut exec_budget = budget; + *cursor = run_component(program, store, rng, &mut updates, &mut exec_budget, *cursor)?; + } + apply_updates(store, &updates); + + if let Some(environment) = program.environment() { + let mut env_updates = Vec::new(); + // The environment never contains `Step`/`Exec` (see `CommandNode`'s + // doc comment), so no budget should ever be spent; 0 is a defensive + // fallback that still can't panic or loop if that invariant is ever + // violated by a malformed IR. + let mut exec_budget = 0; + run_command(program, store, rng, &mut env_updates, &mut exec_budget, environment)?; + apply_updates(store, &env_updates); + } + Ok(()) +} + +fn apply_updates(store: &mut Store, updates: &[PendingUpdate]) { + for update in updates { + store.set(update.target, update.value); + } +} + +/// The total number of controller states across every component — an exec +/// chain can visit each state at most once without repeating, so this bounds +/// how many same-tick `exec` hops [run_command] will follow before +/// concluding the specification has an `exec` cycle with no intervening +/// `step` (which would otherwise recurse forever) and forcibly ending the +/// component's tick instead of overflowing the stack. +fn total_states(program: &IrProgram) -> u32 { + program + .components() + .iter() + .map(|component| component.states.len() as u32) + .sum() +} + +fn run_component( + program: &IrProgram, + store: &mut Store, + rng: &mut R, + updates: &mut Vec, + exec_budget: &mut u32, + cursor: Cursor, +) -> Result { + match cursor { + Cursor::Nil => Ok(Cursor::Nil), + Cursor::Idle { remaining, target } => { + if remaining <= 1 { + Ok(Cursor::Run(target)) + } else { + Ok(Cursor::Idle { + remaining: remaining - 1, + target, + }) + } + } + Cursor::Run(state) => match program.state(state).body { + Some(body) => match run_command(program, store, rng, updates, exec_budget, body)? { + Walk::FellThrough => Ok(Cursor::Nil), + Walk::Transitioned(next) => Ok(next), + }, + None => Ok(Cursor::Nil), + }, + } +} + +fn run_command( + program: &IrProgram, + store: &mut Store, + rng: &mut R, + updates: &mut Vec, + exec_budget: &mut u32, + id: CommandRef, +) -> Result { + match *program.command(id) { + CommandNode::Assign(update) => { + // A missing guard is unconditionally true. A *non-boolean* guard + // is now an error: the original mapped it (and a failed + // evaluation) to `false`, so an assignment whose guard divided by + // zero silently didn't happen — see `Value::as_boolean`. + let guarded = match update.guard { + Some(guard) => eval(program, store, rng, guard)?.as_boolean("the guard of an assignment")?, + None => true, + }; + if guarded { + let value = eval(program, store, rng, update.value)?; + updates.push(PendingUpdate { + target: update.target, + value, + }); + } + Ok(Walk::FellThrough) + } + CommandNode::IfThenElse { + guard, + then_branch, + else_branch, + } => { + let branch = if eval(program, store, rng, guard)?.as_boolean("the condition of an `if` command")? { + then_branch + } else { + else_branch + }; + match branch { + Some(branch) => run_command(program, store, rng, updates, exec_budget, branch), + None => Ok(Walk::FellThrough), + } + } + CommandNode::Let { slot, value, body } => { + let value = eval(program, store, rng, value)?; + store.set(slot, value); + match body { + Some(body) => run_command(program, store, rng, updates, exec_budget, body), + None => Ok(Walk::FellThrough), + } + } + CommandNode::Sequence(left, right) => match run_command(program, store, rng, updates, exec_budget, left)? { + Walk::FellThrough => run_command(program, store, rng, updates, exec_budget, right), + transitioned => Ok(transitioned), + }, + CommandNode::Step { steps, target } => { + // `k <= 0` behaves like an immediate transition to `target` + // *starting next tick* (not this one — this tick simply ends + // here); `k > 0` idles `k` further ticks first. + let k = match steps { + // A non-integer step count can't arise from a checked program + // (`typecheck.rs` requires it numeric and lowering never + // produces a non-integer step count), so this reports a + // compiler bug rather than silently meaning "no delay". + Some(steps) => eval(program, store, rng, steps)?.as_integer("a `step` count")?, + None => 0, + }; + let cursor = if k <= 0 { + Cursor::Run(target) + } else { + Cursor::Idle { + remaining: k as u32, + target, + } + }; + Ok(Walk::Transitioned(cursor)) + } + CommandNode::Exec(target) => { + // Same-tick tail jump: `exec` delegates to `target`'s body + // within the same tick, so its effects land in this tick too. + if *exec_budget == 0 { + log::error!( + "`exec` chain exceeded the total state budget while entering {target:?} — likely an `exec` \ + cycle with no intervening `step`; ending this component's tick instead of looping forever" + ); + return Ok(Walk::Transitioned(Cursor::Nil)); + } + *exec_budget -= 1; + match program.state(target).body { + Some(body) => run_command(program, store, rng, updates, exec_budget, body), + None => Ok(Walk::Transitioned(Cursor::Nil)), + } + } + } +} + +#[cfg(test)] +mod tests { + use rand::SeedableRng; + use rand::rngs::StdRng; + use test_log::test; + + use super::*; + use crate::UntypedStarkSpecification; + use crate::value::EvalErrorKind; + + fn build(source: &str) -> IrProgram { + let spec = UntypedStarkSpecification::parse(source) + .expect("should parse"); + + let typed_spec = crate::StarkSpecification::from_untyped(spec) + .expect("should check"); + IrProgram::from_spec(&typed_spec).expect("should lower") + } + + #[test] + fn buffered_assignments_read_pre_step_state_swap() { + let program = build( + r" + global variables { + int x = 1; + int y = 2; + } + environment { + x' = y; + y' = x; + } + ", + ); + let mut rng = StdRng::seed_from_u64(0); + let mut store = Store::new(&program, &mut rng).expect("should initialise"); + let mut cursors = Vec::new(); + macro_step(&program, &mut store, &mut rng, &mut cursors).expect("should step"); + assert_eq!(store.state_prefix(&program), &[Value::Integer(2), Value::Integer(1)]); + } + + #[test] + fn a_failing_guard_aborts_the_step_instead_of_reading_as_false() { + // Originally the guard `1 / zero > 0` evaluated to the absorbing error + // value, resulting in false. + let program = build( + r" + global variables { + int zero = 0; + int x = 0; + } + environment { + when 1 / zero > 0 x' = 1; + } + ", + ); + let mut rng = StdRng::seed_from_u64(0); + let mut store = Store::new(&program, &mut rng).expect("should initialise"); + let mut cursors = Vec::new(); + + let error = + macro_step(&program, &mut store, &mut rng, &mut cursors).expect_err("the `1 / zero` guard divides by zero"); + assert_eq!(error.kind, EvalErrorKind::DivisionByZero); + // The failure is anchored to the offending `1 / zero`, not reported + // as a bare class of error. + assert!(error.span.is_some(), "a division by zero should carry its source span"); + } + + #[test] + fn a_non_boolean_guard_aborts_the_step() { + // `typecheck.rs` rejects a non-boolean guard, so this is built + // straight against the IR: `Value::as_boolean` must report it rather + // than answering `false` the way the original did. + assert_eq!( + Value::Integer(1).as_boolean("the guard of an assignment"), + Err(EvalErrorKind::ExpectedBoolean { + context: "the guard of an assignment", + found: crate::value::ValueKind::Integer, + }) + ); + } + + #[test] + fn step_idles_the_requested_number_of_ticks() { + let program = build( + r" + global variables { + int ticks = 0; + } + component C { + variables { } + controller { + state A { + ticks' = ticks + 1; + step B; + } + state B { + ticks' = ticks + 100; + step A; + } + } + init A + } + ", + ); + let mut rng = StdRng::seed_from_u64(0); + let mut store = Store::new(&program, &mut rng).expect("should initialise"); + let mut cursors: Vec = program + .components() + .iter() + .flat_map(|component| component.initial.iter()) + .map(|&state| Cursor::Run(state)) + .collect(); + + macro_step(&program, &mut store, &mut rng, &mut cursors).expect("should step"); + assert_eq!(store.state_prefix(&program), &[Value::Integer(1)]); + + macro_step(&program, &mut store, &mut rng, &mut cursors).expect("should step"); + assert_eq!(store.state_prefix(&program), &[Value::Integer(101)]); + } + + #[test] + fn exec_transitions_within_the_same_tick() { + let program = build( + r" + global variables { + int touched = 0; + } + component C { + variables { } + controller { + state A { + exec B; + } + state B { + touched' = 1; + step A; + } + } + init A + } + ", + ); + let mut rng = StdRng::seed_from_u64(0); + let mut store = Store::new(&program, &mut rng).expect("should initialise"); + let mut cursors: Vec = program + .components() + .iter() + .flat_map(|component| component.initial.iter()) + .map(|&state| Cursor::Run(state)) + .collect(); + + macro_step(&program, &mut store, &mut rng, &mut cursors).expect("should step"); + assert_eq!(store.state_prefix(&program), &[Value::Integer(1)]); + } + + #[test] + fn environment_runs_after_controller_updates_are_applied() { + let program = build( + r" + global variables { + int x = 0; + int seen = 0; + } + component C { + variables { } + controller { + state A { + x' = 5; + step A; + } + } + init A + } + environment { + seen' = x; + } + ", + ); + let mut rng = StdRng::seed_from_u64(0); + let mut store = Store::new(&program, &mut rng).expect("should initialise"); + let mut cursors: Vec = program + .components() + .iter() + .flat_map(|component| component.initial.iter()) + .map(|&state| Cursor::Run(state)) + .collect(); + + macro_step(&program, &mut store, &mut rng, &mut cursors).expect("should step"); + // The environment reads `x` *after* the controller's `x' = 5` was + // applied, so `seen` should be `5`, not the pre-step `0`. + assert_eq!(store.state_prefix(&program), &[Value::Integer(5), Value::Integer(5)]); + } + + #[test] + fn parallel_components_read_the_same_pre_step_state() { + let program = build( + r" + global variables { + int x = 1; + int y = 1; + } + component Reader1 { + variables { } + controller { + state A { + x' = y + 10; + step A; + } + } + init A + } + component Reader2 { + variables { } + controller { + state A { + y' = x + 10; + step A; + } + } + init A + } + ", + ); + let mut rng = StdRng::seed_from_u64(0); + let mut store = Store::new(&program, &mut rng).expect("should initialise"); + let mut cursors: Vec = program + .components() + .iter() + .flat_map(|component| component.initial.iter()) + .map(|&state| Cursor::Run(state)) + .collect(); + + macro_step(&program, &mut store, &mut rng, &mut cursors).expect("should step"); + // Both read x=1, y=1 from the *same* pre-step state, not one + // another's freshly-buffered update. + assert_eq!(store.state_prefix(&program), &[Value::Integer(11), Value::Integer(11)]); + } +} diff --git a/crates/stark/src/eval/store.rs b/crates/stark/src/eval/store.rs new file mode 100644 index 00000000..659e4a43 --- /dev/null +++ b/crates/stark/src/eval/store.rs @@ -0,0 +1,93 @@ +//! The flat evaluator store: one `Vec` indexed directly by [SlotId], +//! matching the IR's slot layout (`[0, n_variables)` state, +//! `[n_variables, n_globals)` `const`/`param`, `[n_globals, n_slots)` scratch) +//! instead of the original's `variable -> value` map. + +use rand::Rng; + +use crate::ir::IrProgram; +use crate::ir::SlotId; +use crate::value::EvalError; +use crate::value::Value; + +use super::expr::eval; + +/// `store[slot]`, sized to [IrProgram::n_slots] and indexed by [SlotId] — +/// see the module doc comment. +#[derive(Clone, Debug)] +pub(crate) struct Store { + slots: Vec, +} + +impl Store { + /// Builds a store sized to `program` and runs startup initialisation: every + /// [crate::ir::GlobalInit] (`const`/`param`) in declaration order, then + /// every variable's `initial_value`. + pub(crate) fn new(program: &IrProgram, rng: &mut R) -> Result { + let mut store = Store { + // Just use any default value, should always be overwritten by the + // initialisation below. + slots: vec![Value::Integer(0); program.n_slots() as usize], + }; + + for global in program.globals() { + let value = eval(program, &mut store, rng, global.value)?; + store.set(global.slot, value); + } + + for variable in program.variables() { + let value = eval(program, &mut store, rng, variable.initial_value)?; + store.set(variable.slot, value); + } + Ok(store) + } + + pub(crate) fn load(&self, slot: SlotId) -> Value { + self.slots[slot.value() as usize] + } + + pub(crate) fn set(&mut self, slot: SlotId, value: Value) { + self.slots[slot.value() as usize] = value; + } + + /// The `[0, n_variables)` prefix that a simulation checkpoints. + pub(crate) fn state_prefix(&self, program: &IrProgram) -> &[Value] { + &self.slots[0..program.n_variables() as usize] + } +} + +#[cfg(test)] +mod tests { + use rand::SeedableRng; + use test_log::test; + + use crate::IrProgram; + use crate::UntypedStarkSpecification; + use crate::eval::store::Store; + use crate::value::Value; + + fn lower_source(source: &str) -> IrProgram { + let spec = UntypedStarkSpecification::parse(source).expect("should parse"); + + let typed_spec = crate::StarkSpecification::from_untyped(spec).expect("should check"); + IrProgram::from_spec(&typed_spec).expect("should lower") + } + + #[test] + fn runs_globals_then_variable_initial_values() { + let program = lower_source( + r" + const c = 2; + param p = c * 3; + global variables { + int x range [0, 100] = p + 1; + } + ", + ); + let mut rng = rand::rngs::StdRng::seed_from_u64(0); + let store = Store::new(&program, &mut rng).expect("should initialise"); + + let state = store.state_prefix(&program); + assert_eq!(state, &[Value::Integer(7)]); + } +} diff --git a/crates/stark/src/eval/system.rs b/crates/stark/src/eval/system.rs new file mode 100644 index 00000000..0e32e275 --- /dev/null +++ b/crates/stark/src/eval/system.rs @@ -0,0 +1,69 @@ +//! One sampled system state: the store plus every component's controller +//! cursor, minus the controller/environment indirection that lowering +//! already collapsed into the [IrProgram] itself. +//! +//! This exists as its own type — rather than living inline in [super::sim] — +//! because robustness analysis needs *many* of them at once: a sample set is +//! a whole distribution of independently-sampled states at one time step (see +//! [super::sequence]), and a perturbed evolution sequence is built by cloning +//! one and applying a perturbation to the copy. A single simulation is then +//! just the degenerate one-sample case. + +use rand::Rng; + +use crate::ir::IrProgram; +use crate::value::EvalError; +use crate::value::Value; + +use super::step::Cursor; +use super::step::macro_step; +use super::store::Store; + +/// A complete sampled state of the system: everything a macro-step reads and +/// writes. +#[derive(Clone, Debug)] +pub(crate) struct SystemState { + pub(crate) store: Store, + pub(crate) cursors: Vec, +} + +impl SystemState { + /// Samples an initial state: runs startup initialisation (see + /// [Store::new]) and puts every component in its `init` state. + /// + /// Sampling matters here, not just at each step: an initial value may + /// itself be random (`x = R[0,10]`), so calling this `n` times with one + /// RNG yields `n` *different* initial states drawn from the same initial + /// distribution — which is exactly how a sample set is generated. + pub(crate) fn new(program: &IrProgram, rng: &mut R) -> Result { + let store = Store::new(program, rng)?; + // Every component's `init` is a parallel composition of controller + // states (`ComponentIr::initial`); flattening every component's + // initial states into one `Vec` is exactly that composition: + // nothing cares which "side" a cursor came from, only that every + // cursor advances against the same pre-step state each tick (see + // `eval::step`). + let cursors = program + .components() + .iter() + .flat_map(|component| component.initial.iter()) + .map(|&state| Cursor::Run(state)) + .collect(); + Ok(SystemState { store, cursors }) + } + + /// Advances this state by one macro-step, in place. + /// + /// On an [EvalError] the state is left as it was (the failed phase's + /// buffered updates are dropped rather than half-applied), so a caller + /// can report the state that triggered the failure. + pub(crate) fn sample_next(&mut self, program: &IrProgram, rng: &mut R) -> Result<(), EvalError> { + macro_step(program, &mut self.store, rng, &mut self.cursors) + } + + /// The `[0, n_variables)` state prefix — what a trajectory records and + /// what a perturbation writes to. + pub(crate) fn variables(&self, program: &IrProgram) -> &[Value] { + self.store.state_prefix(program) + } +} diff --git a/crates/stark/src/ir.rs b/crates/stark/src/ir.rs new file mode 100644 index 00000000..a677df95 --- /dev/null +++ b/crates/stark/src/ir.rs @@ -0,0 +1,1688 @@ +//! The evaluation IR that `lower.rs` produces: a flat arena of small, mostly +//! `Copy` nodes rather than a closure tree, so evaluation walks an array +//! instead of chasing pointers. +//! +//! Populated by lowering: constants/parameters (as [GlobalInit]), variables +//! (as [VariableInfo]), functions (as [FunctionIr]), penalties (as +//! [PenaltyIr]), components/controller states (as [ComponentIr]/[StateIr]) +//! and the environment block, together with the shared expression/statement/ +//! command arenas they're built from, plus perturbations, distances and +//! ROBTL formulas ([PerturbationIr]/ +//! [DistanceIr]/[FormulaIr], each its own small `Box`-free arena analogous to +//! the expression one, with [PerturbationDecl]/[DistanceDecl]/[FormulaDecl] +//! marking which arena entries are named top-level declarations rather than +//! sub-nodes only reachable through one). +//! +//! # Index types +//! +//! Every arena is addressed by its own index type, all backed by `u32` rather +//! than `usize` so the nodes holding them stay small. Each carries its own tag +//! so that, say, an [ExprRef] can never be mixed up with a [SlotId] at a call +//! site even though both are "just a `u32`" underneath. +//! +//! # Robustness sub-languages +//! +//! [PerturbationIr], [DistanceIr] and [FormulaIr] mirror the shape of +//! `ast.rs`'s perturbation, distance and formula expressions, each collapsed +//! the same way the expression arena is: a `Reference(DefRef)` (a reference to +//! another named declaration of the same kind) resolves at lowering time to +//! the referent's `*Id`, so no name lookups survive into the IR. A top-level +//! `perturbation`/`distance`/`formula name = ..;` declaration lowers to one +//! *root* node, pushed last (post-order, same as expressions); its +//! `Sequence`/`Iteration`/`Eventually`/etc. operands are themselves `*Id`s +//! into the very same arena, so a declaration and everything it is built from +//! share one flat, `Box`-free index space. [PerturbationDecl]/[DistanceDecl]/ +//! [FormulaDecl] separately record which arena entries are those named roots +//! (as opposed to intermediate sub-nodes only reachable *through* a root) — +//! the same distinction [IrProgram::variables] draws from [IrProgram::exprs]. + +use std::fmt; + +use merc_utilities::Span; +use merc_utilities::TagIndex; + +use crate::StarkSpecification; +use crate::diagnostics::Diagnostics; +use crate::lower::lower; +use crate::types::StarkType; +use crate::value::Value; + +// --------------------------------------------------------------------------- +// Index types (see the module documentation) +// --------------------------------------------------------------------------- + +pub struct ExprTag; +/// An index into [IrProgram]'s expression arena. +pub type ExprRef = TagIndex; + +pub struct StmtTag; +/// An index into [IrProgram]'s statement arena (function bodies). +pub type StmtRef = TagIndex; + +pub struct SlotTag; +/// An index into the flat value store the evaluator maintains — see +/// [IrProgram::n_variables] for the layout of that store. +pub type SlotId = TagIndex; + +pub struct FunctionTag; +/// An index into [IrProgram]'s lowered functions, assigned in declaration +/// order (which — since STARK forbids recursion — is always a valid +/// topological order of the call graph). +pub type FunctionId = TagIndex; + +pub struct PenaltyTag; +/// An index into [IrProgram]'s lowered penalties. +pub type PenaltyId = TagIndex; + +pub struct CommandTag; +/// An index into [IrProgram]'s command arena (controller state bodies and +/// the environment block). +pub type CommandRef = TagIndex; + +pub struct IrStateTag; +/// An index into [IrProgram]'s flat, cross-component controller state list. +/// The AST's own `StateId` (see `ast.rs`) is already flat across every +/// component (`SymbolTable` keeps one `Vec` for the whole +/// specification, not one per component), so this is a straight 1:1 mapping +/// from it — kept as its own tag purely so the IR never has to import an +/// `ast::` index type to name a slice of its own arena. +pub type IrStateId = TagIndex; + +pub struct ComponentTag; +/// An index into [IrProgram]'s lowered components. +pub type ComponentId = TagIndex; + +pub struct PerturbationTag; +/// An index into [IrProgram]'s perturbation-expression arena +/// ([IrProgram::perturbations]). Both the root node of a top-level +/// `perturbation name = ..;` declaration (see [PerturbationDecl]) and every +/// sub-node reached from it (a `Sequence`'s operands, an `Iteration`'s +/// argument) share this one index space, mirroring [ExprRef]. +pub type PerturbationId = TagIndex; + +pub struct DistanceTag; +/// An index into [IrProgram]'s distance-expression arena +/// ([IrProgram::distances]) — same shape as [PerturbationId]. +pub type DistanceId = TagIndex; + +pub struct FormulaTag; +/// An index into [IrProgram]'s ROBTL-formula arena ([IrProgram::formulas]) — +/// same shape as [PerturbationId]. +pub type FormulaId = TagIndex; + +// --------------------------------------------------------------------------- +// Expressions +// --------------------------------------------------------------------------- + +/// A binary operator as the IR needs it: `BinaryOp::Pow` from the AST +/// collapses into `MathBinary(MathBinaryFunction::Pow, ..)` during lowering +/// (see [ExprNode]'s doc comment), so this has no `Pow` case of its own. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BinaryOp { + Mult, + Div, + IntDiv, + Add, + Subtract, + Mod, + Less, + Leq, + Eq, + Geq, + Greater, + BitAnd, + And, + BitOr, + Or, +} + +/// The unary half of `ast::MathFunction`, split out so the evaluator never +/// has to check arity for a math call. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MathUnaryFunction { + Abs, + Acos, + Asin, + Atan, + Cbrt, + Ceil, + Cos, + Cosh, + Exp, + Expm1, + Floor, + Log, + Log10, + Log1p, + Signum, + Sin, + Sinh, + Sqrt, + Tan, +} + +/// The binary half of `ast::MathFunction`. Also where `BinaryOp::Pow` (`^`) +/// lands, since `BinaryOp::Pow` and `MathFunction::Pow` are the same +/// operation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MathBinaryFunction { + Atan2, + Hypot, + Max, + Min, + Pow, +} + +/// A `{ start, len }` slice into [IrProgram::expr_lists], keeping argument +/// and element lists contiguous rather than each becoming its own `Vec`. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ExprList { + pub start: u32, + pub len: u32, +} + +impl ExprList { + pub const EMPTY: ExprList = ExprList { start: 0, len: 0 }; +} + +/// One node of the expression arena. +/// +/// Deliberate simplifications made while lowering: +/// - `Ty` / custom type names disappear; only [StarkType] and slot indices +/// survive (in [IrProgram::expr_types] / [IrProgram::slots]). +/// - `Expression::Reference` (to a constant, parameter or variable) and +/// `Expression::Iterator` both become `Load(slot)` — the distinction +/// between a global, a constant and a `let` binding is erased, since it is +/// exactly what the slot index already encodes. A reference to a `type` +/// element instead folds to `Literal(Value::Custom(..))`, since its value +/// is known outright at lowering time, not computed from an expression. +/// - `FunctionStatement::Block` disappears (it only ever wraps one +/// statement). +#[derive(Clone, Copy, Debug)] +pub enum ExprNode { + Literal(Value), + /// An expression that cannot be evaluated, carrying a `&'static str` + /// naming why. Lowering emits this only for AST shapes that no grammar + /// production can currently produce (`ExpressionKind::Iterator`, which + /// needs an aggregate/lambda context — see `plan.md`), + /// so reaching one at run time means lowering has a bug; `eval` reports it + /// as [crate::value::EvalErrorKind::Unreachable] rather than inventing a + /// value. Before errors became a `Result`, this was a `Literal` holding + /// the old absorbing `Value::Error`. + Unreachable(&'static str), + /// A read of `store[slot]` — the whole point of this IR: every name + /// resolution already did gets baked into the node. + Load(SlotId), + Not(ExprRef), + /// Arithmetic negation (`-x`). **Always widens to `Real`, even for an + /// integer operand** — matching the original, which routes unary `-`/`+` + /// through the *same* always-widening double-valued mechanism as the + /// math functions rather than through a dedicated integer-preserving + /// path. So `-a + 2` is `real`, not `int`, when `a` is an `int` — + /// surprising for a spec author writing `-a` expecting an int to stay + /// one; matched here for fidelity with the original tool, but worth + /// reconsidering if it surprises users badly enough in practice. + Negate(ExprRef), + /// `+x`. Unlike most unary-plus operators this is *not* the identity at + /// the type level: it widens exactly like `Negate`, through the same + /// mechanism (see [ExprNode::Negate]'s doc comment), so `+a` for an + /// integer `a` is `real`, not `a` unchanged. + /// The *value* is unchanged; only the representation widens. + Widen(ExprRef), + Binary(BinaryOp, ExprRef, ExprRef), + MathUnary(MathUnaryFunction, ExprRef), + MathBinary(MathBinaryFunction, ExprRef, ExprRef), + Select { + guard: ExprRef, + then_branch: ExprRef, + else_branch: ExprRef, + }, + Call { + function: FunctionId, + arguments: ExprList, + }, + /// `R` + SampleUnit, + /// `R[min,max]` + SampleRange { + min: ExprRef, + max: ExprRef, + }, + /// `N[mean,variance]` + SampleNormal { + mean: ExprRef, + variance: ExprRef, + }, + /// `U[..]` + SampleChoice(ExprList), +} + +// --------------------------------------------------------------------------- +// Statements (function bodies) +// --------------------------------------------------------------------------- + +/// A function body statement. `Let` is *just* `{ slot, value, body }` — no +/// scope chain, since `slot` is already resolved by lowering. +#[derive(Clone, Copy, Debug)] +pub enum StmtNode { + Return(ExprRef), + IfThenElse { + guard: ExprRef, + then_branch: StmtRef, + else_branch: Option, + }, + Let { + slot: SlotId, + value: ExprRef, + body: StmtRef, + }, +} + +// --------------------------------------------------------------------------- +// Slots, globals, variables, functions, penalties +// --------------------------------------------------------------------------- + +/// What kind of thing a [SlotId] was allocated for — for debugging / +/// pretty-printing only, the evaluator's flat store doesn't need it at +/// runtime. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SlotKind { + /// `[0, n_variables)`: the simulation state, read/write each step. + Variable, + /// `[n_variables, n_globals)`: a `const` or `param`, written once at + /// startup. + Global, + /// `[n_globals, n_slots)`: a function argument or `let` binding. + Local, +} + +/// A slot's name, type and kind, kept around purely for debugging and +/// pretty-printing (`Display`, future diagnostics) — the evaluator indexes +/// the store directly and never needs this. +#[derive(Clone, Debug)] +pub struct SlotInfo { + pub name: String, + pub ty: StarkType, + pub kind: SlotKind, + pub span: Span, +} + +/// A slot in the `[0, n_variables)` state prefix: its declared range bounds +/// (if any) and its initial value, both already lowered. +#[derive(Clone, Copy, Debug)] +pub struct VariableInfo { + pub slot: SlotId, + pub range: Option<(ExprRef, ExprRef)>, + pub initial_value: ExprRef, +} + +/// A `const`/`param` initializer: `store[slot] = eval(value)`, executed once +/// at startup, in declaration order. +#[derive(Clone, Copy, Debug)] +pub struct GlobalInit { + pub slot: SlotId, + pub value: ExprRef, +} + +/// A lowered `function name(args) { body }`. +#[derive(Clone, Debug)] +pub struct FunctionIr { + pub name: String, + /// One slot per declared argument, positional. + pub arguments: Vec, + pub return_type: StarkType, + pub body: StmtRef, +} + +/// A lowered `penalty name = expr;`. Carries its `name` (unlike the rest of +/// this section, which didn't need one before [DistanceIr::AtomicLeft]/ +/// [DistanceIr::AtomicRight] started referencing a penalty by [PenaltyId] — +/// printing `< #3` in [IrProgram]'s `Display` impl would otherwise be +/// unreadable). +#[derive(Clone, Debug)] +pub struct PenaltyIr { + pub name: String, + pub value: ExprRef, +} + +// --------------------------------------------------------------------------- +// Controllers and the environment +// --------------------------------------------------------------------------- + +/// A buffered `[when guard] target' = value;`, shared by controller and +/// environment lowering. Mirrors `ast::Update`, but with the target variable +/// resolved to its [SlotId]. **Buffered, not applied immediately**: every +/// read in the same step sees the pre-update value (see [CommandNode]'s doc +/// comment) — the evaluator is responsible for collecting these and +/// applying them only once the whole step has run. +#[derive(Clone, Copy, Debug)] +pub struct Update { + pub target: SlotId, + pub guard: Option, + pub value: ExprRef, +} + +/// One node of the command arena: a controller state's body or the +/// environment block, both lowered to the same node type since the only +/// difference between them is that an environment never contains a `Step`/ +/// `Exec`. +/// +/// A `Vec`/`Vec` — a +/// `{ .. }` block — lowers to a left-associated chain of `Sequence(prior, +/// next)` nodes, one per list element; an empty block lowers to no node at +/// all (`None` at the call site), since there is nothing to run. +/// +/// **Buffered update semantics**: `Assign` does not write through to +/// `store[slot]` when evaluated — it is the evaluator's job to collect every +/// `Assign` reached during a step into a list and apply them all at the end, +/// so `x' = y; y' = x;` reads *both* sides from the pre-step state (the +/// classic swap). Lowering only has to preserve the structure faithfully; +/// see the `buffered_swap_*` tests in `lower.rs`. +/// +/// **Where control-flow termination lives**: this arena does not itself +/// enforce that every path through a controller state reaches a `Step`/ +/// `Exec` — it just mirrors the source's structure. A `Sequence(a, b)` whose +/// `a` is (or contains) a `Step`/`Exec` has an unreachable `b`; that is an +/// evaluator concern (stop walking the chain once a transition is hit), not +/// a lowering one. +#[derive(Clone, Copy, Debug)] +pub enum CommandNode { + Assign(Update), + IfThenElse { + guard: ExprRef, + then_branch: Option, + else_branch: Option, + }, + /// `let slot = value in body` — no scope chain, `slot` is already + /// resolved, same as `StmtNode::Let`. + Let { + slot: SlotId, + value: ExprRef, + body: Option, + }, + /// Runs its left node, then its right node. + Sequence(CommandRef, CommandRef), + /// `[steps #] step target;` — controller-only. `steps` (if present) is + /// evaluated once per step. + Step { + steps: Option, + target: IrStateId, + }, + /// `exec target;` — controller-only. + Exec(IrStateId), +} + +/// A lowered `state name { .. }`. +#[derive(Clone, Debug)] +pub struct StateIr { + pub name: String, + pub component: ComponentId, + /// `None` only for a state with an empty body — legal to parse, though a + /// state that never reaches a `step`/`exec` cannot make progress. + pub body: Option, +} + +/// A lowered `component name { .. }`. States are held flat on [IrProgram] +/// (see [IrStateId]'s doc comment); this only lists which of them are this +/// component's. +#[derive(Clone, Debug)] +pub struct ComponentIr { + pub name: String, + pub states: Vec, + /// The `init` expression: the parallel composition of initial states. + pub initial: Vec, +} + +// --------------------------------------------------------------------------- +// Robustness sub-languages: perturbation / distance / ROBTL formula +// (see the module documentation) +// --------------------------------------------------------------------------- + +/// A comparison operator, used by [DistanceIr::Threshold] and +/// [FormulaIr::Distance]. Kept as its own type (mirroring `ast::ComparisonOp`) +/// so `ir.rs` doesn't need to depend on `ast`, the same reason [BinaryOp] +/// doesn't reuse `ast::BinaryOp` directly. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ComparisonOp { + Less, + Leq, + Eq, + Geq, + Greater, +} + +/// An unguarded `target <- value` inside a perturbation's atomic block — +/// like [Update] but with no `guard` field, matching +/// `ast::PerturbationAssignment` (a perturbation assignment can never be +/// guarded; see `plan.md`). +#[derive(Clone, Copy, Debug)] +pub struct PerturbationAssignment { + pub target: SlotId, + pub value: ExprRef, +} + +/// One node of [IrProgram]'s perturbation arena. +#[derive(Clone, Debug)] +pub enum PerturbationIr { + /// The empty perturbation — leaves every trajectory unperturbed. + Nil, + /// A reference to another named `perturbation` declaration. + Reference(PerturbationId), + /// `[ v1 <- e1, v2 <- e2, .. ] @ time`. + Atomic { + assignments: Vec, + time: ExprRef, + }, + /// `left ; right`. + Sequence(PerturbationId, PerturbationId), + /// `argument ^ iterations`. + Iteration { + argument: PerturbationId, + iterations: ExprRef, + }, +} + +/// A top-level `perturbation name = ..;` declaration: `name` plus the +/// [PerturbationId] of its root node in [IrProgram::perturbations]. +#[derive(Clone, Debug)] +pub struct PerturbationDecl { + pub name: String, + pub root: PerturbationId, +} + +/// One node of [IrProgram]'s distance arena — same shape as [PerturbationIr]. +#[derive(Clone, Debug)] +pub enum DistanceIr { + /// A reference to another named `distance` declaration. + Reference(DistanceId), + /// `< penalty`. + AtomicLeft(PenaltyId), + /// `> penalty`. + AtomicRight(PenaltyId), + /// `\F[from,to] argument`. + Eventually { + from: ExprRef, + to: ExprRef, + argument: DistanceId, + }, + /// `\G[from,to] argument`. + Globally { + from: ExprRef, + to: ExprRef, + argument: DistanceId, + }, + /// `left \U[from,to] right`. + Until { + from: ExprRef, + to: ExprRef, + left: DistanceId, + right: DistanceId, + }, + /// `left op threshold`. + Threshold { + op: ComparisonOp, + left: DistanceId, + threshold: ExprRef, + }, + Min(DistanceId, DistanceId), + Max(DistanceId, DistanceId), + /// `w1 * d1 + w2 * d2 + ...`. + LinearCombination(Vec<(ExprRef, DistanceId)>), +} + +/// A top-level `distance name = ..;` declaration: `name` plus the +/// [DistanceId] of its root node in [IrProgram::distances]. +#[derive(Clone, Debug)] +pub struct DistanceDecl { + pub name: String, + pub root: DistanceId, +} + +/// One node of [IrProgram]'s ROBTL-formula arena — same shape as +/// [PerturbationIr]. +#[derive(Clone, Debug)] +pub enum FormulaIr { + True, + False, + /// A reference to another named `formula` declaration. + Reference(FormulaId), + /// `\D[distance, perturbation] op value`. + Distance { + distance: DistanceId, + perturbation: PerturbationId, + op: ComparisonOp, + value: ExprRef, + }, + Not(FormulaId), + /// `\G[from,to] argument`. + Globally { + from: ExprRef, + to: ExprRef, + argument: FormulaId, + }, + /// `\F[from,to] argument`. + Eventually { + from: ExprRef, + to: ExprRef, + argument: FormulaId, + }, + And(FormulaId, FormulaId), + Or(FormulaId, FormulaId), + /// `left \U[from,to] right`. + Until { + from: ExprRef, + to: ExprRef, + left: FormulaId, + right: FormulaId, + }, +} + +/// A top-level `formula name = ..;` declaration: `name` plus the [FormulaId] +/// of its root node in [IrProgram::formulas]. +#[derive(Clone, Debug)] +pub struct FormulaDecl { + pub name: String, + pub root: FormulaId, +} + +// --------------------------------------------------------------------------- +// The program +// --------------------------------------------------------------------------- + +/// The result of lowering: one flat arena, plus the tables that index into +/// it. +#[derive(Clone, Debug, Default)] +pub struct IrProgram { + pub(crate) exprs: Vec, + pub(crate) expr_spans: Vec, + pub(crate) expr_types: Vec, + pub(crate) expr_lists: Vec, + + pub(crate) stmts: Vec, + pub(crate) commands: Vec, + + pub(crate) slots: Vec, + pub(crate) variables: Vec, + pub(crate) globals: Vec, + pub(crate) functions: Vec, + pub(crate) penalties: Vec, + + pub(crate) states: Vec, + pub(crate) components: Vec, + /// The environment block, if the specification has one. + pub(crate) environment: Option, + + pub(crate) perturbations: Vec, + pub(crate) perturbation_decls: Vec, + pub(crate) distances: Vec, + pub(crate) distance_decls: Vec, + pub(crate) formulas: Vec, + pub(crate) formula_decls: Vec, +} + +impl IrProgram { + /// Lowers `spec` to an [IrProgram]. + /// + /// The `Result` exists for a not-yet-implemented-construct error class (see + /// this module's doc comment) — currently always `Ok`, since every construct + /// the grammar supports lowers. + pub fn from_spec(spec: &StarkSpecification) -> Result { + lower(spec) + } + + pub fn expr(&self, id: ExprRef) -> &ExprNode { + &self.exprs[id.value() as usize] + } + + pub fn expr_span(&self, id: ExprRef) -> &Span { + &self.expr_spans[id.value() as usize] + } + + pub fn expr_type(&self, id: ExprRef) -> &StarkType { + &self.expr_types[id.value() as usize] + } + + pub fn expr_list(&self, list: ExprList) -> &[ExprRef] { + let start = list.start as usize; + &self.expr_lists[start..start + list.len as usize] + } + + pub fn stmt(&self, id: StmtRef) -> &StmtNode { + &self.stmts[id.value() as usize] + } + + pub fn command(&self, id: CommandRef) -> &CommandNode { + &self.commands[id.value() as usize] + } + + pub fn state(&self, id: IrStateId) -> &StateIr { + &self.states[id.value() as usize] + } + + pub fn components(&self) -> &[ComponentIr] { + &self.components + } + + pub fn component(&self, id: ComponentId) -> &ComponentIr { + &self.components[id.value() as usize] + } + + pub fn environment(&self) -> Option { + self.environment + } + + pub fn slot(&self, id: SlotId) -> &SlotInfo { + &self.slots[id.value() as usize] + } + + /// The number of `[0, n_variables)` slots. + pub fn n_variables(&self) -> u32 { + self.variables.len() as u32 + } + + /// The number of `[0, n_globals)` slots, both variables and global + /// variables. + pub fn n_globals(&self) -> u32 { + self.n_variables() + self.globals.len() as u32 + } + + /// The total number of slots the evaluator's store must hold. Function arguments and `let` bindings + /// occupy `[n_globals, n_slots)` + pub fn n_slots(&self) -> u32 { + self.slots.len() as u32 + } + + pub fn variables(&self) -> &[VariableInfo] { + &self.variables + } + + pub fn globals(&self) -> &[GlobalInit] { + &self.globals + } + + pub fn functions(&self) -> &[FunctionIr] { + &self.functions + } + + pub fn function(&self, id: FunctionId) -> &FunctionIr { + &self.functions[id.value() as usize] + } + + pub fn penalties(&self) -> &[PenaltyIr] { + &self.penalties + } + + pub fn penalty(&self, id: PenaltyId) -> &PenaltyIr { + &self.penalties[id.value() as usize] + } + + /// The raw perturbation-expression arena: both the named roots (see + /// [Self::perturbation_decls]) and every sub-node reachable from them. + pub fn perturbations(&self) -> &[PerturbationIr] { + &self.perturbations + } + + pub fn perturbation(&self, id: PerturbationId) -> &PerturbationIr { + &self.perturbations[id.value() as usize] + } + + /// The top-level `perturbation name = ..;` declarations, in source order. + pub fn perturbation_decls(&self) -> &[PerturbationDecl] { + &self.perturbation_decls + } + + /// The raw distance-expression arena — see [Self::perturbations]. + pub fn distances(&self) -> &[DistanceIr] { + &self.distances + } + + pub fn distance(&self, id: DistanceId) -> &DistanceIr { + &self.distances[id.value() as usize] + } + + /// The top-level `distance name = ..;` declarations, in source order. + pub fn distance_decls(&self) -> &[DistanceDecl] { + &self.distance_decls + } + + /// The raw ROBTL-formula arena — see [Self::perturbations]. + pub fn formulas(&self) -> &[FormulaIr] { + &self.formulas + } + + pub fn formula(&self, id: FormulaId) -> &FormulaIr { + &self.formulas[id.value() as usize] + } + + /// The top-level `formula name = ..;` declarations, in source order. + pub fn formula_decls(&self) -> &[FormulaDecl] { + &self.formula_decls + } + + /// Independently re-checks the arena's internal consistency. + pub fn validate(&self) -> Result<(), String> { + let check_expr = |id: ExprRef| -> Result<(), String> { + if (id.value() as usize) < self.exprs.len() { + Ok(()) + } else { + Err(format!( + "{id:?} out of bounds for an arena of {} expression(s)", + self.exprs.len() + )) + } + }; + + let check_slot = |id: SlotId| -> Result<(), String> { + if (id.value() as usize) < self.slots.len() { + Ok(()) + } else { + Err(format!("{id:?} out of bounds for {} slot(s)", self.slots.len())) + } + }; + + let check_stmt = |id: StmtRef| -> Result<(), String> { + if (id.value() as usize) < self.stmts.len() { + Ok(()) + } else { + Err(format!("{id:?} out of bounds for {} statement(s)", self.stmts.len())) + } + }; + + let check_command = |id: CommandRef| -> Result<(), String> { + if (id.value() as usize) < self.commands.len() { + Ok(()) + } else { + Err(format!("{id:?} out of bounds for {} command(s)", self.commands.len())) + } + }; + + let check_state = |id: IrStateId| -> Result<(), String> { + if (id.value() as usize) < self.states.len() { + Ok(()) + } else { + Err(format!("{id:?} out of bounds for {} state(s)", self.states.len())) + } + }; + + let check_penalty = |id: PenaltyId| -> Result<(), String> { + if (id.value() as usize) < self.penalties.len() { + Ok(()) + } else { + Err(format!( + "{id:?} out of bounds for {} penalty/-ies", + self.penalties.len() + )) + } + }; + + let check_perturbation = |id: PerturbationId| -> Result<(), String> { + if (id.value() as usize) < self.perturbations.len() { + Ok(()) + } else { + Err(format!( + "{id:?} out of bounds for {} perturbation node(s)", + self.perturbations.len() + )) + } + }; + + let check_distance = |id: DistanceId| -> Result<(), String> { + if (id.value() as usize) < self.distances.len() { + Ok(()) + } else { + Err(format!( + "{id:?} out of bounds for {} distance node(s)", + self.distances.len() + )) + } + }; + + let check_formula = |id: FormulaId| -> Result<(), String> { + if (id.value() as usize) < self.formulas.len() { + Ok(()) + } else { + Err(format!( + "{id:?} out of bounds for {} formula node(s)", + self.formulas.len() + )) + } + }; + + if self.exprs.len() != self.expr_spans.len() || self.exprs.len() != self.expr_types.len() { + return Err(format!( + "arena length mismatch: {} expr(s), {} span(s), {} type(s)", + self.exprs.len(), + self.expr_spans.len(), + self.expr_types.len() + )); + } + + for (index, node) in self.exprs.iter().enumerate() { + match *node { + ExprNode::Literal(_) | ExprNode::Unreachable(_) | ExprNode::SampleUnit => {} + ExprNode::Load(slot) => check_slot(slot)?, + ExprNode::Not(inner) + | ExprNode::Negate(inner) + | ExprNode::Widen(inner) + | ExprNode::MathUnary(_, inner) => check_expr(inner)?, + ExprNode::Binary(_, left, right) | ExprNode::MathBinary(_, left, right) => { + check_expr(left)?; + check_expr(right)?; + } + ExprNode::Select { + guard, + then_branch, + else_branch, + } => { + check_expr(guard)?; + check_expr(then_branch)?; + check_expr(else_branch)?; + } + ExprNode::Call { function, arguments } => { + if (function.value() as usize) >= self.functions.len() { + return Err(format!( + "{function:?} out of bounds for {} function(s)", + self.functions.len() + )); + } + for &argument in self.expr_list_bounds_checked(arguments, index)? { + check_expr(argument)?; + } + } + ExprNode::SampleRange { min, max } => { + check_expr(min)?; + check_expr(max)?; + } + ExprNode::SampleNormal { mean, variance } => { + check_expr(mean)?; + check_expr(variance)?; + } + ExprNode::SampleChoice(list) => { + for &element in self.expr_list_bounds_checked(list, index)? { + check_expr(element)?; + } + } + } + } + + for node in &self.stmts { + match *node { + StmtNode::Return(value) => check_expr(value)?, + StmtNode::IfThenElse { + guard, + then_branch, + else_branch, + } => { + check_expr(guard)?; + check_stmt(then_branch)?; + if let Some(else_branch) = else_branch { + check_stmt(else_branch)?; + } + } + StmtNode::Let { slot, value, body } => { + check_slot(slot)?; + check_expr(value)?; + check_stmt(body)?; + } + } + } + + // The slot partition itself: `[0, n_variables)` variables, then + // `[n_variables, n_globals)` globals, then locals. `n_variables()` / + // `n_globals()` derive these boundaries from `variables.len()` / + // `globals.len()` rather than by scanning `slots`, and the evaluator + // takes the state prefix as a contiguous slice on that basis — so the + // layout has to actually hold, not merely be intended. + let n_variables = self.n_variables() as usize; + let n_globals = self.n_globals() as usize; + if n_globals > self.slots.len() { + return Err(format!( + "slot partition overflows: {n_variables} variable(s) + {} global(s) exceeds {} slot(s)", + self.globals.len(), + self.slots.len() + )); + } + for (index, slot) in self.slots.iter().enumerate() { + let expected = if index < n_variables { + SlotKind::Variable + } else if index < n_globals { + SlotKind::Global + } else { + SlotKind::Local + }; + if slot.kind != expected { + return Err(format!( + "slot #{index} (`{}`) is {:?}, but the partition \ + ([0,{n_variables}) variables, [{n_variables},{n_globals}) globals) requires {expected:?}", + slot.name, slot.kind + )); + } + } + + for variable in &self.variables { + check_slot(variable.slot)?; + if (variable.slot.value() as usize) >= n_variables { + return Err(format!( + "{:?} (`{}`) is a variable but lies outside the [0,{n_variables}) state prefix", + variable.slot, + self.slot(variable.slot).name + )); + } + check_expr(variable.initial_value)?; + if let Some((min, max)) = variable.range { + check_expr(min)?; + check_expr(max)?; + } + } + + for global in &self.globals { + check_slot(global.slot)?; + let slot = global.slot.value() as usize; + if slot < n_variables || slot >= n_globals { + return Err(format!( + "{:?} (`{}`) is a global but lies outside [{n_variables},{n_globals})", + global.slot, + self.slot(global.slot).name + )); + } + check_expr(global.value)?; + } + + for function in &self.functions { + for &argument in &function.arguments { + check_slot(argument)?; + if (argument.value() as usize) < n_globals { + return Err(format!( + "{argument:?} (`{}`) is a function argument but lies inside the \ + [0,{n_globals}) variable/global range", + self.slot(argument).name + )); + } + } + check_stmt(function.body)?; + } + + for penalty in &self.penalties { + check_expr(penalty.value)?; + } + + for node in &self.commands { + match *node { + CommandNode::Assign(update) => { + check_slot(update.target)?; + if let Some(guard) = update.guard { + check_expr(guard)?; + } + check_expr(update.value)?; + } + CommandNode::IfThenElse { + guard, + then_branch, + else_branch, + } => { + check_expr(guard)?; + if let Some(then_branch) = then_branch { + check_command(then_branch)?; + } + if let Some(else_branch) = else_branch { + check_command(else_branch)?; + } + } + CommandNode::Let { slot, value, body } => { + check_slot(slot)?; + check_expr(value)?; + if let Some(body) = body { + check_command(body)?; + } + } + CommandNode::Sequence(left, right) => { + check_command(left)?; + check_command(right)?; + } + CommandNode::Step { steps, target } => { + if let Some(steps) = steps { + check_expr(steps)?; + } + check_state(target)?; + } + CommandNode::Exec(target) => check_state(target)?, + } + } + + for state in &self.states { + if let Some(body) = state.body { + check_command(body)?; + } + if (state.component.value() as usize) >= self.components.len() { + return Err(format!( + "{:?} out of bounds for {} component(s)", + state.component, + self.components.len() + )); + } + } + for component in &self.components { + for &state in &component.states { + check_state(state)?; + } + for &state in &component.initial { + check_state(state)?; + } + } + if let Some(environment) = self.environment { + check_command(environment)?; + } + + for node in &self.perturbations { + match node { + PerturbationIr::Nil => {} + PerturbationIr::Reference(target) => check_perturbation(*target)?, + PerturbationIr::Atomic { assignments, time } => { + for assignment in assignments { + check_slot(assignment.target)?; + check_expr(assignment.value)?; + } + check_expr(*time)?; + } + PerturbationIr::Sequence(left, right) => { + check_perturbation(*left)?; + check_perturbation(*right)?; + } + PerturbationIr::Iteration { argument, iterations } => { + check_perturbation(*argument)?; + check_expr(*iterations)?; + } + } + } + for decl in &self.perturbation_decls { + check_perturbation(decl.root)?; + } + + for node in &self.distances { + match node { + DistanceIr::Reference(target) => check_distance(*target)?, + DistanceIr::AtomicLeft(penalty) | DistanceIr::AtomicRight(penalty) => check_penalty(*penalty)?, + DistanceIr::Eventually { from, to, argument } | DistanceIr::Globally { from, to, argument } => { + check_expr(*from)?; + check_expr(*to)?; + check_distance(*argument)?; + } + DistanceIr::Until { from, to, left, right } => { + check_expr(*from)?; + check_expr(*to)?; + check_distance(*left)?; + check_distance(*right)?; + } + DistanceIr::Threshold { left, threshold, .. } => { + check_distance(*left)?; + check_expr(*threshold)?; + } + DistanceIr::Min(left, right) | DistanceIr::Max(left, right) => { + check_distance(*left)?; + check_distance(*right)?; + } + DistanceIr::LinearCombination(terms) => { + for &(weight, distance) in terms { + check_expr(weight)?; + check_distance(distance)?; + } + } + } + } + for decl in &self.distance_decls { + check_distance(decl.root)?; + } + + for node in &self.formulas { + match node { + FormulaIr::True | FormulaIr::False => {} + FormulaIr::Reference(target) => check_formula(*target)?, + FormulaIr::Distance { + distance, + perturbation, + value, + .. + } => { + check_distance(*distance)?; + check_perturbation(*perturbation)?; + check_expr(*value)?; + } + FormulaIr::Not(inner) => check_formula(*inner)?, + FormulaIr::Globally { from, to, argument } | FormulaIr::Eventually { from, to, argument } => { + check_expr(*from)?; + check_expr(*to)?; + check_formula(*argument)?; + } + FormulaIr::And(left, right) | FormulaIr::Or(left, right) => { + check_formula(*left)?; + check_formula(*right)?; + } + FormulaIr::Until { from, to, left, right } => { + check_expr(*from)?; + check_expr(*to)?; + check_formula(*left)?; + check_formula(*right)?; + } + } + } + for decl in &self.formula_decls { + check_formula(decl.root)?; + } + + Ok(()) + } + + fn expr_list_bounds_checked(&self, list: ExprList, expr_index: usize) -> Result<&[ExprRef], String> { + let start = list.start as usize; + let end = start + list.len as usize; + if end > self.expr_lists.len() { + return Err(format!( + "expression {expr_index}'s argument list [{start}, {end}) is out of bounds for {} list slot(s)", + self.expr_lists.len() + )); + } + Ok(&self.expr_lists[start..end]) + } +} + +impl fmt::Display for IrProgram { + /// Walks the arena and prints resolved, indented, source-like text with + /// slot names substituted in — this is what's used to inspect lowering + /// output (and what the snapshot tests assert on), since the raw + /// `#[derive(Debug)]` form (`Binary(Add, ExprRef(3), ExprRef(7))`) is + /// unreadable. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for global in &self.globals { + let slot = self.slot(global.slot); + writeln!( + f, + "#{}:{} {} = {};", + global.slot.value(), + slot.ty, + slot.name, + self.display_expr(global.value) + )?; + } + if !self.globals.is_empty() { + writeln!(f)?; + } + + for variable in &self.variables { + let slot = self.slot(variable.slot); + write!(f, "variable #{}:{} {}", variable.slot.value(), slot.ty, slot.name)?; + if let Some((min, max)) = variable.range { + write!(f, " range [{}, {}]", self.display_expr(min), self.display_expr(max))?; + } + writeln!(f, " = {};", self.display_expr(variable.initial_value))?; + } + if !self.variables.is_empty() { + writeln!(f)?; + } + + for (index, function) in self.functions.iter().enumerate() { + if index > 0 { + writeln!(f)?; + } + let arguments = function + .arguments + .iter() + .map(|&slot| { + let info = self.slot(slot); + format!("#{}:{} {}", slot.value(), info.ty, info.name) + }) + .collect::>() + .join(", "); + writeln!(f, "fn {}({arguments}) -> {} {{", function.name, function.return_type)?; + self.display_stmt(f, function.body, 1)?; + writeln!(f, "}}")?; + } + if !self.functions.is_empty() { + writeln!(f)?; + } + + for (index, component) in self.components.iter().enumerate() { + if index > 0 { + writeln!(f)?; + } + writeln!(f, "component {} {{", component.name)?; + for &state in &component.states { + let state = self.state(state); + writeln!(f, " state {} {{", state.name)?; + if let Some(body) = state.body { + self.display_command(f, body, 2)?; + } + writeln!(f, " }}")?; + } + let initial = component + .initial + .iter() + .map(|&id| self.state(id).name.clone()) + .collect::>() + .join(", "); + writeln!(f, " init {initial}")?; + writeln!(f, "}}")?; + } + if !self.components.is_empty() { + writeln!(f)?; + } + + if let Some(environment) = self.environment { + writeln!(f, "environment {{")?; + self.display_command(f, environment, 1)?; + writeln!(f, "}}")?; + writeln!(f)?; + } + + for penalty in &self.penalties { + writeln!(f, "penalty {} = {};", penalty.name, self.display_expr(penalty.value))?; + } + if !self.penalties.is_empty() { + writeln!(f)?; + } + + for decl in &self.perturbation_decls { + writeln!( + f, + "perturbation {} = {};", + decl.name, + self.display_perturbation(decl.root) + )?; + } + if !self.perturbation_decls.is_empty() { + writeln!(f)?; + } + + for decl in &self.distance_decls { + writeln!(f, "distance {} = {};", decl.name, self.display_distance(decl.root))?; + } + if !self.distance_decls.is_empty() { + writeln!(f)?; + } + + for decl in &self.formula_decls { + writeln!(f, "formula {} = {};", decl.name, self.display_formula(decl.root))?; + } + + Ok(()) + } +} + +impl IrProgram { + fn display_command(&self, f: &mut fmt::Formatter<'_>, id: CommandRef, indent: usize) -> fmt::Result { + let pad = " ".repeat(indent); + match *self.command(id) { + CommandNode::Assign(update) => { + let target = self.slot(update.target); + if let Some(guard) = update.guard { + write!(f, "{pad}when {} ", self.display_expr(guard))?; + } else { + write!(f, "{pad}")?; + } + writeln!(f, "{}' = {};", target.name, self.display_expr(update.value)) + } + CommandNode::IfThenElse { + guard, + then_branch, + else_branch, + } => { + writeln!(f, "{pad}if {} {{", self.display_expr(guard))?; + if let Some(then_branch) = then_branch { + self.display_command(f, then_branch, indent + 1)?; + } + if let Some(else_branch) = else_branch { + writeln!(f, "{pad}}} else {{")?; + self.display_command(f, else_branch, indent + 1)?; + } + writeln!(f, "{pad}}}") + } + CommandNode::Let { slot, value, body } => { + let info = self.slot(slot); + writeln!( + f, + "{pad}let {} #{} = {};", + info.name, + slot.value(), + self.display_expr(value) + )?; + if let Some(body) = body { + self.display_command(f, body, indent)?; + } + Ok(()) + } + CommandNode::Sequence(left, right) => { + self.display_command(f, left, indent)?; + self.display_command(f, right, indent) + } + CommandNode::Step { steps, target } => { + write!(f, "{pad}step {}", self.state(target).name)?; + if let Some(steps) = steps { + write!(f, " x {}", self.display_expr(steps))?; + } + writeln!(f, ";") + } + CommandNode::Exec(target) => writeln!(f, "{pad}exec {};", self.state(target).name), + } + } + + fn display_stmt(&self, f: &mut fmt::Formatter<'_>, id: StmtRef, indent: usize) -> fmt::Result { + let pad = " ".repeat(indent); + match *self.stmt(id) { + StmtNode::Return(value) => writeln!(f, "{pad}return {}", self.display_expr(value)), + StmtNode::IfThenElse { + guard, + then_branch, + else_branch, + } => { + writeln!(f, "{pad}if {} {{", self.display_expr(guard))?; + self.display_stmt(f, then_branch, indent + 1)?; + if let Some(else_branch) = else_branch { + writeln!(f, "{pad}}} else {{")?; + self.display_stmt(f, else_branch, indent + 1)?; + } + writeln!(f, "{pad}}}") + } + StmtNode::Let { slot, value, body } => { + let info = self.slot(slot); + writeln!( + f, + "{pad}let {} #{} = {};", + info.name, + slot.value(), + self.display_expr(value) + )?; + self.display_stmt(f, body, indent) + } + } + } + + /// Renders an expression as source-like text, substituting slot names. + fn display_expr(&self, id: ExprRef) -> String { + match *self.expr(id) { + ExprNode::Literal(value) => value.to_string(), + ExprNode::Unreachable(what) => format!(""), + ExprNode::Load(slot) => format!("load #{}:{}", slot.value(), self.slot(slot).name), + ExprNode::Not(inner) => format!("!{}", self.display_expr(inner)), + ExprNode::Negate(inner) => format!("-{}", self.display_expr(inner)), + ExprNode::Widen(inner) => format!("+{}", self.display_expr(inner)), + ExprNode::Binary(op, left, right) => { + format!( + "({} {} {})", + self.display_expr(left), + display_binary_op(op), + self.display_expr(right) + ) + } + ExprNode::MathUnary(function, inner) => { + format!("{}({})", display_math_unary(function), self.display_expr(inner)) + } + ExprNode::MathBinary(function, left, right) => format!( + "{}({}, {})", + display_math_binary(function), + self.display_expr(left), + self.display_expr(right) + ), + ExprNode::Select { + guard, + then_branch, + else_branch, + } => format!( + "select({}, {}, {})", + self.display_expr(guard), + self.display_expr(then_branch), + self.display_expr(else_branch) + ), + ExprNode::Call { function, arguments } => { + let function = self.function(function); + let arguments = self + .expr_list(arguments) + .iter() + .map(|&argument| self.display_expr(argument)) + .collect::>() + .join(", "); + format!("{}({arguments})", function.name) + } + ExprNode::SampleUnit => "R".to_string(), + ExprNode::SampleRange { min, max } => { + format!("R[{}, {}]", self.display_expr(min), self.display_expr(max)) + } + ExprNode::SampleNormal { mean, variance } => { + format!("N[{}, {}]", self.display_expr(mean), self.display_expr(variance)) + } + ExprNode::SampleChoice(list) => { + let elements = self + .expr_list(list) + .iter() + .map(|&element| self.display_expr(element)) + .collect::>() + .join(", "); + format!("U[{elements}]") + } + } + } + + /// The name of the top-level `perturbation name = ..;` declaration whose + /// root is `id` — a linear search over [Self::perturbation_decls], fine + /// for `Display` (debug/test use only, never a hot path). Every + /// `PerturbationIr::Reference` is built from a resolved `DefRef` at + /// lowering time (see `lower.rs`), so it always names a real root; the + /// fallback only matters if the arena were hand-corrupted, as + /// `validate_rejects_a_corrupted_arena`-style tests do. + fn perturbation_decl_name(&self, id: PerturbationId) -> &str { + self.perturbation_decls + .iter() + .find(|decl| decl.root == id) + .map(|decl| decl.name.as_str()) + .unwrap_or("") + } + + fn distance_decl_name(&self, id: DistanceId) -> &str { + self.distance_decls + .iter() + .find(|decl| decl.root == id) + .map(|decl| decl.name.as_str()) + .unwrap_or("") + } + + fn formula_decl_name(&self, id: FormulaId) -> &str { + self.formula_decls + .iter() + .find(|decl| decl.root == id) + .map(|decl| decl.name.as_str()) + .unwrap_or("") + } + + fn display_perturbation(&self, id: PerturbationId) -> String { + match self.perturbation(id) { + PerturbationIr::Nil => "nil".to_string(), + PerturbationIr::Reference(target) => self.perturbation_decl_name(*target).to_string(), + PerturbationIr::Atomic { assignments, time } => { + let assignments = assignments + .iter() + .map(|assignment| { + format!( + "{} <- {}", + self.slot(assignment.target).name, + self.display_expr(assignment.value) + ) + }) + .collect::>() + .join(", "); + format!("[{assignments}] @ {}", self.display_expr(*time)) + } + PerturbationIr::Sequence(left, right) => { + format!( + "{} ; {}", + self.display_perturbation(*left), + self.display_perturbation(*right) + ) + } + PerturbationIr::Iteration { argument, iterations } => { + format!( + "({})^{}", + self.display_perturbation(*argument), + self.display_expr(*iterations) + ) + } + } + } + + fn display_distance(&self, id: DistanceId) -> String { + match self.distance(id) { + DistanceIr::Reference(target) => self.distance_decl_name(*target).to_string(), + DistanceIr::AtomicLeft(penalty) => format!("< {}", self.penalty(*penalty).name), + DistanceIr::AtomicRight(penalty) => format!("> {}", self.penalty(*penalty).name), + DistanceIr::Eventually { from, to, argument } => format!( + "\\F[{}, {}] {}", + self.display_expr(*from), + self.display_expr(*to), + self.display_distance(*argument) + ), + DistanceIr::Globally { from, to, argument } => format!( + "\\G[{}, {}] {}", + self.display_expr(*from), + self.display_expr(*to), + self.display_distance(*argument) + ), + DistanceIr::Until { from, to, left, right } => format!( + "{} \\U[{}, {}] {}", + self.display_distance(*left), + self.display_expr(*from), + self.display_expr(*to), + self.display_distance(*right) + ), + DistanceIr::Threshold { op, left, threshold } => format!( + "{} {} {}", + self.display_distance(*left), + display_comparison_op(*op), + self.display_expr(*threshold) + ), + DistanceIr::Min(left, right) => format!( + "min({}, {})", + self.display_distance(*left), + self.display_distance(*right) + ), + DistanceIr::Max(left, right) => format!( + "max({}, {})", + self.display_distance(*left), + self.display_distance(*right) + ), + DistanceIr::LinearCombination(terms) => terms + .iter() + .map(|&(weight, distance)| { + format!("{} * {}", self.display_expr(weight), self.display_distance(distance)) + }) + .collect::>() + .join(" + "), + } + } + + fn display_formula(&self, id: FormulaId) -> String { + match self.formula(id) { + FormulaIr::True => "true".to_string(), + FormulaIr::False => "false".to_string(), + FormulaIr::Reference(target) => self.formula_decl_name(*target).to_string(), + FormulaIr::Distance { + distance, + perturbation, + op, + value, + } => format!( + "\\D[{}, {}] {} {}", + self.distance_decl_name(*distance), + self.perturbation_decl_name(*perturbation), + display_comparison_op(*op), + self.display_expr(*value) + ), + FormulaIr::Not(inner) => format!("!{}", self.display_formula(*inner)), + FormulaIr::Globally { from, to, argument } => format!( + "\\G[{}, {}] {}", + self.display_expr(*from), + self.display_expr(*to), + self.display_formula(*argument) + ), + FormulaIr::Eventually { from, to, argument } => format!( + "\\F[{}, {}] {}", + self.display_expr(*from), + self.display_expr(*to), + self.display_formula(*argument) + ), + FormulaIr::And(left, right) => { + format!("({} && {})", self.display_formula(*left), self.display_formula(*right)) + } + FormulaIr::Or(left, right) => { + format!("({} || {})", self.display_formula(*left), self.display_formula(*right)) + } + FormulaIr::Until { from, to, left, right } => format!( + "{} \\U[{}, {}] {}", + self.display_formula(*left), + self.display_expr(*from), + self.display_expr(*to), + self.display_formula(*right) + ), + } + } +} + +fn display_binary_op(op: BinaryOp) -> &'static str { + match op { + BinaryOp::Mult => "*", + BinaryOp::Div => "/", + BinaryOp::IntDiv => "div", + BinaryOp::Add => "+", + BinaryOp::Subtract => "-", + BinaryOp::Mod => "%", + BinaryOp::Less => "<", + BinaryOp::Leq => "<=", + BinaryOp::Eq => "==", + BinaryOp::Geq => ">=", + BinaryOp::Greater => ">", + BinaryOp::BitAnd => "&", + BinaryOp::And => "&&", + BinaryOp::BitOr => "|", + BinaryOp::Or => "||", + } +} + +fn display_math_unary(function: MathUnaryFunction) -> &'static str { + match function { + MathUnaryFunction::Abs => "abs", + MathUnaryFunction::Acos => "acos", + MathUnaryFunction::Asin => "asin", + MathUnaryFunction::Atan => "atan", + MathUnaryFunction::Cbrt => "cbrt", + MathUnaryFunction::Ceil => "ceil", + MathUnaryFunction::Cos => "cos", + MathUnaryFunction::Cosh => "cosh", + MathUnaryFunction::Exp => "exp", + MathUnaryFunction::Expm1 => "expm1", + MathUnaryFunction::Floor => "floor", + MathUnaryFunction::Log => "log", + MathUnaryFunction::Log10 => "log10", + MathUnaryFunction::Log1p => "log1p", + MathUnaryFunction::Signum => "signum", + MathUnaryFunction::Sin => "sin", + MathUnaryFunction::Sinh => "sinh", + MathUnaryFunction::Sqrt => "sqrt", + MathUnaryFunction::Tan => "tan", + } +} + +fn display_math_binary(function: MathBinaryFunction) -> &'static str { + match function { + MathBinaryFunction::Atan2 => "atan2", + MathBinaryFunction::Hypot => "hypot", + MathBinaryFunction::Max => "max", + MathBinaryFunction::Min => "min", + MathBinaryFunction::Pow => "pow", + } +} + +fn display_comparison_op(op: ComparisonOp) -> &'static str { + match op { + ComparisonOp::Less => "<", + ComparisonOp::Leq => "<=", + ComparisonOp::Eq => "==", + ComparisonOp::Geq => ">=", + ComparisonOp::Greater => ">", + } +} diff --git a/crates/stark/src/lib.rs b/crates/stark/src/lib.rs new file mode 100644 index 00000000..ec7e130b --- /dev/null +++ b/crates/stark/src/lib.rs @@ -0,0 +1,28 @@ +#![doc = include_str!("../README.md")] +// The crate documents its private items (`cargo doc --document-private-items`), +// so the design-rationale module docs may point at the private helpers and +// fields they describe. +#![allow(rustdoc::private_intra_doc_links)] + +mod ast; +mod consume; +mod diagnostics; +pub mod eval; +pub mod ir; +mod lower; +mod parse; +mod precedence; +mod resolve; +mod specification; +mod typecheck; +mod types; +pub mod value; + +pub(crate) use parse::*; + +pub use ast::DefId; +pub use ast::UntypedStarkSpecification; +pub use diagnostics::Diagnostics; +pub use ir::IrProgram; +pub use resolve::DefKind; +pub use specification::StarkSpecification; diff --git a/crates/stark/src/lower.rs b/crates/stark/src/lower.rs new file mode 100644 index 00000000..80477b18 --- /dev/null +++ b/crates/stark/src/lower.rs @@ -0,0 +1,2003 @@ +use std::collections::HashMap; + +use log::debug; +use log::trace; +use merc_utilities::Span; + +use crate::ast; +use crate::ast::Binding; +use crate::ast::DefId; +use crate::ast::Expression; +use crate::ast::ExpressionKind; +use crate::ast::Function; +use crate::ast::FunctionStatement; +use crate::ast::LocalId; +use crate::ast::MathFunction; +use crate::ast::Ty; +use crate::ast::Variable; +use crate::diagnostics::Diagnostics; +use crate::ir::BinaryOp; +use crate::ir::CommandNode; +use crate::ir::CommandRef; +use crate::ir::ComparisonOp; +use crate::ir::ComponentId; +use crate::ir::ComponentIr; +use crate::ir::DistanceDecl; +use crate::ir::DistanceId; +use crate::ir::DistanceIr; +use crate::ir::ExprList; +use crate::ir::ExprNode; +use crate::ir::ExprRef; +use crate::ir::FormulaDecl; +use crate::ir::FormulaId; +use crate::ir::FormulaIr; +use crate::ir::FunctionId; +use crate::ir::FunctionIr; +use crate::ir::GlobalInit; +use crate::ir::IrProgram; +use crate::ir::IrStateId; +use crate::ir::MathBinaryFunction; +use crate::ir::MathUnaryFunction; +use crate::ir::PenaltyId; +use crate::ir::PenaltyIr; +use crate::ir::PerturbationAssignment; +use crate::ir::PerturbationDecl; +use crate::ir::PerturbationId; +use crate::ir::PerturbationIr; +use crate::ir::SlotId; +use crate::ir::SlotInfo; +use crate::ir::SlotKind; +use crate::ir::StateIr; +use crate::ir::StmtNode; +use crate::ir::StmtRef; +use crate::ir::Update; +use crate::ir::VariableInfo; +use crate::resolve::SymbolTable; +use crate::specification::StarkSpecification; +use crate::typecheck::TypeTable; +use crate::types::StarkType; +use crate::value::CustomValue; +use crate::value::Value; + +/// Lowers `spec` to an [IrProgram]. +/// +/// The `Result` exists for a not-yet-implemented-construct error class (see +/// this module's doc comment) — currently always `Ok`, since every construct +/// the grammar supports lowers. +pub(crate) fn lower(spec: &StarkSpecification) -> Result { + let mut lowerer = Lowerer::new(spec); + + lowerer.allocate_variable_slots(); + lowerer.allocate_global_slots(); + lowerer.lower_globals(); + lowerer.lower_functions(); + lowerer.lower_variables(); + lowerer.lower_components(); + lowerer.lower_environment(); + lowerer.lower_penalties(); + lowerer.lower_perturbations(); + lowerer.lower_distances(); + lowerer.lower_formulas(); + + debug!( + "lowered {} expression(s), {} statement(s), {} command(s), {} slot(s), {} global(s), \ + {} variable(s), {} function(s), {} component(s)/{} state(s), {} penalty/-ies, \ + {} perturbation(s), {} distance(s), {} formula(s); {} diagnostic(s)", + lowerer.exprs.len(), + lowerer.stmts.len(), + lowerer.commands.len(), + lowerer.slots.len(), + lowerer.globals.len(), + lowerer.variables.len(), + lowerer.functions.len(), + lowerer.components.len(), + lowerer.states.len(), + lowerer.penalties.len(), + lowerer.perturbation_decls.len(), + lowerer.distance_decls.len(), + lowerer.formula_decls.len(), + lowerer.diagnostics.items().len() + ); + + let program = IrProgram { + exprs: lowerer.exprs, + expr_spans: lowerer.expr_spans, + expr_types: lowerer.expr_types, + expr_lists: lowerer.expr_lists, + stmts: lowerer.stmts, + commands: lowerer.commands, + slots: lowerer.slots, + variables: lowerer.variables, + globals: lowerer.globals, + functions: lowerer.functions, + penalties: lowerer.penalties, + states: lowerer.states, + components: lowerer.components, + environment: lowerer.environment, + perturbations: lowerer.perturbations, + perturbation_decls: lowerer.perturbation_decls, + distances: lowerer.distances, + distance_decls: lowerer.distance_decls, + formulas: lowerer.formulas, + formula_decls: lowerer.formula_decls, + }; + + debug_assert!( + program.validate().is_ok(), + "lower produced an internally inconsistent arena: {:?}", + program.validate().err() + ); + + lowerer.diagnostics.into_result(program) +} + +/// [lower]'s `Result` return type is kept even though every construct in the +/// grammar now lowers successfully +/// +/// One deliberate deviation from the plan's stated order ("Globals, +/// Variables, Functions"): a variable's initializer may call a function +/// declared earlier in the source (`resolve.rs` resolves functions *before* +/// variables for exactly this reason), so this pass lowers functions +/// *before* variables — a function's [FunctionId] and return type must exist +/// before anything that calls it can be lowered. Constants and parameters +/// can never call a function (they resolve before functions do), so globals +/// keep their place first. +/// +/// Because `spec` only exists if resolution and type checking both +/// succeeded, every `DefRef::id`/`StateRef::id`/`Binding` is `Some` and every +/// `DefId` is typed — violations are asserted (`.expect`/`debug_assert!`) +/// rather than diagnosed, mirroring `resolve.rs`'s and `typecheck.rs`'s own +/// contracts. +pub(crate) struct Lowerer<'a> { + spec: &'a StarkSpecification, + symbols: &'a SymbolTable, + types: &'a TypeTable, + /// Every `type` element's `DefId`, pre-mapped to the [CustomValue] it + /// folds to. + custom_values: HashMap, + + exprs: Vec, + expr_spans: Vec, + expr_types: Vec, + expr_lists: Vec, + + stmts: Vec, + commands: Vec, + + slots: Vec, + /// `DefId -> SlotId` for every constant, parameter and variable. + def_slots: Vec>, + /// `LocalId -> SlotId` for every function argument and `let` binding. + local_slots: Vec>, + /// `DefId -> FunctionId` for every function, filled in as each is + /// lowered (in declaration order). + def_functions: Vec>, + /// The function currently being lowered, if any — `None` while lowering + /// a global/variable initializer or a penalty, which aren't inside any + /// function body. + current_function: Option, + /// `ast::StateId -> IrStateId`, a straight 1:1 mapping since the AST's + /// own `StateId` is already flat across every component (see + /// [IrStateId]'s doc comment). Allocated up front per component, before + /// any state body is lowered, so a `step`/`exec` to a later sibling + /// state resolves just as well as one to an earlier sibling. + def_states: Vec>, + /// `DefId -> PenaltyId`, filled in as each `penalty` is lowered — needed + /// once [DistanceIr::AtomicLeft]/[DistanceIr::AtomicRight] can reference + /// one by name. + def_penalties: Vec>, + /// `DefId -> PerturbationId` (the referenced declaration's *root* node), + /// filled in as each `perturbation` is lowered — mirrors `def_functions`. + def_perturbations: Vec>, + /// `DefId -> DistanceId`, mirrors `def_perturbations`. + def_distances: Vec>, + /// `DefId -> FormulaId`, mirrors `def_perturbations`. + def_formulas: Vec>, + + variables: Vec, + globals: Vec, + functions: Vec, + penalties: Vec, + states: Vec, + components: Vec, + environment: Option, + perturbations: Vec, + perturbation_decls: Vec, + distances: Vec, + distance_decls: Vec, + formulas: Vec, + formula_decls: Vec, + + diagnostics: Diagnostics, +} + +impl<'a> Lowerer<'a> { + fn new(spec: &'a StarkSpecification) -> Self { + let symbols = spec.symbols(); + let types = spec.types(); + Lowerer { + spec, + symbols, + types, + custom_values: build_custom_value_map(spec), + exprs: Vec::new(), + expr_spans: Vec::new(), + expr_types: Vec::new(), + expr_lists: Vec::new(), + stmts: Vec::new(), + commands: Vec::new(), + slots: Vec::new(), + def_slots: vec![None; symbols.defs.len()], + local_slots: vec![None; symbols.locals.len()], + def_functions: vec![None; symbols.defs.len()], + current_function: None, + def_states: vec![None; symbols.states.len()], + def_penalties: vec![None; symbols.defs.len()], + def_perturbations: vec![None; symbols.defs.len()], + def_distances: vec![None; symbols.defs.len()], + def_formulas: vec![None; symbols.defs.len()], + variables: Vec::new(), + globals: Vec::new(), + functions: Vec::new(), + penalties: Vec::new(), + states: Vec::new(), + components: Vec::new(), + environment: None, + perturbations: Vec::new(), + perturbation_decls: Vec::new(), + distances: Vec::new(), + distance_decls: Vec::new(), + formulas: Vec::new(), + formula_decls: Vec::new(), + diagnostics: Diagnostics::new(), + } + } + + // -- Arena builders --------------------------------------------------- + + fn push_expr(&mut self, node: ExprNode, span: Span, ty: StarkType) -> ExprRef { + let id = ExprRef::new(self.exprs.len() as u32); + self.exprs.push(node); + self.expr_spans.push(span); + self.expr_types.push(ty); + id + } + + fn push_expr_list(&mut self, items: Vec) -> ExprList { + let start = self.expr_lists.len() as u32; + let len = items.len() as u32; + self.expr_lists.extend(items); + ExprList { start, len } + } + + fn push_stmt(&mut self, node: StmtNode) -> StmtRef { + let id = StmtRef::new(self.stmts.len() as u32); + self.stmts.push(node); + id + } + + fn push_command(&mut self, node: CommandNode) -> CommandRef { + let id = CommandRef::new(self.commands.len() as u32); + self.commands.push(node); + id + } + + fn alloc_slot(&mut self, name: String, ty: StarkType, kind: SlotKind, span: Span) -> SlotId { + let id = SlotId::new(self.slots.len() as u32); + trace!("allocating slot {id:?} for `{name}` : {ty} ({kind:?})"); + self.slots.push(SlotInfo { name, ty, kind, span }); + id + } + + /// Records `slot` as `id`'s, asserting `id` was not already allocated one. + /// A second allocation would silently orphan the first slot — every + /// reference lowered before the overwrite keeps pointing at it — which is + /// exactly the kind of arena corruption that only shows up as a nonsense + /// value much later. + fn bind_def_slot(&mut self, id: DefId, slot: SlotId) { + debug_assert!( + self.def_slots[id.value()].is_none(), + "{id:?} (`{}`) allocated a second slot {slot:?}, overwriting {:?}", + self.symbols.def(id).name, + self.def_slots[id.value()] + ); + self.def_slots[id.value()] = Some(slot); + } + + /// [Self::bind_def_slot]'s counterpart for `let` bindings and function + /// arguments. Each is bound exactly once — the no-recursion property means + /// no binding is ever live twice, which is precisely what lets every local + /// have one statically allocated slot instead of a call frame. + fn bind_local_slot(&mut self, id: LocalId, slot: SlotId) { + debug_assert!( + self.local_slots[id.value()].is_none(), + "local `{}` ({id:?}) allocated a second slot {slot:?}, overwriting {:?} — \ + the no-recursion invariant the flat slot layout depends on is broken", + self.symbols.local(id).name, + self.local_slots[id.value()] + ); + self.local_slots[id.value()] = Some(slot); + } + + /// The type `typecheck.rs` assigned `id`. Every `DefId` reaching lowering + /// is typed (a `StarkSpecification` only exists after a clean type check), + /// so a missing entry is a bug in that pass rather than user error — it is + /// asserted here and degrades to [StarkType::Error] in release. + fn type_of_def(&self, id: DefId) -> StarkType { + debug_assert!( + self.types.type_of(id).is_some(), + "{id:?} (`{}`) reached lowering without a type", + self.symbols.def(id).name + ); + self.types.type_of(id).cloned().unwrap_or(StarkType::Error) + } + + fn expr_type(&self, id: ExprRef) -> StarkType { + self.expr_types[id.value() as usize].clone() + } + + // -- Slot allocation ---------------------------------------------------- + + /// Allocates `[0, n_variables)`: the global `variables { .. }` block, + /// then every component's local one. + fn allocate_variable_slots(&mut self) { + for variable in &self.spec.ast().variables { + self.allocate_variable_slot(variable); + } + for component in &self.spec.ast().components { + for variable in &component.variables { + self.allocate_variable_slot(variable); + } + } + } + + fn allocate_variable_slot(&mut self, variable: &Variable) { + let Some(id) = resolved(variable.id, "variable", &variable.name.name) else { + return; + }; + let ty = self.type_of_def(id); + let slot = self.alloc_slot( + variable.name.name.clone(), + ty, + SlotKind::Variable, + variable.name.span.clone(), + ); + self.bind_def_slot(id, slot); + } + + /// Allocates `[n_variables, n_globals)`: `const`s then `param`s, each in + /// declaration order. Both already have a type from `typecheck.rs`, so — + /// unlike locals — there's no need to defer filling in [SlotInfo::ty]. + fn allocate_global_slots(&mut self) { + for constant in &self.spec.ast().constants { + let Some(id) = resolved(constant.id, "constant", &constant.name.name) else { + continue; + }; + let ty = self.type_of_def(id); + let slot = self.alloc_slot( + constant.name.name.clone(), + ty, + SlotKind::Global, + constant.name.span.clone(), + ); + self.bind_def_slot(id, slot); + } + for parameter in &self.spec.ast().parameters { + let Some(id) = resolved(parameter.id, "parameter", ¶meter.name.name) else { + continue; + }; + let ty = self.type_of_def(id); + let slot = self.alloc_slot( + parameter.name.name.clone(), + ty, + SlotKind::Global, + parameter.name.span.clone(), + ); + self.bind_def_slot(id, slot); + } + } + + // -- Globals, variables, penalties -------------------------------------- + + fn lower_globals(&mut self) { + for constant in &self.spec.ast().constants { + self.lower_global(constant.id, "constant", &constant.name.name, &constant.value); + } + for parameter in &self.spec.ast().parameters { + self.lower_global(parameter.id, "parameter", ¶meter.name.name, ¶meter.value); + } + } + + fn lower_global(&mut self, id: Option, kind: &str, name: &str, value: &Expression) { + let Some(id) = resolved(id, kind, name) else { return }; + let slot = self.def_slots[id.value()].expect("global slot allocated during slot allocation"); + let value = self.lower_expression(value); + trace!("lowered {kind} `{name}` -> {slot:?} = {value:?}"); + self.globals.push(GlobalInit { slot, value }); + } + + fn lower_variables(&mut self) { + for variable in &self.spec.ast().variables { + self.lower_variable(variable); + } + for component in &self.spec.ast().components { + for variable in &component.variables { + self.lower_variable(variable); + } + } + } + + fn lower_variable(&mut self, variable: &Variable) { + let Some(id) = resolved(variable.id, "variable", &variable.name.name) else { + return; + }; + let slot = self.def_slots[id.value()].expect("variable slot allocated during slot allocation"); + trace!("lowering variable `{}` -> {slot:?}", variable.name.name); + let range = variable + .range + .as_ref() + .map(|range| (self.lower_expression(&range.min), self.lower_expression(&range.max))); + let initial_value = self.lower_expression(&variable.initial_value); + self.variables.push(VariableInfo { + slot, + range, + initial_value, + }); + } + + fn lower_penalties(&mut self) { + for penalty in &self.spec.ast().penalties { + let value = self.lower_expression(&penalty.value); + let penalty_id = PenaltyId::new(self.penalties.len() as u32); + self.penalties.push(PenaltyIr { + name: penalty.name.name.clone(), + value, + }); + trace!("lowered penalty `{}` -> {penalty_id:?}", penalty.name.name); + if let Some(id) = resolved(penalty.id, "penalty", &penalty.name.name) { + self.def_penalties[id.value()] = Some(penalty_id); + } + } + } + + // -- Sub-languages: perturbation / distance / formula -------------------- + // + // All three follow the same shape as expression lowering: a post-order + // walk that pushes children before their parent and returns the parent's + // `*Id`. `Reference(DefRef)` resolves to the referent's root `*Id` via + // `def_perturbations`/`def_distances`/`def_formulas`, filled in as each + // top-level declaration is lowered — sound because `resolve.rs` declares + // each of these only after its own body resolves (see this module's doc + // comment), so a reference can never target something not yet lowered. + + fn lower_perturbations(&mut self) { + for perturbation in &self.spec.ast().perturbations { + let root = self.lower_perturbation_expression(&perturbation.value); + if let Some(id) = perturbation.id { + self.def_perturbations[id.value()] = Some(root); + } + trace!("lowered perturbation `{}` -> {root:?}", perturbation.name.name); + self.perturbation_decls.push(PerturbationDecl { + name: perturbation.name.name.clone(), + root, + }); + } + } + + fn lower_perturbation_expression(&mut self, expression: &ast::PerturbationExpression) -> PerturbationId { + match expression { + ast::PerturbationExpression::Nil => self.push_perturbation(PerturbationIr::Nil), + ast::PerturbationExpression::Reference(reference) => { + let target_id = reference + .id + .expect("perturbation reference resolved by a clean resolution"); + let target = self.def_perturbations[target_id.value()].unwrap_or_else(|| { + panic!( + "reference to `{}` lowered before its target — no-forward-references should make this impossible", + reference.name.name + ) + }); + self.push_perturbation(PerturbationIr::Reference(target)) + } + ast::PerturbationExpression::Atomic { assignments, time } => { + let assignments = assignments + .iter() + .map(|assignment| { + let value = self.lower_expression(&assignment.value); + let target_id = assignment + .target + .id + .expect("perturbation assignment target resolved by a clean resolution"); + let target = + self.def_slots[target_id.value()].expect("variable slot allocated during slot allocation"); + PerturbationAssignment { target, value } + }) + .collect(); + let time = self.lower_expression(time); + self.push_perturbation(PerturbationIr::Atomic { assignments, time }) + } + ast::PerturbationExpression::Sequence(left, right) => { + let left = self.lower_perturbation_expression(left); + let right = self.lower_perturbation_expression(right); + self.push_perturbation(PerturbationIr::Sequence(left, right)) + } + ast::PerturbationExpression::Iteration { argument, iterations } => { + let argument = self.lower_perturbation_expression(argument); + let iterations = self.lower_expression(iterations); + self.push_perturbation(PerturbationIr::Iteration { argument, iterations }) + } + } + } + + fn push_perturbation(&mut self, node: PerturbationIr) -> PerturbationId { + let id = PerturbationId::new(self.perturbations.len() as u32); + self.perturbations.push(node); + id + } + + fn lower_distances(&mut self) { + for distance in &self.spec.ast().distances { + let root = self.lower_distance_expression(&distance.value); + if let Some(id) = distance.id { + self.def_distances[id.value()] = Some(root); + } + trace!("lowered distance `{}` -> {root:?}", distance.name.name); + self.distance_decls.push(DistanceDecl { + name: distance.name.name.clone(), + root, + }); + } + } + + fn lower_distance_expression(&mut self, expression: &ast::DistanceExpression) -> DistanceId { + match expression { + ast::DistanceExpression::Reference(reference) => { + let target_id = reference.id.expect("distance reference resolved by a clean resolution"); + let target = self.def_distances[target_id.value()].unwrap_or_else(|| { + panic!( + "reference to `{}` lowered before its target — no-forward-references should make this impossible", + reference.name.name + ) + }); + self.push_distance(DistanceIr::Reference(target)) + } + ast::DistanceExpression::AtomicLeft(reference) => { + let penalty = self.lower_penalty_ref(reference); + self.push_distance(DistanceIr::AtomicLeft(penalty)) + } + ast::DistanceExpression::AtomicRight(reference) => { + let penalty = self.lower_penalty_ref(reference); + self.push_distance(DistanceIr::AtomicRight(penalty)) + } + ast::DistanceExpression::Eventually { from, to, argument } => { + let from = self.lower_expression(from); + let to = self.lower_expression(to); + let argument = self.lower_distance_expression(argument); + self.push_distance(DistanceIr::Eventually { from, to, argument }) + } + ast::DistanceExpression::Globally { from, to, argument } => { + let from = self.lower_expression(from); + let to = self.lower_expression(to); + let argument = self.lower_distance_expression(argument); + self.push_distance(DistanceIr::Globally { from, to, argument }) + } + ast::DistanceExpression::Until { from, to, left, right } => { + let from = self.lower_expression(from); + let to = self.lower_expression(to); + let left = self.lower_distance_expression(left); + let right = self.lower_distance_expression(right); + self.push_distance(DistanceIr::Until { from, to, left, right }) + } + ast::DistanceExpression::Threshold { op, left, threshold } => { + let left = self.lower_distance_expression(left); + let threshold = self.lower_expression(threshold); + self.push_distance(DistanceIr::Threshold { + op: map_comparison_op(*op), + left, + threshold, + }) + } + ast::DistanceExpression::Min(left, right) => { + let left = self.lower_distance_expression(left); + let right = self.lower_distance_expression(right); + self.push_distance(DistanceIr::Min(left, right)) + } + ast::DistanceExpression::Max(left, right) => { + let left = self.lower_distance_expression(left); + let right = self.lower_distance_expression(right); + self.push_distance(DistanceIr::Max(left, right)) + } + ast::DistanceExpression::LinearCombination(terms) => { + let terms = terms + .iter() + .map(|(weight, distance)| { + let weight = self.lower_expression(weight); + let distance = self.lower_distance_expression(distance); + (weight, distance) + }) + .collect(); + self.push_distance(DistanceIr::LinearCombination(terms)) + } + } + } + + fn lower_penalty_ref(&self, reference: &ast::DefRef) -> PenaltyId { + let id = reference.id.expect("penalty reference resolved by a clean resolution"); + self.def_penalties[id.value()].expect("penalty lowered during penalty lowering") + } + + fn push_distance(&mut self, node: DistanceIr) -> DistanceId { + let id = DistanceId::new(self.distances.len() as u32); + self.distances.push(node); + id + } + + fn lower_formulas(&mut self) { + for formula in &self.spec.ast().formulas { + let root = self.lower_robtl_formula(&formula.value); + if let Some(id) = formula.id { + self.def_formulas[id.value()] = Some(root); + } + trace!("lowered formula `{}` -> {root:?}", formula.name.name); + self.formula_decls.push(FormulaDecl { + name: formula.name.name.clone(), + root, + }); + } + } + + fn lower_robtl_formula(&mut self, formula: &ast::RobtlFormula) -> FormulaId { + match formula { + ast::RobtlFormula::True => self.push_formula(FormulaIr::True), + ast::RobtlFormula::False => self.push_formula(FormulaIr::False), + ast::RobtlFormula::Reference(reference) => { + let target_id = reference.id.expect("formula reference resolved by a clean resolution"); + let target = self.def_formulas[target_id.value()].unwrap_or_else(|| { + panic!( + "reference to `{}` lowered before its target — no-forward-references should make this impossible", + reference.name.name + ) + }); + self.push_formula(FormulaIr::Reference(target)) + } + ast::RobtlFormula::Distance { + distance, + perturbation, + op, + value, + } => { + let distance_id = distance.id.expect("distance reference resolved by a clean resolution"); + let distance = + self.def_distances[distance_id.value()].expect("distance lowered during distance lowering"); + let perturbation_id = perturbation + .id + .expect("perturbation reference resolved by a clean resolution"); + let perturbation = self.def_perturbations[perturbation_id.value()] + .expect("perturbation lowered during perturbation lowering"); + let value = self.lower_expression(value); + self.push_formula(FormulaIr::Distance { + distance, + perturbation, + op: map_comparison_op(*op), + value, + }) + } + ast::RobtlFormula::Not(inner) => { + let inner = self.lower_robtl_formula(inner); + self.push_formula(FormulaIr::Not(inner)) + } + ast::RobtlFormula::Globally { from, to, argument } => { + let from = self.lower_expression(from); + let to = self.lower_expression(to); + let argument = self.lower_robtl_formula(argument); + self.push_formula(FormulaIr::Globally { from, to, argument }) + } + ast::RobtlFormula::Eventually { from, to, argument } => { + let from = self.lower_expression(from); + let to = self.lower_expression(to); + let argument = self.lower_robtl_formula(argument); + self.push_formula(FormulaIr::Eventually { from, to, argument }) + } + ast::RobtlFormula::And(left, right) => { + let left = self.lower_robtl_formula(left); + let right = self.lower_robtl_formula(right); + self.push_formula(FormulaIr::And(left, right)) + } + ast::RobtlFormula::Or(left, right) => { + let left = self.lower_robtl_formula(left); + let right = self.lower_robtl_formula(right); + self.push_formula(FormulaIr::Or(left, right)) + } + ast::RobtlFormula::Until { from, to, left, right } => { + let from = self.lower_expression(from); + let to = self.lower_expression(to); + let left = self.lower_robtl_formula(left); + let right = self.lower_robtl_formula(right); + self.push_formula(FormulaIr::Until { from, to, left, right }) + } + } + } + + fn push_formula(&mut self, node: FormulaIr) -> FormulaId { + let id = FormulaId::new(self.formulas.len() as u32); + self.formulas.push(node); + id + } + + // -- Components / controllers ------------------------------------------ + + fn lower_components(&mut self) { + for component in &self.spec.ast().components { + self.lower_component(component); + } + } + + fn lower_component(&mut self, component: &ast::Component) { + if component.id.is_none() { + return; + } + trace!( + "lowering component `{}` with {} state(s)", + component.name.name, + component.states.len() + ); + let component_id = ComponentId::new(self.components.len() as u32); + + // Every state's `IrStateId` (and a placeholder `StateIr`) is + // allocated before any body is lowered, since a `step`/`exec` may + // target a state declared later in the same component. + let mut state_ids = Vec::with_capacity(component.states.len()); + for state in &component.states { + let Some(id) = resolved(state.id, "controller state", &state.name.name) else { + continue; + }; + let ir_state = IrStateId::new(self.states.len() as u32); + self.states.push(StateIr { + name: state.name.name.clone(), + component: component_id, + body: None, + }); + self.def_states[id.value()] = Some(ir_state); + state_ids.push(ir_state); + } + + for state in &component.states { + let Some(id) = state.id else { continue }; + let ir_state = self.def_states[id.value()].expect("state ir id allocated above"); + trace!("lowering state `{}` -> {ir_state:?}", state.name.name); + let body = self.lower_controller_command_list(&state.body); + self.states[ir_state.value() as usize].body = body; + } + + let initial = component + .init + .iter() + .map(|state_ref| self.lower_state_ref(state_ref)) + .collect(); + + self.components.push(ComponentIr { + name: component.name.name.clone(), + states: state_ids, + initial, + }); + } + + fn lower_state_ref(&self, state_ref: &ast::StateRef) -> IrStateId { + let id = state_ref.id.expect("state reference resolved by a clean resolution"); + self.def_states[id.value()].expect("state ir id allocated during component lowering") + } + + /// Lowers a `{ .. }` block of commands to a left-associated chain of + /// `Sequence` nodes, in source order. `None` for an empty block — there + /// is nothing to run, so no node is pushed for it. + fn lower_controller_command_list(&mut self, commands: &[ast::ControllerCommand]) -> Option { + let mut result: Option = None; + for command in commands { + let Some(node) = self.lower_controller_command(command) else { + continue; + }; + result = Some(match result { + None => node, + Some(previous) => self.push_command(CommandNode::Sequence(previous, node)), + }); + } + result + } + + /// `None` only for `ControllerCommand::Block(&[])`, an empty nested + /// block — every other command always lowers to a node. + fn lower_controller_command(&mut self, command: &ast::ControllerCommand) -> Option { + match command { + ast::ControllerCommand::Step { steps, target } => { + let steps = steps.as_ref().map(|steps| self.lower_expression(steps)); + let target = self.lower_state_ref(target); + Some(self.push_command(CommandNode::Step { steps, target })) + } + ast::ControllerCommand::Exec(target) => { + let target = self.lower_state_ref(target); + Some(self.push_command(CommandNode::Exec(target))) + } + ast::ControllerCommand::Let { id, name, value, body } => { + let value_ref = self.lower_expression(value); + let ty = self.expr_type(value_ref); + let local_id = id.expect("let binding resolved by a clean resolution"); + let slot = self.alloc_slot(name.name.clone(), ty, SlotKind::Local, name.span.clone()); + self.bind_local_slot(local_id, slot); + let body = self.lower_controller_command_list(body); + Some(self.push_command(CommandNode::Let { + slot, + value: value_ref, + body, + })) + } + ast::ControllerCommand::Assignment(update) => { + let update = self.lower_update(update); + Some(self.push_command(CommandNode::Assign(update))) + } + ast::ControllerCommand::IfThenElse { + guard, + then_branch, + else_branch, + } => { + let guard = self.lower_expression(guard); + let then_branch = self.lower_controller_command_list(then_branch); + let else_branch = else_branch + .as_ref() + .and_then(|branch| self.lower_controller_command_list(branch)); + Some(self.push_command(CommandNode::IfThenElse { + guard, + then_branch, + else_branch, + })) + } + // Not a distinct runtime construct — a nested `{ .. }` only + // introduces grouping in the source, so it lowers to the same + // `Sequence` chain a top-level list would (and, like any list, + // may legitimately be empty). + ast::ControllerCommand::Block(inner) => self.lower_controller_command_list(inner), + } + } + + fn lower_update(&mut self, update: &ast::Update) -> Update { + let guard = update.guard.as_ref().map(|guard| self.lower_expression(guard)); + let value = self.lower_expression(&update.value); + let target_id = update.target.id.expect("update target resolved by a clean resolution"); + let target = self.def_slots[target_id.value()].expect("variable slot allocated during slot allocation"); + Update { target, guard, value } + } + + // -- Environment -------------------------------------------------------- + + fn lower_environment(&mut self) { + let Some(environment) = &self.spec.ast().environment else { + return; + }; + trace!( + "lowering the environment block with {} command(s)", + environment.commands.len() + ); + self.environment = self.lower_environment_commands(&environment.commands); + } + + /// Same idea as [Self::lower_controller_command_list], but over + /// `ast::EnvironmentCommand` — there is no `Step`/`Exec` here, so a + /// block simply runs to completion once every command in it has. + fn lower_environment_commands(&mut self, commands: &[ast::EnvironmentCommand]) -> Option { + let mut result: Option = None; + for command in commands { + let Some(node) = self.lower_environment_command(command) else { + continue; + }; + result = Some(match result { + None => node, + Some(previous) => self.push_command(CommandNode::Sequence(previous, node)), + }); + } + result + } + + fn lower_environment_command(&mut self, command: &ast::EnvironmentCommand) -> Option { + match command { + ast::EnvironmentCommand::Assignment(update) => { + let update = self.lower_update(update); + Some(self.push_command(CommandNode::Assign(update))) + } + ast::EnvironmentCommand::IfThenElse { + guard, + then_branch, + else_branch, + } => { + let guard = self.lower_expression(guard); + let then_branch = self.lower_environment_command(then_branch); + let else_branch = else_branch + .as_ref() + .and_then(|branch| self.lower_environment_command(branch)); + Some(self.push_command(CommandNode::IfThenElse { + guard, + then_branch, + else_branch, + })) + } + // `let a = e1 and b = e2(a) and .. in body`: each binding sees + // every binding before it (matching `resolve.rs`'s nested-scope + // treatment of the same construct), so this lowers to nested + // `Let`s, innermost-bound-last-declared first, around `body`. + ast::EnvironmentCommand::Let { bindings, body } => self.lower_environment_let(bindings, body), + ast::EnvironmentCommand::Block(inner) => self.lower_environment_commands(inner), + } + } + + fn lower_environment_let( + &mut self, + bindings: &[ast::LocalVariable], + body: &ast::EnvironmentCommand, + ) -> Option { + let Some((first, rest)) = bindings.split_first() else { + return self.lower_environment_command(body); + }; + let value_ref = self.lower_expression(&first.value); + let ty = self.expr_type(value_ref); + let local_id = first.id.expect("let binding resolved by a clean resolution"); + let slot = self.alloc_slot(first.name.name.clone(), ty, SlotKind::Local, first.name.span.clone()); + self.bind_local_slot(local_id, slot); + let inner = self.lower_environment_let(rest, body); + Some(self.push_command(CommandNode::Let { + slot, + value: value_ref, + body: inner, + })) + } + + // -- Functions ------------------------------------------------------ + + fn lower_functions(&mut self) { + for function in &self.spec.ast().functions { + self.lower_function(function); + } + } + + fn lower_function(&mut self, function: &Function) { + let Some(id) = resolved(function.id, "function", &function.name.name) else { + return; + }; + trace!( + "lowering function `{}` with {} argument(s)", + function.name.name, + function.arguments.len() + ); + + let mut arguments = Vec::with_capacity(function.arguments.len()); + for argument in &function.arguments { + let Some(local_id) = resolved(argument.id, "function argument", &argument.name.name) else { + continue; + }; + let ty = self.lower_ty(&argument.ty); + let slot = self.alloc_slot( + argument.name.name.clone(), + ty, + SlotKind::Local, + argument.name.span.clone(), + ); + self.bind_local_slot(local_id, slot); + arguments.push(slot); + } + + // Assigned before the body is lowered (rather than after) so a call + // to this very function inside its own body — impossible per the + // no-recursion invariant, but this keeps the invariant assertable + // instead of assumed — would still resolve consistently. + let function_id = FunctionId::new(self.functions.len() as u32); + self.def_functions[id.value()] = Some(function_id); + + let return_type = self + .types + .signature_of(id) + .map(|signature| signature.return_type.clone()) + .unwrap_or(StarkType::Error); + + let previous_function = self.current_function; + self.current_function = Some(function_id); + let body = self.lower_function_statement(&function.body); + self.current_function = previous_function; + + trace!("lowered function `{}` -> {function_id:?}", function.name.name); + self.functions.push(FunctionIr { + name: function.name.name.clone(), + arguments, + return_type, + body, + }); + } + + /// A `FunctionStatement::Let`'s slot is allocated here, lazily, rather + /// than in a separate up-front pass over every function body: since no + /// local is ever read outside the function it belongs to (STARK's + /// scoping forbids it), the plan's "one slot-allocation pass before any + /// lowering" requirement is satisfied just as well by allocating each + /// local's slot the first time lowering reaches its binding site, in + /// declaration order — the final ranges (`variables`, then `globals`, + /// then this scratch tail) come out identical either way. + fn lower_function_statement(&mut self, statement: &FunctionStatement) -> StmtRef { + match statement { + FunctionStatement::Return(value) => { + let value = self.lower_expression(value); + self.push_stmt(StmtNode::Return(value)) + } + FunctionStatement::IfThenElse { + guard, + then_branch, + else_branch, + } => { + let guard = self.lower_expression(guard); + let then_branch = self.lower_function_statement(then_branch); + let else_branch = else_branch.as_ref().map(|branch| self.lower_function_statement(branch)); + self.push_stmt(StmtNode::IfThenElse { + guard, + then_branch, + else_branch, + }) + } + FunctionStatement::Let { id, name, value, body } => { + let value = self.lower_expression(value); + let ty = self.expr_type(value); + let local_id = id.expect("let binding resolved by a clean resolution"); + let slot = self.alloc_slot(name.name.clone(), ty, SlotKind::Local, name.span.clone()); + self.bind_local_slot(local_id, slot); + let body = self.lower_function_statement(body); + self.push_stmt(StmtNode::Let { slot, value, body }) + } + FunctionStatement::Block(inner) => self.lower_function_statement(inner), + } + } + + fn lower_ty(&self, ty: &Ty) -> StarkType { + match ty { + Ty::Integer => StarkType::Integer, + Ty::Real => StarkType::Real, + Ty::Boolean => StarkType::Boolean, + // Already validated by `typecheck.rs`'s `ty_of_annotation`; no + // need to re-check it names a declared `type` here. + Ty::Named(name) => StarkType::Custom(name.clone()), + } + } + + // -- Expressions ------------------------------------------------------ + + fn lower_expression(&mut self, expr: &Expression) -> ExprRef { + let span = expr.span.clone(); + match &expr.node { + ExpressionKind::False => self.push_expr(ExprNode::Literal(Value::Boolean(false)), span, StarkType::Boolean), + ExpressionKind::True => self.push_expr(ExprNode::Literal(Value::Boolean(true)), span, StarkType::Boolean), + ExpressionKind::Integer(value) => { + self.push_expr(ExprNode::Literal(Value::Integer(*value)), span, StarkType::Integer) + } + ExpressionKind::Real(value) => { + self.push_expr(ExprNode::Literal(Value::Real(*value)), span, StarkType::Real) + } + ExpressionKind::Iterator => { + // Only reachable from aggregate/lambda contexts, none of + // which exist in the current grammar (see `ast.rs` and + // `plan.md`) — `typecheck.rs` types this `Error` without + // diagnosing it for the same reason. + debug_assert!( + false, + "ExpressionKind::Iterator is unreachable: no aggregate context exists in the current grammar" + ); + self.push_expr( + ExprNode::Unreachable("an iterator outside any aggregate context"), + span, + StarkType::Error, + ) + } + ExpressionKind::Reference { binding, .. } => { + let binding = binding.expect("reference resolved by a clean resolution"); + self.lower_reference(binding, span) + } + ExpressionKind::Normal { mean, std_dev } => { + let mean = self.lower_expression(mean); + let variance = self.lower_expression(std_dev); + self.push_expr( + ExprNode::SampleNormal { mean, variance }, + span, + StarkType::random(StarkType::Real), + ) + } + ExpressionKind::Uniform { values } => { + let mut merged: Option = None; + let mut lowered = Vec::with_capacity(values.len()); + for value in values { + let value_ref = self.lower_expression(value); + let ty = self.expr_type(value_ref); + merged = Some(match merged { + None => ty, + Some(acc) => acc.merge(&ty), + }); + lowered.push(value_ref); + } + let list = self.push_expr_list(lowered); + let ty = StarkType::random(merged.unwrap_or(StarkType::Error)); + self.push_expr(ExprNode::SampleChoice(list), span, ty) + } + ExpressionKind::Range { min, max } => match (min, max) { + (Some(min), Some(max)) => { + let min = self.lower_expression(min); + let max = self.lower_expression(max); + self.push_expr( + ExprNode::SampleRange { min, max }, + span, + StarkType::random(StarkType::Real), + ) + } + // The grammar only ever produces `R` (neither bound) or + // `R[min,max]` (both) — a mixed case can't be parsed, so + // treating it the same as `R` (matching `typecheck.rs`'s own + // `_ => ..` here) never actually applies to any real input. + _ => self.push_expr(ExprNode::SampleUnit, span, StarkType::random(StarkType::Real)), + }, + ExpressionKind::Not(inner) => { + let inner = self.lower_expression(inner); + let ty = self.expr_type(inner); + self.push_expr(ExprNode::Not(inner), span, ty) + } + // Both widen to `real`, matching the original — see + // `ExprNode::Negate`/`ExprNode::Widen`'s doc comments. + ExpressionKind::UnaryPlus(inner) => { + let inner = self.lower_expression(inner); + let ty = self.combine_to_real_unary(inner); + self.push_expr(ExprNode::Widen(inner), span, ty) + } + ExpressionKind::UnaryMinus(inner) => { + let inner = self.lower_expression(inner); + let ty = self.combine_to_real_unary(inner); + self.push_expr(ExprNode::Negate(inner), span, ty) + } + ExpressionKind::Binary(op, left, right) => self.lower_binary(*op, left, right, span), + ExpressionKind::Ternary { + guard, + then_branch, + else_branch, + } => { + let guard_ref = self.lower_expression(guard); + let then_ref = self.lower_expression(then_branch); + let else_ref = self.lower_expression(else_branch); + let guard_ty = self.expr_type(guard_ref); + let merged = self.expr_type(then_ref).merge(&self.expr_type(else_ref)); + let ty = if !merged.is_error() && guard_ty.is_random() { + StarkType::random(merged) + } else { + merged + }; + self.push_expr( + ExprNode::Select { + guard: guard_ref, + then_branch: then_ref, + else_branch: else_ref, + }, + span, + ty, + ) + } + ExpressionKind::Call { function, arguments } => self.lower_call(function, arguments, span), + ExpressionKind::MathCall { function, arguments } => self.lower_math_call(*function, arguments, span), + } + } + + fn lower_reference(&mut self, binding: Binding, span: Span) -> ExprRef { + match binding { + Binding::Local(local_id) => { + let slot = self.local_slots[local_id.value()].expect("local slot allocated before its first use"); + let ty = self.slots[slot.value() as usize].ty.clone(); + self.push_expr(ExprNode::Load(slot), span, ty) + } + Binding::Def(def_id) => { + if let Some(&custom) = self.custom_values.get(&def_id) { + // A `type` element's value is a fixed ordinal, known + // outright at lowering time — no slot, no expression to + // evaluate, just a literal. + let ty = StarkType::Custom(self.symbols.def(custom.type_id).name.clone()); + self.push_expr(ExprNode::Literal(Value::Custom(custom)), span, ty) + } else { + let slot = self.def_slots[def_id.value()].expect("def slot allocated during slot allocation"); + let ty = self.slots[slot.value() as usize].ty.clone(); + self.push_expr(ExprNode::Load(slot), span, ty) + } + } + } + } + + /// Always widens to `real`, propagating randomness from either operand + /// — the original's rule for the same operators. Mirrors + /// `typecheck.rs`'s `combine_to_real`, minus the diagnostics — `spec` + /// already type-checked, so there is nothing left to reject here. + fn combine_to_real(&self, left: ExprRef, right: ExprRef) -> StarkType { + if self.expr_type(left).is_random() || self.expr_type(right).is_random() { + StarkType::random(StarkType::Real) + } else { + StarkType::Real + } + } + + /// [Self::combine_to_real]'s single-operand counterpart, for unary `+`/ + /// `-` (see `ExprNode::Negate`/`ExprNode::Widen`'s doc comments on why + /// those widen too, not just the math functions). + fn combine_to_real_unary(&self, inner: ExprRef) -> StarkType { + if self.expr_type(inner).is_random() { + StarkType::random(StarkType::Real) + } else { + StarkType::Real + } + } + + fn lower_binary(&mut self, op: ast::BinaryOp, left: &Expression, right: &Expression, span: Span) -> ExprRef { + use ast::BinaryOp as AstOp; + match op { + AstOp::Pow => { + let left = self.lower_expression(left); + let right = self.lower_expression(right); + let ty = self.combine_to_real(left, right); + self.push_expr(ExprNode::MathBinary(MathBinaryFunction::Pow, left, right), span, ty) + } + AstOp::Mult | AstOp::Div | AstOp::IntDiv | AstOp::Add | AstOp::Subtract | AstOp::Mod => { + let left = self.lower_expression(left); + let right = self.lower_expression(right); + let ty = self.expr_type(left).merge(&self.expr_type(right)); + self.push_expr(ExprNode::Binary(map_binary_op(op), left, right), span, ty) + } + AstOp::Less | AstOp::Leq | AstOp::Eq | AstOp::Geq | AstOp::Greater => { + let left = self.lower_expression(left); + let right = self.lower_expression(right); + let ty = if self.expr_type(left).is_random() || self.expr_type(right).is_random() { + StarkType::random(StarkType::Boolean) + } else { + StarkType::Boolean + }; + self.push_expr(ExprNode::Binary(map_binary_op(op), left, right), span, ty) + } + AstOp::BitAnd | AstOp::And | AstOp::BitOr | AstOp::Or => { + let left = self.lower_expression(left); + let right = self.lower_expression(right); + let ty = if self.expr_type(left).is_random() || self.expr_type(right).is_random() { + StarkType::random(StarkType::Boolean) + } else { + StarkType::Boolean + }; + self.push_expr(ExprNode::Binary(map_binary_op(op), left, right), span, ty) + } + } + } + + fn lower_call(&mut self, function: &ast::DefRef, arguments: &[Expression], span: Span) -> ExprRef { + let callee_def_id = function.id.expect("call target resolved by a clean resolution"); + let callee_function_id = self.def_functions[callee_def_id.value()].unwrap_or_else(|| { + panic!( + "call to `{}` lowered before its callee — the no-recursion invariant should make this impossible", + function.name.name + ) + }); + // The no-recursion invariant the flat slot layout depends on: a + // function can only call one declared strictly before it, so it is + // always already lowered. Only meaningful function-to-function (a + // variable initializer or penalty calling a function has no + // "current function" to compare against, and needs no such check — + // the callee being in `def_functions` at all already proves it was + // lowered first). + if let Some(current) = self.current_function { + debug_assert!( + callee_function_id.value() < current.value(), + "call to {callee_function_id:?} from {current:?} violates the no-recursion invariant" + ); + } + + let mut lowered_arguments = Vec::with_capacity(arguments.len()); + for argument in arguments { + lowered_arguments.push(self.lower_expression(argument)); + } + debug_assert_eq!( + lowered_arguments.len(), + self.functions[callee_function_id.value() as usize].arguments.len(), + "argument count mismatch for `{}` survived type checking", + function.name.name + ); + let arguments = self.push_expr_list(lowered_arguments); + let return_type = self.functions[callee_function_id.value() as usize].return_type.clone(); + self.push_expr( + ExprNode::Call { + function: callee_function_id, + arguments, + }, + span, + return_type, + ) + } + + fn lower_math_call(&mut self, function: MathFunction, arguments: &[Expression], span: Span) -> ExprRef { + match function { + MathFunction::Atan2 | MathFunction::Hypot | MathFunction::Max | MathFunction::Min | MathFunction::Pow => { + debug_assert_eq!( + arguments.len(), + 2, + "binary math function {function:?} parsed with {} argument(s)", + arguments.len() + ); + let left = self.lower_expression(&arguments[0]); + let right = self.lower_expression(&arguments[1]); + let ty = self.combine_to_real(left, right); + self.push_expr(ExprNode::MathBinary(map_math_binary(function), left, right), span, ty) + } + _ => { + debug_assert_eq!( + arguments.len(), + 1, + "unary math function {function:?} parsed with {} argument(s)", + arguments.len() + ); + let inner = self.lower_expression(&arguments[0]); + let ty = if self.expr_type(inner).is_random() { + StarkType::random(StarkType::Real) + } else { + StarkType::Real + }; + self.push_expr(ExprNode::MathUnary(map_math_unary(function), inner), span, ty) + } + } + } +} + +/// Asserts that `resolve.rs` filled in a declaration's id, naming the +/// declaration if it did not. +/// +/// A `StarkSpecification` only exists after a clean resolution, so every +/// `id` reaching lowering is `Some` and a `None` is a bug in that pass. It is +/// asserted rather than diagnosed (there is no user error to report), but +/// still returned as an `Option` so release builds skip the declaration +/// instead of panicking — lowering an incomplete program is strictly better +/// than aborting the process. +fn resolved(id: Option, kind: &str, name: &str) -> Option { + debug_assert!(id.is_some(), "{kind} `{name}` reached lowering unresolved"); + id +} + +/// Builds the `type` element `DefId -> CustomValue` map once up front. An +/// element's `DefId` isn't stored back onto the AST by `resolve.rs` (`type` +/// declarations keep their elements as plain `Identifier`s), so this looks +/// each one back up by name via [SymbolTable::by_name] instead. +fn build_custom_value_map(spec: &StarkSpecification) -> HashMap { + let mut map = HashMap::new(); + for declaration in &spec.ast().types { + let Some(type_id) = declaration.id else { continue }; + for (ordinal, element) in declaration.elements.iter().enumerate() { + if let Some(element_id) = spec.symbols().by_name(&element.name) { + map.insert( + element_id, + CustomValue { + type_id, + element: ordinal as u32, + }, + ); + } + } + } + map +} + +fn map_binary_op(op: ast::BinaryOp) -> BinaryOp { + match op { + ast::BinaryOp::Pow => unreachable!("BinaryOp::Pow is lowered as MathBinary(Pow, ..), not Binary"), + ast::BinaryOp::Mult => BinaryOp::Mult, + ast::BinaryOp::Div => BinaryOp::Div, + ast::BinaryOp::IntDiv => BinaryOp::IntDiv, + ast::BinaryOp::Add => BinaryOp::Add, + ast::BinaryOp::Subtract => BinaryOp::Subtract, + ast::BinaryOp::Mod => BinaryOp::Mod, + ast::BinaryOp::Less => BinaryOp::Less, + ast::BinaryOp::Leq => BinaryOp::Leq, + ast::BinaryOp::Eq => BinaryOp::Eq, + ast::BinaryOp::Geq => BinaryOp::Geq, + ast::BinaryOp::Greater => BinaryOp::Greater, + ast::BinaryOp::BitAnd => BinaryOp::BitAnd, + ast::BinaryOp::And => BinaryOp::And, + ast::BinaryOp::BitOr => BinaryOp::BitOr, + ast::BinaryOp::Or => BinaryOp::Or, + } +} + +fn map_math_binary(function: MathFunction) -> MathBinaryFunction { + match function { + MathFunction::Atan2 => MathBinaryFunction::Atan2, + MathFunction::Hypot => MathBinaryFunction::Hypot, + MathFunction::Max => MathBinaryFunction::Max, + MathFunction::Min => MathBinaryFunction::Min, + MathFunction::Pow => MathBinaryFunction::Pow, + other => unreachable!("{other:?} is not a binary math function"), + } +} + +fn map_math_unary(function: MathFunction) -> MathUnaryFunction { + match function { + MathFunction::Abs => MathUnaryFunction::Abs, + MathFunction::Acos => MathUnaryFunction::Acos, + MathFunction::Asin => MathUnaryFunction::Asin, + MathFunction::Atan => MathUnaryFunction::Atan, + MathFunction::Cbrt => MathUnaryFunction::Cbrt, + MathFunction::Ceil => MathUnaryFunction::Ceil, + MathFunction::Cos => MathUnaryFunction::Cos, + MathFunction::Cosh => MathUnaryFunction::Cosh, + MathFunction::Exp => MathUnaryFunction::Exp, + MathFunction::Expm1 => MathUnaryFunction::Expm1, + MathFunction::Floor => MathUnaryFunction::Floor, + MathFunction::Log => MathUnaryFunction::Log, + MathFunction::Log10 => MathUnaryFunction::Log10, + MathFunction::Log1p => MathUnaryFunction::Log1p, + MathFunction::Signum => MathUnaryFunction::Signum, + MathFunction::Sin => MathUnaryFunction::Sin, + MathFunction::Sinh => MathUnaryFunction::Sinh, + MathFunction::Sqrt => MathUnaryFunction::Sqrt, + MathFunction::Tan => MathUnaryFunction::Tan, + other => unreachable!("{other:?} is not a unary math function"), + } +} + +fn map_comparison_op(op: ast::ComparisonOp) -> ComparisonOp { + match op { + ast::ComparisonOp::Less => ComparisonOp::Less, + ast::ComparisonOp::Leq => ComparisonOp::Leq, + ast::ComparisonOp::Eq => ComparisonOp::Eq, + ast::ComparisonOp::Geq => ComparisonOp::Geq, + ast::ComparisonOp::Greater => ComparisonOp::Greater, + } +} + +#[cfg(test)] +mod tests { + use test_log::test; + + use super::*; + + use crate::StarkSpecification; + use crate::ast::UntypedStarkSpecification; + use crate::ir::ExprNode; + use crate::ir::IrProgram; + use crate::ir::StmtNode; + + fn lower_source(src: &str) -> IrProgram { + let spec = UntypedStarkSpecification::parse(src).unwrap_or_else(|e| panic!("failed to parse: {e}")); + + let spec = + StarkSpecification::from_untyped(spec).unwrap_or_else(|d| panic!("failed to check:\n{}", d.render(src))); + lower(&spec).unwrap_or_else(|d| panic!("failed to lower:\n{}", d.render(src))) + } + + #[test] + fn lowers_a_constant_to_a_global_init() { + let program = lower_source("const a = 1 + 2;"); + assert_eq!(program.globals().len(), 1); + let global = &program.globals()[0]; + assert_eq!(program.slot(global.slot).name, "a"); + assert!(matches!( + program.expr(global.value), + ExprNode::Binary(BinaryOp::Add, ..) + )); + program.validate().unwrap(); + } + + #[test] + fn lowers_a_variable_with_a_range() { + let program = lower_source("variables { int x range[0, 10] = 5; }"); + assert_eq!(program.variables().len(), 1); + let variable = &program.variables()[0]; + assert_eq!(program.slot(variable.slot).name, "x"); + assert!(variable.range.is_some()); + program.validate().unwrap(); + } + + #[test] + fn variable_slots_occupy_the_lowest_range() { + // Constants/parameters resolve before variables (`resolve.rs`'s + // fixed kind order), but slot *numbers* must still put variables + // first — this is the one place source/resolve order and slot order + // deliberately diverge. + let program = lower_source("const c = 1;\nparam p = 2;\nvariables { int x = 0; }"); + let variable_slot = program.variables()[0].slot; + let global_slots: Vec<_> = program.globals().iter().map(|g| g.slot).collect(); + for global_slot in global_slots { + assert!( + variable_slot.value() < global_slot.value(), + "variable slot {variable_slot:?} should come before global slot {global_slot:?}" + ); + } + } + + #[test] + fn two_functions_with_same_named_arguments_get_distinct_slots() { + let program = lower_source("function f(int x) { return x; }\nfunction g(int x) { return x; }"); + assert_eq!(program.functions().len(), 2); + assert_ne!(program.functions()[0].arguments[0], program.functions()[1].arguments[0]); + } + + #[test] + fn let_shadowing_an_argument_gets_its_own_slot() { + let program = lower_source("function f(int x) { let x = x + 1 in return x; }"); + let function = &program.functions()[0]; + let argument_slot = function.arguments[0]; + let StmtNode::Let { slot: let_slot, .. } = program.stmt(function.body) else { + panic!("expected a let statement"); + }; + assert_ne!(argument_slot, *let_slot); + } + + #[test] + fn call_to_an_earlier_function_resolves_to_its_function_id() { + let program = + lower_source("function inc(int x) { return x + 1; }\nfunction twice(int x) { return inc(inc(x)); }"); + assert_eq!(program.functions().len(), 2); + let twice = &program.functions()[1]; + let StmtNode::Return(value) = program.stmt(twice.body) else { + panic!("expected a return statement"); + }; + let ExprNode::Call { function, .. } = program.expr(*value) else { + panic!("expected a call"); + }; + assert_eq!( + function.value(), + 0, + "should call `inc`, the first (and only other) function" + ); + program.validate().unwrap(); + } + + #[test] + fn a_type_element_reference_folds_to_a_literal() { + // `type` declarations resolve (and are typed) before variables in + // `resolve.rs`'s fixed kind order, so a variable's initial value can + // reference an element of one — unlike a `const`, which resolves + // before `type` declarations are even seen, or a `penalty`, which + // must be numerical. + let program = lower_source("type Color = Red | Green | Blue;\nvariables { Color c = Green; }"); + let variable = &program.variables()[0]; + match program.expr(variable.initial_value) { + ExprNode::Literal(Value::Custom(custom)) => assert_eq!(custom.element, 1), + other => panic!("expected a custom literal, found {other:?}"), + } + } + + #[test] + fn pow_lowers_to_a_math_binary_node() { + let program = lower_source("const c = 2 ^ 3;"); + let global = &program.globals()[0]; + assert!(matches!( + program.expr(global.value), + ExprNode::MathBinary(MathBinaryFunction::Pow, ..) + )); + } + + #[test] + fn unary_plus_widens_to_real_like_unary_minus() { + // Both `+` and `-` route through the same always-widening + // mechanism as the math functions, so neither is integer-preserving + // — see `ExprNode::Widen`/`ExprNode::Negate`'s doc comments. + let program = lower_source("const c = +1;"); + let global = &program.globals()[0]; + assert!(matches!( + program.expr(global.value), + ExprNode::Widen(inner) if matches!(program.expr(*inner), ExprNode::Literal(Value::Integer(1))) + )); + assert_eq!(*program.expr_type(global.value), StarkType::Real); + } + + #[test] + fn buffered_swap_reads_pre_state_slots() { + // Both sides of a `let`-based swap read the value bound *before* the + // swap happened — this doesn't exercise controller/environment + // lowering (not implemented yet), but confirms the same principle + // holds for an ordinary function-local `let`, which the buffered + // controller/environment update semantics will build on. + let program = lower_source("function f(int a, int b) { let t = a in return b + t; }"); + let function = &program.functions()[0]; + let (a_slot, b_slot) = (function.arguments[0], function.arguments[1]); + let StmtNode::Let { + slot: t_slot, + value, + body, + } = program.stmt(function.body) + else { + panic!("expected a let statement"); + }; + assert!(matches!(program.expr(*value), ExprNode::Load(slot) if *slot == a_slot)); + let StmtNode::Return(sum) = program.stmt(*body) else { + panic!("expected a return statement"); + }; + let ExprNode::Binary(BinaryOp::Add, left, right) = program.expr(*sum) else { + panic!("expected an addition"); + }; + assert!(matches!(program.expr(*left), ExprNode::Load(slot) if *slot == b_slot)); + assert!(matches!(program.expr(*right), ExprNode::Load(slot) if *slot == *t_slot)); + } + + #[test] + fn display_renders_source_like_text() { + let program = lower_source("const a = 1;\nfunction f(int x) { return x + a; }"); + let rendered = program.to_string(); + assert!(rendered.contains("fn f"), "{rendered}"); + assert!(rendered.contains("return"), "{rendered}"); + assert!(rendered.contains("load"), "{rendered}"); + } + + #[test] + fn lowers_a_component_with_a_self_looping_state() { + let program = + lower_source("component C {\n variables { }\n controller {\n state A { step A; }\n }\n init A\n}"); + assert_eq!(program.components().len(), 1); + let component = &program.components()[0]; + assert_eq!(component.name, "C"); + assert_eq!(component.states.len(), 1); + assert_eq!(component.initial, component.states); + + let state = program.state(component.states[0]); + assert_eq!(state.name, "A"); + let CommandNode::Step { target, .. } = program.command(state.body.expect("non-empty body")) else { + panic!("expected a step"); + }; + assert_eq!(*target, component.states[0], "should step to itself"); + program.validate().unwrap(); + } + + #[test] + fn step_to_a_later_sibling_state_resolves() { + // `A` targets `B`, declared afterwards — states are pre-allocated + // before any body is lowered so this forward reference resolves. + let program = lower_source( + "component C {\n variables { }\n controller {\n state A { step B; }\n state B { step B; }\n }\n init A\n}", + ); + let component = &program.components()[0]; + let (a, b) = (component.states[0], component.states[1]); + let CommandNode::Step { target, .. } = program.command(program.state(a).body.unwrap()) else { + panic!("expected a step"); + }; + assert_eq!(*target, b); + } + + #[test] + fn controller_assignment_is_sequenced_before_its_step() { + let program = lower_source( + "global variables { int x = 0; }\ncomponent C {\n variables { }\n controller {\n state A { x' = x + 1; step A; }\n }\n init A\n}", + ); + let component = &program.components()[0]; + let body = program.state(component.states[0]).body.expect("non-empty body"); + let CommandNode::Sequence(first, second) = program.command(body) else { + panic!("expected a sequence of the assignment and the step"); + }; + assert!(matches!(program.command(*first), CommandNode::Assign(_))); + assert!(matches!(program.command(*second), CommandNode::Step { .. })); + program.validate().unwrap(); + } + + #[test] + fn environment_buffered_swap_reads_pre_state_slots() { + // The classic swap, this time through real environment lowering + // (rather than a function-local `let` standing in for it, as + // `buffered_swap_reads_pre_state_slots` above does): both + // assignments must read the *pre*-step value, matching the + // "collect updates, apply them all at the end of the step" + // semantics. + let program = lower_source("global variables { int x = 1; int y = 2; }\nenvironment { x' = y; y' = x; }"); + let environment = program.environment().expect("environment block lowered"); + let CommandNode::Sequence(first, second) = program.command(environment) else { + panic!("expected a sequence of the two assignments"); + }; + let CommandNode::Assign(update_x) = program.command(*first) else { + panic!("expected the first assignment"); + }; + let CommandNode::Assign(update_y) = program.command(*second) else { + panic!("expected the second assignment"); + }; + assert_eq!(program.slot(update_x.target).name, "x"); + assert_eq!(program.slot(update_y.target).name, "y"); + let x_slot = update_x.target; + let y_slot = update_y.target; + assert!(matches!(program.expr(update_x.value), ExprNode::Load(slot) if *slot == y_slot)); + assert!(matches!(program.expr(update_y.value), ExprNode::Load(slot) if *slot == x_slot)); + program.validate().unwrap(); + } + + #[test] + fn environment_let_bindings_chain_and_see_each_other() { + let program = + lower_source("global variables { int x = 1; }\nenvironment { let a = x and b = a + 1 in { x' = b; } }"); + let environment = program.environment().expect("environment block lowered"); + let CommandNode::Let { slot: a_slot, body, .. } = program.command(environment) else { + panic!("expected the outer `let a = ..`"); + }; + let CommandNode::Let { + value: b_value, body, .. + } = program.command(body.expect("non-empty body")) + else { + panic!("expected the nested `let b = ..`"); + }; + // `b`'s value (`a + 1`) reads the slot the outer `let` just bound. + let ExprNode::Binary(BinaryOp::Add, left, _) = program.expr(*b_value) else { + panic!("expected `a + 1`"); + }; + assert!(matches!(program.expr(*left), ExprNode::Load(slot) if slot == a_slot)); + // The `b`-let's own body is the innermost `{ x' = b; }` block — a + // plain assignment, not another `let`. + assert!(matches!( + program.command(body.expect("non-empty body")), + CommandNode::Assign(_) + )); + program.validate().unwrap(); + } + + #[test] + fn environment_if_with_no_else_lowers_with_no_else_branch() { + let program = + lower_source("global variables { bool flag = true; int x = 0; }\nenvironment { if (flag) { x' = 1; } }"); + let environment = program.environment().expect("environment block lowered"); + let CommandNode::IfThenElse { else_branch, .. } = program.command(environment) else { + panic!("expected an if-then-else"); + }; + assert!(else_branch.is_none()); + program.validate().unwrap(); + } + + #[test] + fn validate_rejects_a_corrupted_arena() { + let mut program = lower_source("const a = 1;"); + // Corrupt the arena the same way a lowering bug would: an + // out-of-bounds `ExprRef` in an otherwise-valid global. + program.globals[0].value = ExprRef::new(999); + assert!(program.validate().is_err()); + } + + #[test] + fn validate_rejects_a_variable_outside_the_state_prefix() { + // The evaluator takes `[0, n_variables())` as its state vector by + // slicing, so a `VariableInfo` pointing anywhere else would silently + // checkpoint and perturb the wrong slot rather than fail loudly. + let mut program = lower_source("const a = 1; variables { int x = 0; }"); + let global_slot = program.globals[0].slot; + program.variables[0].slot = global_slot; + assert!(program.validate().is_err()); + } + + #[test] + fn validate_rejects_a_permuted_slot_partition() { + // `n_variables()`/`n_globals()` derive the partition boundaries from + // the `variables`/`globals` lengths rather than by scanning `slots`, + // so a slot carrying the wrong kind for its index has to be caught. + let mut program = lower_source("const a = 1; variables { int x = 0; }"); + assert_eq!(program.n_variables(), 1); + program.slots[0].kind = SlotKind::Local; + assert!(program.validate().is_err()); + } + + #[test] + fn validate_accepts_the_slot_partition_it_lowers() { + // The positive counterpart to the two tests above: a spec with all + // three slot kinds lays them out in the order the partition requires. + let program = lower_source( + "const a = 1; param p = 2; variables { int x = 0; } \ + function f(int y) { let z = y + 1 in return z; }", + ); + program.validate().unwrap(); + let kinds: Vec<_> = program.slots.iter().map(|slot| slot.kind).collect(); + assert_eq!( + kinds, + vec![ + SlotKind::Variable, + SlotKind::Global, + SlotKind::Global, + SlotKind::Local, + SlotKind::Local + ] + ); + } + + #[test] + fn validate_rejects_a_corrupted_statement() { + // Same idea as `validate_rejects_a_corrupted_arena`, but for a ref + // that only appears *inside* the statement arena (an `IfThenElse`'s + // `else_branch`) rather than off a top-level global/variable — this + // is the case `validate()` used to skip entirely. + let mut program = lower_source("function f(int x) { if (x > 0) return 1; else return 2; }"); + let function = program.functions()[0].clone(); + let StmtNode::IfThenElse { else_branch, .. } = program.stmt(function.body) else { + panic!("expected an if-then-else statement"); + }; + assert!(else_branch.is_some(), "expected an else branch"); + let index = function.body.value() as usize; + let StmtNode::IfThenElse { else_branch, .. } = &mut program.stmts[index] else { + panic!("expected an if-then-else statement"); + }; + *else_branch = Some(StmtRef::new(999)); + assert!(program.validate().is_err()); + } + + // -- Perturbations / distances / formulas ------------------------------- + + #[test] + fn lowers_a_penalty_with_its_name() { + let program = lower_source("penalty rho = 1 + 2"); + assert_eq!(program.penalties().len(), 1); + assert_eq!(program.penalties()[0].name, "rho"); + program.validate().unwrap(); + } + + #[test] + fn perturbation_nil_lowers_to_the_nil_node() { + let program = lower_source("perturbation p = nil;"); + assert_eq!(program.perturbation_decls().len(), 1); + let decl = &program.perturbation_decls()[0]; + assert_eq!(decl.name, "p"); + assert!(matches!(program.perturbation(decl.root), PerturbationIr::Nil)); + program.validate().unwrap(); + } + + #[test] + fn perturbation_atomic_lowers_its_assignment_and_time() { + let program = lower_source("global variables { real x = 0; }\nperturbation p = [x <- x + 1] @ 5;"); + let decl = &program.perturbation_decls()[0]; + let PerturbationIr::Atomic { assignments, time } = program.perturbation(decl.root) else { + panic!("expected an atomic perturbation"); + }; + assert_eq!(assignments.len(), 1); + assert_eq!(program.slot(assignments[0].target).name, "x"); + assert!(matches!(program.expr(*time), ExprNode::Literal(Value::Integer(5)))); + program.validate().unwrap(); + } + + #[test] + fn perturbation_sequence_and_iteration_chain_their_operands() { + let program = lower_source("global variables { real x = 0; }\nperturbation p = ([x <- 1]@0 ; [x <- 2]@0)^3;"); + let decl = &program.perturbation_decls()[0]; + let PerturbationIr::Iteration { argument, iterations } = program.perturbation(decl.root) else { + panic!("expected an iteration"); + }; + assert!(matches!( + program.expr(*iterations), + ExprNode::Literal(Value::Integer(3)) + )); + assert!(matches!(program.perturbation(*argument), PerturbationIr::Sequence(..))); + program.validate().unwrap(); + } + + #[test] + fn perturbation_reference_resolves_to_the_earlier_declarations_root() { + let program = lower_source("perturbation a = nil;\nperturbation b = a;"); + assert_eq!(program.perturbation_decls().len(), 2); + let a_root = program.perturbation_decls()[0].root; + let PerturbationIr::Reference(target) = program.perturbation(program.perturbation_decls()[1].root) else { + panic!("expected a reference"); + }; + assert_eq!(*target, a_root); + program.validate().unwrap(); + } + + #[test] + fn distance_atomic_left_and_right_reference_the_penalty() { + let program = lower_source("penalty rho = 1\ndistance dl = < rho;\ndistance dr = > rho;"); + assert_eq!(program.distance_decls().len(), 2); + let DistanceIr::AtomicLeft(penalty) = program.distance(program.distance_decls()[0].root) else { + panic!("expected an atomic-left distance"); + }; + assert_eq!(program.penalty(*penalty).name, "rho"); + let DistanceIr::AtomicRight(penalty) = program.distance(program.distance_decls()[1].root) else { + panic!("expected an atomic-right distance"); + }; + assert_eq!(program.penalty(*penalty).name, "rho"); + program.validate().unwrap(); + } + + #[test] + fn distance_eventually_globally_and_threshold_lower_their_bounds() { + let program = lower_source( + "penalty rho = 1\ndistance base = < rho <= 2.0;\ndistance ev = \\F[0, 10] base;\ndistance gl = \\G[0, 10] base;", + ); + let names: Vec<_> = program.distance_decls().iter().map(|d| d.name.as_str()).collect(); + assert_eq!(names, ["base", "ev", "gl"]); + assert!(matches!( + program.distance(program.distance_decls()[0].root), + DistanceIr::Threshold { .. } + )); + assert!(matches!( + program.distance(program.distance_decls()[1].root), + DistanceIr::Eventually { .. } + )); + assert!(matches!( + program.distance(program.distance_decls()[2].root), + DistanceIr::Globally { .. } + )); + program.validate().unwrap(); + } + + #[test] + fn distance_min_and_max_lower_their_operands() { + let program = lower_source( + "penalty rho1 = 1\npenalty rho2 = 2\ndistance d1 = < rho1;\ndistance d2 = < rho2;\ndistance smaller = min(d1, d2);\ndistance larger = max(d1, d2);", + ); + let find = |name: &str| program.distance_decls().iter().find(|d| d.name == name).unwrap(); + assert!(matches!(program.distance(find("smaller").root), DistanceIr::Min(..))); + assert!(matches!(program.distance(find("larger").root), DistanceIr::Max(..))); + program.validate().unwrap(); + } + + #[test] + fn distance_until_and_reference_lower() { + let program = lower_source( + "penalty rho1 = 1\npenalty rho2 = 2\ndistance d1 = < rho1;\ndistance d2 = < rho2;\ndistance u = d1 \\U[0, 5] d2;\ndistance alias = u;", + ); + let u = program.distance_decls().iter().find(|d| d.name == "u").unwrap().clone(); + assert!(matches!(program.distance(u.root), DistanceIr::Until { .. })); + let alias = program.distance_decls().iter().find(|d| d.name == "alias").unwrap(); + assert!(matches!(program.distance(alias.root), DistanceIr::Reference(target) if *target == u.root)); + program.validate().unwrap(); + } + + #[test] + fn formula_true_false_and_distance_lower() { + let program = lower_source( + "penalty rho = 1\nperturbation p = nil;\ndistance d = < rho;\nformula t = true;\nformula f = false;\nformula df = \\D[d, p] >= 1.0;", + ); + let names: Vec<_> = program.formula_decls().iter().map(|d| d.name.as_str()).collect(); + assert_eq!(names, ["t", "f", "df"]); + assert!(matches!( + program.formula(program.formula_decls()[0].root), + FormulaIr::True + )); + assert!(matches!( + program.formula(program.formula_decls()[1].root), + FormulaIr::False + )); + let df = program.formula_decls()[2].clone(); + let FormulaIr::Distance { + distance, + perturbation, + op, + .. + } = program.formula(df.root) + else { + panic!("expected a distance formula"); + }; + assert_eq!(*distance, program.distance_decls()[0].root); + assert_eq!(*perturbation, program.perturbation_decls()[0].root); + assert_eq!(*op, ComparisonOp::Geq); + program.validate().unwrap(); + } + + #[test] + fn formula_boolean_and_temporal_combinators_lower_their_operands() { + let program = lower_source( + "formula a = true;\nformula b = false;\nformula both = a && b;\nformula either = a || b;\nformula negated = !a;\nformula ev = \\F[0, 10] a;\nformula gl = \\G[0, 10] a;\nformula until = a \\U[0, 10] b;", + ); + let find = |name: &str| program.formula_decls().iter().find(|d| d.name == name).unwrap().clone(); + assert!(matches!(program.formula(find("both").root), FormulaIr::And(..))); + assert!(matches!(program.formula(find("either").root), FormulaIr::Or(..))); + assert!(matches!(program.formula(find("negated").root), FormulaIr::Not(..))); + assert!(matches!(program.formula(find("ev").root), FormulaIr::Eventually { .. })); + assert!(matches!(program.formula(find("gl").root), FormulaIr::Globally { .. })); + assert!(matches!(program.formula(find("until").root), FormulaIr::Until { .. })); + program.validate().unwrap(); + } + + #[test] + fn formula_reference_resolves_to_the_earlier_declarations_root() { + let program = lower_source("formula a = true;\nformula b = a;"); + let a_root = program.formula_decls()[0].root; + assert!(matches!( + program.formula(program.formula_decls()[1].root), + FormulaIr::Reference(target) if *target == a_root + )); + program.validate().unwrap(); + } + + #[test] + fn display_renders_penalty_perturbation_distance_and_formula_source_like_text() { + let program = lower_source( + "penalty rho = 1\nperturbation p = nil;\ndistance d = < rho;\nformula phi = \\D[d, p] >= 1.0;", + ); + let rendered = program.to_string(); + assert!(rendered.contains("penalty rho ="), "{rendered}"); + assert!(rendered.contains("perturbation p = nil;"), "{rendered}"); + assert!(rendered.contains("distance d = < rho;"), "{rendered}"); + assert!(rendered.contains("formula phi = \\D[d, p] >= 1;"), "{rendered}"); + } +} diff --git a/crates/stark/src/parse.rs b/crates/stark/src/parse.rs new file mode 100644 index 00000000..3b53ef6f --- /dev/null +++ b/crates/stark/src/parse.rs @@ -0,0 +1,69 @@ +//! The parser entry point: derives [StarkParser] from `stark_grammar.pest` and +//! wraps it as [UntypedStarkSpecification::parse]. +//! +//! This module is only the `pest` frontend — it produces a parse tree of +//! [Rule]s and hands it straight to `consume.rs`, which builds the AST, and to +//! the Pratt parsers in `precedence.rs` for the expression sub-languages. The +//! grammar itself is the single source of truth for the concrete syntax; see +//! `src/stark_grammar.pest`. + +use pest::Parser; +use pest_derive::Parser; + +use merc_utilities::MercError; + +use crate::ast::UntypedStarkSpecification; +use crate::consume::ParseNode; + +#[derive(Parser)] +#[grammar = "stark_grammar.pest"] +pub struct StarkParser; + +impl UntypedStarkSpecification { + /// Parse the given stark specification into an AST. + pub fn parse(input: &str) -> Result { + let mut result = StarkParser::parse(Rule::UntypedStarkSpecification, input)?; + let root = result.next().expect("Could not parse STARK specification"); + Ok(StarkParser::UntypedStarkSpecification(ParseNode::new(root))?) + } +} + +#[cfg(test)] +mod tests { + use crate::ast::UntypedStarkSpecification; + + #[test] + fn test_parse_engine_stark() { + if let Err(x) = UntypedStarkSpecification::parse(include_str!("../../../examples/stark/engine.stark")) { + panic!("Failed to parse: {}", x); + } + } + + #[test] + fn test_parse_random_walk_stark() { + if let Err(x) = UntypedStarkSpecification::parse(include_str!("../../../examples/stark/random_walk.stark")) { + panic!("Failed to parse: {}", x); + } + } + + #[test] + fn test_parse_single_vehicle_stark() { + if let Err(x) = UntypedStarkSpecification::parse(include_str!("../../../examples/stark/single_vehicle.stark")) { + panic!("Failed to parse: {}", x); + } + } + + #[test] + fn test_parse_toll_stark() { + if let Err(x) = UntypedStarkSpecification::parse(include_str!("../../../examples/stark/toll.stark")) { + panic!("Failed to parse: {}", x); + } + } + + #[test] + fn test_parse_two_vehicles_stark() { + if let Err(x) = UntypedStarkSpecification::parse(include_str!("../../../examples/stark/two_vehicles.stark")) { + panic!("Failed to parse: {}", x); + } + } +} diff --git a/crates/stark/src/precedence.rs b/crates/stark/src/precedence.rs new file mode 100644 index 00000000..82e168e9 --- /dev/null +++ b/crates/stark/src/precedence.rs @@ -0,0 +1,740 @@ +//! Pratt parsers for the STARK sub-languages. +//! +//! The `pest` grammar only produces a flat `prefix* primary postfix* (infix ...)*` +//! token stream for each expression language; these parsers turn that stream into +//! the priority/associativity-resolved AST defined in `ast.rs`. +//! +//! Every `Expression` node built here carries the [Span] of the source text it +//! was parsed from (see [Expression]); the perturbation / distance / +//! ROBTL sub-language nodes do not carry their own spans, but the `Expression`s +//! nested inside them do. + +use std::sync::LazyLock; + +use pest::error::ErrorVariant; +use pest::iterators::Pair; +use pest::iterators::Pairs; +use pest::pratt_parser::Assoc; +use pest::pratt_parser::Op; +use pest::pratt_parser::PrattParser; + +use merc_pest_consume::Error; +use merc_utilities::Span; +use merc_utilities::Spanned; + +use crate::ast::BinaryOp; +use crate::ast::ComparisonOp; +use crate::ast::DefRef; +use crate::ast::DistanceExpression; +use crate::ast::Expression; +use crate::ast::ExpressionKind; +use crate::ast::Identifier; +use crate::ast::MathFunction; +use crate::ast::PerturbationAssignment; +use crate::ast::PerturbationExpression; +use crate::ast::RobtlFormula; +use crate::consume::ParseResult; +use crate::parse::Rule; + +// --------------------------------------------------------------------------- +// Small helpers +// --------------------------------------------------------------------------- + +fn identifier(pair: &Pair<'_, Rule>) -> Identifier { + Identifier::new(pair.as_str().to_string(), pair.as_span().into()) +} + +fn error(pair: &Pair<'_, Rule>, message: impl Into) -> ParseResult { + Err(Error::new_from_span( + ErrorVariant::CustomError { + message: message.into(), + }, + pair.as_span(), + )) +} + +/// Parses an `Expression` node: `PrattExpression ~ ExpressionTernary?`. `?:` +/// binds looser than everything the Pratt parser handles (see the grammar +/// comment above `Expression`), so it lives here, outside the Pratt chain, +/// as a wrapper around it rather than as one more postfix operator. +#[allow(clippy::result_large_err)] +pub(crate) fn parse_expression_node(pair: Pair<'_, Rule>) -> ParseResult { + let span: Span = pair.as_span().into(); + let mut children = pair.into_inner(); + let guard = parse_expression( + children + .next() + .expect("Expression always starts with a PrattExpression") + .into_inner(), + )?; + match children.next() { + None => Ok(guard), + Some(ternary) => { + let mut branches = ternary.into_inner(); + let then_branch = Box::new(parse_expression_node( + branches.next().expect("ternary requires a then branch"), + )?); + let else_branch = Box::new(parse_expression_node( + branches.next().expect("ternary requires an else branch"), + )?); + Ok(Spanned::new( + ExpressionKind::Ternary { + guard: Box::new(guard), + then_branch, + else_branch, + }, + span, + )) + } + } +} + +/// Collect the `Expression` children of a node and parse each. +#[allow(clippy::result_large_err)] +fn expression_arguments(pair: Pair<'_, Rule>) -> ParseResult> { + pair.into_inner() + .filter(|p| p.as_rule() == Rule::Expression) + .map(parse_expression_node) + .collect() +} + +/// The covering span from the start of `left` to the end of `right`. +fn cover(left: &Span, right: &Span) -> Span { + Span { + start: left.start, + end: right.end, + } +} + +fn math_function(name: &str) -> MathFunction { + match name { + "abs" => MathFunction::Abs, + "acos" => MathFunction::Acos, + "asin" => MathFunction::Asin, + "atan" => MathFunction::Atan, + "cbrt" => MathFunction::Cbrt, + "ceil" => MathFunction::Ceil, + "cos" => MathFunction::Cos, + "cosh" => MathFunction::Cosh, + "exp" => MathFunction::Exp, + "expm1" => MathFunction::Expm1, + "floor" => MathFunction::Floor, + "log" => MathFunction::Log, + "log10" => MathFunction::Log10, + "log1p" => MathFunction::Log1p, + "signum" => MathFunction::Signum, + "sin" => MathFunction::Sin, + "sinh" => MathFunction::Sinh, + "sqrt" => MathFunction::Sqrt, + "tan" => MathFunction::Tan, + "atan2" => MathFunction::Atan2, + "hypot" => MathFunction::Hypot, + "max" => MathFunction::Max, + "min" => MathFunction::Min, + "pow" => MathFunction::Pow, + other => unreachable!("unknown math function: {other}"), + } +} + +fn comparison_op(text: &str) -> ComparisonOp { + match text { + "<" => ComparisonOp::Less, + "<=" => ComparisonOp::Leq, + "==" => ComparisonOp::Eq, + ">=" => ComparisonOp::Geq, + ">" => ComparisonOp::Greater, + other => unreachable!("unknown comparison operator: {other}"), + } +} + +// --------------------------------------------------------------------------- +// Expressions +// --------------------------------------------------------------------------- + +pub static EXPRESSION_PRATT_PARSER: LazyLock> = LazyLock::new(|| { + // Precedence is defined lowest (loosest) to highest (tightest). + PrattParser::new() + .op(Op::infix(Rule::ExpressionOr, Assoc::Left)) + .op(Op::infix(Rule::ExpressionBitOr, Assoc::Left)) + .op(Op::infix(Rule::ExpressionAnd, Assoc::Left)) + .op(Op::infix(Rule::ExpressionBitAnd, Assoc::Left)) + .op(Op::infix(Rule::ExpressionLess, Assoc::Left) + | Op::infix(Rule::ExpressionLeq, Assoc::Left) + | Op::infix(Rule::ExpressionEq, Assoc::Left) + | Op::infix(Rule::ExpressionGeq, Assoc::Left) + | Op::infix(Rule::ExpressionGreater, Assoc::Left)) + .op(Op::infix(Rule::ExpressionAdd, Assoc::Left) | Op::infix(Rule::ExpressionSubtract, Assoc::Left)) + .op(Op::infix(Rule::ExpressionMult, Assoc::Left) + | Op::infix(Rule::ExpressionDiv, Assoc::Left) + | Op::infix(Rule::ExpressionIntDiv, Assoc::Left) + | Op::infix(Rule::ExpressionMod, Assoc::Left)) + .op(Op::infix(Rule::ExpressionPow, Assoc::Right)) + .op(Op::prefix(Rule::ExpressionNot) + | Op::prefix(Rule::ExpressionUnaryPlus) + | Op::prefix(Rule::ExpressionUnaryMinus)) + .op(Op::postfix(Rule::ExpressionCall)) +}); + +#[allow(clippy::result_large_err)] +fn parse_expression_primary(primary: Pair<'_, Rule>) -> ParseResult { + // A parenthesized sub-expression: `primary` here already *is* the inner + // `Expression` node, so just recurse and reuse its own span. + if primary.as_rule() == Rule::Expression { + return parse_expression_node(primary); + } + + let span: Span = primary.as_span().into(); + let expr = match primary.as_rule() { + Rule::INTEGER => match primary.as_str().parse::() { + Ok(value) => ExpressionKind::Integer(value), + Err(_) => { + return error( + &primary, + format!( + "integer literal `{}` does not fit in a 64-bit integer", + primary.as_str() + ), + ); + } + }, + Rule::REAL => match primary.as_str().parse::() { + Ok(value) => ExpressionKind::Real(value), + Err(_) => return error(&primary, format!("invalid real literal `{}`", primary.as_str())), + }, + Rule::ID => ExpressionKind::Reference { + name: primary.as_str().to_string(), + binding: None, + }, + Rule::ExpressionTrue => ExpressionKind::True, + Rule::ExpressionFalse => ExpressionKind::False, + Rule::ExpressionIterator => ExpressionKind::Iterator, + Rule::ExpressionNormal => { + let mut args = expression_arguments(primary)?.into_iter(); + ExpressionKind::Normal { + mean: Box::new(args.next().expect("normal distribution requires a mean")), + std_dev: Box::new(args.next().expect("normal distribution requires a std dev")), + } + } + Rule::ExpressionUniform => ExpressionKind::Uniform { + values: expression_arguments(primary)?, + }, + Rule::ExpressionRandom => { + let mut args = expression_arguments(primary)?.into_iter(); + ExpressionKind::Range { + min: args.next().map(Box::new), + max: args.next().map(Box::new), + } + } + Rule::ExpressionUnaryMathCall | Rule::ExpressionBinaryMathCall => { + let mut children = primary.into_inner(); + let function = math_function( + children + .next() + .expect("math call should contain a function name") + .as_str(), + ); + let arguments = children + .filter(|p| p.as_rule() == Rule::Expression) + .map(parse_expression_node) + .collect::>>()?; + ExpressionKind::MathCall { function, arguments } + } + rule => unreachable!("unexpected expression primary: {rule:?}"), + }; + Ok(Spanned::new(expr, span)) +} + +#[allow(clippy::result_large_err)] +pub fn parse_expression(pairs: Pairs) -> ParseResult { + EXPRESSION_PRATT_PARSER + .map_primary(parse_expression_primary) + .map_prefix(|op, rhs| { + let rhs = rhs?; + let span = cover(&op.as_span().into(), &rhs.span); + let expr = match op.as_rule() { + Rule::ExpressionNot => ExpressionKind::Not(Box::new(rhs)), + Rule::ExpressionUnaryPlus => ExpressionKind::UnaryPlus(Box::new(rhs)), + Rule::ExpressionUnaryMinus => ExpressionKind::UnaryMinus(Box::new(rhs)), + rule => unreachable!("unexpected expression prefix operator: {rule:?}"), + }; + Ok(Spanned::new(expr, span)) + }) + .map_infix(|lhs, op, rhs| { + let lhs = lhs?; + let rhs = rhs?; + let span = cover(&lhs.span, &rhs.span); + let op = match op.as_rule() { + Rule::ExpressionPow => BinaryOp::Pow, + Rule::ExpressionMult => BinaryOp::Mult, + Rule::ExpressionDiv => BinaryOp::Div, + Rule::ExpressionIntDiv => BinaryOp::IntDiv, + Rule::ExpressionAdd => BinaryOp::Add, + Rule::ExpressionSubtract => BinaryOp::Subtract, + Rule::ExpressionMod => BinaryOp::Mod, + Rule::ExpressionLess => BinaryOp::Less, + Rule::ExpressionLeq => BinaryOp::Leq, + Rule::ExpressionEq => BinaryOp::Eq, + Rule::ExpressionGeq => BinaryOp::Geq, + Rule::ExpressionGreater => BinaryOp::Greater, + Rule::ExpressionBitAnd => BinaryOp::BitAnd, + Rule::ExpressionAnd => BinaryOp::And, + Rule::ExpressionBitOr => BinaryOp::BitOr, + Rule::ExpressionOr => BinaryOp::Or, + rule => unreachable!("unexpected expression binary operator: {rule:?}"), + }; + Ok(Spanned::new( + ExpressionKind::Binary(op, Box::new(lhs), Box::new(rhs)), + span, + )) + }) + .map_postfix(|target, postfix| { + let target = target?; + let span = cover(&target.span, &postfix.as_span().into()); + match postfix.as_rule() { + Rule::ExpressionCall => { + let name = match &target.node { + ExpressionKind::Reference { name, .. } => name.clone(), + _ => return error(&postfix, "only a plain function name can be called"), + }; + let function = DefRef::new(Identifier::new(name, target.span.clone())); + let arguments = expression_arguments(postfix)?; + Ok(Spanned::new(ExpressionKind::Call { function, arguments }, span)) + } + rule => unreachable!("unexpected expression postfix operator: {rule:?}"), + } + }) + .parse(pairs) +} + +// --------------------------------------------------------------------------- +// Perturbation expressions +// --------------------------------------------------------------------------- + +pub static PERTURBATION_PRATT_PARSER: LazyLock> = LazyLock::new(|| { + PrattParser::new() + .op(Op::infix(Rule::PerturbationSemicolon, Assoc::Left)) + .op(Op::postfix(Rule::PerturbationPow)) +}); + +#[allow(clippy::result_large_err)] +fn parse_perturbation_primary(primary: Pair<'_, Rule>) -> ParseResult { + match primary.as_rule() { + Rule::PerturbationExpression => parse_perturbation_expression(primary.into_inner()), + Rule::PerturbationNil => Ok(PerturbationExpression::Nil), + Rule::ID => Ok(PerturbationExpression::Reference(DefRef::new(identifier(&primary)))), + Rule::PerturbationAtomic => { + let mut assignments = Vec::new(); + let mut time = None; + for child in primary.into_inner() { + match child.as_rule() { + Rule::PerturbationAssignment => { + let mut inner = child.into_inner(); + let target = DefRef::new(identifier(&inner.next().expect("assignment target"))); + let value = parse_expression_node(inner.next().expect("assignment value"))?; + assignments.push(PerturbationAssignment { target, value }); + } + Rule::Expression => time = Some(parse_expression_node(child)?), + rule => unreachable!("unexpected perturbation atomic child: {rule:?}"), + } + } + Ok(PerturbationExpression::Atomic { + assignments, + time: time.expect("atomic perturbation requires an @time"), + }) + } + rule => unreachable!("unexpected perturbation primary: {rule:?}"), + } +} + +#[allow(clippy::result_large_err)] +pub fn parse_perturbation_expression(pairs: Pairs) -> ParseResult { + PERTURBATION_PRATT_PARSER + .map_primary(parse_perturbation_primary) + .map_infix(|lhs, op, rhs| match op.as_rule() { + Rule::PerturbationSemicolon => Ok(PerturbationExpression::Sequence(Box::new(lhs?), Box::new(rhs?))), + rule => unreachable!("unexpected perturbation infix operator: {rule:?}"), + }) + .map_postfix(|argument, postfix| match postfix.as_rule() { + Rule::PerturbationPow => { + let iterations = parse_expression_node( + postfix + .into_inner() + .find(|p| p.as_rule() == Rule::Expression) + .expect("iteration requires an exponent expression"), + )?; + Ok(PerturbationExpression::Iteration { + argument: Box::new(argument?), + iterations, + }) + } + rule => unreachable!("unexpected perturbation postfix operator: {rule:?}"), + }) + .parse(pairs) +} + +// --------------------------------------------------------------------------- +// Distance expressions +// --------------------------------------------------------------------------- + +pub static DISTANCE_PRATT_PARSER: LazyLock> = LazyLock::new(|| { + PrattParser::new() + .op(Op::infix(Rule::DistanceInfixUntil, Assoc::Left)) + .op(Op::postfix(Rule::DistancePostfixThreshold)) + .op(Op::prefix(Rule::DistancePrefixF) | Op::prefix(Rule::DistancePrefixG)) +}); + +/// Parse the two `Expression` children (`from`, `to`) of an interval operator. +#[allow(clippy::result_large_err)] +fn parse_interval(pair: Pair<'_, Rule>) -> ParseResult<(Expression, Expression)> { + let mut args = expression_arguments(pair)?.into_iter(); + Ok(( + args.next().expect("interval requires a lower bound"), + args.next().expect("interval requires an upper bound"), + )) +} + +#[allow(clippy::result_large_err)] +fn parse_distance_primary(primary: Pair<'_, Rule>) -> ParseResult { + match primary.as_rule() { + Rule::DistanceExpression => parse_distance_expression(primary.into_inner()), + Rule::DistanceAtomicLeft => Ok(DistanceExpression::AtomicLeft(DefRef::new(identifier( + &primary.into_inner().next().expect("penalty reference"), + )))), + Rule::DistanceAtomicRight => Ok(DistanceExpression::AtomicRight(DefRef::new(identifier( + &primary.into_inner().next().expect("penalty reference"), + )))), + Rule::ID => Ok(DistanceExpression::Reference(DefRef::new(identifier(&primary)))), + Rule::DistanceMin => { + let (left, right) = parse_distance_pair(primary)?; + Ok(DistanceExpression::Min(Box::new(left), Box::new(right))) + } + Rule::DistanceMax => { + let (left, right) = parse_distance_pair(primary)?; + Ok(DistanceExpression::Max(Box::new(left), Box::new(right))) + } + Rule::DistanceLinearCombination => { + let mut terms = Vec::new(); + let mut children = primary.into_inner().peekable(); + while let Some(weight_pair) = children.next() { + let weight = parse_expression_node(weight_pair)?; + let distance_pair = children.next().expect("linear combination term requires a distance"); + let distance = parse_distance_expression(distance_pair.into_inner())?; + terms.push((weight, distance)); + } + Ok(DistanceExpression::LinearCombination(terms)) + } + rule => unreachable!("unexpected distance primary: {rule:?}"), + } +} + +/// Parse the two `DistanceExpression` children of `min(..)` / `max(..)`. +#[allow(clippy::result_large_err)] +fn parse_distance_pair(pair: Pair<'_, Rule>) -> ParseResult<(DistanceExpression, DistanceExpression)> { + let mut children = pair.into_inner().filter(|p| p.as_rule() == Rule::DistanceExpression); + let left = parse_distance_expression(children.next().expect("first argument").into_inner())?; + let right = parse_distance_expression(children.next().expect("second argument").into_inner())?; + Ok((left, right)) +} + +#[allow(clippy::result_large_err)] +pub fn parse_distance_expression(pairs: Pairs) -> ParseResult { + DISTANCE_PRATT_PARSER + .map_primary(parse_distance_primary) + .map_prefix(|op, rhs| { + let (from, to) = parse_interval(op.clone())?; + let argument = Box::new(rhs?); + match op.as_rule() { + Rule::DistancePrefixF => Ok(DistanceExpression::Eventually { from, to, argument }), + Rule::DistancePrefixG => Ok(DistanceExpression::Globally { from, to, argument }), + rule => unreachable!("unexpected distance prefix operator: {rule:?}"), + } + }) + .map_infix(|lhs, op, rhs| match op.as_rule() { + Rule::DistanceInfixUntil => { + let (from, to) = parse_interval(op)?; + Ok(DistanceExpression::Until { + from, + to, + left: Box::new(lhs?), + right: Box::new(rhs?), + }) + } + rule => unreachable!("unexpected distance infix operator: {rule:?}"), + }) + .map_postfix(|lhs, op| match op.as_rule() { + Rule::DistancePostfixThreshold => { + let mut children = op.into_inner(); + let comparison = comparison_op(children.next().expect("threshold operator").as_str()); + let threshold = parse_expression_node(children.next().expect("threshold value"))?; + Ok(DistanceExpression::Threshold { + op: comparison, + left: Box::new(lhs?), + threshold, + }) + } + rule => unreachable!("unexpected distance postfix operator: {rule:?}"), + }) + .parse(pairs) +} + +// --------------------------------------------------------------------------- +// ROBTL formulas +// --------------------------------------------------------------------------- + +pub static ROBTL_PRATT_PARSER: LazyLock> = LazyLock::new(|| { + PrattParser::new() + .op(Op::infix(Rule::RobtlOr, Assoc::Left)) + .op(Op::infix(Rule::RobtlAnd, Assoc::Left)) + .op(Op::infix(Rule::RobtlUntil, Assoc::Left)) + .op(Op::prefix(Rule::RobtlNot) | Op::prefix(Rule::RobtlGlobally) | Op::prefix(Rule::RobtlEventually)) +}); + +#[allow(clippy::result_large_err)] +fn parse_robtl_primary(primary: Pair<'_, Rule>) -> ParseResult { + match primary.as_rule() { + Rule::RobtlFormula => parse_robtl_formula(primary.into_inner()), + Rule::RobtlTrue => Ok(RobtlFormula::True), + Rule::RobtlFalse => Ok(RobtlFormula::False), + Rule::ID => Ok(RobtlFormula::Reference(DefRef::new(identifier(&primary)))), + Rule::RobtlDistance => { + let mut children = primary.into_inner(); + let distance = DefRef::new(identifier(&children.next().expect("distance reference"))); + let perturbation = DefRef::new(identifier(&children.next().expect("perturbation reference"))); + let op = comparison_op(children.next().expect("comparison operator").as_str()); + let value = parse_expression_node(children.next().expect("threshold value"))?; + Ok(RobtlFormula::Distance { + distance, + perturbation, + op, + value, + }) + } + rule => unreachable!("unexpected ROBTL primary: {rule:?}"), + } +} + +#[allow(clippy::result_large_err)] +pub fn parse_robtl_formula(pairs: Pairs) -> ParseResult { + ROBTL_PRATT_PARSER + .map_primary(parse_robtl_primary) + .map_prefix(|op, rhs| match op.as_rule() { + Rule::RobtlNot => Ok(RobtlFormula::Not(Box::new(rhs?))), + Rule::RobtlGlobally => { + let (from, to) = parse_interval(op)?; + Ok(RobtlFormula::Globally { + from, + to, + argument: Box::new(rhs?), + }) + } + Rule::RobtlEventually => { + let (from, to) = parse_interval(op)?; + Ok(RobtlFormula::Eventually { + from, + to, + argument: Box::new(rhs?), + }) + } + rule => unreachable!("unexpected ROBTL prefix operator: {rule:?}"), + }) + .map_infix(|lhs, op, rhs| match op.as_rule() { + Rule::RobtlAnd => Ok(RobtlFormula::And(Box::new(lhs?), Box::new(rhs?))), + Rule::RobtlOr => Ok(RobtlFormula::Or(Box::new(lhs?), Box::new(rhs?))), + Rule::RobtlUntil => { + let (from, to) = parse_interval(op)?; + Ok(RobtlFormula::Until { + from, + to, + left: Box::new(lhs?), + right: Box::new(rhs?), + }) + } + rule => unreachable!("unexpected ROBTL infix operator: {rule:?}"), + }) + .parse(pairs) +} + +#[cfg(test)] +mod tests { + use crate::ast::BinaryOp; + use crate::ast::DistanceExpression; + use crate::ast::ExpressionKind; + use crate::ast::MathFunction; + use crate::ast::PerturbationExpression; + use crate::ast::RobtlFormula; + use crate::ast::Ty; + use crate::ast::UntypedStarkSpecification; + + /// Parse `const c = ;` and return the parsed expression (span discarded). + fn expr(src: &str) -> ExpressionKind { + let spec = UntypedStarkSpecification::parse(&format!("const c = {src};")).expect("should parse"); + spec.constants.into_iter().next().expect("one constant").value.node + } + + #[test] + fn arithmetic_precedence() { + // `1 + 2 * 3` must group as `1 + (2 * 3)`. + match expr("1 + 2 * 3") { + ExpressionKind::Binary(BinaryOp::Add, lhs, rhs) => { + assert!(matches!(lhs.node, ExpressionKind::Integer(1))); + assert!(matches!(rhs.node, ExpressionKind::Binary(BinaryOp::Mult, _, _))); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn power_is_right_associative() { + // `2 ^ 3 ^ 2` must group as `2 ^ (3 ^ 2)`. + match expr("2 ^ 3 ^ 2") { + ExpressionKind::Binary(BinaryOp::Pow, lhs, rhs) => { + assert!(matches!(lhs.node, ExpressionKind::Integer(2))); + assert!(matches!(rhs.node, ExpressionKind::Binary(BinaryOp::Pow, _, _))); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn comparison_binds_looser_than_bitand() { + // `a > b & c` must group as `(a > b) & c` (relations tighter than `&`). + match expr("a > b & c") { + ExpressionKind::Binary(BinaryOp::BitAnd, lhs, _) => { + assert!(matches!(lhs.node, ExpressionKind::Binary(BinaryOp::Greater, _, _))); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn unary_minus_and_not() { + assert!(matches!(expr("-x"), ExpressionKind::UnaryMinus(_))); + assert!(matches!(expr("!x"), ExpressionKind::Not(_))); + } + + #[test] + fn math_calls_and_user_calls() { + match expr("max(1, 2)") { + ExpressionKind::MathCall { + function: MathFunction::Max, + arguments, + } => assert_eq!(arguments.len(), 2), + other => panic!("unexpected: {other:?}"), + } + match expr("abs(x)") { + ExpressionKind::MathCall { + function: MathFunction::Abs, + arguments, + } => assert_eq!(arguments.len(), 1), + other => panic!("unexpected: {other:?}"), + } + // A non-builtin name is a user call, not a math call; the callee is + // unresolved (`id: None`) until name resolution runs. + match expr("eval_bd(x)") { + ExpressionKind::Call { function, arguments } => { + assert_eq!(function.name.name, "eval_bd"); + assert!(function.id.is_none()); + assert_eq!(arguments.len(), 1); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn call_target_must_be_a_plain_name() { + // `(a + b)(c)` is not a legal call: the callee must be a bare name. + assert!(UntypedStarkSpecification::parse("const c = (a + b)(c);").is_err()); + } + + #[test] + fn identifiers_starting_with_keyword_prefixes() { + // `italic`/`Rate` must be identifiers, not `it` / `R` followed by junk. + assert!(matches!( + expr("italic"), + ExpressionKind::Reference { name, .. } if name == "italic" + )); + assert!(matches!( + expr("Rate"), + ExpressionKind::Reference { name, .. } if name == "Rate" + )); + } + + #[test] + fn unresolved_reference_has_no_binding() { + assert!(matches!(expr("x"), ExpressionKind::Reference { binding: None, .. })); + } + + #[test] + fn ternary() { + assert!(matches!(expr("a ? b : c"), ExpressionKind::Ternary { .. })); + } + + #[test] + fn distributions() { + assert!(matches!(expr("N[0, 1]"), ExpressionKind::Normal { .. })); + assert!(matches!(expr("U[1, 2, 3]"), ExpressionKind::Uniform { values } if values.len() == 3)); + assert!(matches!( + expr("R[0, 1]"), + ExpressionKind::Range { + min: Some(_), + max: Some(_) + } + )); + assert!(matches!(expr("R"), ExpressionKind::Range { min: None, max: None })); + } + + #[test] + fn integer_overflow_is_an_error() { + assert!(UntypedStarkSpecification::parse("const c = 99999999999999999999999;").is_err()); + } + + #[test] + fn expression_span_covers_whole_subexpression() { + let spec = UntypedStarkSpecification::parse("const c = 1 + 2;").expect("should parse"); + let value = &spec.constants[0].value; + // `const c = ` is 10 chars; `1 + 2` spans [10, 15). + assert_eq!(value.span.start, 10); + assert_eq!(value.span.end, 15); + } + + #[test] + fn variable_with_range_and_type() { + let spec = UntypedStarkSpecification::parse("global variables { int counter range [0, 10] = 0; }") + .expect("should parse"); + let var = &spec.variables[0]; + assert!(var.global); + assert!(matches!(var.ty, Ty::Integer)); + assert!(var.range.is_some()); + assert_eq!(var.name.name, "counter"); + assert!(var.id.is_none()); + } + + #[test] + fn perturbation_sequence_and_iteration() { + let spec = + UntypedStarkSpecification::parse("perturbation p = ([x <- 1]@0); ([y <- 2]@0)^3;").expect("should parse"); + // `a ; b^3` groups as `a ; (b^3)`. + match &spec.perturbations[0].value { + PerturbationExpression::Sequence(_, right) => { + assert!(matches!(**right, PerturbationExpression::Iteration { .. })); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn distance_and_formula() { + let spec = UntypedStarkSpecification::parse("distance d = \\G[0, 10] < rho;\nformula f = \\D[d, p] <= 5;") + .expect("should parse"); + assert!(matches!(spec.distances[0].value, DistanceExpression::Globally { .. })); + match &spec.formulas[0].value { + RobtlFormula::Distance { + distance, perturbation, .. + } => { + assert_eq!(distance.name.name, "d"); + assert_eq!(perturbation.name.name, "p"); + } + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/crates/stark/src/resolve.rs b/crates/stark/src/resolve.rs new file mode 100644 index 00000000..1580a8ca --- /dev/null +++ b/crates/stark/src/resolve.rs @@ -0,0 +1,1708 @@ +//! Name resolution: assigns every declaration a stable [DefId]/[StateId]/ +//! [LocalId] and rewrites every reference in place to point at the +//! declaration it names. +//! +//! STARK has no forward references: a name is only visible to expressions that +//! come *after* its declaration in source order. Concretely, this means one +//! linear walk over the declarations is sufficient: by the time a name is used, +//! everything it could legally refer to has already been registered. +//! +//! There are two exceptions, both handled by registering names in a first +//! pass before any body is resolved: +//! +//! * Controller states: `step`/`exec` inside a state may target a *later* +//! state in the same component, so each component's states are registered up front. +//! * State variables: every `variables`/`global variables` block and every +//! component's variable block is declared before anything else in the +//! specification, so a function body, environment block or component may +//! read a state variable regardless of where it is declared. This mirrors +//! the original, which has a dedicated pass collecting exactly these names +//! ahead of everything else. +//! +//! Caveat: [UntypedStarkSpecification] buckets declarations by kind (all +//! constants, then all parameters, then all variables, …) rather than +//! preserving one linear source-order list, so this pass resolves in a +//! fixed kind order — constants/parameters, then types, then functions, +//! then variables, then components, then the environment, then +//! penalties/perturbations/distances/formulas — rather than true +//! interleaved source order. Functions are resolved before variables +//! because variable initializers call helper functions (e.g. +//! `eval_rd(INIT_SPEED)`) in several of the example specs, even though the +//! functions themselves are declared first in the source too. This only +//! differs from true source order when declarations of different kinds +//! reference each other out of this grouping, which none of the example +//! specs do (once a couple of their own pre-existing bugs — a stray +//! reference to a global instead of a same-named parameter, an +//! under-scoped `let` — are fixed; see the fixed-up `examples/stark/*.stark` +//! files). +//! +//! Because variables are pre-declared, their names also resolve inside +//! expressions that are evaluated once at load time, before any variable +//! store exists — a `const`/`param` value, or a variable's own range or +//! initializer, including a self-reference like `real X = X;`. Resolution +//! order no longer rules those out, so a post-pass +//! ([Resolver::check_static_expressions]) rejects them explicitly, including +//! reads reached indirectly through a function call. The original accepts +//! all of these and evaluates them to its absorbing error value at runtime +//! with no diagnostic. +//! +//! This pass only binds names — it does not compute or check types (see +//! `typecheck.rs`). A reference that fails to resolve is left with its `id` +//! (or `binding`) as `None` and a diagnostic is recorded; `typecheck.rs` +//! treats `None` as already-erred and does not re-report it. + +use std::collections::HashMap; + +use log::debug; +use log::trace; +use merc_utilities::Span; + +use crate::ast::*; +use crate::diagnostics::DiagnosticKind; +use crate::diagnostics::Diagnostics; + +/// What kind of thing a top-level [DefId] names. +#[derive(Clone, Debug)] +pub enum DefKind { + Constant, + Parameter, + Variable { + global: bool, + }, + Function { + argument_count: usize, + }, + Penalty, + Component, + /// An element of a custom `type X = A | B | C;` declaration. + TypeElement { + type_name: String, + }, + Type, + Perturbation, + Distance, + Formula, +} + +impl DefKind { + /// Whether a plain `ExpressionKind::Reference` may resolve to this kind. + fn is_referenceable_value(&self) -> bool { + matches!( + self, + DefKind::Constant | DefKind::Parameter | DefKind::Variable { .. } | DefKind::TypeElement { .. } + ) + } + + fn describe(&self) -> &'static str { + match self { + DefKind::Constant => "a constant", + DefKind::Parameter => "a parameter", + DefKind::Variable { .. } => "a variable", + DefKind::Function { .. } => "a function", + DefKind::Penalty => "a penalty", + DefKind::Component => "a component", + DefKind::TypeElement { .. } => "a type element", + DefKind::Type => "a type", + DefKind::Perturbation => "a perturbation", + DefKind::Distance => "a distance", + DefKind::Formula => "a formula", + } + } + + fn is_variable_kind(kind: &DefKind) -> bool { + matches!(kind, DefKind::Variable { .. }) + } + fn is_function_kind(kind: &DefKind) -> bool { + matches!(kind, DefKind::Function { .. }) + } + fn is_penalty_kind(kind: &DefKind) -> bool { + matches!(kind, DefKind::Penalty) + } + fn is_distance_kind(kind: &DefKind) -> bool { + matches!(kind, DefKind::Distance) + } + fn is_perturbation_kind(kind: &DefKind) -> bool { + matches!(kind, DefKind::Perturbation) + } + fn is_formula_kind(kind: &DefKind) -> bool { + matches!(kind, DefKind::Formula) + } +} + +pub struct DefEntry { + pub kind: DefKind, + pub name: String, + pub span: Span, +} + +pub struct StateEntry { + pub name: String, + pub span: Span, + /// The component this state belongs to. + pub component: DefId, +} + +pub struct LocalEntry { + pub name: String, + pub span: Span, +} + +/// The result of name resolution: every declaration encountered, indexed by +/// the [DefId] / [StateId] / [LocalId] assigned to it. +#[derive(Default)] +pub struct SymbolTable { + pub defs: Vec, + pub states: Vec, + pub locals: Vec, + /// Top-level names, for lookups that don't go through an already-resolved + /// [DefRef]. + pub names: HashMap, +} + +impl SymbolTable { + pub fn def(&self, id: DefId) -> &DefEntry { + &self.defs[id.value()] + } + + pub fn state(&self, id: StateId) -> &StateEntry { + &self.states[id.value()] + } + + pub fn local(&self, id: LocalId) -> &LocalEntry { + &self.locals[id.value()] + } + + pub fn by_name(&self, name: &str) -> Option { + self.names.get(name).copied() + } +} + +/// Resolves every name in `spec` in place, returning the resulting +/// [SymbolTable] together with every diagnostic found along the way. +pub fn resolve(spec: &mut UntypedStarkSpecification) -> (SymbolTable, Diagnostics) { + let mut resolver = Resolver { + table: SymbolTable::default(), + scopes: Vec::new(), + diagnostics: Diagnostics::new(), + functions_reading_variables: HashMap::new(), + }; + resolver.resolve_specification(spec); + + // Every scope opened during the walk must have been closed again. + debug_assert!( + resolver.scopes.is_empty(), + "{} local scope(s) left open after resolution", + resolver.scopes.len() + ); + // `declare` always pushes a `DefEntry` and inserts into `names` together. + debug_assert_eq!( + resolver.table.defs.len(), + resolver.table.names.len(), + "symbol table's `defs` and `names` disagree on how many names were declared" + ); + + debug!( + "resolved {} definition(s), {} controller state(s), {} local binding(s); {} diagnostic(s)", + resolver.table.defs.len(), + resolver.table.states.len(), + resolver.table.locals.len(), + resolver.diagnostics.items().len() + ); + + // The contract every later pass relies on: a specification that resolved + // cleanly has *no* `None` ids left anywhere. Checking it here means a + // resolver bug surfaces as a failure in this pass rather than as a + // confusing `unwrap` far downstream in lowering. + #[cfg(debug_assertions)] + if !resolver.diagnostics.has_errors() { + assert_fully_resolved(spec); + } + + (resolver.table, resolver.diagnostics) +} + +struct Resolver { + table: SymbolTable, + /// Local scopes (function arguments, `let` bindings), innermost last. + scopes: Vec>, + diagnostics: Diagnostics, + /// Functions that read a state variable, mapped to the name of one such + /// variable (for the diagnostic). Populated by + /// [Resolver::check_static_expressions]; empty before then. + functions_reading_variables: HashMap, +} + +impl Resolver { + // -- Declaring names ------------------------------------------------ + + /// Registers a new top-level declaration. On a name clash, records a + /// duplicate-definition diagnostic and leaves the *new* declaration + /// unregistered (`names` keeps pointing at the first one, matching + /// "first wins"); the caller should leave that declaration's `id` as + /// `None`. + fn declare(&mut self, name: &Identifier, kind: DefKind) -> Option { + if let Some(&existing) = self.table.names.get(&name.name) { + let first = self.table.def(existing).span.clone(); + self.diagnostics.error( + name.span.clone(), + DiagnosticKind::DuplicateDefinition { + name: name.name.clone(), + first, + }, + ); + return None; + } + let id = DefId::new(self.table.defs.len()); + trace!("declaring {} `{}` as {id:?}", kind.describe(), name.name); + self.table.defs.push(DefEntry { + kind, + name: name.name.clone(), + span: name.span.clone(), + }); + self.table.names.insert(name.name.clone(), id); + debug_assert_eq!( + self.table.def(id).name, + name.name, + "`{}` was filed under the wrong id", + name.name + ); + Some(id) + } + + fn declare_state( + &mut self, + name: &Identifier, + component: DefId, + states: &mut HashMap, + ) -> Option { + if let Some(&existing) = states.get(&name.name) { + let first = self.table.state(existing).span.clone(); + self.diagnostics.error( + name.span.clone(), + DiagnosticKind::DuplicateControllerState { + name: name.name.clone(), + first, + }, + ); + return None; + } + let id = StateId::new(self.table.states.len()); + trace!("declaring controller state `{}` as {id:?}", name.name); + self.table.states.push(StateEntry { + name: name.name.clone(), + span: name.span.clone(), + component, + }); + states.insert(name.name.clone(), id); + Some(id) + } + + /// Opens a new local scope, declaring all of `bindings` at once (so + /// e.g. `let a = 1 and b = 2 in ..` puts both `a` and `b` in the same + /// frame). Duplicate names *within this same frame* are diagnosed and + /// get no id; duplicates against an outer scope are just ordinary + /// shadowing and are allowed. The returned vector has the same length + /// and order as `bindings`. + fn push_scope(&mut self, bindings: &[&Identifier]) -> Vec> { + let mut frame = HashMap::new(); + let mut ids = Vec::with_capacity(bindings.len()); + for name in bindings { + if let Some(&existing) = frame.get(&name.name) { + let first = self.table.local(existing).span.clone(); + self.diagnostics.error( + name.span.clone(), + DiagnosticKind::DuplicateBinding { + name: name.name.clone(), + first, + }, + ); + ids.push(None); + continue; + } + let id = LocalId::new(self.table.locals.len()); + trace!("binding local `{}` as {id:?} at depth {}", name.name, self.scopes.len()); + self.table.locals.push(LocalEntry { + name: name.name.clone(), + span: name.span.clone(), + }); + frame.insert(name.name.clone(), id); + ids.push(Some(id)); + } + self.scopes.push(frame); + debug_assert_eq!( + ids.len(), + bindings.len(), + "push_scope must return one id slot per binding" + ); + ids + } + + fn pop_scope(&mut self) { + debug_assert!(!self.scopes.is_empty(), "pop_scope without a matching push_scope"); + self.scopes.pop(); + } + + // -- Looking up names ------------------------------------------------- + + fn lookup_local(&self, name: &str) -> Option { + self.scopes.iter().rev().find_map(|frame| frame.get(name).copied()) + } + + fn unknown_symbol(&mut self, name: &Identifier) { + self.diagnostics.error( + name.span.clone(), + DiagnosticKind::UnknownSymbol { + name: name.name.clone(), + }, + ); + } + + /// Resolves a [DefRef] against the top-level namespace, requiring the + /// resolved declaration's kind to satisfy `expected`. + fn resolve_def_ref( + &mut self, + reference: &mut DefRef, + expected: impl Fn(&DefKind) -> bool, + expected_desc: &'static str, + ) { + let Some(id) = self.table.names.get(&reference.name.name).copied() else { + self.unknown_symbol(&reference.name); + return; + }; + if expected(&self.table.def(id).kind) { + reference.id = Some(id); + } else { + let kind = self.table.def(id).kind.clone(); + self.diagnostics.error( + reference.name.span.clone(), + DiagnosticKind::IllegalUseOfName { + name: reference.name.name.clone(), + found: kind.describe(), + expected: expected_desc, + }, + ); + } + } + + fn resolve_state_ref(&mut self, reference: &mut StateRef, states: &HashMap) { + match states.get(&reference.name.name) { + Some(&id) => reference.id = Some(id), + None => { + self.diagnostics.error( + reference.name.span.clone(), + DiagnosticKind::UnknownControllerState { + name: reference.name.name.clone(), + }, + ); + } + } + } + + /// Resolves a name reference inside an ordinary expression: locals + /// shadow top-level declarations, and only "value" kinds are legal here. + fn resolve_reference(&mut self, name: &str, span: &Span) -> Option { + if let Some(id) = self.lookup_local(name) { + return Some(Binding::Local(id)); + } + let Some(id) = self.table.names.get(name).copied() else { + self.diagnostics + .error(span.clone(), DiagnosticKind::UnknownSymbol { name: name.to_string() }); + return None; + }; + if self.table.def(id).kind.is_referenceable_value() { + Some(Binding::Def(id)) + } else { + let kind = self.table.def(id).kind.clone(); + self.diagnostics.error( + span.clone(), + DiagnosticKind::IllegalUseOfName { + name: name.to_string(), + found: kind.describe(), + expected: "a constant, parameter, variable or type element", + }, + ); + None + } + } + + // -- Top-level walk ----------------------------------------------------- + + fn resolve_specification(&mut self, spec: &mut UntypedStarkSpecification) { + debug!( + "resolving specification: {} constant(s), {} parameter(s), {} type(s), {} function(s), \ + {} variable(s), {} component(s), {} penalty/-ies, {} perturbation(s), {} distance(s), {} formula(s)", + spec.constants.len(), + spec.parameters.len(), + spec.types.len(), + spec.functions.len(), + spec.variables.len(), + spec.components.len(), + spec.penalties.len(), + spec.perturbations.len(), + spec.distances.len(), + spec.formulas.len() + ); + // State variables are visible everywhere, not just after their own + // declaration, so register them all before resolving any body. + for variable in &mut spec.variables { + self.declare_variable(variable); + } + for component in &mut spec.components { + for variable in &mut component.variables { + self.declare_variable(variable); + } + } + for constant in &mut spec.constants { + self.resolve_expression(&mut constant.value); + constant.id = self.declare(&constant.name, DefKind::Constant); + } + for parameter in &mut spec.parameters { + self.resolve_expression(&mut parameter.value); + parameter.id = self.declare(¶meter.name, DefKind::Parameter); + } + for ty in &mut spec.types { + self.resolve_type_declaration(ty); + } + for function in &mut spec.functions { + self.resolve_function(function); + } + for variable in &mut spec.variables { + self.resolve_variable(variable); + } + for component in &mut spec.components { + self.resolve_component(component); + } + if let Some(environment) = &mut spec.environment { + self.resolve_environment_commands(&mut environment.commands); + } + for penalty in &mut spec.penalties { + self.resolve_expression(&mut penalty.value); + penalty.id = self.declare(&penalty.name, DefKind::Penalty); + } + for perturbation in &mut spec.perturbations { + self.resolve_perturbation(&mut perturbation.value); + perturbation.id = self.declare(&perturbation.name, DefKind::Perturbation); + } + for distance in &mut spec.distances { + self.resolve_distance(&mut distance.value); + distance.id = self.declare(&distance.name, DefKind::Distance); + } + for formula in &mut spec.formulas { + self.resolve_robtl(&mut formula.value); + formula.id = self.declare(&formula.name, DefKind::Formula); + } + self.check_static_expressions(spec); + } + + /// Reports state variables read from expressions that are evaluated once + /// at load time, before any variable store exists. Pre-declaring + /// variables makes those names resolve everywhere, so this is what keeps + /// `const a = X;` and `real X = X;` from being silently accepted — the + /// original has this hole, evaluating such reads to its absorbing error + /// value at runtime with no diagnostic. + /// + /// Runs as a post-pass so every function is resolved and its + /// [Self::functions_reading_variables] entry is known. + fn check_static_expressions(&mut self, spec: &UntypedStarkSpecification) { + // A function body may legitimately read a variable — it is called + // from controllers and environment blocks, where the store exists. + // Calling one from a static expression is what makes it a problem, + // so the offending functions have to be identified first. + // + // `spec.functions` is a valid topological order: a function can only + // call one declared before it (its own `DefId` is registered after + // its body resolves, so there is no recursion), which means each + // callee's entry is already final when its caller is visited. + for function in &spec.functions { + let reads = self.statement_reads_variable(&function.body); + if let (Some(id), Some(name)) = (function.id, reads) { + self.functions_reading_variables.insert(id, name); + } + } + + for constant in &spec.constants { + self.reject_state_variables(&constant.value, "const"); + } + for parameter in &spec.parameters { + self.reject_state_variables(¶meter.value, "param"); + } + let variables = spec + .variables + .iter() + .chain(spec.components.iter().flat_map(|c| c.variables.iter())); + for variable in variables { + if let Some(range) = &variable.range { + self.reject_state_variables(&range.min, "variable range"); + self.reject_state_variables(&range.max, "variable range"); + } + self.reject_state_variables(&variable.initial_value, "variable initializer"); + } + } + + /// Records a diagnostic for every state variable `expr` reads, whether + /// directly or through a function call. `context` names the kind of + /// static expression, for the message. + fn reject_state_variables(&mut self, expr: &Expression, context: &'static str) { + for_each_subexpression(expr, &mut |expr| { + let offender = match &expr.node { + ExpressionKind::Reference { + binding: Some(Binding::Def(id)), + name, + } if DefKind::is_variable_kind(&self.table.def(*id).kind) => Some((name.clone(), None)), + ExpressionKind::Call { function, .. } => function + .id + .and_then(|id| self.functions_reading_variables.get(&id)) + .map(|variable| (variable.clone(), Some(function.name.name.clone()))), + _ => None, + }; + if let Some((name, via)) = offender { + self.diagnostics.error( + expr.span.clone(), + DiagnosticKind::StateVariableInStaticExpression { name, context, via }, + ); + } + }); + } + + /// The name of some state variable `statement` reads, directly or + /// through a call, if there is one. + fn statement_reads_variable(&self, statement: &FunctionStatement) -> Option { + let mut found = None; + let mut visit_expression = |expr: &Expression| { + for_each_subexpression(expr, &mut |expr| { + if found.is_some() { + return; + } + found = match &expr.node { + ExpressionKind::Reference { + binding: Some(Binding::Def(id)), + name, + } if DefKind::is_variable_kind(&self.table.def(*id).kind) => Some(name.clone()), + ExpressionKind::Call { function, .. } => function + .id + .and_then(|id| self.functions_reading_variables.get(&id)) + .cloned(), + _ => None, + }; + }); + }; + for_each_statement_expression(statement, &mut visit_expression); + found + } + + /// Registers a variable's name. Split out from [Self::resolve_variable] + /// so every variable in the specification can be declared in one pass up + /// front; the initializer and range are resolved later. + fn declare_variable(&mut self, variable: &mut Variable) { + variable.id = self.declare( + &variable.name, + DefKind::Variable { + global: variable.global, + }, + ); + } + + /// Resolves the parts of a variable that reference other names. The name + /// itself is already registered by [Self::declare_variable]. + fn resolve_variable(&mut self, variable: &mut Variable) { + if let Some(range) = &mut variable.range { + self.resolve_expression(&mut range.min); + self.resolve_expression(&mut range.max); + } + self.resolve_expression(&mut variable.initial_value); + } + + fn resolve_type_declaration(&mut self, ty: &mut TypeDeclaration) { + // A type name colliding with one of its own elements isn't caught by + // the general duplicate check below (neither is registered yet at + // the point we'd check), so it needs its own check. + if ty.elements.iter().any(|e| e.name == ty.name.name) { + self.diagnostics.error( + ty.name.span.clone(), + DiagnosticKind::TypeElementSharesTypeName { + name: ty.name.name.clone(), + }, + ); + } else { + ty.id = self.declare(&ty.name, DefKind::Type); + } + for element in &ty.elements { + self.declare( + element, + DefKind::TypeElement { + type_name: ty.name.name.clone(), + }, + ); + } + } + + fn resolve_function(&mut self, function: &mut Function) { + trace!( + "resolving function `{}` with {} argument(s)", + function.name.name, + function.arguments.len() + ); + let bindings: Vec<&Identifier> = function.arguments.iter().map(|arg| &arg.name).collect(); + let ids = self.push_scope(&bindings); + for (argument, id) in function.arguments.iter_mut().zip(ids) { + argument.id = id; + } + self.resolve_function_statement(&mut function.body); + self.pop_scope(); + + // Registered *after* the body so the function cannot call itself — + // see the module doc comment. + function.id = self.declare( + &function.name, + DefKind::Function { + argument_count: function.arguments.len(), + }, + ); + } + + fn resolve_function_statement(&mut self, statement: &mut FunctionStatement) { + match statement { + FunctionStatement::Return(value) => self.resolve_expression(value), + FunctionStatement::IfThenElse { + guard, + then_branch, + else_branch, + } => { + self.resolve_expression(guard); + self.resolve_function_statement(then_branch); + if let Some(else_branch) = else_branch { + self.resolve_function_statement(else_branch); + } + } + FunctionStatement::Let { id, name, value, body } => { + self.resolve_expression(value); + let ids = self.push_scope(&[&*name]); + *id = ids.into_iter().next().flatten(); + self.resolve_function_statement(body); + self.pop_scope(); + } + FunctionStatement::Block(inner) => self.resolve_function_statement(inner), + } + } + + fn resolve_component(&mut self, component: &mut Component) { + trace!( + "resolving component `{}` with {} variable(s) and {} state(s)", + component.name.name, + component.variables.len(), + component.states.len() + ); + for variable in &mut component.variables { + self.resolve_variable(variable); + } + component.id = self.declare(&component.name, DefKind::Component); + let Some(component_id) = component.id else { + return; + }; + + // States can reference each other regardless of declaration order, + // so register them all before resolving any body. + let mut states = HashMap::new(); + for state in &mut component.states { + state.id = self.declare_state(&state.name, component_id, &mut states); + } + for state in &mut component.states { + self.resolve_controller_commands(&mut state.body, &states); + } + for target in &mut component.init { + self.resolve_state_ref(target, &states); + } + } + + fn resolve_controller_commands(&mut self, commands: &mut [ControllerCommand], states: &HashMap) { + for command in commands { + match command { + ControllerCommand::Step { steps, target } => { + if let Some(steps) = steps { + self.resolve_expression(steps); + } + self.resolve_state_ref(target, states); + } + ControllerCommand::Exec(target) => self.resolve_state_ref(target, states), + ControllerCommand::Let { id, name, value, body } => { + self.resolve_expression(value); + let ids = self.push_scope(&[&*name]); + *id = ids.into_iter().next().flatten(); + self.resolve_controller_commands(body, states); + self.pop_scope(); + } + ControllerCommand::Assignment(update) => self.resolve_update(update), + ControllerCommand::IfThenElse { + guard, + then_branch, + else_branch, + } => { + self.resolve_expression(guard); + self.resolve_controller_commands(then_branch, states); + if let Some(else_branch) = else_branch { + self.resolve_controller_commands(else_branch, states); + } + } + ControllerCommand::Block(inner) => self.resolve_controller_commands(inner, states), + } + } + } + + fn resolve_environment_commands(&mut self, commands: &mut [EnvironmentCommand]) { + for command in commands { + self.resolve_environment_command(command); + } + } + + fn resolve_environment_command(&mut self, command: &mut EnvironmentCommand) { + match command { + EnvironmentCommand::Assignment(update) => self.resolve_update(update), + EnvironmentCommand::IfThenElse { + guard, + then_branch, + else_branch, + } => { + self.resolve_expression(guard); + self.resolve_environment_command(then_branch); + if let Some(else_branch) = else_branch { + self.resolve_environment_command(else_branch); + } + } + EnvironmentCommand::Let { bindings, body } => { + // `let a = e1 and b = e2(a) and ... in body`: each binding's + // value can see every binding *before* it in the same chain + // (this is what `toll.stark`'s `new_sens_speed = new_s_speed( + // ..., token)` relies on, referencing the `token` bound + // immediately before it) — so each binding opens its own + // nested scope rather than all of them sharing one frame. + for binding in bindings.iter_mut() { + self.resolve_expression(&mut binding.value); + let ids = self.push_scope(&[&binding.name]); + binding.id = ids.into_iter().next().flatten(); + } + self.resolve_environment_command(body); + for _ in bindings.iter() { + self.pop_scope(); + } + } + EnvironmentCommand::Block(inner) => self.resolve_environment_commands(inner), + } + } + + fn resolve_update(&mut self, update: &mut Update) { + if let Some(guard) = &mut update.guard { + self.resolve_expression(guard); + } + self.resolve_expression(&mut update.value); + self.resolve_def_ref(&mut update.target, DefKind::is_variable_kind, "a variable"); + } + + // -- Sub-languages -------------------------------------------------- + + fn resolve_perturbation(&mut self, perturbation: &mut PerturbationExpression) { + match perturbation { + PerturbationExpression::Nil => {} + PerturbationExpression::Reference(reference) => { + self.resolve_def_ref(reference, DefKind::is_perturbation_kind, "a perturbation") + } + PerturbationExpression::Atomic { assignments, time } => { + for assignment in assignments { + self.resolve_expression(&mut assignment.value); + self.resolve_def_ref(&mut assignment.target, DefKind::is_variable_kind, "a variable"); + } + self.resolve_expression(time); + } + PerturbationExpression::Sequence(left, right) => { + self.resolve_perturbation(left); + self.resolve_perturbation(right); + } + PerturbationExpression::Iteration { argument, iterations } => { + self.resolve_perturbation(argument); + self.resolve_expression(iterations); + } + } + } + + fn resolve_distance(&mut self, distance: &mut DistanceExpression) { + match distance { + DistanceExpression::Reference(reference) => { + self.resolve_def_ref(reference, DefKind::is_distance_kind, "a distance") + } + DistanceExpression::AtomicLeft(reference) | DistanceExpression::AtomicRight(reference) => { + self.resolve_def_ref(reference, DefKind::is_penalty_kind, "a penalty") + } + DistanceExpression::Eventually { from, to, argument } + | DistanceExpression::Globally { from, to, argument } => { + self.resolve_expression(from); + self.resolve_expression(to); + self.resolve_distance(argument); + } + DistanceExpression::Until { from, to, left, right } => { + self.resolve_expression(from); + self.resolve_expression(to); + self.resolve_distance(left); + self.resolve_distance(right); + } + DistanceExpression::Threshold { left, threshold, .. } => { + self.resolve_distance(left); + self.resolve_expression(threshold); + } + DistanceExpression::Min(left, right) | DistanceExpression::Max(left, right) => { + self.resolve_distance(left); + self.resolve_distance(right); + } + DistanceExpression::LinearCombination(terms) => { + for (weight, distance) in terms { + self.resolve_expression(weight); + self.resolve_distance(distance); + } + } + } + } + + fn resolve_robtl(&mut self, formula: &mut RobtlFormula) { + match formula { + RobtlFormula::True | RobtlFormula::False => {} + RobtlFormula::Reference(reference) => { + self.resolve_def_ref(reference, DefKind::is_formula_kind, "a formula") + } + RobtlFormula::Distance { + distance, + perturbation, + value, + .. + } => { + self.resolve_def_ref(distance, DefKind::is_distance_kind, "a distance"); + self.resolve_def_ref(perturbation, DefKind::is_perturbation_kind, "a perturbation"); + self.resolve_expression(value); + } + RobtlFormula::Not(inner) => self.resolve_robtl(inner), + RobtlFormula::Globally { from, to, argument } | RobtlFormula::Eventually { from, to, argument } => { + self.resolve_expression(from); + self.resolve_expression(to); + self.resolve_robtl(argument); + } + RobtlFormula::And(left, right) | RobtlFormula::Or(left, right) => { + self.resolve_robtl(left); + self.resolve_robtl(right); + } + RobtlFormula::Until { from, to, left, right } => { + self.resolve_expression(from); + self.resolve_expression(to); + self.resolve_robtl(left); + self.resolve_robtl(right); + } + } + } + + fn resolve_expression(&mut self, expr: &mut Expression) { + match &mut expr.node { + ExpressionKind::False + | ExpressionKind::True + | ExpressionKind::Integer(_) + | ExpressionKind::Real(_) + | ExpressionKind::Iterator => {} + ExpressionKind::Reference { name, binding } => { + *binding = self.resolve_reference(name, &expr.span); + } + ExpressionKind::Normal { mean, std_dev } => { + self.resolve_expression(mean); + self.resolve_expression(std_dev); + } + ExpressionKind::Uniform { values } => { + for value in values { + self.resolve_expression(value); + } + } + ExpressionKind::Range { min, max } => { + if let Some(min) = min { + self.resolve_expression(min); + } + if let Some(max) = max { + self.resolve_expression(max); + } + } + ExpressionKind::Not(inner) | ExpressionKind::UnaryPlus(inner) | ExpressionKind::UnaryMinus(inner) => { + self.resolve_expression(inner); + } + ExpressionKind::Binary(_, left, right) => { + self.resolve_expression(left); + self.resolve_expression(right); + } + ExpressionKind::Ternary { + guard, + then_branch, + else_branch, + } => { + self.resolve_expression(guard); + self.resolve_expression(then_branch); + self.resolve_expression(else_branch); + } + ExpressionKind::Call { function, arguments } => { + for argument in arguments.iter_mut() { + self.resolve_expression(argument); + } + self.resolve_def_ref(function, DefKind::is_function_kind, "a function"); + } + ExpressionKind::MathCall { arguments, .. } => { + for argument in arguments { + self.resolve_expression(argument); + } + } + } + } +} + +/// Applies `visit` to `expr` and every subexpression of it, outermost first. +fn for_each_subexpression(expr: &Expression, visit: &mut impl FnMut(&Expression)) { + visit(expr); + match &expr.node { + ExpressionKind::False + | ExpressionKind::True + | ExpressionKind::Integer(_) + | ExpressionKind::Real(_) + | ExpressionKind::Iterator + | ExpressionKind::Reference { .. } => {} + ExpressionKind::Normal { mean, std_dev } => { + for_each_subexpression(mean, visit); + for_each_subexpression(std_dev, visit); + } + ExpressionKind::Uniform { values } => { + for value in values { + for_each_subexpression(value, visit); + } + } + ExpressionKind::Range { min, max } => { + for bound in [min, max].into_iter().flatten() { + for_each_subexpression(bound, visit); + } + } + ExpressionKind::Not(inner) | ExpressionKind::UnaryPlus(inner) | ExpressionKind::UnaryMinus(inner) => { + for_each_subexpression(inner, visit); + } + ExpressionKind::Binary(_, left, right) => { + for_each_subexpression(left, visit); + for_each_subexpression(right, visit); + } + ExpressionKind::Ternary { + guard, + then_branch, + else_branch, + } => { + for_each_subexpression(guard, visit); + for_each_subexpression(then_branch, visit); + for_each_subexpression(else_branch, visit); + } + ExpressionKind::Call { arguments, .. } | ExpressionKind::MathCall { arguments, .. } => { + for argument in arguments { + for_each_subexpression(argument, visit); + } + } + } +} + +/// Applies `visit` to every expression appearing anywhere in `statement`. +fn for_each_statement_expression(statement: &FunctionStatement, visit: &mut impl FnMut(&Expression)) { + match statement { + FunctionStatement::Return(value) => visit(value), + FunctionStatement::IfThenElse { + guard, + then_branch, + else_branch, + } => { + visit(guard); + for_each_statement_expression(then_branch, visit); + if let Some(else_branch) = else_branch { + for_each_statement_expression(else_branch, visit); + } + } + FunctionStatement::Let { value, body, .. } => { + visit(value); + for_each_statement_expression(body, visit); + } + FunctionStatement::Block(inner) => for_each_statement_expression(inner, visit), + } +} + +/// Asserts the post-condition of a clean resolution: no `id`/`binding` slot +/// anywhere in `spec` is still `None`. +/// +/// This is the invariant every later pass is entitled to assume — `typecheck.rs` +/// treats `None` as "already diagnosed", and the planned lowering pass indexes +/// through these ids unconditionally. If resolution reports no diagnostics but +/// leaves a slot empty, that is a resolver bug, and it is much cheaper to catch +/// it here than as an `unwrap` three passes later. Debug builds only. +#[cfg(debug_assertions)] +fn assert_fully_resolved(spec: &UntypedStarkSpecification) { + fn check_expression(expr: &Expression) { + match &expr.node { + ExpressionKind::False + | ExpressionKind::True + | ExpressionKind::Integer(_) + | ExpressionKind::Real(_) + | ExpressionKind::Iterator => {} + ExpressionKind::Reference { name, binding } => { + assert!( + binding.is_some(), + "reference `{name}` left unbound by a clean resolution" + ); + } + ExpressionKind::Normal { mean, std_dev } => { + check_expression(mean); + check_expression(std_dev); + } + ExpressionKind::Uniform { values } => values.iter().for_each(check_expression), + ExpressionKind::Range { min, max } => { + min.iter().for_each(|e| check_expression(e)); + max.iter().for_each(|e| check_expression(e)); + } + ExpressionKind::Not(inner) | ExpressionKind::UnaryPlus(inner) | ExpressionKind::UnaryMinus(inner) => { + check_expression(inner) + } + ExpressionKind::Binary(_, left, right) => { + check_expression(left); + check_expression(right); + } + ExpressionKind::Ternary { + guard, + then_branch, + else_branch, + } => { + check_expression(guard); + check_expression(then_branch); + check_expression(else_branch); + } + ExpressionKind::Call { function, arguments } => { + assert!( + function.id.is_some(), + "call to `{}` left unresolved", + function.name.name + ); + arguments.iter().for_each(check_expression); + } + ExpressionKind::MathCall { arguments, .. } => arguments.iter().for_each(check_expression), + } + } + + fn check_variable(variable: &Variable) { + assert!( + variable.id.is_some(), + "variable `{}` left undeclared", + variable.name.name + ); + if let Some(range) = &variable.range { + check_expression(&range.min); + check_expression(&range.max); + } + check_expression(&variable.initial_value); + } + + fn check_update(update: &Update) { + update.guard.iter().for_each(check_expression); + check_expression(&update.value); + assert!( + update.target.id.is_some(), + "assignment target `{}` left unresolved", + update.target.name.name + ); + } + + fn check_function_statement(statement: &FunctionStatement) { + match statement { + FunctionStatement::Return(value) => check_expression(value), + FunctionStatement::IfThenElse { + guard, + then_branch, + else_branch, + } => { + check_expression(guard); + check_function_statement(then_branch); + else_branch.iter().for_each(|s| check_function_statement(s)); + } + FunctionStatement::Let { id, name, value, body } => { + check_expression(value); + assert!(id.is_some(), "let binding `{}` left unbound", name.name); + check_function_statement(body); + } + FunctionStatement::Block(inner) => check_function_statement(inner), + } + } + + fn check_controller_commands(commands: &[ControllerCommand]) { + for command in commands { + match command { + ControllerCommand::Step { steps, target } => { + steps.iter().for_each(check_expression); + assert!( + target.id.is_some(), + "step target `{}` left unresolved", + target.name.name + ); + } + ControllerCommand::Exec(target) => { + assert!( + target.id.is_some(), + "exec target `{}` left unresolved", + target.name.name + ); + } + ControllerCommand::Let { id, name, value, body } => { + check_expression(value); + assert!(id.is_some(), "let binding `{}` left unbound", name.name); + check_controller_commands(body); + } + ControllerCommand::Assignment(update) => check_update(update), + ControllerCommand::IfThenElse { + guard, + then_branch, + else_branch, + } => { + check_expression(guard); + check_controller_commands(then_branch); + else_branch.iter().for_each(|b| check_controller_commands(b)); + } + ControllerCommand::Block(inner) => check_controller_commands(inner), + } + } + } + + fn check_environment_command(command: &EnvironmentCommand) { + match command { + EnvironmentCommand::Assignment(update) => check_update(update), + EnvironmentCommand::IfThenElse { + guard, + then_branch, + else_branch, + } => { + check_expression(guard); + check_environment_command(then_branch); + else_branch.iter().for_each(|c| check_environment_command(c)); + } + EnvironmentCommand::Let { bindings, body } => { + for binding in bindings { + check_expression(&binding.value); + assert!(binding.id.is_some(), "let binding `{}` left unbound", binding.name.name); + } + check_environment_command(body); + } + EnvironmentCommand::Block(inner) => inner.iter().for_each(check_environment_command), + } + } + + fn check_perturbation(perturbation: &PerturbationExpression) { + match perturbation { + PerturbationExpression::Nil => {} + PerturbationExpression::Reference(reference) => { + assert!( + reference.id.is_some(), + "perturbation `{}` left unresolved", + reference.name.name + ); + } + PerturbationExpression::Atomic { assignments, time } => { + for assignment in assignments { + check_expression(&assignment.value); + assert!( + assignment.target.id.is_some(), + "perturbation target `{}` left unresolved", + assignment.target.name.name + ); + } + check_expression(time); + } + PerturbationExpression::Sequence(left, right) => { + check_perturbation(left); + check_perturbation(right); + } + PerturbationExpression::Iteration { argument, iterations } => { + check_perturbation(argument); + check_expression(iterations); + } + } + } + + fn check_distance(distance: &DistanceExpression) { + match distance { + DistanceExpression::Reference(reference) + | DistanceExpression::AtomicLeft(reference) + | DistanceExpression::AtomicRight(reference) => { + assert!(reference.id.is_some(), "`{}` left unresolved", reference.name.name); + } + DistanceExpression::Eventually { from, to, argument } + | DistanceExpression::Globally { from, to, argument } => { + check_expression(from); + check_expression(to); + check_distance(argument); + } + DistanceExpression::Until { from, to, left, right } => { + check_expression(from); + check_expression(to); + check_distance(left); + check_distance(right); + } + DistanceExpression::Threshold { left, threshold, .. } => { + check_distance(left); + check_expression(threshold); + } + DistanceExpression::Min(left, right) | DistanceExpression::Max(left, right) => { + check_distance(left); + check_distance(right); + } + DistanceExpression::LinearCombination(terms) => { + for (weight, distance) in terms { + check_expression(weight); + check_distance(distance); + } + } + } + } + + fn check_robtl(formula: &RobtlFormula) { + match formula { + RobtlFormula::True | RobtlFormula::False => {} + RobtlFormula::Reference(reference) => { + assert!( + reference.id.is_some(), + "formula `{}` left unresolved", + reference.name.name + ); + } + RobtlFormula::Distance { + distance, + perturbation, + value, + .. + } => { + assert!( + distance.id.is_some(), + "distance `{}` left unresolved", + distance.name.name + ); + assert!( + perturbation.id.is_some(), + "perturbation `{}` left unresolved", + perturbation.name.name + ); + check_expression(value); + } + RobtlFormula::Not(inner) => check_robtl(inner), + RobtlFormula::Globally { from, to, argument } | RobtlFormula::Eventually { from, to, argument } => { + check_expression(from); + check_expression(to); + check_robtl(argument); + } + RobtlFormula::And(left, right) | RobtlFormula::Or(left, right) => { + check_robtl(left); + check_robtl(right); + } + RobtlFormula::Until { from, to, left, right } => { + check_expression(from); + check_expression(to); + check_robtl(left); + check_robtl(right); + } + } + } + + for constant in &spec.constants { + assert!( + constant.id.is_some(), + "constant `{}` left undeclared", + constant.name.name + ); + check_expression(&constant.value); + } + for parameter in &spec.parameters { + assert!( + parameter.id.is_some(), + "parameter `{}` left undeclared", + parameter.name.name + ); + check_expression(¶meter.value); + } + for ty in &spec.types { + assert!(ty.id.is_some(), "type `{}` left undeclared", ty.name.name); + } + for function in &spec.functions { + assert!( + function.id.is_some(), + "function `{}` left undeclared", + function.name.name + ); + for argument in &function.arguments { + assert!( + argument.id.is_some(), + "argument `{}` of `{}` left unbound", + argument.name.name, + function.name.name + ); + } + check_function_statement(&function.body); + } + for variable in &spec.variables { + check_variable(variable); + } + for component in &spec.components { + assert!( + component.id.is_some(), + "component `{}` left undeclared", + component.name.name + ); + component.variables.iter().for_each(check_variable); + for state in &component.states { + assert!( + state.id.is_some(), + "controller state `{}` left undeclared", + state.name.name + ); + check_controller_commands(&state.body); + } + for target in &component.init { + assert!( + target.id.is_some(), + "init target `{}` left unresolved", + target.name.name + ); + } + } + if let Some(environment) = &spec.environment { + environment.commands.iter().for_each(check_environment_command); + } + for penalty in &spec.penalties { + assert!(penalty.id.is_some(), "penalty `{}` left undeclared", penalty.name.name); + check_expression(&penalty.value); + } + for perturbation in &spec.perturbations { + assert!( + perturbation.id.is_some(), + "perturbation `{}` left undeclared", + perturbation.name.name + ); + check_perturbation(&perturbation.value); + } + for distance in &spec.distances { + assert!( + distance.id.is_some(), + "distance `{}` left undeclared", + distance.name.name + ); + check_distance(&distance.value); + } + for formula in &spec.formulas { + assert!(formula.id.is_some(), "formula `{}` left undeclared", formula.name.name); + check_robtl(&formula.value); + } +} + +#[cfg(test)] +mod tests { + use super::resolve; + use crate::ast::Binding; + use crate::ast::ExpressionKind; + use crate::ast::UntypedStarkSpecification; + use crate::diagnostics::DiagnosticKind; + // Overrides the built-in `#[test]` so `RUST_LOG=merc_stark=trace cargo test` + // shows this pass's `debug!`/`trace!` output. + use test_log::test; + + fn resolve_source( + src: &str, + ) -> ( + UntypedStarkSpecification, + super::SymbolTable, + crate::diagnostics::Diagnostics, + ) { + let mut spec = UntypedStarkSpecification::parse(src).expect("should parse"); + let (table, diagnostics) = resolve(&mut spec); + (spec, table, diagnostics) + } + + #[test] + fn resolves_a_reference_to_an_earlier_constant() { + let (spec, _table, diagnostics) = resolve_source("const a = 1;\nconst b = a + 1;"); + assert!(!diagnostics.has_errors(), "{diagnostics}"); + match &spec.constants[1].value.node { + ExpressionKind::Binary(_, lhs, _) => { + assert!(matches!( + lhs.node, + ExpressionKind::Reference { + binding: Some(Binding::Def(_)), + .. + } + )); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn forward_reference_is_unknown_symbol() { + let (_spec, _table, diagnostics) = resolve_source("const a = b + 1;\nconst b = 1;"); + assert!( + diagnostics.any(|kind| matches!(kind, DiagnosticKind::UnknownSymbol { name } if name == "b")), + "{diagnostics}" + ); + } + + #[test] + fn a_function_may_read_a_state_variable_declared_later() { + // Variables are pre-declared, so this resolves even though the + // `variables` block comes after the function that reads it — and + // even though variables are otherwise resolved after functions. + let (_spec, _table, diagnostics) = + resolve_source("function f() {\n return X * 2.0;\n}\nglobal variables {\n real X = 1.0;\n}"); + assert!(!diagnostics.has_errors(), "{diagnostics}"); + } + + #[test] + fn a_component_variable_is_visible_before_its_component() { + let source = "function f() {\n return v * 2.0;\n}\n\ + component C {\n variables {\n real v = 1.0;\n }\n \ + controller {\n state Idle {\n step Idle;\n }\n }\n init Idle\n}"; + let (_spec, _table, diagnostics) = resolve_source(source); + assert!(!diagnostics.has_errors(), "{diagnostics}"); + } + + /// The name of the state variable reported by the first + /// `StateVariableInStaticExpression` diagnostic, if any. + fn static_violation(diagnostics: &crate::diagnostics::Diagnostics) -> Option { + diagnostics.items().iter().find_map(|item| match &item.kind { + DiagnosticKind::StateVariableInStaticExpression { name, .. } => Some(name.clone()), + _ => None, + }) + } + + #[test] + fn a_constant_cannot_read_a_state_variable() { + // Pre-declaring variables makes `X` resolve here, so without the + // static check this would be silently accepted. + let (_spec, _table, diagnostics) = resolve_source("global variables {\n real X = 1.0;\n}\nconst a = X;"); + assert_eq!(static_violation(&diagnostics).as_deref(), Some("X"), "{diagnostics}"); + } + + #[test] + fn a_variable_initializer_cannot_read_itself() { + let (_spec, _table, diagnostics) = resolve_source("global variables {\n real X = X;\n}"); + assert_eq!(static_violation(&diagnostics).as_deref(), Some("X"), "{diagnostics}"); + } + + #[test] + fn a_variable_initializer_cannot_read_a_variable_through_a_function() { + let (_spec, _table, diagnostics) = resolve_source( + "function f() {\n return X * 2.0;\n}\nglobal variables {\n real X = 1.0;\n real Y = f();\n}", + ); + assert_eq!(static_violation(&diagnostics).as_deref(), Some("X"), "{diagnostics}"); + } + + #[test] + fn a_function_reading_a_variable_is_fine_when_no_static_expression_calls_it() { + let (_spec, _table, diagnostics) = + resolve_source("function f() {\n return X * 2.0;\n}\nglobal variables {\n real X = 1.0;\n}"); + assert!(!diagnostics.has_errors(), "{diagnostics}"); + } + + #[test] + fn a_variable_range_may_still_use_a_constant() { + let (_spec, _table, diagnostics) = + resolve_source("const M = 5.0;\nglobal variables {\n real X range [0,M] = 1.0;\n}"); + assert!(!diagnostics.has_errors(), "{diagnostics}"); + } + + #[test] + fn duplicate_variable_names_are_still_caught_by_the_pre_pass() { + let (_spec, _table, diagnostics) = resolve_source("global variables {\n real X = 1.0;\n real X = 2.0;\n}"); + assert!( + diagnostics.any(|kind| matches!(kind, DiagnosticKind::DuplicateDefinition { name, .. } if name == "X")), + "{diagnostics}" + ); + } + + #[test] + fn duplicate_top_level_name_is_an_error() { + let (_spec, _table, diagnostics) = resolve_source("const a = 1;\nconst a = 2;"); + assert!( + diagnostics.any(|kind| matches!(kind, DiagnosticKind::DuplicateDefinition { name, .. } if name == "a")), + "{diagnostics}" + ); + } + + #[test] + fn calling_a_variable_is_illegal_use_of_name() { + let (_spec, _table, diagnostics) = + // In a `const` this would only report "unknown symbol": constants + // resolve before variables in the fixed kind order (see the module + // doc comment), so `x` isn't declared yet there. A `penalty` + // resolves after both, so the name *is* found — and rejected for + // being the wrong kind, which is what this test is about. + resolve_source("global variables { int x = 0; }\npenalty p = x(1)"); + assert!( + diagnostics.any(|kind| matches!( + kind, + DiagnosticKind::IllegalUseOfName { name, found, expected } + if name == "x" && *found == "a variable" && *expected == "a function" + )), + "{diagnostics}" + ); + } + + #[test] + fn referencing_a_function_as_a_value_is_illegal_use_of_name() { + let (_spec, _table, diagnostics) = + // Likewise: a `penalty` resolves after functions, so `f` is found + // and then rejected as a non-value, rather than being reported as + // an unknown symbol. + resolve_source("function f(int x) { return x; }\npenalty p = f"); + assert!( + diagnostics.any(|kind| matches!( + kind, + DiagnosticKind::IllegalUseOfName { name, found, .. } if name == "f" && *found == "a function" + )), + "{diagnostics}" + ); + } + + #[test] + fn function_cannot_call_itself() { + let (_spec, _table, diagnostics) = resolve_source("function f(int x) { return f(x); }"); + assert!( + diagnostics.any(|kind| matches!(kind, DiagnosticKind::UnknownSymbol { name } if name == "f")), + "{diagnostics}" + ); + } + + #[test] + fn let_binding_shadows_outer_constant() { + let (spec, _table, diagnostics) = + resolve_source("const x = 1;\nfunction f(int y) { let x = 2 in return x + y; }"); + assert!(!diagnostics.has_errors(), "{diagnostics}"); + // Both `x` (the let) and `y` (the argument) resolve locally. + let crate::ast::FunctionStatement::Block(inner) = &spec.functions[0].body else { + panic!("expected a block"); + }; + let crate::ast::FunctionStatement::Let { body, .. } = inner.as_ref() else { + panic!("expected a let statement"); + }; + let crate::ast::FunctionStatement::Return(value) = body.as_ref() else { + panic!("expected a return statement"); + }; + match &value.node { + ExpressionKind::Binary(_, lhs, rhs) => { + assert!(matches!( + lhs.node, + ExpressionKind::Reference { + binding: Some(Binding::Local(_)), + .. + } + )); + assert!(matches!( + rhs.node, + ExpressionKind::Reference { + binding: Some(Binding::Local(_)), + .. + } + )); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn duplicate_function_argument_is_an_error() { + let (_spec, _table, diagnostics) = resolve_source("function f(int x, int x) { return x; }"); + assert!( + diagnostics.any(|kind| matches!(kind, DiagnosticKind::DuplicateBinding { name, .. } if name == "x")), + "{diagnostics}" + ); + } + + #[test] + fn controller_state_can_forward_reference_a_sibling_state() { + let (_spec, _table, diagnostics) = resolve_source( + "component C {\n variables { }\n controller {\n state A { step B; }\n state B { step A; }\n }\n init A\n}", + ); + assert!(!diagnostics.has_errors(), "{diagnostics}"); + } + + #[test] + fn controller_state_cannot_target_another_components_state() { + let (_spec, _table, diagnostics) = resolve_source( + "component C1 {\n variables { }\n controller {\n state A { step B; }\n }\n init A\n}\ncomponent C2 {\n variables { }\n controller {\n state B { exec B; }\n }\n init B\n}", + ); + assert!( + diagnostics.any(|kind| matches!(kind, DiagnosticKind::UnknownControllerState { name } if name == "B")), + "{diagnostics}" + ); + } + + #[test] + fn custom_type_element_is_referenceable() { + // `penalty` is resolved after `type` declarations in this resolver's + // fixed kind order (see the module doc comment), so referencing a + // type element from a penalty value exercises the forward-visibility + // that types grant to everything processed after them. + let (spec, _table, diagnostics) = resolve_source("type Color = Red | Green | Blue;\npenalty p = Red"); + assert!(!diagnostics.has_errors(), "{diagnostics}"); + assert!(matches!( + spec.penalties[0].value.node, + ExpressionKind::Reference { + binding: Some(Binding::Def(_)), + .. + } + )); + } + + #[test] + fn type_element_cannot_share_the_types_own_name() { + let (_spec, _table, diagnostics) = resolve_source("type Color = Color | Blue;\nconst c = 1;"); + assert!( + diagnostics + .any(|kind| matches!(kind, DiagnosticKind::TypeElementSharesTypeName { name } if name == "Color")), + "{diagnostics}" + ); + } + + #[test] + fn assignment_target_must_be_a_variable() { + let (_spec, _table, diagnostics) = resolve_source("const k = 1;\nenvironment { k' = 1; }"); + assert!( + diagnostics.any(|kind| matches!( + kind, + DiagnosticKind::IllegalUseOfName { name, found, expected } + if name == "k" && *found == "a constant" && *expected == "a variable" + )), + "{diagnostics}" + ); + } + + #[test] + fn resolves_every_example_specification_without_errors() { + for (name, source) in [ + ("engine", include_str!("../../../examples/stark/engine.stark")), + ("random_walk", include_str!("../../../examples/stark/random_walk.stark")), + ( + "single_vehicle", + include_str!("../../../examples/stark/single_vehicle.stark"), + ), + ("toll", include_str!("../../../examples/stark/toll.stark")), + ( + "two_vehicles", + include_str!("../../../examples/stark/two_vehicles.stark"), + ), + ("monitoring", include_str!("../../../examples/stark/monitoring.stark")), + ( + "agriculturalDT", + include_str!("../../../examples/stark/agriculturalDT.stark"), + ), + ("tollbooth", include_str!("../../../examples/stark/tollbooth.stark")), + ] { + let mut spec = + UntypedStarkSpecification::parse(source).unwrap_or_else(|e| panic!("{name} failed to parse: {e}")); + let (_table, diagnostics) = resolve(&mut spec); + assert!( + !diagnostics.has_errors(), + "{name} failed to resolve:\n{}", + diagnostics.render(source) + ); + } + } +} diff --git a/crates/stark/src/specification.rs b/crates/stark/src/specification.rs new file mode 100644 index 00000000..40e010a9 --- /dev/null +++ b/crates/stark/src/specification.rs @@ -0,0 +1,75 @@ +use crate::ast::UntypedStarkSpecification; +use crate::diagnostics::Diagnostics; +use crate::resolve::SymbolTable; +use crate::resolve::resolve; +use crate::typecheck::TypeTable; +use crate::typecheck::typecheck; + +/// A STARK specification that has been resolved and type-checked. +pub struct StarkSpecification { + ast: UntypedStarkSpecification, + symbols: SymbolTable, + types: TypeTable, +} + +impl StarkSpecification { + /// The underlying syntax tree, with every reference resolved. + pub fn ast(&self) -> &UntypedStarkSpecification { + &self.ast + } + + /// What every `DefId`, `StateId` and `LocalId` in [Self::ast] refers to. + pub fn symbols(&self) -> &SymbolTable { + &self.symbols + } + + /// The inferred type of every declaration and function signature. + pub fn types(&self) -> &TypeTable { + &self.types + } + + /// Resolves and type-checks this untyped specification. + /// + /// Type checking runs even when resolution reported errors. It simply + /// ignores unresolved entries and updates the presented Diagnostics. + pub fn from_untyped(mut spec: UntypedStarkSpecification) -> Result { + let (symbols, mut diagnostics) = resolve(&mut spec); + let (types, type_diagnostics) = typecheck(&spec, &symbols); + diagnostics.extend(type_diagnostics); + + if diagnostics.has_errors() { + log::debug!( + "specification rejected with {} diagnostic(s)", + diagnostics.items().len() + ); + } else { + log::debug!("specification checked successfully"); + } + + diagnostics.into_result(StarkSpecification { + ast: spec, + symbols, + types, + }) + } +} + +#[cfg(test)] +mod tests { + use test_log::test; + + use crate::ast::UntypedStarkSpecification; + use crate::specification::StarkSpecification; + + #[test] + fn reports_resolve_and_type_errors_together() { + let source = "const c = missing_name; const d = 1 + true;"; + let spec = UntypedStarkSpecification::parse(source).expect("should parse"); + + let diagnostics = StarkSpecification::from_untyped(spec).err().expect("should not check"); + assert!( + diagnostics.items().len() >= 2, + "expected both passes to report: {diagnostics}" + ); + } +} diff --git a/crates/stark/src/typecheck.rs b/crates/stark/src/typecheck.rs new file mode 100644 index 00000000..6250b0cf --- /dev/null +++ b/crates/stark/src/typecheck.rs @@ -0,0 +1,1164 @@ +//! Type checking: expression type inference, plus the function-body +//! inference that gives an unannotated function its return type. +//! +//! Runs after `resolve.rs`: every reference/call already carries a resolved +//! [DefId]/[LocalId], so this pass never re-derives "is this name defined" / +//! "is this the right kind of name" — `resolve.rs` already decided that. +//! Because `resolve.rs` assigns [LocalId]s uniquely across the whole spec +//! (never reused between scopes), a flat `Vec>` indexed by +//! `LocalId` stands in for the original's stack of nested type-evaluation +//! scopes — no scope stack is needed here, only "has this local's type been +//! computed yet". +//! +//! A `None` binding/id (left by `resolve.rs` for something that failed to +//! resolve) is treated as already-erred: this pass returns +//! [StarkType::Error] for it without recording a second diagnostic for the +//! same spot. +//! +//! Two spots deliberately diverge from the original: `&&`/`||` never +//! propagate a `random[..]` result there (the disjunction case even computes +//! whether either operand is random and then never uses it — reading as an +//! unfinished path, not a deliberate choice, since the relational case right +//! next to it does propagate), and neither does the *unary* math-call path, +//! while the binary one does. This port propagates randomness in both cases, +//! for consistency with every other +//! boolean/real-producing operator. No case in the ported +//! `ExpressionTypeInferenceTest` exercises either edge case, so this +//! doesn't contradict anything being ported. + +use log::debug; +use log::trace; + +use crate::ast::*; +use crate::diagnostics::DiagnosticKind; +use crate::diagnostics::Diagnostics; +use crate::resolve::DefKind; +use crate::resolve::SymbolTable; +use crate::types::StarkType; + +/// A function's argument types (positional, matching its declared +/// arguments) and its return type, inferred from its `return` statements +/// (STARK functions have no return-type annotation). +#[derive(Clone, Debug)] +pub struct FunctionSignature { + pub arguments: Vec, + pub return_type: StarkType, +} + +/// The result of type checking: the type of every [DefId] that has one +/// (constants, parameters, variables, type elements — `None` for kinds that +/// don't carry a single expression type, like components or functions), and +/// the signature of every function. +#[derive(Clone, Debug)] +pub struct TypeTable { + def_types: Vec>, + signatures: Vec>, +} + +impl TypeTable { + pub fn type_of(&self, id: DefId) -> Option<&StarkType> { + debug_assert!( + id.value() < self.def_types.len(), + "{id:?} is out of range for a table of {} definition(s) — mismatched SymbolTable?", + self.def_types.len() + ); + self.def_types[id.value()].as_ref() + } + + pub fn signature_of(&self, id: DefId) -> Option<&FunctionSignature> { + debug_assert!( + id.value() < self.signatures.len(), + "{id:?} is out of range for a table of {} definition(s) — mismatched SymbolTable?", + self.signatures.len() + ); + self.signatures[id.value()].as_ref() + } +} + +/// Type-checks `spec` against the `symbols` produced by [crate::resolve::resolve], +/// returning the inferred type of every declaration together with every +/// diagnostic found. +pub fn typecheck(spec: &UntypedStarkSpecification, symbols: &SymbolTable) -> (TypeTable, Diagnostics) { + let mut checker = Checker { + symbols, + def_types: vec![None; symbols.defs.len()], + signatures: vec![None; symbols.defs.len()], + locals: vec![None; symbols.locals.len()], + diagnostics: Diagnostics::new(), + }; + checker.check_specification(spec); + + debug_assert_eq!( + checker.def_types.len(), + symbols.defs.len(), + "the type table must stay indexable by every DefId the symbol table knows" + ); + debug_assert_eq!( + checker.locals.len(), + symbols.locals.len(), + "the local type table must stay indexable by every LocalId the symbol table knows" + ); + + let typed = checker.def_types.iter().filter(|t| t.is_some()).count(); + let signatures = checker.signatures.iter().filter(|s| s.is_some()).count(); + debug!( + "type-checked {typed}/{} definition(s) and {signatures} function signature(s); {} diagnostic(s)", + symbols.defs.len(), + checker.diagnostics.items().len() + ); + + ( + TypeTable { + def_types: checker.def_types, + signatures: checker.signatures, + }, + checker.diagnostics, + ) +} + +struct Checker<'a> { + symbols: &'a SymbolTable, + def_types: Vec>, + signatures: Vec>, + locals: Vec>, + diagnostics: Diagnostics, +} + +impl Checker<'_> { + // -- Small helpers --------------------------------------------------- + + fn set_def_type(&mut self, id: DefId, ty: StarkType) { + // Each declaration is visited exactly once, so its type is written + // exactly once. A second write means the same `DefId` was handed to + // two declarations, which would silently corrupt every later lookup. + debug_assert!( + self.def_types[id.value()].is_none(), + "{id:?} (`{}`) already has type {:?}, cannot also be {ty:?}", + self.symbols.def(id).name, + self.def_types[id.value()] + ); + trace!("{id:?} (`{}`) : {ty}", self.symbols.def(id).name); + self.def_types[id.value()] = Some(ty); + } + + fn def_type(&self, id: DefId) -> StarkType { + self.def_types[id.value()].clone().unwrap_or(StarkType::Error) + } + + fn set_local_type(&mut self, id: LocalId, ty: StarkType) { + // `resolve.rs` assigns every binding site its own `LocalId`, never + // reusing one across scopes — that is exactly what lets this pass get + // away with a flat vector instead of a scope stack, so it is worth + // checking rather than assuming. + debug_assert!( + self.locals[id.value()].is_none(), + "{id:?} (`{}`) already has type {:?}, cannot also be {ty:?}", + self.symbols.local(id).name, + self.locals[id.value()] + ); + trace!("{id:?} (`{}`) : {ty}", self.symbols.local(id).name); + self.locals[id.value()] = Some(ty); + } + + fn local_type(&self, id: LocalId) -> StarkType { + self.locals[id.value()].clone().unwrap_or(StarkType::Error) + } + + fn ty_of_annotation(&mut self, ty: &Ty, span: &Span) -> StarkType { + match ty { + Ty::Integer => StarkType::Integer, + Ty::Real => StarkType::Real, + Ty::Boolean => StarkType::Boolean, + Ty::Named(name) => match self.symbols.by_name(name) { + Some(id) if matches!(self.symbols.def(id).kind, DefKind::Type) => StarkType::Custom(name.clone()), + _ => { + self.diagnostics + .error(span.clone(), DiagnosticKind::UnknownType { name: name.clone() }); + StarkType::Error + } + }, + } + } + + /// `expected.is_compatible_with(actual)`, recording a diagnostic and + /// returning `Error` on mismatch — the `inferAndCheck`/`checkType` + /// pattern: every failure collapses to `Error` so it can't cascade into + /// more than one diagnostic at the point of use. + fn expect(&mut self, expected: &StarkType, actual: StarkType, span: &Span) -> StarkType { + if expected.is_compatible_with(&actual) { + actual + } else { + self.diagnostics.error( + span.clone(), + DiagnosticKind::TypeMismatch { + expected: expected.clone(), + found: actual, + }, + ); + StarkType::Error + } + } + + fn expect_numerical(&mut self, actual: StarkType, span: &Span) -> StarkType { + if actual.is_numerical() { + actual + } else { + self.diagnostics + .error(span.clone(), DiagnosticKind::NotNumerical { found: actual }); + StarkType::Error + } + } + + fn expect_mergeable(&mut self, left: &StarkType, right: &StarkType, span: &Span) { + if !left.can_be_merged_with(right) { + self.diagnostics.error( + span.clone(), + DiagnosticKind::IncompatibleTypes { + left: left.clone(), + right: right.clone(), + }, + ); + } + } + + // -- Top-level walk ---------------------------------------------------- + + fn check_specification(&mut self, spec: &UntypedStarkSpecification) { + for constant in &spec.constants { + let ty = self.check_expression(&constant.value, false); + if let Some(id) = constant.id { + self.set_def_type(id, ty); + } + } + for parameter in &spec.parameters { + let ty = self.check_expression(¶meter.value, false); + if let Some(id) = parameter.id { + self.set_def_type(id, ty); + } + } + // Custom type elements carry their owning type as their `StarkType`; + // this can be computed immediately, no expression involved. + for ty in &spec.types { + if ty.id.is_none() { + continue; + } + for element in &ty.elements { + if let Some(id) = self.symbols.by_name(&element.name) { + self.set_def_type(id, StarkType::Custom(ty.name.name.clone())); + } + } + } + // `resolve.rs` makes state variables visible everywhere, so their + // types have to be known before any body that might read one. The + // declared type comes straight from the annotation, so this needs no + // expression checking and can run ahead of everything else. + for variable in &spec.variables { + self.set_variable_type(variable); + } + for component in &spec.components { + for variable in &component.variables { + self.set_variable_type(variable); + } + } + for function in &spec.functions { + self.check_function(function); + } + for variable in &spec.variables { + self.check_variable(variable); + } + for component in &spec.components { + for variable in &component.variables { + self.check_variable(variable); + } + for state in &component.states { + self.check_controller_commands(&state.body); + } + } + if let Some(environment) = &spec.environment { + for command in &environment.commands { + self.check_environment_command(command); + } + } + for penalty in &spec.penalties { + let ty = self.check_expression(&penalty.value, false); + self.expect_numerical(ty, &penalty.value.span); + } + for perturbation in &spec.perturbations { + self.check_perturbation(&perturbation.value); + } + for distance in &spec.distances { + self.check_distance(&distance.value); + } + for formula in &spec.formulas { + self.check_robtl(&formula.value); + } + } + + /// Records a variable's declared type from its annotation alone. Split + /// out from [Self::check_variable] so every variable's type is known + /// before any body that reads one is checked. + fn set_variable_type(&mut self, variable: &Variable) { + let declared = self.ty_of_annotation(&variable.ty, &variable.name.span); + if let Some(id) = variable.id { + self.set_def_type(id, declared); + } + } + + /// Checks a variable's range and initializer against its declared type. + /// That type is read back from [Self::set_variable_type] rather than + /// re-derived from the annotation, so an unknown type name is reported + /// once rather than once per pass. + fn check_variable(&mut self, variable: &Variable) { + let declared = variable.id.map_or(StarkType::Error, |id| self.def_type(id)); + if let Some(range) = &variable.range { + let min = self.check_expression(&range.min, false); + self.expect_numerical(min, &range.min.span); + let max = self.check_expression(&range.max, false); + self.expect_numerical(max, &range.max.span); + } + let initial = self.check_expression(&variable.initial_value, false); + self.expect(&declared, initial, &variable.initial_value.span); + } + + fn check_function(&mut self, function: &Function) { + trace!("checking function `{}`", function.name.name); + let mut arguments = Vec::with_capacity(function.arguments.len()); + for argument in &function.arguments { + let ty = self.ty_of_annotation(&argument.ty, &argument.name.span); + if let Some(id) = argument.id { + self.set_local_type(id, ty.clone()); + } + arguments.push(ty); + } + // Function bodies may use random expressions (e.g. `single_vehicle.stark`'s + // `new_s_speed` and `engine.stark`'s `temperatureUpdateInOneStep` both + // `return R[...]`). + let return_type = self.check_function_statement(&function.body, true); + if let Some(id) = function.id { + debug_assert!( + self.signatures[id.value()].is_none(), + "function `{}` ({id:?}) already has a signature", + function.name.name + ); + trace!("`{}` : ({arguments:?}) -> {return_type}", function.name.name); + self.signatures[id.value()] = Some(FunctionSignature { arguments, return_type }); + } + } + + /// Returns the type of every `return` reachable from `statement`, merged + /// together: a function has no return-type annotation, so its type is + /// inferred from its body. + fn check_function_statement(&mut self, statement: &FunctionStatement, random_allowed: bool) -> StarkType { + match statement { + FunctionStatement::Return(value) => self.check_expression(value, random_allowed), + FunctionStatement::IfThenElse { + guard, + then_branch, + else_branch, + } => { + let guard_ty = self.check_expression(guard, random_allowed); + self.expect(&StarkType::Boolean, guard_ty, &guard.span); + let then_ty = self.check_function_statement(then_branch, random_allowed); + match else_branch { + Some(else_branch) => { + let else_ty = self.check_function_statement(else_branch, random_allowed); + self.expect_mergeable(&then_ty, &else_ty, &guard.span); + then_ty.merge(&else_ty) + } + None => then_ty, + } + } + FunctionStatement::Let { id, value, body, .. } => { + let value_ty = self.check_expression(value, random_allowed); + if let Some(id) = id { + self.set_local_type(*id, value_ty); + } + self.check_function_statement(body, random_allowed) + } + FunctionStatement::Block(inner) => self.check_function_statement(inner, random_allowed), + } + } + + fn check_controller_commands(&mut self, commands: &[ControllerCommand]) { + for command in commands { + match command { + // The branch a controller takes is deterministic given the + // state (the guard and the step count aren't random), but an + // assignment's *value* may be — `multiscler.stark`'s + // controller injects a randomly-sized therapeutic dose + // (`Rr' = Rr + 1000 + R[-10,10]`), which is exactly the + // counterexample that overturned this pass's original + // "controllers are fully deterministic" assumption. + ControllerCommand::Step { steps, .. } => { + if let Some(steps) = steps { + let ty = self.check_expression(steps, false); + self.expect_numerical(ty, &steps.span); + } + } + ControllerCommand::Exec(_) => {} + ControllerCommand::Let { id, value, body, .. } => { + let value_ty = self.check_expression(value, true); + if let Some(id) = id { + self.set_local_type(*id, value_ty); + } + self.check_controller_commands(body); + } + ControllerCommand::Assignment(update) => self.check_update(update, true), + ControllerCommand::IfThenElse { + guard, + then_branch, + else_branch, + } => { + let guard_ty = self.check_expression(guard, false); + self.expect(&StarkType::Boolean, guard_ty, &guard.span); + self.check_controller_commands(then_branch); + if let Some(else_branch) = else_branch { + self.check_controller_commands(else_branch); + } + } + ControllerCommand::Block(inner) => self.check_controller_commands(inner), + } + } + } + + fn check_environment_command(&mut self, command: &EnvironmentCommand) { + match command { + EnvironmentCommand::Assignment(update) => self.check_update(update, true), + EnvironmentCommand::IfThenElse { + guard, + then_branch, + else_branch, + } => { + let guard_ty = self.check_expression(guard, true); + self.expect(&StarkType::Boolean, guard_ty, &guard.span); + self.check_environment_command(then_branch); + if let Some(else_branch) = else_branch { + self.check_environment_command(else_branch); + } + } + EnvironmentCommand::Let { bindings, body } => { + for binding in bindings { + let ty = self.check_expression(&binding.value, true); + if let Some(id) = binding.id { + self.set_local_type(id, ty); + } + } + self.check_environment_command(body); + } + EnvironmentCommand::Block(inner) => { + for command in inner { + self.check_environment_command(command); + } + } + } + } + + fn check_update(&mut self, update: &Update, random_allowed: bool) { + if let Some(guard) = &update.guard { + let guard_ty = self.check_expression(guard, random_allowed); + self.expect(&StarkType::Boolean, guard_ty, &guard.span); + } + let value_ty = self.check_expression(&update.value, random_allowed); + let target_ty = match update.target.id { + Some(id) => self.def_type(id), + None => return, // already diagnosed by resolve.rs + }; + self.expect(&target_ty, value_ty, &update.value.span); + } + + // -- Sub-languages: no randomness anywhere (interval bounds, thresholds, + // iteration/time controls are all experiment parameters, not model state). + + fn check_perturbation(&mut self, perturbation: &PerturbationExpression) { + match perturbation { + PerturbationExpression::Nil | PerturbationExpression::Reference(_) => {} + PerturbationExpression::Atomic { assignments, time } => { + for assignment in assignments { + // Perturbation assignment values *are* evidenced to use + // randomness in the examples (e.g. `offset_speed <- p_speed * ... * R[0,1]`). + let value_ty = self.check_expression(&assignment.value, true); + let target_ty = match assignment.target.id { + Some(id) => self.def_type(id), + None => continue, + }; + self.expect(&target_ty, value_ty, &assignment.value.span); + } + let time_ty = self.check_expression(time, false); + self.expect_numerical(time_ty, &time.span); + } + PerturbationExpression::Sequence(left, right) => { + self.check_perturbation(left); + self.check_perturbation(right); + } + PerturbationExpression::Iteration { argument, iterations } => { + self.check_perturbation(argument); + let ty = self.check_expression(iterations, false); + self.expect_numerical(ty, &iterations.span); + } + } + } + + fn check_distance(&mut self, distance: &DistanceExpression) { + match distance { + DistanceExpression::Reference(_) + | DistanceExpression::AtomicLeft(_) + | DistanceExpression::AtomicRight(_) => {} + DistanceExpression::Eventually { from, to, argument } + | DistanceExpression::Globally { from, to, argument } => { + self.check_interval(from, to); + self.check_distance(argument); + } + DistanceExpression::Until { from, to, left, right } => { + self.check_interval(from, to); + self.check_distance(left); + self.check_distance(right); + } + DistanceExpression::Threshold { left, threshold, .. } => { + self.check_distance(left); + let ty = self.check_expression(threshold, false); + self.expect_numerical(ty, &threshold.span); + } + DistanceExpression::Min(left, right) | DistanceExpression::Max(left, right) => { + self.check_distance(left); + self.check_distance(right); + } + DistanceExpression::LinearCombination(terms) => { + for (weight, distance) in terms { + let ty = self.check_expression(weight, false); + self.expect_numerical(ty, &weight.span); + self.check_distance(distance); + } + } + } + } + + fn check_robtl(&mut self, formula: &RobtlFormula) { + match formula { + RobtlFormula::True | RobtlFormula::False | RobtlFormula::Reference(_) => {} + RobtlFormula::Distance { value, .. } => { + let ty = self.check_expression(value, false); + self.expect_numerical(ty, &value.span); + } + RobtlFormula::Not(inner) => self.check_robtl(inner), + RobtlFormula::Globally { from, to, argument } | RobtlFormula::Eventually { from, to, argument } => { + self.check_interval(from, to); + self.check_robtl(argument); + } + RobtlFormula::And(left, right) | RobtlFormula::Or(left, right) => { + self.check_robtl(left); + self.check_robtl(right); + } + RobtlFormula::Until { from, to, left, right } => { + self.check_interval(from, to); + self.check_robtl(left); + self.check_robtl(right); + } + } + } + + fn check_interval(&mut self, from: &Expression, to: &Expression) { + let from_ty = self.check_expression(from, false); + self.expect_numerical(from_ty, &from.span); + let to_ty = self.check_expression(to, false); + self.expect_numerical(to_ty, &to.span); + } + + // -- Expressions ------------------------------------------------------ + + /// Always widens to `real` (`2 ^ 3` and `atan2(1,2)` are both `real`, + /// never `int`), propagating randomness from either operand. + fn combine_to_real(&mut self, left: &Expression, right: &Expression, random_allowed: bool) -> StarkType { + let left_ty = self.check_expression(left, random_allowed); + let left_ty = self.expect_numerical(left_ty, &left.span); + let right_ty = self.check_expression(right, random_allowed); + let right_ty = self.expect_numerical(right_ty, &right.span); + if left_ty.is_random() || right_ty.is_random() { + StarkType::random(StarkType::Real) + } else { + StarkType::Real + } + } + + /// [Self::combine_to_real]'s single-operand counterpart, for unary `+`/`-`. + fn combine_to_real_unary(&mut self, inner: &Expression, random_allowed: bool) -> StarkType { + let ty = self.check_expression(inner, random_allowed); + let ty = self.expect_numerical(ty, &inner.span); + if ty.is_random() { + StarkType::random(StarkType::Real) + } else { + StarkType::Real + } + } + + fn check_expression(&mut self, expr: &Expression, random_allowed: bool) -> StarkType { + match &expr.node { + ExpressionKind::False | ExpressionKind::True => StarkType::Boolean, + ExpressionKind::Integer(_) => StarkType::Integer, + ExpressionKind::Real(_) => StarkType::Real, + // Only used inside aggregate/lambda contexts, none of which are + // reachable from the current grammar (see `ast.rs`); typed as + // `Error` rather than given a made-up type. + ExpressionKind::Iterator => StarkType::Error, + ExpressionKind::Reference { binding, .. } => match binding { + Some(Binding::Def(id)) => self.def_type(*id), + Some(Binding::Local(id)) => self.local_type(*id), + None => StarkType::Error, + }, + ExpressionKind::Normal { mean, std_dev } => { + if !random_allowed { + self.diagnostics + .error(expr.span.clone(), DiagnosticKind::RandomNotAllowed); + return StarkType::Error; + } + let mean_ty = self.check_expression(mean, random_allowed); + let mean_ty = self.expect(&StarkType::Real, mean_ty, &mean.span); + let std_ty = self.check_expression(std_dev, random_allowed); + let std_ty = self.expect(&StarkType::Real, std_ty, &std_dev.span); + if mean_ty.is_error() || std_ty.is_error() { + StarkType::Error + } else { + StarkType::random(StarkType::Real) + } + } + ExpressionKind::Uniform { values } => { + if !random_allowed { + self.diagnostics + .error(expr.span.clone(), DiagnosticKind::RandomNotAllowed); + return StarkType::Error; + } + let mut merged: Option = None; + for value in values { + let ty = self.check_expression(value, random_allowed); + merged = Some(match merged { + None => ty, + Some(acc) => { + self.expect_mergeable(&acc, &ty, &value.span); + acc.merge(&ty) + } + }); + } + match merged { + Some(ty) if !ty.is_error() => StarkType::random(ty), + _ => StarkType::Error, + } + } + ExpressionKind::Range { min, max } => { + if !random_allowed { + self.diagnostics + .error(expr.span.clone(), DiagnosticKind::RandomNotAllowed); + return StarkType::Error; + } + match (min, max) { + (Some(min), Some(max)) => { + let min_ty = self.check_expression(min, random_allowed); + let min_ty = self.expect(&StarkType::Real, min_ty, &min.span); + let max_ty = self.check_expression(max, random_allowed); + let max_ty = self.expect(&StarkType::Real, max_ty, &max.span); + if min_ty.is_error() || max_ty.is_error() { + StarkType::Error + } else { + StarkType::random(StarkType::Real) + } + } + _ => StarkType::random(StarkType::Real), + } + } + ExpressionKind::Not(inner) => { + let ty = self.check_expression(inner, random_allowed); + self.expect(&StarkType::Boolean, ty, &inner.span) + } + ExpressionKind::UnaryPlus(inner) | ExpressionKind::UnaryMinus(inner) => { + // Matches the original, which routes `+`/`-` through the + // *same* always-widening double-valued mechanism as + // `abs`/`sqrt`/etc., so unary +/- on an `int` widens the + // result to `real`, and so does everything built on top of + // it (`-a + 2` is `real`, not `int`, when `a` is an `int`). + // Surprising for a spec author writing `-a` expecting an int + // to stay one; matched here for fidelity with the original + // tool, but worth reconsidering if it surprises users badly + // enough in practice. + self.combine_to_real_unary(inner, random_allowed) + } + ExpressionKind::Binary(op, left, right) => self.check_binary(*op, left, right, random_allowed), + ExpressionKind::Ternary { + guard, + then_branch, + else_branch, + } => { + let guard_ty = self.check_expression(guard, random_allowed); + let guard_ty = self.expect(&StarkType::Boolean, guard_ty, &guard.span); + let then_ty = self.check_expression(then_branch, random_allowed); + let else_ty = self.check_expression(else_branch, random_allowed); + self.expect_mergeable(&then_ty, &else_ty, &else_branch.span); + let merged = then_ty.merge(&else_ty); + if !merged.is_error() && guard_ty.is_random() { + StarkType::random(merged) + } else { + merged + } + } + ExpressionKind::Call { function, arguments } => self.check_call(function, arguments, random_allowed), + ExpressionKind::MathCall { function, arguments } => { + self.check_math_call(*function, arguments, random_allowed) + } + } + } + + fn check_binary(&mut self, op: BinaryOp, left: &Expression, right: &Expression, random_allowed: bool) -> StarkType { + match op { + BinaryOp::Pow => self.combine_to_real(left, right, random_allowed), + BinaryOp::Mult | BinaryOp::Div | BinaryOp::IntDiv | BinaryOp::Add | BinaryOp::Subtract | BinaryOp::Mod => { + let left_ty = self.check_expression(left, random_allowed); + let left_ty = self.expect_numerical(left_ty, &left.span); + let right_ty = self.check_expression(right, random_allowed); + let right_ty = self.expect_numerical(right_ty, &right.span); + left_ty.merge(&right_ty) + } + BinaryOp::Less | BinaryOp::Leq | BinaryOp::Eq | BinaryOp::Geq | BinaryOp::Greater => { + let left_ty = self.check_expression(left, random_allowed); + let right_ty = self.check_expression(right, random_allowed); + self.expect_mergeable(&left_ty, &right_ty, &right.span); + if left_ty.is_random() || right_ty.is_random() { + StarkType::random(StarkType::Boolean) + } else { + StarkType::Boolean + } + } + BinaryOp::BitAnd | BinaryOp::And | BinaryOp::BitOr | BinaryOp::Or => { + let left_ty = self.check_expression(left, random_allowed); + let left_ty = self.expect(&StarkType::Boolean, left_ty, &left.span); + let right_ty = self.check_expression(right, random_allowed); + let right_ty = self.expect(&StarkType::Boolean, right_ty, &right.span); + if left_ty.is_random() || right_ty.is_random() { + StarkType::random(StarkType::Boolean) + } else { + StarkType::Boolean + } + } + } + } + + fn check_call(&mut self, function: &DefRef, arguments: &[Expression], random_allowed: bool) -> StarkType { + let Some(id) = function.id else { + // Already diagnosed by resolve.rs; still check the arguments so + // unrelated mistakes in them are still reported. + for argument in arguments { + self.check_expression(argument, random_allowed); + } + return StarkType::Error; + }; + let Some(signature) = self.signatures[id.value()].clone() else { + // The callee's own signature failed to type-check. + for argument in arguments { + self.check_expression(argument, random_allowed); + } + return StarkType::Error; + }; + if signature.arguments.len() != arguments.len() { + self.diagnostics.error( + function.name.span.clone(), + DiagnosticKind::ArityMismatch { + name: function.name.name.clone(), + expected: signature.arguments.len(), + found: arguments.len(), + }, + ); + for argument in arguments { + self.check_expression(argument, random_allowed); + } + return StarkType::Error; + } + for (expected, argument) in signature.arguments.iter().zip(arguments) { + let actual = self.check_expression(argument, random_allowed); + self.expect(expected, actual, &argument.span); + } + signature.return_type + } + + fn check_math_call(&mut self, function: MathFunction, arguments: &[Expression], random_allowed: bool) -> StarkType { + match function { + MathFunction::Atan2 | MathFunction::Hypot | MathFunction::Max | MathFunction::Min | MathFunction::Pow => { + // Unlike user-defined calls, a math call's arity is fixed by + // the grammar (`BinaryMathFunction ~ "(" ~ Expression ~ "," + // ~ Expression ~ ")"`), so a mismatch here is a parser bug + // rather than a user error — and the indexing below would + // otherwise panic without saying why. + debug_assert_eq!( + arguments.len(), + 2, + "binary math function {function:?} parsed with {} argument(s)", + arguments.len() + ); + self.combine_to_real(&arguments[0], &arguments[1], random_allowed) + } + _ => { + debug_assert_eq!( + arguments.len(), + 1, + "unary math function {function:?} parsed with {} argument(s)", + arguments.len() + ); + let ty = self.check_expression(&arguments[0], random_allowed); + let ty = self.expect_numerical(ty, &arguments[0].span); + if ty.is_random() { + StarkType::random(StarkType::Real) + } else { + StarkType::Real + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::typecheck; + use crate::ast::UntypedStarkSpecification; + use crate::diagnostics::DiagnosticKind; + use crate::resolve::resolve; + use crate::types::StarkType; + use test_log::test; + + /// Resolves `src` (asserting resolution itself succeeds, since these + /// tests are only interested in typecheck-time diagnostics) and returns + /// the diagnostics from `typecheck`. + fn typecheck_source(src: &str) -> crate::diagnostics::Diagnostics { + let mut spec = UntypedStarkSpecification::parse(src).unwrap_or_else(|e| panic!("failed to parse: {e}")); + let (symbols, resolve_diagnostics) = resolve(&mut spec); + assert!( + !resolve_diagnostics.has_errors(), + "failed to resolve:\n{}", + resolve_diagnostics.render(src) + ); + typecheck(&spec, &symbols).1 + } + + #[test] + fn call_with_wrong_argument_count_is_an_error() { + // Constants are resolved before functions (kind-processing order — + // see `resolve.rs`), so the call has to live somewhere later in that + // order, e.g. a variable's initial value. + let diagnostics = typecheck_source("function f(int x) { return x; }\nvariables { int c = f(1, 2); }"); + assert!( + diagnostics.any(|kind| matches!( + kind, + DiagnosticKind::ArityMismatch { + expected: 1, + found: 2, + .. + } + )), + "{diagnostics}" + ); + } + + #[test] + fn non_boolean_if_guard_is_an_error() { + let diagnostics = typecheck_source("function f() { if (1) return 1; else return 2; }"); + assert!( + diagnostics.any(|kind| matches!( + kind, + DiagnosticKind::TypeMismatch { + expected: StarkType::Boolean, + found: StarkType::Integer, + } + )), + "{diagnostics}" + ); + } + + #[test] + fn incompatible_assignment_is_an_error() { + let diagnostics = typecheck_source("variables { bool flag range[0,1] = true; }\nenvironment { flag' = 1; }"); + assert!( + diagnostics.any(|kind| matches!( + kind, + DiagnosticKind::TypeMismatch { + expected: StarkType::Boolean, + found: StarkType::Integer, + } + )), + "{diagnostics}" + ); + } + + #[test] + fn random_expression_in_a_disallowed_context_is_an_error() { + // Variable range bounds are one of the contexts `random_allowed` is + // deliberately `false` for (see the module doc comment / plan). + let diagnostics = typecheck_source("variables { real x range[0, R] = 0.; }"); + assert!( + diagnostics.any(|kind| matches!(kind, DiagnosticKind::RandomNotAllowed)), + "{diagnostics}" + ); + } + + #[test] + fn ternary_branch_mismatch_is_an_error() { + let diagnostics = typecheck_source("function f() { return true ? 1 : false; }"); + assert!( + diagnostics.any(|kind| matches!(kind, DiagnosticKind::IncompatibleTypes { .. })), + "{diagnostics}" + ); + } + + #[test] + fn function_return_type_merge_failure_is_an_error() { + let diagnostics = typecheck_source("function f(bool b) { if (b) return 1; else return true; }"); + assert!( + diagnostics.any(|kind| matches!(kind, DiagnosticKind::IncompatibleTypes { .. })), + "{diagnostics}" + ); + } + + #[test] + fn unknown_type_annotation_is_an_error() { + let diagnostics = typecheck_source("variables { Missing x = 0; }"); + assert!( + diagnostics.any(|kind| matches!(kind, DiagnosticKind::UnknownType { name } if name == "Missing")), + "{diagnostics}" + ); + } + + #[test] + fn typechecks_every_example_specification_without_errors() { + for (name, source) in [ + ("engine", include_str!("../../../examples/stark/engine.stark")), + ("random_walk", include_str!("../../../examples/stark/random_walk.stark")), + ( + "single_vehicle", + include_str!("../../../examples/stark/single_vehicle.stark"), + ), + ("toll", include_str!("../../../examples/stark/toll.stark")), + ( + "two_vehicles", + include_str!("../../../examples/stark/two_vehicles.stark"), + ), + ("monitoring", include_str!("../../../examples/stark/monitoring.stark")), + ( + "agriculturalDT", + include_str!("../../../examples/stark/agriculturalDT.stark"), + ), + ("tollbooth", include_str!("../../../examples/stark/tollbooth.stark")), + ] { + let mut spec = + UntypedStarkSpecification::parse(source).unwrap_or_else(|e| panic!("{name} failed to parse: {e}")); + let (symbols, resolve_diagnostics) = resolve(&mut spec); + assert!( + !resolve_diagnostics.has_errors(), + "{name} failed to resolve:\n{}", + resolve_diagnostics.render(source) + ); + let (_types, diagnostics) = typecheck(&spec, &symbols); + assert!( + !diagnostics.has_errors(), + "{name} failed to typecheck:\n{}", + diagnostics.render(source) + ); + } + } + + /// The original tool's own expression-type-inference test cases, ported. + /// + /// The original tests a bare expression directly against its inference + /// pass, with "is a random expression allowed here" as an explicit + /// parameter. There's no equivalent "just an expression, no + /// spec" entry point here, so each case is hosted inside the smallest + /// construct that gives it the right `random_allowed` context: a + /// zero-argument function body (`random_allowed = true`, matching the + /// original's shared `typeTests` table, which is always checked with + /// randomness allowed) or a `const` value (`random_allowed = false`, + /// for the handful of individual tests that check the non-random path). + mod ported_from_expression_type_inference_test { + use super::typecheck; + use crate::ast::UntypedStarkSpecification; + use crate::resolve::resolve; + use crate::types::StarkType; + + fn infer_in_function_body(expr: &str) -> StarkType { + let source = format!("function f() {{ return {expr}; }}"); + let mut spec = + UntypedStarkSpecification::parse(&source).unwrap_or_else(|e| panic!("failed to parse `{expr}`: {e}")); + let (symbols, resolve_diagnostics) = resolve(&mut spec); + assert!( + !resolve_diagnostics.has_errors(), + "failed to resolve `{expr}`:\n{}", + resolve_diagnostics.render(&source) + ); + let (types, diagnostics) = typecheck(&spec, &symbols); + assert!( + !diagnostics.has_errors(), + "failed to typecheck `{expr}`:\n{}", + diagnostics.render(&source) + ); + let id = spec.functions[0].id.expect("function should resolve"); + types + .signature_of(id) + .expect("function should have a signature") + .return_type + .clone() + } + + fn infer_in_function_body_with_argument(arg_ty: &str, expr: &str) -> StarkType { + let source = format!("function f({arg_ty} x) {{ return {expr}; }}"); + let mut spec = + UntypedStarkSpecification::parse(&source).unwrap_or_else(|e| panic!("failed to parse `{expr}`: {e}")); + let (symbols, resolve_diagnostics) = resolve(&mut spec); + assert!( + !resolve_diagnostics.has_errors(), + "failed to resolve `{expr}`:\n{}", + resolve_diagnostics.render(&source) + ); + let (types, diagnostics) = typecheck(&spec, &symbols); + assert!( + !diagnostics.has_errors(), + "failed to typecheck `{expr}`:\n{}", + diagnostics.render(&source) + ); + let id = spec.functions[0].id.expect("function should resolve"); + types + .signature_of(id) + .expect("function should have a signature") + .return_type + .clone() + } + + fn infer_as_constant(expr: &str) -> StarkType { + let source = format!("const c = {expr};"); + let mut spec = + UntypedStarkSpecification::parse(&source).unwrap_or_else(|e| panic!("failed to parse `{expr}`: {e}")); + let (symbols, resolve_diagnostics) = resolve(&mut spec); + assert!( + !resolve_diagnostics.has_errors(), + "failed to resolve `{expr}`:\n{}", + resolve_diagnostics.render(&source) + ); + let (types, diagnostics) = typecheck(&spec, &symbols); + assert!( + !diagnostics.has_errors(), + "failed to typecheck `{expr}`:\n{}", + diagnostics.render(&source) + ); + let id = spec.constants[0].id.expect("constant should resolve"); + types.type_of(id).expect("constant should have a type").clone() + } + + fn random(t: StarkType) -> StarkType { + StarkType::random(t) + } + + /// The original's shared `typeTests` map (`testExpressions`), always + /// checked with randomness allowed. + #[test] + fn test_expressions() { + let cases: Vec<(&str, StarkType)> = vec![ + ("2", StarkType::Integer), + ("2.", StarkType::Real), + ("true", StarkType::Boolean), + ("false", StarkType::Boolean), + ("2+3", StarkType::Integer), + ("2.+3", StarkType::Real), + ("2+3.", StarkType::Real), + ("2.+3.", StarkType::Real), + ("true & true", StarkType::Boolean), + ("true | true", StarkType::Boolean), + ("2 ^ 3", StarkType::Real), + ("2 * 3", StarkType::Integer), + ("2. * 3", StarkType::Real), + ("2 * 3.", StarkType::Real), + ("2. * 3.", StarkType::Real), + ("2 + 3", StarkType::Integer), + ("2. + 3", StarkType::Real), + ("2 + 3.", StarkType::Real), + ("2. + 3.", StarkType::Real), + ("2. < 3", StarkType::Boolean), + ("!true", StarkType::Boolean), + ("(2<3?1.0:2.0)", StarkType::Real), + ("(2<3?1.0:2)", StarkType::Real), + ("(2<3?1:2.0)", StarkType::Real), + ("(2<3?1:2)", StarkType::Integer), + ("abs(1)", StarkType::Real), + ("acos(1)", StarkType::Real), + ("asin(1)", StarkType::Real), + ("atan(1)", StarkType::Real), + ("cbrt(1)", StarkType::Real), + ("ceil(1)", StarkType::Real), + ("cos(1)", StarkType::Real), + ("cosh(1)", StarkType::Real), + ("exp(1)", StarkType::Real), + ("expm1(1)", StarkType::Real), + ("floor(1)", StarkType::Real), + ("log(1)", StarkType::Real), + ("log10(1)", StarkType::Real), + ("log1p(1)", StarkType::Real), + ("signum(1)", StarkType::Real), + ("sin(1)", StarkType::Real), + ("sinh(1)", StarkType::Real), + ("sqrt(1)", StarkType::Real), + ("tan(1)", StarkType::Real), + ("atan2(1,2)", StarkType::Real), + ("hypot(1,2)", StarkType::Real), + ("max(1,2)", StarkType::Real), + ("min(1,2)", StarkType::Real), + ("pow(1,2)", StarkType::Real), + ("N[0.,1.]", random(StarkType::Real)), + ("N[0,1]", random(StarkType::Real)), + ("U[true,false]", random(StarkType::Boolean)), + ("U[1,2,3]", random(StarkType::Integer)), + ("U[1.0,2,3]", random(StarkType::Real)), + ("R", random(StarkType::Real)), + ("R[1, 10]", random(StarkType::Real)), + ("(R), + /// The result of a type error. + Error, +} + +impl StarkType { + /// Wraps `inner` as a random value of that type. + /// + /// Flattens `random(Random(t))` to `Random(t)` rather than nesting, + /// matching the original. + pub fn random(inner: StarkType) -> StarkType { + match inner { + StarkType::Random(content) => StarkType::Random(content), + other => StarkType::Random(Box::new(other)), + } + } + + /// This type with any `Random` wrapper stripped. Non-random types return + /// a clone of themselves. + pub fn deterministic(&self) -> StarkType { + match self { + StarkType::Random(content) => (**content).clone(), + other => other.clone(), + } + } + + /// Whether this is (possibly randomly) a numerical type (`int` or `real`). + pub fn is_numerical(&self) -> bool { + matches!(self.deterministic(), StarkType::Integer | StarkType::Real) + } + + /// Whether this is random at the top level. + pub fn is_random(&self) -> bool { + matches!(self, StarkType::Random(_)) + } + + /// Whether this is exactly the error type. + pub fn is_error(&self) -> bool { + matches!(self, StarkType::Error) + } + + pub fn is_integer(&self) -> bool { + matches!(self.deterministic(), StarkType::Integer) + } + + pub fn is_real(&self) -> bool { + matches!(self.deterministic(), StarkType::Real) + } + + pub fn is_boolean(&self) -> bool { + matches!(self.deterministic(), StarkType::Boolean) + } + + pub fn is_custom(&self) -> bool { + matches!(self.deterministic(), StarkType::Custom(_)) + } + + /// Whether a value of type `actual` may be used where `self` is required. + /// Ignores randomness on both sides, treating `Random(t)` as `t`. Integer + /// widens to real but not vice versa: `real x = 1;` is fine, `int x = 1.0;` + /// is not. + pub fn is_compatible_with(&self, actual: &StarkType) -> bool { + match self.deterministic() { + StarkType::Integer => actual.is_integer(), + StarkType::Real => actual.is_numerical(), + StarkType::Boolean => actual.is_boolean(), + StarkType::Custom(name) => matches!(actual.deterministic(), StarkType::Custom(other) if other == name), + StarkType::Error => false, + StarkType::Random(_) => unreachable!("deterministic() never returns Random"), + } + } + + /// Whether `self` and `other` can be combined in a symmetric position. + /// Either side already being `Error` is always accepted, so one mistake + /// doesn't cascade into a second diagnostic at the same spot. + pub fn can_be_merged_with(&self, other: &StarkType) -> bool { + if self.is_error() || other.is_error() { + return true; + } + matches!( + (self.deterministic(), other.deterministic()), + (StarkType::Integer, StarkType::Integer) + | (StarkType::Real, StarkType::Real) + | (StarkType::Integer, StarkType::Real) + | (StarkType::Real, StarkType::Integer) + | (StarkType::Boolean, StarkType::Boolean) + ) || matches!((self.deterministic(), other.deterministic()), (StarkType::Custom(a), StarkType::Custom(b)) if a == b) + } + + /// Combines `self` and `other` into their common type, propagating a + /// `Random` wrapper if either side carries one. + /// + /// Returns `Error` if the two types have nothing in common. + pub fn merge(&self, other: &StarkType) -> StarkType { + if self.is_error() || other.is_error() { + return StarkType::Error; + } + + let base = match (self.deterministic(), other.deterministic()) { + (StarkType::Integer, StarkType::Integer) => StarkType::Integer, + (StarkType::Real, StarkType::Real) => StarkType::Real, + (StarkType::Integer, StarkType::Real) | (StarkType::Real, StarkType::Integer) => StarkType::Real, + (StarkType::Boolean, StarkType::Boolean) => StarkType::Boolean, + (StarkType::Custom(a), StarkType::Custom(b)) if a == b => StarkType::Custom(a), + _ => StarkType::Error, + }; + + if base.is_error() { + return StarkType::Error; + } + + if self.is_random() || other.is_random() { + StarkType::random(base) + } else { + base + } + } +} + +impl fmt::Display for StarkType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + StarkType::Integer => write!(f, "int"), + StarkType::Real => write!(f, "real"), + StarkType::Boolean => write!(f, "bool"), + StarkType::Custom(name) => write!(f, "{name}"), + StarkType::Random(content) => write!(f, "random[{content}]"), + StarkType::Error => write!(f, "error"), + } + } +} + +#[cfg(test)] +mod tests { + use super::StarkType; + + fn random(t: StarkType) -> StarkType { + StarkType::random(t) + } + + #[test] + fn merge_same_type_is_identity() { + assert_eq!(StarkType::Integer.merge(&StarkType::Integer), StarkType::Integer); + assert_eq!(StarkType::Real.merge(&StarkType::Real), StarkType::Real); + assert_eq!(StarkType::Boolean.merge(&StarkType::Boolean), StarkType::Boolean); + } + + #[test] + fn merge_widens_integer_and_real_to_real() { + assert_eq!(StarkType::Integer.merge(&StarkType::Real), StarkType::Real); + assert_eq!(StarkType::Real.merge(&StarkType::Integer), StarkType::Real); + } + + #[test] + fn merge_incompatible_kinds_is_error() { + assert_eq!(StarkType::Boolean.merge(&StarkType::Integer), StarkType::Error); + assert_eq!( + StarkType::Custom("Color".into()).merge(&StarkType::Custom("Shape".into())), + StarkType::Error + ); + } + + #[test] + fn merge_propagates_randomness_from_either_side() { + assert_eq!( + StarkType::Integer.merge(&random(StarkType::Real)), + random(StarkType::Real) + ); + assert_eq!( + StarkType::Real.merge(&random(StarkType::Integer)), + random(StarkType::Real) + ); + assert_eq!( + random(StarkType::Integer).merge(&random(StarkType::Real)), + random(StarkType::Real) + ); + } + + #[test] + fn merge_never_wraps_error_in_random() { + // Incompatible kinds stay a bare Error even when random. + assert_eq!( + random(StarkType::Boolean).merge(&random(StarkType::Integer)), + StarkType::Error + ); + } + + #[test] + fn random_flattens_nested_random() { + assert_eq!(StarkType::random(random(StarkType::Real)), random(StarkType::Real)); + } + + #[test] + fn is_compatible_with_allows_integer_to_widen_to_real() { + assert!(StarkType::Real.is_compatible_with(&StarkType::Integer)); + assert!(!StarkType::Integer.is_compatible_with(&StarkType::Real)); + } + + #[test] + fn is_compatible_with_ignores_randomness() { + assert!(StarkType::Integer.is_compatible_with(&random(StarkType::Integer))); + assert!(StarkType::Real.is_compatible_with(&random(StarkType::Integer))); + } + + #[test] + fn is_compatible_with_error_expected_is_always_false() { + assert!(!StarkType::Error.is_compatible_with(&StarkType::Integer)); + } + + #[test] + fn can_be_merged_with_is_permissive_around_errors() { + assert!(StarkType::Error.can_be_merged_with(&StarkType::Boolean)); + assert!(StarkType::Boolean.can_be_merged_with(&StarkType::Error)); + assert!(!StarkType::Boolean.can_be_merged_with(&StarkType::Integer)); + } + + #[test] + fn numerical_and_random_flags() { + assert!(StarkType::Integer.is_numerical()); + assert!(random(StarkType::Real).is_numerical()); + assert!(!StarkType::Boolean.is_numerical()); + assert!(random(StarkType::Integer).is_random()); + assert!(!StarkType::Integer.is_random()); + } + + #[test] + fn display_matches_stark_source_syntax() { + assert_eq!(StarkType::Integer.to_string(), "int"); + assert_eq!(StarkType::Real.to_string(), "real"); + assert_eq!(StarkType::Boolean.to_string(), "bool"); + assert_eq!(random(StarkType::Real).to_string(), "random[real]"); + assert_eq!(StarkType::Custom("Color".into()).to_string(), "Color"); + } + + /// The original tool's own type-lattice test cases, ported. + /// Table-driven, kept close to the original's structure (rows of + /// `[a, b, expected_merge]` / `[expected, actual]`) so it's easy to + /// cross-reference; the hand-written tests above already cover the + /// *reasoning* (why each case holds), this covers the same ground the + /// original test suite checked. + mod ported_from_stark_type_test { + use super::random; + use crate::types::StarkType; + + fn custom() -> StarkType { + StarkType::Custom("testType".into()) + } + + /// `[a, b, expected a.merge(b)]`. Also every row is checked for + /// `a.can_be_merged_with(b) == true`. + fn mergeable_types() -> Vec<(StarkType, StarkType, StarkType)> { + vec![ + // Custom + (custom(), custom(), custom()), + (custom(), random(custom()), random(custom())), + // Integer + (StarkType::Integer, StarkType::Integer, StarkType::Integer), + (StarkType::Integer, StarkType::Real, StarkType::Real), + ( + StarkType::Integer, + random(StarkType::Integer), + random(StarkType::Integer), + ), + (StarkType::Integer, random(StarkType::Real), random(StarkType::Real)), + // Real + (StarkType::Real, StarkType::Integer, StarkType::Real), + (StarkType::Real, StarkType::Real, StarkType::Real), + (StarkType::Real, random(StarkType::Integer), random(StarkType::Real)), + (StarkType::Real, random(StarkType::Real), random(StarkType::Real)), + // Boolean + (StarkType::Boolean, StarkType::Boolean, StarkType::Boolean), + ( + StarkType::Boolean, + random(StarkType::Boolean), + random(StarkType::Boolean), + ), + // Random[Integer] + ( + random(StarkType::Integer), + StarkType::Integer, + random(StarkType::Integer), + ), + (random(StarkType::Integer), StarkType::Real, random(StarkType::Real)), + ( + random(StarkType::Integer), + random(StarkType::Integer), + random(StarkType::Integer), + ), + ( + random(StarkType::Integer), + random(StarkType::Real), + random(StarkType::Real), + ), + // Random[Real] + (random(StarkType::Real), StarkType::Integer, random(StarkType::Real)), + (random(StarkType::Real), StarkType::Real, random(StarkType::Real)), + ( + random(StarkType::Real), + random(StarkType::Integer), + random(StarkType::Real), + ), + ( + random(StarkType::Real), + random(StarkType::Real), + random(StarkType::Real), + ), + // Random[Boolean] + ( + random(StarkType::Boolean), + StarkType::Boolean, + random(StarkType::Boolean), + ), + ( + random(StarkType::Boolean), + random(StarkType::Boolean), + random(StarkType::Boolean), + ), + ] + } + + /// `[a, b]`, each expected to have `a.can_be_merged_with(b) == false` + /// and `a.merge(b) == Error`. + fn unmergeable_types() -> Vec<(StarkType, StarkType)> { + vec![ + (custom(), StarkType::Boolean), + (custom(), StarkType::Integer), + (custom(), StarkType::Real), + (custom(), random(StarkType::Boolean)), + (custom(), random(StarkType::Integer)), + (custom(), random(StarkType::Real)), + (StarkType::Integer, StarkType::Boolean), + (StarkType::Integer, random(StarkType::Boolean)), + (StarkType::Real, StarkType::Boolean), + (StarkType::Real, random(StarkType::Boolean)), + (StarkType::Boolean, StarkType::Integer), + (StarkType::Boolean, StarkType::Real), + (StarkType::Boolean, random(StarkType::Integer)), + (StarkType::Boolean, random(StarkType::Real)), + ] + } + + /// `[expected, actual]`, each expected to have + /// `expected.is_compatible_with(actual) == true`. + fn compatible_types() -> Vec<(StarkType, StarkType)> { + vec![ + (StarkType::Boolean, StarkType::Boolean), + (StarkType::Boolean, random(StarkType::Boolean)), + (StarkType::Integer, StarkType::Integer), + (StarkType::Integer, random(StarkType::Integer)), + (StarkType::Real, StarkType::Integer), + (StarkType::Real, StarkType::Real), + (StarkType::Real, random(StarkType::Integer)), + (StarkType::Real, random(StarkType::Real)), + (custom(), custom()), + (custom(), random(custom())), + ] + } + + #[test] + fn types_that_should_be_merged() { + for (a, b, _) in mergeable_types() { + assert!(a.can_be_merged_with(&b), "{a} should be merged with {b}"); + } + } + + #[test] + fn types_that_cannot_be_merged() { + for (a, b) in unmergeable_types() { + assert!(!a.can_be_merged_with(&b), "{a} should not be merged with {b}"); + } + } + + #[test] + fn merging_types_results() { + for (a, b, expected) in mergeable_types() { + assert_eq!(expected, a.merge(&b), "{a}.merge({b})"); + } + } + + #[test] + fn error_merging_types_results() { + for (a, b) in unmergeable_types() { + assert_eq!(StarkType::Error, a.merge(&b), "{a}.merge({b})"); + } + } + + #[test] + fn subtyping() { + for (expected, actual) in compatible_types() { + assert!( + expected.is_compatible_with(&actual), + "{expected}.is_compatible_with({actual})" + ); + } + } + } +} diff --git a/crates/stark/src/value.rs b/crates/stark/src/value.rs new file mode 100644 index 00000000..ff280f2e --- /dev/null +++ b/crates/stark/src/value.rs @@ -0,0 +1,602 @@ +use std::fmt; + +use merc_utilities::Span; +use thiserror::Error; + +use crate::ast::DefId; +use crate::resolve::SymbolTable; +use crate::types::StarkType; + +/// The *class* of an evaluation failure, without a source location for runtime +/// errors. +#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] +pub enum EvalErrorKind { + /// Integer `/`, `//` or `%` with a zero divisor, or the `i64::MIN / -1` + /// overflow. The only variant reachable from a well-typed program. + #[error("division by zero")] + DivisionByZero, + /// A binary operator applied to operands it isn't defined for — e.g. `true + 1`. + #[error("operator `{op}` is not defined for {left} and {right}")] + UnsupportedBinaryOperands { + op: &'static str, + left: ValueKind, + right: ValueKind, + }, + /// A unary operator applied to an operand it isn't defined for — e.g. `!1`. + #[error("operator `{op}` is not defined for {operand}")] + UnsupportedUnaryOperand { op: &'static str, operand: ValueKind }, + /// A guard or condition that didn't evaluate to a boolean. + #[error("{context} must be a boolean, but was {found}")] + ExpectedBoolean { context: &'static str, found: ValueKind }, + /// A `step` count that didn't evaluate to an integer. + #[error("{context} must be an integer, but was {found}")] + ExpectedInteger { context: &'static str, found: ValueKind }, + /// A sampling bound (`R[a,b]`, `N[m,v]`) that didn't evaluate to a number. + #[error("{context} must be a number, but was {found}")] + ExpectedNumber { context: &'static str, found: ValueKind }, + /// A function body fell off the end without reaching a `return`. Should + /// never occur and be handled by the typechecker. + #[error("function body reached no `return` on this path")] + MissingReturn, + /// An `ir::ExprNode::Unreachable` was evaluated. A bug in the lowering. + #[error("evaluated an expression that should be unreachable: {0}")] + Unreachable(&'static str), + /// A distance was computed between two sample sets whose sizes aren't a + /// multiple of one another, for example a zero scale. + #[error( + "cannot compare sample sets of size {reference} and {perturbed}: the latter must be a multiple of the former" + )] + IncompatibleSampleSizes { reference: usize, perturbed: usize }, + /// A robustness analysis was asked for with a zero sample size, so there + /// is no distribution to compute a distance over. + #[error("a robustness analysis needs at least one sample per step")] + EmptySampleSet, +} + +/// An evaluation failure, anchored to the source [Span] of the offending +/// expression when one is known. +/// +/// Equality compares both the kind and the span +#[derive(Clone, Debug, Error, PartialEq, Eq)] +#[error("{kind}")] +pub struct EvalError { + #[source] + pub kind: EvalErrorKind, + pub span: Option, +} + +impl EvalError { + /// An error with no known source location. + pub fn new(kind: EvalErrorKind) -> EvalError { + EvalError { kind, span: None } + } + + /// Attaches `span` unless a (more specific, inner span was already + /// recorded. + pub fn or_span(mut self, span: &Span) -> EvalError { + if self.span.is_none() { + self.span = Some(span.clone()); + } + self + } + + /// Renders this error against its `source` text. Falls back to the bare + /// message when no span is known. + pub fn render(&self, source: &str) -> String { + match &self.span { + Some(span) => format!("error: {}\n{}", self.kind, span.render(source)), + None => format!("error: {}", self.kind), + } + } +} + +impl From for EvalError { + fn from(kind: EvalErrorKind) -> EvalError { + EvalError::new(kind) + } +} + +/// The kind of a [Value]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ValueKind { + Integer, + Real, + Boolean, + Custom, +} + +impl fmt::Display for ValueKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ValueKind::Integer => write!(f, "an integer"), + ValueKind::Real => write!(f, "a real"), + ValueKind::Boolean => write!(f, "a boolean"), + ValueKind::Custom => write!(f, "a custom value"), + } + } +} + +/// An instance of a user-defined `type X = A | B | C;` value. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CustomValue { + /// The `DefId` of the owning `type X = ...;` declaration. + pub type_id: DefId, + /// The index of the declared element. + pub element: u32, +} + +/// A runtime value flowing through the evaluator. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Value { + Integer(i64), + Real(f64), + Boolean(bool), + Custom(CustomValue), +} + +impl Value { + /// This type of a value. + /// + /// The `symbols` resolves a [CustomValue]'s `type_id` back to the type's + /// declared name. + pub fn type_of(&self, symbols: &SymbolTable) -> StarkType { + match self { + Value::Integer(_) => StarkType::Integer, + Value::Real(_) => StarkType::Real, + Value::Boolean(_) => StarkType::Boolean, + Value::Custom(custom) => StarkType::Custom(symbols.def(custom.type_id).name.clone()), + } + } + + /// This value's case, for error reporting — see [ValueKind]. + pub fn kind(self) -> ValueKind { + match self { + Value::Integer(_) => ValueKind::Integer, + Value::Real(_) => ValueKind::Real, + Value::Boolean(_) => ValueKind::Boolean, + Value::Custom(_) => ValueKind::Custom, + } + } + + /// Reads this value as a boolean. + pub fn as_boolean(self, context: &'static str) -> Result { + match self { + Value::Boolean(value) => Ok(value), + other => Err(EvalErrorKind::ExpectedBoolean { + context, + found: other.kind(), + }), + } + } + + /// Reads this value as an integer. + pub fn as_integer(self, context: &'static str) -> Result { + match self { + Value::Integer(value) => Ok(value), + other => Err(EvalErrorKind::ExpectedInteger { + context, + found: other.kind(), + }), + } + } + + /// Widens either numeric case to `f64`. Errors on a non-numeric values.. + pub fn as_f64(self, context: &'static str) -> Result { + match self { + Value::Integer(value) => Ok(value as f64), + Value::Real(value) => Ok(value), + other => Err(EvalErrorKind::ExpectedNumber { + context, + found: other.kind(), + }), + } + } + + /// Integer overflow wraps rather than panicking. + pub fn sum(self, other: Value) -> Result { + numeric_op("+", self, other, i64::wrapping_add, |a, b| a + b) + } + + pub fn product(self, other: Value) -> Result { + numeric_op("*", self, other, i64::wrapping_mul, |a, b| a * b) + } + + pub fn subtraction(self, other: Value) -> Result { + numeric_op("-", self, other, i64::wrapping_sub, |a, b| a - b) + } + + /// Integer division by zero (and the `i64::MIN / -1` overflow) is + /// [EvalErrorKind::DivisionByZero]; real division keeps `f64`'s + /// behaviour. + pub fn division(self, other: Value) -> Result { + match (self, other) { + (Value::Integer(a), Value::Integer(b)) => a + .checked_div(b) + .map(Value::Integer) + .ok_or(EvalErrorKind::DivisionByZero), + (Value::Integer(a), Value::Real(b)) => Ok(Value::Real(a as f64 / b)), + (Value::Real(a), Value::Integer(b)) => Ok(Value::Real(a / b as f64)), + (Value::Real(a), Value::Real(b)) => Ok(Value::Real(a / b)), + (left, right) => Err(unsupported("/", left, right)), + } + } + + /// Same zero/overflow guard as [Value::division]. + pub fn modulo(self, other: Value) -> Result { + match (self, other) { + (Value::Integer(a), Value::Integer(b)) => a + .checked_rem(b) + .map(Value::Integer) + .ok_or(EvalErrorKind::DivisionByZero), + (Value::Integer(a), Value::Real(b)) => Ok(Value::Real(a as f64 % b)), + (Value::Real(a), Value::Integer(b)) => Ok(Value::Real(a % b as f64)), + (Value::Real(a), Value::Real(b)) => Ok(Value::Real(a % b)), + (left, right) => Err(unsupported("%", left, right)), + } + } + + /// Truncating integer division. + pub fn int_div(self, other: Value) -> Result { + match (self, other) { + (Value::Integer(a), Value::Integer(b)) => a + .checked_div(b) + .map(Value::Integer) + .ok_or(EvalErrorKind::DivisionByZero), + (Value::Integer(a), Value::Real(b)) => Ok(Value::Real((a as f64 / b).trunc())), + (Value::Real(a), Value::Integer(b)) => Ok(Value::Real((a / b as f64).trunc())), + (Value::Real(a), Value::Real(b)) => Ok(Value::Real((a / b).trunc())), + (left, right) => Err(unsupported("//", left, right)), + } + } + + /// Returns a bare `bool` rather than a [Value::Boolean], so that the result + /// does not have be matched. + pub fn is_less_than(self, other: Value) -> Result { + comparison_op("<", self, other, |a, b| a < b, |a, b| a < b) + } + + /// See [Value::is_less_than]. + pub fn is_less_or_equal_than(self, other: Value) -> Result { + comparison_op("<=", self, other, |a, b| a <= b, |a, b| a <= b) + } + + /// See [Value::is_less_than]. + pub fn is_greater_or_equal_than(self, other: Value) -> Result { + comparison_op(">=", self, other, |a, b| a >= b, |a, b| a >= b) + } + + /// See [Value::is_less_than]. + pub fn is_greater_than(self, other: Value) -> Result { + comparison_op(">", self, other, |a, b| a > b, |a, b| a > b) + } + + /// See [Value::is_less_than]. + pub fn is_equal_to(self, other: Value) -> Result { + match (self, other) { + (Value::Boolean(a), Value::Boolean(b)) => Ok(a == b), + (Value::Custom(a), Value::Custom(b)) => Ok(a == b), + // Exact integer comparison when both sides are `Integer` (not + // widened through `f64`, which loses precision above 2^53), + // matching the original's own integer fast path. + _ => comparison_op("==", self, other, |a, b| a == b, |a, b| a == b), + } + } + + /// Computes the boolean and of two boolean values. + pub fn and(self, other: Value) -> Result { + match (self, other) { + (Value::Boolean(a), Value::Boolean(b)) => Ok(a && b), + (left, right) => Err(unsupported("&&", left, right)), + } + } + + /// See [Value::and]. + pub fn or(self, other: Value) -> Result { + match (self, other) { + (Value::Boolean(a), Value::Boolean(b)) => Ok(a || b), + (left, right) => Err(unsupported("||", left, right)), + } + } + + /// Always widens to `Real` even for integer values, matching the original + /// behaviour. + pub fn apply_unary(self, op: &'static str, f: impl Fn(f64) -> f64) -> Result { + match self { + Value::Integer(v) => Ok(Value::Real(f(v as f64))), + Value::Real(v) => Ok(Value::Real(f(v))), + operand => Err(EvalErrorKind::UnsupportedUnaryOperand { + op, + operand: operand.kind(), + }), + } + } + + /// The binary counterpart of [Value::apply_unary], used for every + /// `MathBinaryFunction`. + pub fn apply_binary( + self, + other: Value, + op: &'static str, + f: impl Fn(f64, f64) -> f64, + ) -> Result { + match (self, other) { + (Value::Integer(a), Value::Integer(b)) => Ok(Value::Real(f(a as f64, b as f64))), + (Value::Integer(a), Value::Real(b)) => Ok(Value::Real(f(a as f64, b))), + (Value::Real(a), Value::Integer(b)) => Ok(Value::Real(f(a, b as f64))), + (Value::Real(a), Value::Real(b)) => Ok(Value::Real(f(a, b))), + (left, right) => Err(unsupported(op, left, right)), + } + } +} + +/// The [EvalErrorKind::UnsupportedBinaryOperands] for a binary `op` — the +/// fallthrough every operation below shares. +fn unsupported(op: &'static str, left: Value, right: Value) -> EvalErrorKind { + EvalErrorKind::UnsupportedBinaryOperands { + op, + left: left.kind(), + right: right.kind(), + } +} + +/// Applies the operation to the two numeric operands, widening to `Real` if +/// either is a `Real`. +fn numeric_op( + op: &'static str, + lhs: Value, + rhs: Value, + int_op: impl Fn(i64, i64) -> i64, + real_op: impl Fn(f64, f64) -> f64, +) -> Result { + match (lhs, rhs) { + (Value::Integer(a), Value::Integer(b)) => Ok(Value::Integer(int_op(a, b))), + (Value::Integer(a), Value::Real(b)) => Ok(Value::Real(real_op(a as f64, b))), + (Value::Real(a), Value::Integer(b)) => Ok(Value::Real(real_op(a, b as f64))), + (Value::Real(a), Value::Real(b)) => Ok(Value::Real(real_op(a, b))), + (left, right) => Err(unsupported(op, left, right)), + } +} + +/// The same as [numeric_op] but returning a bare `bool` directly. +fn comparison_op( + op: &'static str, + lhs: Value, + rhs: Value, + int_op: impl Fn(i64, i64) -> bool, + real_op: impl Fn(f64, f64) -> bool, +) -> Result { + match (lhs, rhs) { + (Value::Integer(a), Value::Integer(b)) => Ok(int_op(a, b)), + (Value::Integer(a), Value::Real(b)) => Ok(real_op(a as f64, b)), + (Value::Real(a), Value::Integer(b)) => Ok(real_op(a, b as f64)), + (Value::Real(a), Value::Real(b)) => Ok(real_op(a, b)), + (left, right) => Err(unsupported(op, left, right)), + } +} + +/// Boolean negation (`!x`). +impl std::ops::Not for Value { + type Output = Result; + + fn not(self) -> Result { + match self { + Value::Boolean(v) => Ok(!v), + operand => Err(EvalErrorKind::UnsupportedUnaryOperand { + op: "!", + operand: operand.kind(), + }), + } + } +} + +impl fmt::Display for Value { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Value::Integer(value) => write!(f, "{value}"), + Value::Real(value) => write!(f, "{value}"), + Value::Boolean(value) => write!(f, "{value}"), + Value::Custom(custom) => write!(f, "{:?}#{}", custom.type_id, custom.element), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn type_of_plain_values() { + let symbols = SymbolTable::default(); + assert_eq!(Value::Integer(1).type_of(&symbols), StarkType::Integer); + assert_eq!(Value::Real(1.0).type_of(&symbols), StarkType::Real); + assert_eq!(Value::Boolean(true).type_of(&symbols), StarkType::Boolean); + } + + #[test] + fn display_formats_plain_values() { + assert_eq!(Value::Integer(42).to_string(), "42"); + assert_eq!(Value::Real(1.5).to_string(), "1.5"); + assert_eq!(Value::Boolean(false).to_string(), "false"); + } + + #[test] + fn sum_preserves_integer_then_widens() { + assert_eq!(Value::Integer(1).sum(Value::Integer(2)), Ok(Value::Integer(3))); + assert_eq!(Value::Integer(1).sum(Value::Real(2.0)), Ok(Value::Real(3.0))); + assert_eq!(Value::Real(1.0).sum(Value::Integer(2)), Ok(Value::Real(3.0))); + assert_eq!(Value::Real(1.0).sum(Value::Real(2.0)), Ok(Value::Real(3.0))); + } + + #[test] + fn arithmetic_on_a_non_numeric_operand_names_both_sides() { + assert_eq!( + Value::Boolean(true).sum(Value::Integer(1)), + Err(EvalErrorKind::UnsupportedBinaryOperands { + op: "+", + left: ValueKind::Boolean, + right: ValueKind::Integer, + }) + ); + assert_eq!( + Value::Integer(1).sum(Value::Boolean(true)).unwrap_err().to_string(), + "operator `+` is not defined for an integer and a boolean" + ); + } + + #[test] + fn product_and_subtraction_promote_the_same_way() { + assert_eq!(Value::Integer(3).product(Value::Integer(4)), Ok(Value::Integer(12))); + assert_eq!(Value::Integer(3).product(Value::Real(4.0)), Ok(Value::Real(12.0))); + assert_eq!(Value::Integer(5).subtraction(Value::Integer(2)), Ok(Value::Integer(3))); + assert_eq!(Value::Real(5.0).subtraction(Value::Integer(2)), Ok(Value::Real(3.0))); + } + + #[test] + fn integer_division_and_modulo_by_zero_error_instead_of_panicking() { + assert_eq!( + Value::Integer(1).division(Value::Integer(0)), + Err(EvalErrorKind::DivisionByZero) + ); + assert_eq!( + Value::Integer(1).modulo(Value::Integer(0)), + Err(EvalErrorKind::DivisionByZero) + ); + assert_eq!( + Value::Integer(1).int_div(Value::Integer(0)), + Err(EvalErrorKind::DivisionByZero) + ); + // The i64::MIN / -1 overflow is likewise caught, not a panic. + assert_eq!( + Value::Integer(i64::MIN).division(Value::Integer(-1)), + Err(EvalErrorKind::DivisionByZero) + ); + } + + #[test] + fn integer_division_truncates_like_java_int_division() { + assert_eq!(Value::Integer(7).division(Value::Integer(2)), Ok(Value::Integer(3))); + assert_eq!(Value::Integer(-7).division(Value::Integer(2)), Ok(Value::Integer(-3))); + } + + #[test] + fn real_division_by_zero_keeps_f64_infinities() { + assert_eq!( + Value::Real(1.0).division(Value::Real(0.0)), + Ok(Value::Real(f64::INFINITY)) + ); + assert!(matches!( + Value::Real(0.0).division(Value::Real(0.0)), + Ok(Value::Real(v)) if v.is_nan() + )); + } + + #[test] + fn int_div_always_truncates_towards_zero() { + assert_eq!(Value::Integer(7).int_div(Value::Integer(2)), Ok(Value::Integer(3))); + assert_eq!(Value::Real(7.5).int_div(Value::Integer(2)), Ok(Value::Real(3.0))); + assert_eq!(Value::Real(-7.5).int_div(Value::Integer(2)), Ok(Value::Real(-3.0))); + } + + #[test] + fn comparisons_are_numeric_only_and_widen() { + assert_eq!(Value::Integer(1).is_less_than(Value::Integer(2)), Ok(true)); + assert_eq!(Value::Integer(2).is_less_than(Value::Real(2.5)), Ok(true)); + assert_eq!( + Value::Boolean(true).is_less_than(Value::Integer(1)), + Err(unsupported("<", Value::Boolean(true), Value::Integer(1))) + ); + } + + #[test] + fn equality_covers_numeric_boolean_and_custom() { + assert_eq!(Value::Integer(2).is_equal_to(Value::Real(2.0)), Ok(true)); + assert_eq!(Value::Boolean(true).is_equal_to(Value::Boolean(true)), Ok(true)); + assert_eq!(Value::Boolean(true).is_equal_to(Value::Boolean(false)), Ok(false)); + let a = Value::Custom(CustomValue { + type_id: DefId::new(0), + element: 1, + }); + let b = Value::Custom(CustomValue { + type_id: DefId::new(0), + element: 1, + }); + let c = Value::Custom(CustomValue { + type_id: DefId::new(0), + element: 2, + }); + assert_eq!(a.is_equal_to(b), Ok(true)); + assert_eq!(a.is_equal_to(c), Ok(false)); + assert!(Value::Integer(1).is_equal_to(Value::Boolean(true)).is_err()); + } + + #[test] + fn equality_compares_large_integers_exactly() { + // 2^53 + 1 and 2^53 + 2 collapse to the same f64, so this would + // wrongly compare equal if `is_equal_to` widened through `f64`. + let a = (1i64 << 53) + 1; + let b = (1i64 << 53) + 2; + assert_eq!(Value::Integer(a).is_equal_to(Value::Integer(b)), Ok(false)); + } + + #[test] + fn and_or_are_boolean_only() { + assert_eq!(Value::Boolean(true).and(Value::Boolean(false)), Ok(false)); + assert_eq!(Value::Boolean(true).or(Value::Boolean(false)), Ok(true)); + assert!(Value::Integer(1).and(Value::Boolean(true)).is_err()); + } + + #[test] + fn boolean_not_is_distinct_from_arithmetic_negation() { + // `!` (boolean) is `std::ops::Not`; arithmetic `-x`/`+x` + // (`ExprNode::Negate`/`Widen`) go through `apply_unary` instead — + // see those two variants' doc comments in `ir.rs`. + assert_eq!(!Value::Boolean(true), Ok(false)); + assert!((!Value::Integer(1)).is_err()); + } + + #[test] + fn arithmetic_negate_and_widen_always_widen_to_real() { + // `-x`/`+x` are *not* integer-preserving, matching the original, + // which routes both through the same always-widening double-valued + // mechanism as the math functions — see `ExprNode::Negate`'s doc + // comment. + assert_eq!(Value::Integer(3).apply_unary("-", |x| -x), Ok(Value::Real(-3.0))); + assert_eq!(Value::Real(3.0).apply_unary("-", |x| -x), Ok(Value::Real(-3.0))); + assert!(Value::Boolean(true).apply_unary("-", |x| -x).is_err()); + assert_eq!(Value::Integer(3).apply_unary("+", |x| x), Ok(Value::Real(3.0))); + } + + #[test] + fn math_functions_always_widen_to_real() { + // Pins the original's widening rule: `max(1, 2)` is `Real(2.0)`, not + // `Integer(2)`, because applying a double-valued operation to an + // integer always yields a real. + assert_eq!( + Value::Integer(1).apply_binary(Value::Integer(2), "max", f64::max), + Ok(Value::Real(2.0)) + ); + assert_eq!(Value::Integer(4).apply_unary("sqrt", f64::sqrt), Ok(Value::Real(2.0))); + } + + #[test] + fn as_boolean_errors_on_a_non_boolean_guard() { + // A deliberate behaviour change: the original silently answered + // `false` for a non-boolean guard. + assert_eq!(Value::Boolean(true).as_boolean("a guard"), Ok(true)); + assert_eq!(Value::Boolean(false).as_boolean("a guard"), Ok(false)); + assert_eq!( + Value::Integer(1).as_boolean("a guard"), + Err(EvalErrorKind::ExpectedBoolean { + context: "a guard", + found: ValueKind::Integer, + }) + ); + } + + #[test] + fn as_number_widens_either_numeric_case() { + assert_eq!(Value::Integer(3).as_f64("a bound"), Ok(3.0)); + assert_eq!(Value::Real(3.5).as_f64("a bound"), Ok(3.5)); + assert!(Value::Boolean(true).as_f64("a bound").is_err()); + } +} diff --git a/crates/stark/stark_grammar.pest b/crates/stark/stark_grammar.pest new file mode 100644 index 00000000..c77bd1d0 --- /dev/null +++ b/crates/stark/stark_grammar.pest @@ -0,0 +1,302 @@ +// STARK Specification Language grammar (port of StarkSpecificationLanguage.g4) +// +// Structural rules are *named* (not silent) so that `consume.rs` can match them +// with `match_nodes!`. Pure grouping / operator-list rules stay silent (`_`). +// Expressions are parsed with a Pratt parser (see `precedence.rs`); the grammar +// only emits a flat prefix/primary/postfix/infix token stream for them. + +WHITESPACE = _{ " " | "\t" | "\r" | "\n" | "\u{000C}" } +COMMENT = _{ "/*" ~ (!"*/" ~ ANY)* ~ "*/" } + +// Identifiers and literals +DIGIT = _{ '0'..'9' } +LETTER = _{ 'a'..'z' | 'A'..'Z' | "_" } + +// Reserved words that must not be parsed as identifiers. Without this an ID +// could swallow a following declaration keyword (e.g. the `;` sequence operator +// in a perturbation greedily consuming the next `perturbation`/`formula`). +// Note: the distribution/aggregate names (N, U, R, min, max, it) are *contextual* +// and intentionally excluded so they remain usable as identifiers. +KEYWORD = @{ + ( "const" | "param" | "global" | "variables" | "type" | "environment" + | "penalty" | "function" | "component" | "perturbation" | "distance" + | "formula" | "controller" | "state" | "init" | "when" | "step" | "exec" + | "let" | "in" | "and" | "if" | "else" | "return" | "range" + | "int" | "real" | "bool" | "true" | "false" | "nil" ) + ~ !(LETTER | DIGIT) +} + +ID = @{ !KEYWORD ~ LETTER ~ (LETTER | DIGIT)* } +NEXT_ID = @{ ID ~ "'" } +INTEGER = @{ DIGIT+ } +REAL = @{ ((DIGIT* ~ "." ~ DIGIT+) | (DIGIT+ ~ ".")) ~ (("E" | "e") ~ "-"? ~ DIGIT+)? } + +// Entry point +UntypedStarkSpecification = { SOI ~ Element* ~ EOI } + +Element = _{ + DeclarationConstant + | DeclarationParameter + | DeclarationVariables + | DeclarationType + | DeclarationEnvironment + | DeclarationPenalty + | DeclarationFunction + | DeclarationComponent + | DeclarationPerturbation + | DeclarationDistance + | DeclarationFormula +} + +// Declarations +DeclarationConstant = { "const" ~ ID ~ "=" ~ Expression ~ ";" } +DeclarationParameter = { "param" ~ ID ~ "=" ~ Expression ~ ";" } +DeclarationPenalty = { "penalty" ~ ID ~ "=" ~ Expression } + +DeclarationFormula = { "formula" ~ ID ~ "=" ~ RobtlFormula ~ ";" } +DeclarationDistance = { "distance" ~ ID ~ "=" ~ DistanceExpression ~ ";" } +DeclarationPerturbation = { "perturbation" ~ ID ~ "=" ~ PerturbationExpression ~ ";" } + +// Types and variables +DeclarationType = { "type" ~ ID ~ "=" ~ TypeElement ~ ("|" ~ TypeElement)* ~ ";" } +TypeElement = { ID } + +DeclarationVariables = { GlobalMarker? ~ "variables" ~ "{" ~ VariableDeclaration* ~ "}" } +GlobalMarker = { "global" } +VariableDeclaration = { Ty ~ ID ~ VariableRange? ~ "=" ~ Expression ~ ";" } +VariableRange = { "range" ~ "[" ~ Expression ~ "," ~ Expression ~ "]" } + +Ty = { TyInt | TyReal | TyBool | TyCustom } + TyInt = @{ "int" ~ !(LETTER | DIGIT) } + TyReal = @{ "real" ~ !(LETTER | DIGIT) } + TyBool = @{ "bool" ~ !(LETTER | DIGIT) } + TyCustom = { ID } + +// Functions +DeclarationFunction = { + "function" ~ ID ~ "(" ~ (FunctionArgument ~ ("," ~ FunctionArgument)*)? ~ ")" ~ FunctionBlock +} +FunctionArgument = { Ty ~ ID } + +FunctionStatement = _{ + FunctionReturn + | FunctionIfThenElse + | FunctionBlock + | FunctionLet +} +FunctionLet = { "let" ~ ID ~ "=" ~ Expression ~ "in" ~ FunctionStatement } +FunctionIfThenElse = { "if" ~ "(" ~ Expression ~ ")" ~ FunctionStatement ~ ("else" ~ FunctionStatement)? } +FunctionReturn = { "return" ~ Expression ~ ";" } +FunctionBlock = { "{" ~ FunctionStatement ~ "}" } + +// Components and controllers +DeclarationComponent = { + "component" ~ ID ~ "{" ~ + "variables" ~ "{" ~ VariableDeclaration* ~ "}" ~ + "controller" ~ "{" ~ ControllerState* ~ "}" ~ + "init" ~ ControllerExpression ~ + "}" +} + +ControllerState = { "state" ~ ID ~ ControllerBlock } +ControllerBlock = { "{" ~ ControllerCommand* ~ "}" } + +ControllerCommand = _{ + ControllerStep + | ControllerExec + | ControllerLet + | ControllerAssignment + | ControllerIfThenElse + | ControllerBlock +} + +ControllerStep = { (Expression ~ "#")? ~ "step" ~ ID ~ ";" } +ControllerExec = { "exec" ~ ID ~ ";" } +ControllerLet = { "let" ~ ID ~ "=" ~ Expression ~ "in" ~ ControllerBlock } +ControllerAssignment = { WhenGuard? ~ NEXT_ID ~ "=" ~ Expression ~ ";" } +ControllerIfThenElse = { "if" ~ "(" ~ Expression ~ ")" ~ ControllerBlock ~ ("else" ~ ControllerBlock)? } + +// `init` expression: a parallel composition of controller state references. +ControllerExpression = { ID ~ ("||" ~ ID)* } + +WhenGuard = { "when" ~ Expression } + +// Environment +DeclarationEnvironment = { "environment" ~ EnvironmentBlock } +EnvironmentBlock = { "{" ~ EnvironmentCommand* ~ "}" } + +EnvironmentCommand = _{ + EnvironmentLet + | EnvironmentIfThenElse + | EnvironmentAssignment + | EnvironmentBlock +} + +EnvironmentAssignment = { WhenGuard? ~ NEXT_ID ~ "=" ~ Expression ~ ";" } +EnvironmentIfThenElse = { "if" ~ "(" ~ Expression ~ ")" ~ EnvironmentCommand ~ ("else" ~ EnvironmentCommand)? } +EnvironmentLet = { "let" ~ LocalVariable ~ ("and" ~ LocalVariable)* ~ "in" ~ EnvironmentCommand } +LocalVariable = { ID ~ "=" ~ Expression } + +// Expressions. `?:` binds looser than every other operator (matching C/Java/JS +// convention, and required for `2<3?1.0:2.0` to parse as `(2<3) ? 1.0 : 2.0` +// rather than `2 < (3?1.0:2.0)`), so it can't be one more postfix operator in +// the Pratt token stream below — a postfix operator always binds to the +// nearest preceding primary in that flat stream, regardless of any precedence +// declared on the Pratt parser side. Instead `Expression` wraps a plain +// (ternary-free) Pratt chain with an optional trailing `?:`. +Expression = { PrattExpression ~ ExpressionTernary? } + ExpressionTernary = { "?" ~ Expression ~ ":" ~ Expression } + +// A flat prefix* primary postfix* (infix ...)* stream for the Pratt parser. +PrattExpression = { ExpressionPrefix* ~ ExpressionPrimary ~ ExpressionPostfix* ~ (ExpressionInfix ~ ExpressionPrefix* ~ ExpressionPrimary ~ ExpressionPostfix*)* } + +// Prefix operators +ExpressionPrefix = _{ ExpressionNot | ExpressionUnaryPlus | ExpressionUnaryMinus } + ExpressionNot = { "!" } + ExpressionUnaryPlus = { "+" } + ExpressionUnaryMinus = { "-" } + +// Infix operators (multi-character variants listed before their prefixes) +ExpressionInfix = _{ + ExpressionPow + | ExpressionMult + | ExpressionIntDiv + | ExpressionDiv + | ExpressionAdd + | ExpressionSubtract + | ExpressionMod + | ExpressionLeq + | ExpressionLess + | ExpressionEq + | ExpressionGeq + | ExpressionGreater + | ExpressionAnd + | ExpressionBitAnd + | ExpressionOr + | ExpressionBitOr +} + ExpressionPow = { "^" } + ExpressionMult = { "*" } + ExpressionIntDiv = { "//" } + ExpressionDiv = { "/" } + ExpressionAdd = { "+" } + ExpressionSubtract = { "-" } + ExpressionMod = { "%" } + ExpressionLeq = { "<=" } + ExpressionLess = { "<" } + ExpressionEq = { "==" } + ExpressionGeq = { ">=" } + ExpressionGreater = { ">" } + ExpressionAnd = { "&&" } + ExpressionBitAnd = { "&" } + ExpressionOr = { "||" } + ExpressionBitOr = { "|" } + +// Postfix operators +ExpressionPostfix = _{ ExpressionCall } + ExpressionCall = { "(" ~ (Expression ~ ("," ~ Expression)*)? ~ ")" } + +ExpressionPrimary = _{ + "(" ~ Expression ~ ")" + | ExpressionTrue + | ExpressionFalse + | ExpressionNormal + | ExpressionUniform + | ExpressionRandom + | ExpressionIterator + | ExpressionUnaryMathCall + | ExpressionBinaryMathCall + | REAL + | INTEGER + | ID +} + ExpressionTrue = @{ "true" ~ !(LETTER | DIGIT) } + ExpressionFalse = @{ "false" ~ !(LETTER | DIGIT) } + ExpressionIterator = @{ "it" ~ !(LETTER | DIGIT) } + ExpressionNormal = { "N" ~ "[" ~ Expression ~ "," ~ Expression ~ "]" } + ExpressionUniform = { "U" ~ "[" ~ Expression ~ ("," ~ Expression)* ~ "]" } + // `R[from,to]` is a random value; bare `R` (not followed by an identifier char) + // is the parameterless random value. Anything else falls through to `ID`. + ExpressionRandom = { "R" ~ ("[" ~ Expression ~ "," ~ Expression ~ "]" | !(LETTER | DIGIT)) } + + ExpressionUnaryMathCall = { UnaryMathFn ~ "(" ~ Expression ~ ")" } + ExpressionBinaryMathCall = { BinaryMathFn ~ "(" ~ Expression ~ "," ~ Expression ~ ")" } + UnaryMathFn = @{ + ( "abs" | "acos" | "asin" | "atan" | "cbrt" | "ceil" | "cosh" | "cos" + | "expm1" | "exp" | "floor" | "log10" | "log1p" | "log" | "signum" + | "sinh" | "sin" | "sqrt" | "tan" ) ~ !(LETTER | DIGIT) + } + BinaryMathFn = @{ ( "atan2" | "hypot" | "max" | "min" | "pow" ) ~ !(LETTER | DIGIT) } + +// Perturbation expressions +PerturbationExpression = { PerturbationPrimary ~ PerturbationPostfix* ~ (PerturbationInfix ~ PerturbationPrimary ~ PerturbationPostfix*)* } + +PerturbationInfix = _{ PerturbationSemicolon } + PerturbationSemicolon = { ";" } + +PerturbationPostfix = _{ PerturbationPow } + PerturbationPow = { "^" ~ Expression } + +PerturbationPrimary = _{ + PerturbationNil + | "(" ~ PerturbationExpression ~ ")" + | PerturbationAtomic + | ID +} + PerturbationNil = @{ "nil" ~ !(LETTER | DIGIT) } + PerturbationAtomic = { "[" ~ PerturbationAssignment ~ ("," ~ PerturbationAssignment)* ~ "]" ~ "@" ~ Expression } + +PerturbationAssignment = { ID ~ "<-" ~ Expression } + +// Distance expressions +DistanceExpression = { DistancePrefix* ~ DistancePrimary ~ DistancePostfix* ~ (DistanceInfix ~ DistancePrefix* ~ DistancePrimary ~ DistancePostfix*)* } + +DistancePrefix = _{ DistancePrefixF | DistancePrefixG } + DistancePrefixF = { "\\F" ~ "[" ~ Expression ~ "," ~ Expression ~ "]" } + DistancePrefixG = { "\\G" ~ "[" ~ Expression ~ "," ~ Expression ~ "]" } + +DistanceInfix = _{ DistanceInfixUntil } + DistanceInfixUntil = { "\\U" ~ "[" ~ Expression ~ "," ~ Expression ~ "]" } + +DistancePostfix = _{ DistancePostfixThreshold } + DistancePostfixThreshold = { DistanceThresholdOp ~ Expression } + DistanceThresholdOp = { "<=" | "<" | ">=" | ">" } + +DistancePrimary = _{ + DistanceMin + | DistanceMax + | DistanceAtomicLeft + | DistanceAtomicRight + | "(" ~ DistanceExpression ~ ")" + | DistanceLinearCombination + | ID +} + DistanceAtomicLeft = { "<" ~ ID } + DistanceAtomicRight = { ">" ~ ID } + DistanceMin = { "min" ~ "(" ~ DistanceExpression ~ "," ~ DistanceExpression ~ ")" } + DistanceMax = { "max" ~ "(" ~ DistanceExpression ~ "," ~ DistanceExpression ~ ")" } + DistanceLinearCombination = { Expression ~ "*" ~ DistanceExpression ~ ("+" ~ Expression ~ "*" ~ DistanceExpression)* } + +// ROBTL formulas +RobtlFormula = { RobtlPrefix* ~ RobtlPrimary ~ (RobtlInfix ~ RobtlPrefix* ~ RobtlPrimary)* } + +RobtlPrefix = _{ RobtlNot | RobtlGlobally | RobtlEventually } + RobtlNot = { "!" } + RobtlGlobally = { "\\G" ~ "[" ~ Expression ~ "," ~ Expression ~ "]" } + RobtlEventually = { "\\F" ~ "[" ~ Expression ~ "," ~ Expression ~ "]" } + +RobtlInfix = _{ RobtlAnd | RobtlOr | RobtlUntil } + RobtlAnd = { "&&" } + RobtlOr = { "||" } + RobtlUntil = { "\\U" ~ "[" ~ Expression ~ "," ~ Expression ~ "]" } + +RobtlPrimary = _{ + RobtlTrue + | RobtlFalse + | RobtlDistance + | ID +} + RobtlTrue = @{ "true" ~ !(LETTER | DIGIT) } + RobtlFalse = @{ "false" ~ !(LETTER | DIGIT) } + RobtlDistance = { "\\D" ~ "[" ~ ID ~ "," ~ ID ~ "]" ~ RobtlComparison ~ Expression } + RobtlComparison = { "<=" | "<" | "==" | ">=" | ">" } diff --git a/crates/stark/tests/simulation_test.rs b/crates/stark/tests/simulation_test.rs new file mode 100644 index 00000000..5492ee00 --- /dev/null +++ b/crates/stark/tests/simulation_test.rs @@ -0,0 +1,60 @@ +//! Runs the evaluator end-to-end over a sample of example specifications for +//! a fixed number of steps under a fixed seed, and asserts every step +//! succeeds — a smoke test for single-trajectory simulation. +//! +//! This used to assert only that no step produced an all-`Value::Error` +//! state, which was the strongest check available while a failed evaluation +//! was a *value*: a single errored variable, or a guard that silently +//! evaluated to `false` because its expression failed, both slipped through. +//! Now that evaluation returns a `Result` (see `value.rs`), any failure +//! anywhere in a step surfaces here as an `Err`. +//! +//! The sample covers specifications the *evaluator* handles today: every +//! example lowers (`tests/lowering.rs` covers all of them), but evaluating +//! `perturbation`/`distance`/`formula` is Milestone C and not yet +//! implemented, so specs relying on them are exercised only up to lowering. + +use merc_stark::IrProgram; +use merc_stark::StarkSpecification; +use merc_stark::UntypedStarkSpecification; +use merc_stark::eval::RecordingObserver; +use merc_stark::eval::Simulation; +use test_case::test_case; + +#[test_case(include_str!("../../../examples/stark/random_walk.stark") ; "random_walk.stark")] +#[test_case(include_str!("../../../examples/stark/multiscler.stark") ; "multiscler.stark")] +#[test_case(include_str!("../../../examples/stark/polistil_race.stark") ; "polistil_race.stark")] +fn runs_fifty_steps_without_erroring(source: &str) { + let spec = UntypedStarkSpecification::parse(source).unwrap_or_else(|e| panic!("failed to parse: {e}")); + + let spec = + StarkSpecification::from_untyped(spec).unwrap_or_else(|d| panic!("failed to check:\n{}", d.render(source))); + let program = IrProgram::from_spec(&spec).unwrap_or_else(|d| panic!("failed to lower:\n{}", d.render(source))); + + let mut simulation = Simulation::new(&program, 0).unwrap_or_else(|e| panic!("failed to initialise: {e}")); + let mut observer = RecordingObserver::default(); + if let Err(e) = simulation.run(50, &mut observer) { + panic!("failed at step {}: {e}", simulation.step_count() + 1); + } + + assert_eq!(observer.trajectory.len(), 50); +} + +#[test] +fn same_seed_reproduces_the_same_trajectory() { + let source = include_str!("../../../examples/stark/random_walk.stark"); + let spec = UntypedStarkSpecification::parse(source).expect("should parse"); + + let spec = StarkSpecification::from_untyped(spec).expect("should check"); + let program = IrProgram::from_spec(&spec).expect("should lower"); + + let mut a = Simulation::new(&program, 42).expect("should initialise"); + let mut observer_a = RecordingObserver::default(); + a.run(20, &mut observer_a).expect("should run"); + + let mut b = Simulation::new(&program, 42).expect("should initialise"); + let mut observer_b = RecordingObserver::default(); + b.run(20, &mut observer_b).expect("should run"); + + assert_eq!(observer_a.trajectory, observer_b.trajectory); +} diff --git a/crates/stark/tests/stark_examples.rs b/crates/stark/tests/stark_examples.rs new file mode 100644 index 00000000..584f249f --- /dev/null +++ b/crates/stark/tests/stark_examples.rs @@ -0,0 +1,47 @@ +//! Parses and checks every `.stark` file under `examples/stark/`. +//! +//! Each file is exercised end-to-end: parse into an [UntypedStarkSpecification], +//! then [UntypedStarkSpecification::check] (name resolution + type checking). + +use merc_stark::IrProgram; +use merc_stark::StarkSpecification; +use merc_stark::UntypedStarkSpecification; +use test_case::test_case; + +#[test_case(include_str!("../../../examples/stark/engine.stark") ; "engine.stark")] +#[test_case(include_str!("../../../examples/stark/random_walk.stark") ; "random_walk.stark")] +#[test_case(include_str!("../../../examples/stark/single_vehicle.stark") ; "single_vehicle.stark")] +#[test_case(include_str!("../../../examples/stark/toll.stark") ; "toll.stark")] +#[test_case(include_str!("../../../examples/stark/two_vehicles.stark") ; "two_vehicles.stark")] +#[test_case(include_str!("../../../examples/stark/monitoring.stark") ; "monitoring.stark")] +#[test_case(include_str!("../../../examples/stark/agriculturalDT.stark") ; "agriculturalDT.stark")] +#[test_case(include_str!("../../../examples/stark/tollbooth.stark") ; "tollbooth.stark")] +#[test_case(include_str!("../../../examples/stark/engine_full.stark") ; "engine_full.stark")] +#[test_case(include_str!("../../../examples/stark/isocitrate.stark") ; "isocitrate.stark")] +#[test_case(include_str!("../../../examples/stark/envzompr.stark") ; "envzompr.stark")] +#[test_case(include_str!("../../../examples/stark/vehicle_full.stark") ; "vehicle_full.stark")] +#[test_case(include_str!("../../../examples/stark/multiscler.stark") ; "multiscler.stark")] +#[test_case(include_str!("../../../examples/stark/lotka.stark") ; "lotka.stark")] +#[test_case(include_str!("../../../examples/stark/polistil.stark") ; "polistil.stark")] +#[test_case(include_str!("../../../examples/stark/turtle.stark") ; "turtle.stark")] +#[test_case(include_str!("../../../examples/stark/turtle_hospital.stark") ; "turtle_hospital.stark")] +#[test_case(include_str!("../../../examples/stark/repressilator.stark") ; "repressilator.stark")] +#[test_case(include_str!("../../../examples/stark/reactionsystems_running.stark") ; "reactionsystems_running.stark")] +#[test_case(include_str!("../../../examples/stark/reactionsystems_lacoperon.stark") ; "reactionsystems_lacoperon.stark")] +#[test_case(include_str!("../../../examples/stark/reactionsystems_synapse.stark") ; "reactionsystems_synapse.stark")] +#[test_case(include_str!("../../../examples/stark/reactionsystems_synapse_3neuron.stark") ; "reactionsystems_synapse_3neuron.stark")] +#[test_case(include_str!("../../../examples/stark/abz2025_single_lane_two_cars.stark") ; "abz2025_single_lane_two_cars.stark")] +#[test_case(include_str!("../../../examples/stark/abz2025_one_lane_three_cars.stark") ; "abz2025_one_lane_three_cars.stark")] +#[test_case(include_str!("../../../examples/stark/abz2025_two_lanes_two_cars.stark") ; "abz2025_two_lanes_two_cars.stark")] +#[test_case(include_str!("../../../examples/stark/polistil_race.stark") ; "polistil_race.stark")] +#[test_case(include_str!("../../../examples/stark/ventilator.stark") ; "ventilator.stark")] +fn checks_example_specification(source: &str) { + let spec = UntypedStarkSpecification::parse(source).unwrap_or_else(|e| panic!("failed to parse: {e}")); + let spec = + StarkSpecification::from_untyped(spec).unwrap_or_else(|d| panic!("failed to check:\n{}", d.render(source))); + let program = IrProgram::from_spec(&spec).unwrap_or_else(|d| panic!("failed to lower:\n{}", d.render(source))); + + program + .validate() + .unwrap_or_else(|e| panic!("lowered an inconsistent arena: {e}")); +} diff --git a/crates/stark/tests/verification_test.rs b/crates/stark/tests/verification_test.rs new file mode 100644 index 00000000..e456d691 --- /dev/null +++ b/crates/stark/tests/verification_test.rs @@ -0,0 +1,108 @@ +//! Runs a robustness analysis end to end over an example specification — +//! the counterpart of `simulation.rs`'s single-trajectory smoke test. +//! +//! The point of these tests is that the whole stack *runs and agrees with +//! itself*, not that any particular verdict is the "right" one: a verdict +//! depends on the sample size, and the small sizes used here (real analyses +//! use hundreds of samples) are chosen to keep the tests fast. What is +//! asserted is therefore structural — no evaluation fails, the same seed +//! reproduces the same answer, and a `nil` perturbation is at distance zero +//! from the unperturbed system, which must hold at any sample size. + +use merc_stark::StarkSpecification; +use merc_stark::UntypedStarkSpecification; +use merc_stark::eval::Analysis; +use merc_stark::eval::AnalysisOptions; +use merc_stark::ir::IrProgram; + +/// Deliberately tiny: `\G[400,1000]` in the spec below drives the evolution +/// sequence out to a thousand steps, and every sample is a full trajectory. +fn options() -> AnalysisOptions { + AnalysisOptions { + sample_size: 2, + scale: 1, + bootstrap_replicas: 4, + quantile: 1.96, + } +} + +fn build(source: &str) -> IrProgram { + let spec = UntypedStarkSpecification::parse(source).unwrap_or_else(|e| panic!("failed to parse: {e}")); + + let typed_spec = + StarkSpecification::from_untyped(spec).unwrap_or_else(|d| panic!("failed to check:\n{}", d.render(source))); + IrProgram::from_spec(&typed_spec).unwrap_or_else(|d| panic!("failed to lower:\n{}", d.render(source))) +} + +/// A biochemical model with one penalty, one perturbation, and a `\G` +/// distance over a long interval — the shape almost every example with a +/// `formula` has. +const ISOCITRATE: &str = include_str!("../../../examples/stark/isocitrate.stark"); + +#[test] +fn checks_a_formula_from_an_example_specification() { + let program = build(ISOCITRATE); + let mut analysis = Analysis::new(&program, 0, options()).expect("should initialise"); + let mut sequence = analysis.sample().expect("should sample"); + + let formula = program.formula_decls()[0].root; + analysis + .check(&mut sequence, 0, formula) + .unwrap_or_else(|e| panic!("three-valued check failed: {e}")); +} + +#[test] +fn both_semantics_run_over_an_example_specification() { + let program = build(ISOCITRATE); + let mut analysis = Analysis::new(&program, 1, options()).expect("should initialise"); + let mut sequence = analysis.sample().expect("should sample"); + + let formula = program.formula_decls()[0].root; + analysis + .check_boolean(&mut sequence, 0, formula) + .unwrap_or_else(|e| panic!("boolean check failed: {e}")); +} + +#[test] +fn the_same_seed_reproduces_the_same_distance() { + let program = build(ISOCITRATE); + let distance = program.distance_decls()[0].root; + let perturbation = program.perturbation_decls()[0].root; + + let computed: Vec = (0..2) + .map(|_| { + let mut analysis = Analysis::new(&program, 7, options()).expect("should initialise"); + let mut sequence = analysis.sample().expect("should sample"); + analysis + .distance_under(&mut sequence, 0, distance, perturbation) + .expect("should compute") + }) + .collect(); + + assert_eq!(computed[0], computed[1]); +} + +#[test] +fn a_nil_perturbation_leaves_the_system_at_distance_zero() { + // Appending a `nil` perturbation to a real specification: perturbing by + // nothing must be indistinguishable from not perturbing, whatever the + // sample size and however stochastic the model is. This is the one + // assertion in this file that is a genuine semantic invariant rather + // than a smoke test. + let program = build(&format!("{ISOCITRATE}\nperturbation nothing = nil;\n")); + let mut analysis = Analysis::new(&program, 3, options()).expect("should initialise"); + let mut sequence = analysis.sample().expect("should sample"); + + let distance = program.distance_decls()[0].root; + let nothing = program + .perturbation_decls() + .iter() + .find(|decl| decl.name == "nothing") + .expect("the appended perturbation") + .root; + + let computed = analysis + .distance_under(&mut sequence, 0, distance, nothing) + .expect("should compute"); + assert_eq!(computed, 0.0); +} diff --git a/crates/utilities/src/lib.rs b/crates/utilities/src/lib.rs index aa56665a..b30cfc52 100644 --- a/crates/utilities/src/lib.rs +++ b/crates/utilities/src/lib.rs @@ -15,6 +15,7 @@ mod permutation; mod pest_display_pair; mod random_test; mod sharded_counter; +mod span; mod tagged_index; mod test_logger; mod timing; @@ -35,6 +36,9 @@ pub use pest_display_pair::DisplayPair; pub use random_test::random_test; pub use random_test::random_test_threads; pub use sharded_counter::ShardedCounter; +pub use span::Span; +pub use span::Spanned; +pub use span::respan; pub use tagged_index::MercIndex; pub use tagged_index::TagIndex; pub use test_logger::test_logger; diff --git a/crates/utilities/src/span.rs b/crates/utilities/src/span.rs new file mode 100644 index 00000000..b5df47dd --- /dev/null +++ b/crates/utilities/src/span.rs @@ -0,0 +1,234 @@ +use std::cmp::Ordering; +use std::hash::Hash; +use std::hash::Hasher; +use std::ops::Deref; +use std::ops::DerefMut; + +/// Source location information, spanning from start to end in the source text. +#[derive(Clone, Default, Debug, Eq, Ord, PartialEq, PartialOrd, Hash)] +pub struct Span { + pub start: usize, + pub end: usize, +} + +impl From> for Span { + fn from(span: pest::Span) -> Self { + Span { + start: span.start(), + end: span.end(), + } + } +} + +impl Span { + /// The 1-based (line, column) of `self.start` within `source`, counted in + /// `char`s rather than bytes so the column lines up under multi-byte + /// UTF-8 text. + pub fn start_line_col(&self, source: &str) -> (usize, usize) { + let mut line = 1; + let mut col = 1; + for ch in source[..self.start.min(source.len())].chars() { + if ch == '\n' { + line += 1; + col = 1; + } else { + col += 1; + } + } + (line, col) + } + + /// Renders this span against its `source` text as a caret-annotated + /// snippet, in the `-->`/`|`/`^^^` style `pest` (see + /// `extend_parser_error` in `parse.rs`) and `rustc` diagnostics use, so + /// parser errors and later-pass errors (type errors, …) read + /// consistently: + /// + /// ```text + /// --> 1:23 + /// | + /// 1 | eqn f = undeclared; + /// | ^^^^^^^^^^ + /// ``` + /// + /// A span crossing a newline is underlined only up to the end of its + /// first line; an out-of-range span (e.g. [Span::default] on a synthetic + /// node) renders against the start of `source`. + pub fn render(&self, source: &str) -> String { + let (line, col) = self.start_line_col(source); + let line_text = source.lines().nth(line - 1).unwrap_or(""); + + let span_len = source + .get(self.start..self.end.max(self.start)) + .map_or(1, |text| text.chars().count()) + .max(1); + let underline_len = span_len.min(line_text.chars().count().saturating_sub(col - 1).max(1)); + + let gutter = " ".repeat(line.to_string().len()); + format!( + "{gutter}--> {line}:{col}\n{gutter} |\n{line} | {line_text}\n{gutter} | {}{}", + " ".repeat(col - 1), + "^".repeat(underline_len), + ) + } +} + +/// A value of type `T` paired with the source [Span] it originates from. +/// +/// This mirrors rustc's `Spanned` / node-struct pattern: the wrapper carries +/// the location while the inner `node` holds the actual syntax. It is used to +/// give every expression node a span without threading a `span` field into each +/// enum variant. +/// +/// Equality, ordering and hashing deliberately ignore the [Span] and consider +/// only `node`, so two structurally identical values at different source +/// locations compare and hash equal. Many passes rely on this structural +/// equality (hash maps, deduplication, `assert_eq!` in tests). +#[derive(Clone, Debug)] +pub struct Spanned { + /// The wrapped value. + pub node: T, + /// The source location the value originates from. + pub span: Span, +} + +impl Spanned { + /// Wraps `node` together with its source `span`. + pub fn new(node: T, span: Span) -> Self { + Spanned { node, span } + } + + /// Transforms the wrapped value while preserving the span. + pub fn map(self, function: impl FnOnce(T) -> U) -> Spanned { + Spanned { + node: function(self.node), + span: self.span, + } + } +} + +/// Wraps `node` together with its source `span`; the free-function counterpart +/// of [Spanned::new], mirroring rustc's `respan`. +pub fn respan(span: Span, node: T) -> Spanned { + Spanned { node, span } +} + +impl Deref for Spanned { + type Target = T; + + fn deref(&self) -> &Self::Target { + &self.node + } +} + +impl DerefMut for Spanned { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.node + } +} + +impl PartialEq for Spanned { + fn eq(&self, other: &Self) -> bool { + self.node == other.node + } +} + +impl Eq for Spanned {} + +impl PartialOrd for Spanned { + fn partial_cmp(&self, other: &Self) -> Option { + self.node.partial_cmp(&other.node) + } +} + +impl Ord for Spanned { + fn cmp(&self, other: &Self) -> Ordering { + self.node.cmp(&other.node) + } +} + +impl Hash for Spanned { + fn hash(&self, state: &mut H) { + self.node.hash(state); + } +} + +#[cfg(test)] +mod tests { + use super::Span; + + #[test] + fn test_start_line_col_first_line() { + let span = Span { start: 4, end: 5 }; + assert_eq!(span.start_line_col("eqn f = x;"), (1, 5)); + } + + #[test] + fn test_start_line_col_counts_newlines() { + let source = "sort D;\nmap f: D;\neqn f = undeclared;"; + let start = source.rfind("undeclared").unwrap(); + let span = Span { + start, + end: start + "undeclared".len(), + }; + assert_eq!(span.start_line_col(source), (3, 9)); + } + + #[test] + fn test_start_line_col_multibyte() { + // A multi-byte character before the span must not throw off the + // column, which is counted in `char`s, not bytes. + let source = "eqn é = x;"; + let start = source.rfind('x').unwrap(); + let span = Span { start, end: start + 1 }; + assert_eq!(span.start_line_col(source), (1, 9)); + } + + #[test] + fn test_render_single_line() { + let source = "eqn f = undeclared;"; + let start = source.find("undeclared").unwrap(); + let span = Span { + start, + end: start + "undeclared".len(), + }; + assert_eq!( + span.render(source), + " --> 1:9\n |\n1 | eqn f = undeclared;\n | ^^^^^^^^^^" + ); + } + + #[test] + fn test_render_later_line() { + let source = "sort D;\nmap f: D;\neqn f = undeclared;"; + let start = source.rfind("undeclared").unwrap(); + let span = Span { + start, + end: start + "undeclared".len(), + }; + assert_eq!( + span.render(source), + " --> 3:9\n |\n3 | eqn f = undeclared;\n | ^^^^^^^^^^" + ); + } + + #[test] + fn test_render_clamps_to_line_when_span_crosses_newline() { + let source = "eqn f = x\n+ y;"; + let start = source.find('x').unwrap(); + // A span spuriously extending past the end of the line is still + // underlined only up to that line's end. + let span = Span { + start, + end: source.len(), + }; + assert_eq!(span.render(source), " --> 1:9\n |\n1 | eqn f = x\n | ^"); + } + + #[test] + fn test_render_default_span_points_at_source_start() { + let source = "eqn f = 1;"; + let span = Span::default(); + assert_eq!(span.render(source), " --> 1:1\n |\n1 | eqn f = 1;\n | ^"); + } +} diff --git a/crates/xtask/src/package.rs b/crates/xtask/src/package.rs index f6d08d30..ec18e38f 100644 --- a/crates/xtask/src/package.rs +++ b/crates/xtask/src/package.rs @@ -41,7 +41,7 @@ pub(crate) fn package() -> Result<(), Box> { let workspace_binaries = [ ( workspace_root.clone(), - vec!["merc-lts", "merc-rewrite", "merc-vpg", "merc-sym"], + vec!["merc-lts", "merc-rewrite", "merc-stark", "merc-vpg", "merc-sym"], ), (workspace_root.join("tools/gui"), vec!["merc-ltsgraph"]), (workspace_root.join("tools/mcrl2"), vec!["merc-pbes", "merc-lps"]), diff --git a/examples/stark/LICENSE b/examples/stark/LICENSE new file mode 100644 index 00000000..8f3d380f --- /dev/null +++ b/examples/stark/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly aiState otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/examples/stark/README.md b/examples/stark/README.md new file mode 100644 index 00000000..c9d45a46 --- /dev/null +++ b/examples/stark/README.md @@ -0,0 +1,3 @@ +# Overview + +These examples are taken from the [Stark](https://github.com/the-stark-tool/STARK.git) repository. \ No newline at end of file diff --git a/examples/stark/abz2025_one_lane_three_cars.stark b/examples/stark/abz2025_one_lane_three_cars.stark new file mode 100644 index 00000000..550d665d --- /dev/null +++ b/examples/stark/abz2025_one_lane_three_cars.stark @@ -0,0 +1,186 @@ +/* + * The same RSS car-following idea as `abz2025_single_lane_two_cars.stark`, + * generalised from 2 to 3 chained cars in one lane — car 1 (the middle car) + * is controlled, cars 0 (behind) and 2 (in front) always accelerate at + * roughly `MAX_ACCELERATION`. The controller now compares + * *both* the front gap (`distance1`/`safety_gap1`, between cars 1 and 2) + * and the back gap (`distance0`/`safety_gap0`, between cars 0 and 1) before + * deciding FASTER/SLOWER/IDLE. + * + * The original's `includePhysicsUpdates` has a latent bug ported as-written + * (not fixed, matching this session's convention for ambiguous-but-plausible + * quirks — see e.g. `polistil.stark`'s `out'` note): its loop reassigns + * `currentAccelBack = currentAccelFront` between iterations but never the + * corresponding `currentSpeedBack`, so the *second* iteration's "distance + * travelled by the back car" term uses car 0's old speed instead of car 1's. + * Concretely: `new_distance1` below uses `accel1/2 + speed0` (car 0's old + * speed) where `accel1/2 + speed1` (car 1's) would be correct. The + * corresponding safety-gap update (`new_gap1`) is unaffected — it's driven by + * a separate, correctly-updated variable in the original. + */ + +param RESPONSE_TIME = 1.0; +param VEHICLE_LENGTH = 5.0; + +param MAX_SPEED = 40.0; +param MAX_ACCELERATION = 5.0; +param MAX_ACCEL_OFFSET = 1.0; +param MAX_BRAKE = 5.0; +param MIN_BRAKE = 3.0; +param IDLE_DELTA = 1.0; + +param INIT_SPEED0 = 0.0; +param INIT_SPEED1 = 0.0; +param INIT_SPEED2 = 0.0; +param INIT_ACCEL0 = 1.0; +param INIT_ACCEL1 = 1.0; +param INIT_ACCEL2 = 1.0; +param INIT_DISTANCE0 = 300.0; +param INIT_DISTANCE1 = 300.0; + +param STARTING_STEP = 0; +param FREQUENCY = 2; +param TIMES_TO_APPLY = 100; + +param DRUNK_DRIVER_CHANCE = 0.2; +param BRAKE_CHECK_CHANCE = 0.2; + +param ETA_CRASH = 0.01; +param ETA_SAFETY_GAP_VIOLATION = 0.5; + +param FASTER = 1.0; +param SLOWER = -1.0; +param IDLE = 0.0; + +param INIT_GAP0 = + max(0, RESPONSE_TIME*INIT_SPEED0 + 0.5*MAX_ACCELERATION*RESPONSE_TIME^2 + + (INIT_SPEED0+RESPONSE_TIME*MAX_ACCELERATION)^2/(2*MIN_BRAKE) + - (INIT_SPEED1*INIT_SPEED1)/(2*MAX_BRAKE)) + + VEHICLE_LENGTH; +param INIT_GAP1 = + max(0, RESPONSE_TIME*INIT_SPEED1 + 0.5*MAX_ACCELERATION*RESPONSE_TIME^2 + + (INIT_SPEED1+RESPONSE_TIME*MAX_ACCELERATION)^2/(2*MIN_BRAKE) + - (INIT_SPEED2*INIT_SPEED2)/(2*MAX_BRAKE)) + + VEHICLE_LENGTH; + +global variables { + real speed0 = INIT_SPEED0; + real speed1 = INIT_SPEED1; + real speed2 = INIT_SPEED2; + real accel0 = INIT_ACCEL0; + real accel1 = INIT_ACCEL1; + real accel2 = INIT_ACCEL2; + real gap_distance0 = INIT_DISTANCE0; + real gap_distance1 = INIT_DISTANCE1; + real safety_gap0 = INIT_GAP0; + real safety_gap1 = INIT_GAP1; + real intention = IDLE; +} + +component Vehicle1 { + variables { } + controller { + state Control { + if (gap_distance0 == safety_gap0 && gap_distance1 == safety_gap1) { + intention' = IDLE; + step Control; + } else { + if (gap_distance1 < safety_gap1 && gap_distance0 < safety_gap0) { + if (gap_distance1 > gap_distance0) { + intention' = SLOWER; + } else { + intention' = FASTER; + } + step Control; + } else { + if (gap_distance1 < safety_gap1) { + intention' = SLOWER; + } else { + intention' = FASTER; + } + step Control; + } + } + } + } + init Control +} + +environment { + let + offset = R[0,1] * MAX_ACCEL_OFFSET + and + slower_accel = R[0,1] * (MAX_BRAKE - MIN_BRAKE) + MIN_BRAKE + and + idle_accel = R[0,1] * (2*IDLE_DELTA) - IDLE_DELTA + and + new_accel1 = (intention == FASTER ? MAX_ACCELERATION - offset : (intention == SLOWER ? -slower_accel : idle_accel)) + and + new_accel0 = MAX_ACCELERATION - R[0,1]*MAX_ACCEL_OFFSET + and + new_accel2 = MAX_ACCELERATION - R[0,1]*MAX_ACCEL_OFFSET + and + new_speed0 = min(MAX_SPEED, max(0, speed0 + accel0)) + and + new_speed1 = min(MAX_SPEED, max(0, speed1 + accel1)) + and + new_speed2 = min(MAX_SPEED, max(0, speed2 + accel2)) + and + travel0 = accel0/2 + speed0 + and + travel1 = accel1/2 + speed1 + and + travel2 = accel2/2 + speed2 + and + new_distance0 = gap_distance0 + travel1 - travel0 + /* see file header: original bug uses accel1/2 + speed0 (car 0's old + speed), not accel1/2 + speed1, for this term */ + and + new_distance1 = gap_distance1 + travel2 - (accel1/2 + speed0) + and + new_gap0 = + max(0, RESPONSE_TIME*new_speed0 + 0.5*MAX_ACCELERATION*RESPONSE_TIME^2 + + (new_speed0+RESPONSE_TIME*MAX_ACCELERATION)^2/(2*MIN_BRAKE) + - (new_speed1*new_speed1)/(2*MAX_BRAKE)) + + VEHICLE_LENGTH + and + new_gap1 = + max(0, RESPONSE_TIME*new_speed1 + 0.5*MAX_ACCELERATION*RESPONSE_TIME^2 + + (new_speed1+RESPONSE_TIME*MAX_ACCELERATION)^2/(2*MIN_BRAKE) + - (new_speed2*new_speed2)/(2*MAX_BRAKE)) + + VEHICLE_LENGTH + in { + accel0' = new_accel0; + accel1' = new_accel1; + accel2' = new_accel2; + speed0' = new_speed0; + speed1' = new_speed1; + speed2' = new_speed2; + gap_distance0' = new_distance0; + gap_distance1' = new_distance1; + safety_gap0' = new_gap0; + safety_gap1' = new_gap1; + } +} + +penalty rho_crash = (gap_distance1 > 0.0 || gap_distance0 > 0.0 ? 0.0 : 1.0) +penalty rho_gap_violation = (gap_distance1 > safety_gap1 && gap_distance0 > safety_gap0 ? 0.0 : 1.0) + +distance dist_crash = < rho_crash; +distance dist_crash_interval = \G[STARTING_STEP, STARTING_STEP + TIMES_TO_APPLY*FREQUENCY] dist_crash; +distance dist_gap_violation = < rho_gap_violation; +distance dist_gap_violation_interval = \G[STARTING_STEP, STARTING_STEP + TIMES_TO_APPLY*FREQUENCY] dist_gap_violation; + +/* Each uncontrolled car (0 and 2) independently has a DRUNK_DRIVER_CHANCE + chance of getting a random acceleration each time the perturbation fires. */ +perturbation p_drunk_driver = + ([accel0 <- (R[0,1] < DRUNK_DRIVER_CHANCE ? R[-MAX_BRAKE, MAX_ACCELERATION] : accel0), + accel2 <- (R[0,1] < DRUNK_DRIVER_CHANCE ? R[-MAX_BRAKE, MAX_ACCELERATION] : accel2)]@FREQUENCY)^TIMES_TO_APPLY; + +/* Only the front uncontrolled car (2) brake-checks. */ +perturbation p_brake_check = ([accel2 <- (R[0,1] < BRAKE_CHECK_CHANCE ? -MAX_BRAKE : accel2)]@FREQUENCY)^TIMES_TO_APPLY; + +formula crash_drunk_driver = \D[dist_crash_interval, p_drunk_driver] <= ETA_CRASH; +formula crash_brake_check = \D[dist_crash_interval, p_brake_check] <= ETA_CRASH; +formula gap_violation_drunk_driver = \D[dist_gap_violation_interval, p_drunk_driver] <= ETA_SAFETY_GAP_VIOLATION; +formula gap_violation_brake_check = \D[dist_gap_violation_interval, p_brake_check] <= ETA_SAFETY_GAP_VIOLATION; diff --git a/examples/stark/abz2025_single_lane_two_cars.stark b/examples/stark/abz2025_single_lane_two_cars.stark new file mode 100644 index 00000000..8a38bf57 --- /dev/null +++ b/examples/stark/abz2025_single_lane_two_cars.stark @@ -0,0 +1,172 @@ +/* + * Two cars on a single lane, V1 behind V2, where V1's controller picks + * FASTER/SLOWER/IDLE based on whether the gap to V2 matches a + * Responsibility-Sensitive-Safety (RSS) safety distance (Shalev-Shwartz, + * Shammah, Shashua, "On a formal model of safe and scalable self-driving + * cars", 2017) — the same car-following shape as `toll.stark`/ + * `two_vehicles.stark`/`vehicle_full.stark`, but using the RSS formula for + * the safety gap instead of a fixed braking-distance estimate, and adding + * genuine crash/safety-gap-violation robustness queries under two + * perturbations (drunk driving, brake-checking). + * + * `TwoLanesTwoCars` is *not* a mere scale-up: it adds a second lane, 2D + * (x,y) positions, an explicit lane-change manoeuvre with its own timer, + * and three selectable scenario configurations (1276 lines). That is + * genuinely new mechanics beyond car-following, not covered by this file — + * it's deliberately not ported, unlike every other "near-duplicate" this + * session has skipped, given the scope of this porting effort. + * + * `AISingleLane`/`AIMultipleLanes` depend on an external AI server (a live + * HTTP/socket connection to `highway-env-ai-server`) for V1's controller + * instead of computing it from this specification, so they have no + * textual-STARK equivalent at all — a different kind of gap from the + * online-monitoring and feedback exclusions already documented in + * `crates/stark/plan.md`, but the same conclusion: not portable. + * + * `includePhysicsUpdates` reads `accelV1`/`accelV2` *before* this round's + * `intention`-based reassignment applies (the controller decides this + * round's acceleration from this round's gap, but that acceleration only + * affects speed/distance *next* round) — ported faithfully via this + * grammar's `let` bindings reading the current, not-yet-overwritten + * `accelV1`/`accelV2` for `new_speed_v1`/`travel_v1`/etc., while + * `new_accel_v1` (assigned to `accelV1'`) only takes effect starting next + * round, exactly as in the original. + * + * The perturbations only set `accelV2` (letting the following environment + * step recompute speed/distance/safety-gap from it), rather than + * replicating the original's `includePhysicsUpdates` call *inside* the + * perturbation itself — matching every other perturbation ported in this + * session (e.g. `turtle.stark`'s `p_slower`), since this grammar's + * perturbations only set individual variables, not full derived physics. + * The brake-check perturbation's `BRAKE_CHECK_CHANCE`-guarded choice is + * ported as a single `R[0,1]` comparison inside one ternary, since there is + * only one variable (`accelV2`) it needs to guard — no shared-draw gap here. + */ + +param RESPONSE_TIME = 1.0; +param VEHICLE_LENGTH = 5.0; + +param MAX_SPEED = 40.0; +param MAX_ACCELERATION = 5.0; +param MAX_ACCEL_OFFSET = 5.0; +param MAX_BRAKE = 5.0; +param MIN_BRAKE = 3.0; +param IDLE_DELTA = 1.0; + +param INIT_SPEED_V1 = 0.0; +param INIT_DISTANCE_V1_V2 = 100.0; +param INIT_ACCEL_V1 = 0.0; + +param INIT_SPEED_V2 = 0.0; +param INIT_ACCEL_V2 = 1.0; + +param STARTING_STEP = 4; +param FREQUENCY = 2; +param TIMES_TO_APPLY = 20; + +param BRAKE_CHECK_CHANCE = 0.8; + +param ETA_CRASH = 0.3; +param ETA_SAFETY_GAP_VIOLATION = 0.2; + +param FASTER = 1.0; +param SLOWER = -1.0; +param IDLE = 0.0; + +/* RSS safety distance at the initial (zero) speeds, plus the vehicle length + (the RSS model treats vehicles as points; this adds back the distance + from each vehicle's centre to its front/rear bumper). */ +param INIT_SAFETY_GAP = + max(0, RESPONSE_TIME*INIT_SPEED_V1 + 0.5*MAX_ACCELERATION*RESPONSE_TIME^2 + + (INIT_SPEED_V1+RESPONSE_TIME*MAX_ACCELERATION)^2/(2*MIN_BRAKE) + - (INIT_SPEED_V2*INIT_SPEED_V2)/(2*MAX_BRAKE)) + + VEHICLE_LENGTH; + +global variables { + real speedV1 = INIT_SPEED_V1; + real safetyGap = INIT_SAFETY_GAP; + real accelV1 = INIT_ACCEL_V1; + real gap_distance = INIT_DISTANCE_V1_V2; + real speedV2 = INIT_SPEED_V2; + real accelV2 = INIT_ACCEL_V2; + real intention = IDLE; + real perturbationApplied = 0.0; +} + +component Vehicle1 { + variables { } + controller { + state Control { + if (gap_distance == safetyGap) { + intention' = IDLE; + step Control; + } else { + if (gap_distance > safetyGap) { + intention' = FASTER; + step Control; + } else { + intention' = SLOWER; + step Control; + } + } + } + } + init Control +} + +environment { + let + offset = R[0,1] * MAX_ACCEL_OFFSET + and + slower_accel = R[0,1] * (MAX_BRAKE - MIN_BRAKE) + MIN_BRAKE + and + idle_accel = R[0,1] * (2*IDLE_DELTA) - IDLE_DELTA + and + new_accel_v1 = (intention == FASTER ? MAX_ACCELERATION - offset : (intention == SLOWER ? -slower_accel : idle_accel)) + and + new_speed_v1 = min(MAX_SPEED, max(0, speedV1 + accelV1)) + and + new_speed_v2 = min(MAX_SPEED, max(0, speedV2 + accelV2)) + and + travel_v1 = accelV1/2 + speedV1 + and + travel_v2 = accelV2/2 + speedV2 + and + new_distance = gap_distance - travel_v1 + travel_v2 + and + new_safety_gap = + max(0, RESPONSE_TIME*speedV1 + 0.5*MAX_ACCELERATION*RESPONSE_TIME^2 + + (speedV1+RESPONSE_TIME*MAX_ACCELERATION)^2/(2*MIN_BRAKE) + - (speedV2*speedV2)/(2*MAX_BRAKE)) + + VEHICLE_LENGTH + in { + accelV1' = new_accel_v1; + accelV2' = INIT_ACCEL_V2; + speedV1' = new_speed_v1; + speedV2' = new_speed_v2; + gap_distance' = new_distance; + safetyGap' = new_safety_gap; + perturbationApplied' = 0; + } +} + +penalty rho_crash = (gap_distance > 0.0 ? 0.0 : 1.0) +penalty rho_gap_violation = (gap_distance > safetyGap ? 0.0 : 1.0) + +distance dist_crash = < rho_crash; +distance dist_crash_interval = \G[STARTING_STEP, STARTING_STEP + TIMES_TO_APPLY*FREQUENCY] dist_crash; +distance dist_gap_violation = < rho_gap_violation; +distance dist_gap_violation_interval = \G[STARTING_STEP, STARTING_STEP + TIMES_TO_APPLY*FREQUENCY] dist_gap_violation; + +/* Drunk driving: V2's acceleration becomes uniformly random each time the + perturbation fires. */ +perturbation p_drunk_driver = ([accelV2 <- R[-MAX_BRAKE, MAX_ACCELERATION]]@FREQUENCY)^TIMES_TO_APPLY; + +/* Brake-checking: with probability BRAKE_CHECK_CHANCE, V2 slams on the + brakes; otherwise it keeps accelerating at its nominal rate. */ +perturbation p_brake_check = ([accelV2 <- (R[0,1] < BRAKE_CHECK_CHANCE ? -MAX_BRAKE : INIT_ACCEL_V2)]@FREQUENCY)^TIMES_TO_APPLY; + +formula crash_drunk_driver = \D[dist_crash_interval, p_drunk_driver] <= ETA_CRASH; +formula crash_brake_check = \D[dist_crash_interval, p_brake_check] <= ETA_CRASH; +formula gap_violation_drunk_driver = \D[dist_gap_violation_interval, p_drunk_driver] <= ETA_SAFETY_GAP_VIOLATION; +formula gap_violation_brake_check = \D[dist_gap_violation_interval, p_brake_check] <= ETA_SAFETY_GAP_VIOLATION; diff --git a/examples/stark/abz2025_two_lanes_two_cars.stark b/examples/stark/abz2025_two_lanes_two_cars.stark new file mode 100644 index 00000000..589cd399 --- /dev/null +++ b/examples/stark/abz2025_two_lanes_two_cars.stark @@ -0,0 +1,376 @@ +/* + * Ported from the original `ABZ2025` example's `TwoLanesTwoCars` scenario + * (Scenario 1 of 3 — see below): unlike `abz2025_single_lane_two_cars.stark`/ + * `abz2025_one_lane_three_cars.stark`, this is genuinely new mechanics, not + * a scale variant — two cars on a two-lane highway with explicit (x,y) + * positions, and "my car" (the controlled one) can change lanes + * (`Moving_left`/`Moving_right` controller states) to overtake or make way, + * not just speed up/slow down. + * + * `SCENARIO` (1/2/3 in the original, selected by an `int` field, `main()` + * always runs with `SCENARIO = 1`) only changes the two cars' initial + * positions/lanes and, correspondingly, which side of `getEnvironmentUpdates_N`'s + * near-mirror-image lane-change logic actually engages first — confirmed by + * diffing `getEnvironmentUpdates_1`/`_2`/`_3` directly: scenario 2 is + * scenario 1 with the "other" car's lane-change branches mirrored (starts in + * the left lane and tends back right instead of starting right and tending + * left), and scenario 3 shares scenario 1's environment function entirely, + * only the initial (x,y) differ. So this file ports Scenario 1 as the + * representative case (same precedent as `polistil_race.stark`'s "race" vs + * "siblings"), not all three. + * + * The controller's lane-change decision compares `dist`/`safety_gap` + * against `my_position` (`1` if my car is ahead on the x-axis, `-1` + * otherwise) and `other_lane`/`my_lane`, mirroring the RSS car-following + * logic from the single-lane files but choosing a lane change + * (`Moving_left`/`Moving_right`, a 2-step manoeuvre gated by `my_timer`) + * instead of just braking when the lane is unsafe to stay in. + * + * `reckless_driver`'s perturbation is simplified relative to the original's + * atomic perturbation, which recomputes `other_move`/`other_speed`/ + * `other_x`/`other_y`/`other_lane`/`dist`/`my_position`/`safety_gap` all at + * once from one shared random draw: as with every other perturbation ported + * this session (e.g. `abz2025_single_lane_two_cars.stark`'s + * `p_drunk_driver`), this grammar's perturbations only set the + * directly-manipulated variables (`other_move`/`other_acc`), letting the + * following environment step recompute `dist`/`safety_gap`/`my_position`/ + * `crash` from them — so the perturbation's effect propagates one round + * later than in the original. The original's 40%-chance "just nudge `dist` + * a little instead" fallback branch and its five-step delayed + * initial 5-step delay (no such delay combinator exists in this grammar, + * only the atomic block's own `@time`) are both dropped rather than + * approximated further, since neither has a natural encoding here. + * + * Of the five robustness formulas the original builds (`phi_SAF` — no + * crash, combining a speed-difference-at-crash penalty with a plain crash + * flag; `phi_R2L`/`phi_KIR`/`phi_SO` — lane-discipline/overtake-safety + * monitors using penalty functions not otherwise part of this model), only + * `phi_SAF` (renamed `phi_safe` below) is ported, matching the + * `isocitrate.stark` precedent of picking one representative formula rather + * than porting every diagnostic query. + */ + +param PI = 3.141592653589793; +param VEHICLE_LENGTH = 5.0; +param VEHICLE_WIDTH = 2.0; +param TIMER = 2.0; + +param MAX_SPEED = 40.0; +param MAX_ACCELERATION = 5.0; +param FAST_OFFSET = 2.0; +param MAX_BRAKE = 5.0; +param MIN_BRAKE = 3.0; +param SLOW_OFFSET = 2.0; +param IDLE_OFFSET = 0.4; +param H = 300; + +param MY_INIT_SPEED = 15.0; +param OTHER_INIT_SPEED = 15.0; +param MY_INIT_X = 0.0; +param MY_INIT_Y = 2.0; +param OTHER_INIT_X = 150.0; +param OTHER_INIT_Y = 2.0; + +param FASTER = 1.0; +param SLOWER = -1.0; +param IDLE = 0.0; +param LANE_RIGHT = -1.0; +param LANE_LEFT = 1.0; + +param ETA = 0.01; + +function rss_gap(real rear, real front) { + return max(0, TIMER*rear + 0.5*MAX_ACCELERATION*TIMER^2 + + (rear+TIMER*MAX_ACCELERATION)^2/(2*MIN_BRAKE) + - (front*front)/(2*MAX_BRAKE)) + + VEHICLE_LENGTH; +} + +param INIT_MY_LANE = (MY_INIT_Y <= 4 ? 0.0 : 1.0); +param INIT_OTHER_LANE = (OTHER_INIT_Y <= 4 ? 0.0 : 1.0); +param INIT_MY_POSITION = (MY_INIT_X <= OTHER_INIT_X ? -1.0 : 1.0); +param INIT_DIST = sqrt((OTHER_INIT_X-MY_INIT_X)^2 + (OTHER_INIT_Y-MY_INIT_Y)^2); +/* param initializers can't call functions (see crates/stark/plan.md), + so rss_gap's formula is inlined here for the two possible orderings. */ +param INIT_SAFETY_GAP = + (MY_INIT_X <= OTHER_INIT_X + ? max(0, TIMER*MY_INIT_SPEED + 0.5*MAX_ACCELERATION*TIMER^2 + + (MY_INIT_SPEED+TIMER*MAX_ACCELERATION)^2/(2*MIN_BRAKE) + - (OTHER_INIT_SPEED*OTHER_INIT_SPEED)/(2*MAX_BRAKE)) + VEHICLE_LENGTH + : max(0, TIMER*OTHER_INIT_SPEED + 0.5*MAX_ACCELERATION*TIMER^2 + + (OTHER_INIT_SPEED+TIMER*MAX_ACCELERATION)^2/(2*MIN_BRAKE) + - (MY_INIT_SPEED*MY_INIT_SPEED)/(2*MAX_BRAKE)) + VEHICLE_LENGTH); + +global variables { + real my_x = MY_INIT_X; + real my_y = MY_INIT_Y; + real my_speed = MY_INIT_SPEED; + real intention = IDLE; + real my_acc = IDLE; + real my_lane = INIT_MY_LANE; + real my_move = 0.0; + real my_timer = 0.0; + real my_position = INIT_MY_POSITION; + + real other_x = OTHER_INIT_X; + real other_y = OTHER_INIT_Y; + real other_speed = OTHER_INIT_SPEED; + real other_acc = IDLE; + real other_lane = INIT_OTHER_LANE; + real other_move = 0.0; + real other_timer = TIMER - 1; + + real dist = INIT_DIST; + real safety_gap = INIT_SAFETY_GAP; + real crash = 0.0; +} + +component Vehicle1 { + variables { } + controller { + state Control { + if (my_timer > 0) { + /* BUG FIXED: was `exec Control;`. the original's `Control` is + conditional that idles back into `Control` while `my_timer > 0`; + idling is + tick-consuming (== `step`), and a same-round `exec Control` self-loop + would never terminate. Corrected to `step Control`. */ + step Control; + } else { + if (my_lane == 1) { + if (dist > safety_gap) { + intention' = IDLE; + my_move' = LANE_RIGHT; + my_timer' = TIMER; + step Moving_right; + } else { + if (my_position == 1) { + if (other_lane == 1) { + intention' = IDLE; + my_move' = LANE_RIGHT; + my_timer' = TIMER; + step Moving_right; + } else { + intention' = FASTER; + my_timer' = TIMER; + step Idling; + } + } else { + if (other_lane == 1) { + if (dist == safety_gap) { + intention' = IDLE; + my_timer' = TIMER; + step Idling; + } else { + intention' = SLOWER; + my_timer' = TIMER; + step Idling; + } + } else { + intention' = FASTER; + my_timer' = TIMER; + step Idling; + } + } + } + } else { + if (dist > safety_gap || my_position == 1) { + intention' = FASTER; + my_timer' = TIMER; + step Idling; + } else { + if (other_lane == 0) { + if (dist > safety_gap*0.8) { + intention' = IDLE; + my_move' = LANE_LEFT; + my_timer' = TIMER; + step Moving_left; + } else { + intention' = SLOWER; + my_timer' = TIMER; + step Idling; + } + } else { + intention' = IDLE; + my_timer' = TIMER; + step Idling; + } + } + } + } + } + + state Idling { + /* The original's `Idling` idles one tick while `my_timer > 0`, and + otherwise hands over to `Control`. + BUG FIXED: the then-branch idles back into `Idling`, which is + tick-consuming, so + `exec Idling` (was) -> `step Idling`. The else is a *bare* + `reference("Control")`, i.e. a same-round jump, so `exec Control` is + correct and kept. */ + if (my_timer > 0) { step Idling; } else { exec Control; } + } + + state Moving_right { + if (my_timer > 0) { + /* BUG FIXED: was `exec Moving_right;`. the original's then-branch is + idles into `Moving_right` (tick-consuming == `step`); a same-round + `exec` self-loop would not terminate. */ + step Moving_right; + } else { + if (my_position == 1 || dist > safety_gap) { + intention' = FASTER; + my_move' = 0; + my_lane' = 0; + my_timer' = TIMER; + step Idling; + } else { + if (dist == safety_gap) { + intention' = IDLE; + my_move' = 0; + my_lane' = 0; + my_timer' = TIMER; + step Idling; + } else { + intention' = SLOWER; + my_move' = 0; + my_lane' = 0; + my_timer' = TIMER; + step Idling; + } + } + } + } + + state Moving_left { + if (my_timer > 0) { + /* BUG FIXED: was `exec Moving_left;`. the original's then-branch is + idles into `Moving_left` (tick-consuming == `step`); a same-round + `exec` self-loop would not terminate. */ + step Moving_left; + } else { + if (other_lane == 0 && my_position == -1) { + intention' = FASTER; + my_move' = 0; + my_lane' = 1; + my_timer' = TIMER; + step Idling; + } else { + intention' = SLOWER; + my_move' = 0; + my_lane' = 1; + my_timer' = TIMER; + step Idling; + } + } + } + } + init Control +} + +environment { + let + my_new_acc = + (intention == FASTER ? MAX_ACCELERATION - R[0,1]*FAST_OFFSET + : (intention == SLOWER ? -min(MAX_BRAKE, max(MIN_BRAKE, MAX_BRAKE - R[0,1]*SLOW_OFFSET)) + : R[0,1]*(2*IDLE_OFFSET) - IDLE_OFFSET)) + and + my_travel_x = (my_new_acc/2 + my_speed) * cos((PI/9)*my_move) + and + my_new_x = my_x + my_travel_x + and + my_new_y = min(8, max(0, my_y + (4/TIMER)*my_move)) + and + my_new_lane = (my_new_y >= 4 ? 1 : 0) + and + my_new_speed = min(max(0, my_speed + my_new_acc), MAX_SPEED) + and + token = R[0,1] + and + /* the reaction the "other" car takes if it decides this round (other_timer == 0) */ + other_decided_acc = + (other_lane == 1 + ? (dist > safety_gap || my_position == -1 + ? R[0,1]*(2*IDLE_OFFSET) - IDLE_OFFSET + : (dist > safety_gap + ? (token >= 0.50 ? MAX_ACCELERATION - R[0,1]*FAST_OFFSET + : (token >= 0.20 ? R[0,1]*(2*IDLE_OFFSET) - IDLE_OFFSET + : -(R[0,1]*(MAX_BRAKE-MIN_BRAKE)+MIN_BRAKE))) + : (my_position == 1 ? -(R[0,1]*(MAX_BRAKE-MIN_BRAKE)+MIN_BRAKE) : MAX_ACCELERATION - R[0,1]*FAST_OFFSET))) + : (dist > safety_gap + ? (token >= 0.50 ? MAX_ACCELERATION - R[0,1]*FAST_OFFSET + : (token >= 0.20 ? R[0,1]*(2*IDLE_OFFSET) - IDLE_OFFSET + : -(R[0,1]*(MAX_BRAKE-MIN_BRAKE)+MIN_BRAKE))) + : (my_position == 1 ? -(R[0,1]*(MAX_BRAKE-MIN_BRAKE)+MIN_BRAKE) : MAX_ACCELERATION - R[0,1]*FAST_OFFSET))) + and + other_decided_move = (other_lane == 1 && (dist > safety_gap || my_position == -1) ? LANE_RIGHT : 0) + and + new_other_acc = (other_timer == 0 ? other_decided_acc : other_acc) + and + new_other_move = + (other_timer == 0 + ? other_decided_move + : ((other_y >= 6 && other_move == LANE_LEFT) || (other_y <= 2 && other_move == LANE_RIGHT) ? 0 : other_move)) + and + new_other_timer = (other_timer == 0 ? TIMER - 1 : other_timer - 1) + and + other_new_speed = min(max(0, other_speed + new_other_acc), MAX_SPEED - 5) + and + other_travel_x = (new_other_acc/2 + other_new_speed) * cos((PI/9)*new_other_move) + and + other_new_x = other_x + other_travel_x + and + other_new_y = min(8, max(0, other_y + (4/TIMER)*new_other_move)) + and + other_new_lane = (other_new_y >= 4 ? 1 : 0) + and + new_dist = sqrt((other_new_x-my_new_x)^2 + (other_new_y-my_new_y)^2) + and + new_my_position = (my_new_x >= other_new_x ? 1 : -1) + and + new_safety_gap = (new_my_position == -1 ? rss_gap(my_new_speed, other_new_speed) : rss_gap(other_new_speed, my_new_speed)) + and + new_crash = + ((my_new_lane == other_new_lane && abs(my_new_x-other_new_x) <= VEHICLE_LENGTH) + || (!(my_new_lane == other_new_lane) && abs(my_new_x-other_new_x) <= VEHICLE_LENGTH && abs(my_new_y-other_new_y) <= VEHICLE_WIDTH) + ? 1 : crash) + in { + my_acc' = my_new_acc; + my_speed' = my_new_speed; + my_x' = my_new_x; + my_y' = my_new_y; + my_lane' = my_new_lane; + other_acc' = new_other_acc; + other_timer' = new_other_timer; + other_move' = new_other_move; + other_speed' = other_new_speed; + other_x' = other_new_x; + other_y' = other_new_y; + other_lane' = other_new_lane; + dist' = new_dist; + my_position' = new_my_position; + safety_gap' = new_safety_gap; + my_timer' = my_timer - 1; + crash' = new_crash; + } +} + +penalty rho_si = (crash == 1 ? 0.5*sqrt(my_speed*my_speed + other_speed*other_speed - 2*my_speed*other_speed)/MAX_SPEED : 0.0) +penalty rho_crash = crash + +distance atomic_si = < rho_si; +distance max_si = \G[0,H] atomic_si; +distance atomic_crash = < rho_crash; +distance max_crash = \G[0,H] atomic_crash; + +/* Only the directly-manipulated variables are perturbed; see file header + for why `other_speed`/`other_x`/`other_y`/`other_lane`/`dist`/ + `safety_gap`/`crash` are left to the following environment step. */ +perturbation p_reckless_driver = + ([other_move <- (R[0,1] > 0.4 ? (other_lane == 0 ? LANE_LEFT : LANE_RIGHT) : other_move), + other_acc <- (R[0,1] > 0.4 ? R[-IDLE_OFFSET, IDLE_OFFSET] : other_acc)]@2)^50; + +formula phi_si = \D[max_si, p_reckless_driver] <= ETA; +formula phi_crash = \D[max_crash, p_reckless_driver] <= ETA; +formula phi_combined = phi_si && phi_crash; +formula phi_safe = \G[0,100] phi_combined; diff --git a/examples/stark/agriculturalDT.stark b/examples/stark/agriculturalDT.stark new file mode 100644 index 00000000..ca3fc665 --- /dev/null +++ b/examples/stark/agriculturalDT.stark @@ -0,0 +1,168 @@ +/* + * Ported from the original `agriculturalDT` example: + * a tractor driving toward a fixed waypoint (FINAL_POSX, FINAL_POSY) under a + * heading/speed control law, sensing its own speed with noise. + * + * `evaluateDeltaZero` in the original returns both the speed and steering + * updates as a two-element array (computed together); STARK functions return + * a single value, so it's split into `eval_speed_zero`/`eval_steer_zero` + * below, each recomputing the shared terms — mirroring how the original + * itself evaluates the shared control law twice, once per update, rather + * than reusing one computed array. + * + * `dirAngleNoise`/`steerAngleNoise`/`speedNoise` are declared as state slots + * in the original but never read or assigned anywhere, so they're omitted + * here. Likewise `MIN_ACC`/`MAX_ACC`/`INIT_ACC` and an `acc` variable exist + * only in commented-out code in the original and are omitted. + * + * The asymmetric `centre` formula (the `if (eTheta>0)` branches differ only + * in whether the last denominator uses `sin(eTheta)` or `sin(eRT)`) is + * preserved exactly as written in the original — it reads like deliberate + * numerical-stability shaping from the control law's derivation, not a + * copy-paste mistake, so it isn't "fixed" here. + */ + +param L = 1.85; +param MAX_STEER_ANGLE = 0.3490658503988659; /* pi/9 */ +param MIN_SPEED = 0.0; +param MAX_SPEED = 3.0; +param TIME_OUT = 1; +param DIST_EPS = 0.1; +param DIR_EPS = 0.0; + +param INIT_POSX = 0.0; +param INIT_POSY = 0.0; +param INIT_DIRANGLE = 1.5707963267948966; /* pi/2 */ +param INIT_STEERANGLE = 0.0; +param INIT_SPEED = 0.0; +param FINAL_POSX = 30.0; +param FINAL_POSY = 42.0; +param INIT_DIST = sqrt((FINAL_POSX-INIT_POSX)*(FINAL_POSX-INIT_POSX) + (FINAL_POSY-INIT_POSY)*(FINAL_POSY-INIT_POSY)); +param FINAL_DIRANGLE = 1.0471975511965976; /* pi/3 */ +param FINAL_SPEED = 0.0; + +param Kx = 0.15; +param Kd = 1/INIT_DIST; +param Kl = 1.8; +param Ko = 8; +param Kt = 0.01; +param KRT = 0.6; + +function get_theta_rt(real x, real y, real dist) { + if (dist > DIST_EPS) { + return atan((FINAL_POSY - y) / (FINAL_POSX - x)); + } else { + return FINAL_DIRANGLE; + } +} + +function eval_speed_zero(real x, real y, real theta, real dist) { + let kd = (dist > DIST_EPS ? 1/dist : Kd) in + let ex = cos(theta)*(FINAL_POSX-x) + sin(theta)*(FINAL_POSY-y) in + let ey = -sin(theta)*(FINAL_POSX-x) + cos(theta)*(FINAL_POSY-y) in + let eTheta = FINAL_DIRANGLE - theta in + let thetaRT = get_theta_rt(x, y, dist) in + let eRT = FINAL_DIRANGLE - thetaRT in + let centre = (eTheta > 0 + ? Kt*tan(eTheta) + (kd*ey - Kl*dist*sin(eRT)*cos(eTheta))/(Ko*cos(eTheta)) + (KRT*sin(eRT)*sin(eRT)/(sin(eTheta)*cos(eTheta))) + : Kt*tan(eTheta) + (kd*ey - Kl*dist*sin(eRT)*cos(eTheta))/(Ko*cos(eTheta)) + (KRT*sin(eRT)*sin(eRT)/(sin(eRT)*cos(eTheta))) + ) in + return min(MAX_SPEED, max(MIN_SPEED, Kx * (kd*ex + Kl*dist*sin(eRT)*sin(eTheta) + Ko*sin(eTheta)*centre))); +} + +function eval_steer_zero(real x, real y, real theta, real dist) { + let kd = (dist > DIST_EPS ? 1/dist : Kd) in + let ey = -sin(theta)*(FINAL_POSX-x) + cos(theta)*(FINAL_POSY-y) in + let eTheta = FINAL_DIRANGLE - theta in + let thetaRT = get_theta_rt(x, y, dist) in + let eRT = FINAL_DIRANGLE - thetaRT in + let centre = (eTheta > 0 + ? Kt*tan(eTheta) + (kd*ey - Kl*dist*sin(eRT)*cos(eTheta))/(Ko*cos(eTheta)) + (KRT*sin(eRT)*sin(eRT)/(sin(eTheta)*cos(eTheta))) + : Kt*tan(eTheta) + (kd*ey - Kl*dist*sin(eRT)*cos(eTheta))/(Ko*cos(eTheta)) + (KRT*sin(eRT)*sin(eRT)/(sin(eRT)*cos(eTheta))) + ) in + return min(MAX_STEER_ANGLE, max(-MAX_STEER_ANGLE, atan(L*centre))); +} + +global variables { + real posX = INIT_POSX; + real posY = INIT_POSY; + real dirAngle = INIT_DIRANGLE; + real steerAngle = INIT_STEERANGLE; + real speed = INIT_SPEED; + real sensedSpeed = INIT_SPEED; + real dist_to_target = sqrt((FINAL_POSX-INIT_POSX)*(FINAL_POSX-INIT_POSX) + (FINAL_POSY-INIT_POSY)*(FINAL_POSY-INIT_POSY)); + real diffAngle = abs(FINAL_DIRANGLE - INIT_DIRANGLE); + int timer = 0; +} + +component Tractor { + variables { } + controller { + state Ctrl { + if (dist_to_target > DIST_EPS || diffAngle > DIR_EPS) { + speed' = eval_speed_zero(posX, posY, dirAngle, dist_to_target); + steerAngle' = eval_steer_zero(posX, posY, dirAngle, dist_to_target); + timer' = TIME_OUT; + step Idle; + } else { + speed' = eval_speed_zero(posX, posY, dirAngle, dist_to_target); + steerAngle' = eval_steer_zero(posX, posY, dirAngle, dist_to_target); + timer' = TIME_OUT; + step Stop; + } + } + state Idle { + if (timer > 0) { + step Idle; + } else { + /* BUG FIXED: was `step Ctrl;`. The original's `Idle` is + conditional: while `timer > 0` it idles back into `Idle`, + otherwise it continues into `Ctrl`. That else branch is a *bare* + reference, i.e. a same-tick jump (`exec`), not a time-consuming + `step`. As written, `step Ctrl` doubled the effective + control period (Ctrl ran every other round instead of resuming + immediately when the timer expired). Corrected to `exec Ctrl`, matching + the pre-existing toll.stark/two_vehicles.stark timer idiom. */ + exec Ctrl; + } + } + state Stop { + if (timer > 0) { + step Stop; + } else { + if (dist_to_target > DIST_EPS || diffAngle > DIR_EPS) { + speed' = eval_speed_zero(posX, posY, dirAngle, dist_to_target); + steerAngle' = eval_steer_zero(posX, posY, dirAngle, dist_to_target); + timer' = TIME_OUT; + step Idle; + } else { + speed' = 0; + steerAngle' = 0; + timer' = TIME_OUT; + step Stop; + } + } + } + } + init Ctrl +} + +environment { + let + newX = posX + speed * cos(dirAngle) + and + newY = posY + speed * sin(dirAngle) + and + newTheta = dirAngle + tan(steerAngle) * speed / L + and + newDist = sqrt((FINAL_POSX-newX)*(FINAL_POSX-newX) + (FINAL_POSY-newY)*(FINAL_POSY-newY)) + in { + posX' = newX; + posY' = newY; + dirAngle' = newTheta; + sensedSpeed' = min(MAX_SPEED, max(MIN_SPEED, speed + R[0,1] * 0.25 - 0.125)); + dist_to_target' = newDist; + diffAngle' = abs(FINAL_DIRANGLE - newTheta); + timer' = timer - 1; + } +} diff --git a/examples/stark/engine.stark b/examples/stark/engine.stark new file mode 100644 index 00000000..72eef08a --- /dev/null +++ b/examples/stark/engine.stark @@ -0,0 +1,211 @@ +param MIN_TEMP = 0; +param MAX_TEMP = 120; +param STRESS_INCR = 0.1; +param LOW = 0; +param HALF = 1; +param FULL = 2; +param OK = 0; +param HOT = 1; +param INITIAL_TEMP = 95.0; + +param TAU = 100; +param K = 100; +param H = 1000; + +param TEMP_OFFSET = -1.5; +param ETA_1 = 0.0; +param ETA_2 = 0.02; +param ETA_3 = 0.05; +param ETA_4 = 0.3; + +param COOL_ATTACK = 1.8; +param ZETA_1 = 0.5; +param ZETA_2 = 0.3; + +variables { + real p1 range [MIN_TEMP, MAX_TEMP] = INITIAL_TEMP; + real p2 range [MIN_TEMP, MAX_TEMP] = INITIAL_TEMP; + real p3 range [MIN_TEMP, MAX_TEMP] = INITIAL_TEMP; + real p4 range [MIN_TEMP, MAX_TEMP] = INITIAL_TEMP; + real p5 range [MIN_TEMP, MAX_TEMP] = INITIAL_TEMP; + real p6 range [MIN_TEMP, MAX_TEMP] = INITIAL_TEMP; + real stress range [0,1] = 0.0; + real temp range [MIN_TEMP, MAX_TEMP] = INITIAL_TEMP; + bool cool = false; + int speed = HALF; + real fn = 0.0; + int time_step = 0; +} + + +function temperatureUpdateInOneStep(bool cool, int speed) { + if (cool) { + return R[-1.2, -0.8]; + } else { + if (speed == LOW ){ + return R[0.1, 0.3]; + } else { + if (speed == HALF) { + return R[0.3, 0.7]; + } else { + return R[0.7, 1.2]; + } + } + } +} + +function partialStress (real a){ + if (a>100) { + return 1.0; + } else { + return 0.0; + } +} + +function isStressed (real a1, real a2, real a3, real a4, real a5, real a6) { + return partialStress(a1) + partialStress(a2) + partialStress(a3) + partialStress(a4) + partialStress(a5) + partialStress(a6); +} + +function pen_temp (real temperature1, real temperature2) { + return abs(temperature1 - temperature2)/abs(MAX_TEMP - MIN_TEMP); +} + + +function pert_cool (real temperature1, bool cooler) { + if (temperature1 >= 99.8-COOL_ATTACK) { + return cooler; + } else { + return false; + } +} + + + +component Engine{ + variables{ + real ch_temp range [MIN_TEMP, MAX_TEMP] = INITIAL_TEMP; + int ch_wrn = OK; + int ch_speed = HALF; + int ch_out = HALF; + int ch_in = HALF; + } + controller { + state Ctrl { + if (ch_temp >= 99.8) { + cool' = true; + step Cooling; + } else { + exec Check; + } + } + state Check { + if (ch_speed == LOW) { + speed' = LOW; + cool' = false; + step Ctrl; + } else { + speed' = ch_in; + cool' = false; + step Ctrl; + } + } + state Cooling { + 4#step Check; + } + state IDS { + if (temp>101.0 & !cool) { + ch_wrn' = HOT; + ch_speed' = LOW; + ch_out' = FULL; + step IDS; + } else { + ch_wrn' = OK; + ch_speed' = HALF; + ch_out' = HALF; + step IDS; + } + } + } + init Ctrl || IDS +} + +environment { + temp' = temp + temperatureUpdateInOneStep(cool, speed); + ch_temp' = ch_temp + temperatureUpdateInOneStep(cool, speed); + fn' = (time_step*fn + max(0.0, stress - ch_wrn))/(1+time_step); + time_step' = time_step + 1; + p1' = temp; + p2' = p1; + p3' = p2; + p4' = p3; + p5' = p4; + p6' = p5; + if (isStressed(p1,p2,p3,p4,p5,p6) > 3) { + stress' = stress + STRESS_INCR; + } +} + + + +penalty rho_temperature = pen_temp(temp,ch_temp) + +penalty rho_warning = ch_wrn + +penalty rho_stress = stress + +penalty rho_fn = fn + + + +distance expr_temperature = < rho_temperature; + +distance expr_warning = < rho_warning; + +distance expr_stress = < rho_stress; + +distance min_temperature = \F[TAU,TAU+K-1] expr_temperature; + +distance max_temperature = \G[TAU,TAU+K-1] expr_temperature; + +distance max_warning = \G[TAU,TAU+K+10] expr_warning; + +distance max_stress = \G[TAU,TAU+K+10] expr_stress; + +distance expr_false_negative = < rho_fn; + +distance condition_1 = expr_stress <= ZETA_1; + +distance condition_2 = expr_warning >= ZETA_2; + +distance until_dist = condition_1 \U[0,TAU+K+10] condition_2; + + + +perturbation fake_temperature = [ch_temp <- temp * TEMP_OFFSET * R[0,1]]@0; + +perturbation it_fake_temperature = [fn <- fn]@100;fake_temperature^K; + +perturbation fake_cooling = ([cool <- pert_cool(temp,cool)]@0)^K; + + + +formula phi_1 = \D[min_temperature,it_fake_temperature] >= ETA_1; + +formula phi_2 = \D[max_temperature,it_fake_temperature] <= ETA_2; + +formula phi_3 = \D[max_warning,it_fake_temperature] <= ETA_3; + +formula phi_4 = \D[max_stress,it_fake_temperature] > ETA_4; + +formula phi_5 = phi_1 && phi_2; + +formula phi_6 = phi_3 && phi_4; + +formula phi_7 = !phi_5 || phi_6; + +formula phi = \F[0,H] phi_7; + + +formula psi_1 = \D[until_dist, fake_cooling] < 1; + +formula psi = psi_1 \U[0,H] \D[expr_false_negative, fake_cooling] <= ETA_3; diff --git a/examples/stark/engine_full.stark b/examples/stark/engine_full.stark new file mode 100644 index 00000000..e821a53d --- /dev/null +++ b/examples/stark/engine_full.stark @@ -0,0 +1,226 @@ +/* + * Ported from the original `engine` example: a more + * elaborate variant of `engine.stark` (which came from `Engine.jspec`) — + * same P1..P6/stress/temp/cool/speed model, plus a false-negative/false-positive + * tracker (`fn`/`fp`) and richer ROBTL formulas (implication via De Morgan's + * law — this grammar has no `->` operator — and an until-distance property). + * + * `fn`/`fp` are ported exactly as the original computes them + * (`(counter*fn + max(0, stress-ch_wrn))/(counter+1)`, etc.), even though + * `ch_wrn` is one of the enum values OK=5/HOT=6 while `stress` ranges over + * [0,1] — subtracting a small [0,1] value from 5 or 6 makes `fn` always 0 + * and `fp` always ~5-6, which doesn't read as a meaningful false-negative + * rate. This looks like a latent bug in the original (perhaps meant to + * compare against a HOT/not-HOT indicator), but since — unlike the + * `accel`/`acc` mixups fixed elsewhere in these examples — there's no + * unambiguous evidence of what was intended, it's ported as written rather + * than "corrected" on a guess. + * + * The original's "assign, then continue" and "idle, then continue" forms + * are both STARK's time-consuming `step`; a bare controller reference with + * no action is an immediate `exec` (matching how `engine.stark` already + * treats the same `Ctrl -> Check` transition). The original's delayed, + * repeated perturbation (wait 100 steps, then iterate N times) is + * approximated as `[...]@100 ; ...` iterated `^N` — this grammar's + * `@time` already means "at this future time", so `@100` folds the two + * together. `perturbation_cool`'s conditional update + * (`if (temp >= threshold) leave cool unchanged else set cool <- OFF`) has + * no `when`-guarded perturbation assignment in this grammar, so it's + * expressed as an unconditional assignment to a ternary that only changes + * `cool` when the condition holds. The `Phi_003`..`Phi_006` threshold-sweep + * (same formula shape, four ETA values) and the three `sequence_pert_*` + * CSV/bootstrap sampling routines are omitted as pure parameter-sweep + * duplication of the `phi1_1_2_*` shape already ported below. + */ + +param ON = 0; +param OFF = 1; +param SLOW = 2; +param HALF = 3; +param FULL = 4; +param OK = 5; +param HOT = 6; +param LOW = 7; + +param MIN_TEMP = 0; +param MAX_TEMP = 150; +param STRESS_INCR = 0.1; + +param INITIAL_TEMP_VALUE = 95.0; +param N = 100; +param TAU = 100; +param K = TAU + N + 10; +param H = 1000; +param TEMP_OFFSET_1 = -1.0; +param TEMP_OFFSET_15 = -1.5; +param TEMP_OFFSET_2 = -2.0; +param COOL_OFFSET = 1.8; +param ETA1 = 0.0; +param ETA2 = 0.02; +param ETA3 = 0.05; +param ETA4 = 0.3; + +function is_stressing(real p1, real p2, real p3, real p4, real p5, real p6) { + return ((p1 >= 100 ? 1 : 0) + (p2 >= 100 ? 1 : 0) + (p3 >= 100 ? 1 : 0) + + (p4 >= 100 ? 1 : 0) + (p5 >= 100 ? 1 : 0) + (p6 >= 100 ? 1 : 0)) > 3; +} + +function next_temp(real temp, real variation) { + return max(MIN_TEMP, min(MAX_TEMP, temp + variation)); +} + +function temp_variation(real cool, real ch_speed) { + if (cool == ON) { + return -1.2 + R[0,1] * 0.4; + } else { + if (ch_speed == SLOW) { + return 0.1 + R[0,1] * 0.2; + } else { + if (ch_speed == HALF) { + return 0.3 + R[0,1] * 0.4; + } else { + return 0.7 + R[0,1] * 0.5; + } + } + } +} + +global variables { + real p1 range [MIN_TEMP, MAX_TEMP] = INITIAL_TEMP_VALUE; + real p2 range [MIN_TEMP, MAX_TEMP] = INITIAL_TEMP_VALUE; + real p3 range [MIN_TEMP, MAX_TEMP] = INITIAL_TEMP_VALUE; + real p4 range [MIN_TEMP, MAX_TEMP] = INITIAL_TEMP_VALUE; + real p5 range [MIN_TEMP, MAX_TEMP] = INITIAL_TEMP_VALUE; + real p6 range [MIN_TEMP, MAX_TEMP] = INITIAL_TEMP_VALUE; + real stress range [0,1] = 0.0; + real temp range [MIN_TEMP, MAX_TEMP] = INITIAL_TEMP_VALUE; + real ch_temp range [MIN_TEMP, MAX_TEMP] = INITIAL_TEMP_VALUE; + int cool = OFF; + int ch_speed = HALF; + int ch_wrn = OK; + int ch_in = HALF; + int ch_out = HALF; + real fn = 0.0; + real fp = 0.0; + real counter = 0.0; +} + +component Engine { + variables { } + controller { + state Ctrl { + if (ch_temp >= 99.8) { + cool' = ON; + step Cooling; + } else { + exec Check; + } + } + state Cooling { + 4#step Check; + } + state Check { + if (ch_speed == SLOW) { + ch_speed' = SLOW; + cool' = OFF; + step Ctrl; + } else { + ch_speed' = ch_in; + cool' = OFF; + step Ctrl; + } + } + state IDS { + if (temp > 101.0 && cool == OFF) { + ch_wrn' = HOT; + ch_speed' = LOW; + ch_out' = FULL; + step IDS; + } else { + ch_wrn' = OK; + ch_speed' = HALF; + ch_out' = HALF; + step IDS; + } + } + } + init Ctrl || IDS +} + +environment { + let + newTemp = next_temp(temp, temp_variation(cool, ch_speed)) + in { + p1' = temp; + p2' = p1; + p3' = p2; + p4' = p3; + p5' = p4; + p6' = p5; + if (is_stressing(p1,p2,p3,p4,p5,p6)) { + stress' = max(0.0, min(1.0, stress + STRESS_INCR)); + } + temp' = newTemp; + ch_temp' = newTemp; + fn' = (counter*fn + max(0.0, stress - ch_wrn)) / (counter+1); + fp' = (counter*fp + max(0.0, ch_wrn - stress)) / (counter+1); + counter' = counter + 1; + } +} + +penalty rho_temp_atomic = abs(temp - ch_temp) / abs(MAX_TEMP - MIN_TEMP) +penalty rho_warning = (ch_wrn == HOT ? 1.0 : 0.0) +penalty rho_stress = stress +penalty rho_fn = fn + +distance temp_atomic = < rho_temp_atomic; +distance warning_atomic = < rho_warning; +distance stress_atomic = < rho_stress; +distance false_negative = < rho_fn; + +distance temp_eventually = \F[TAU,TAU+N] temp_atomic; +distance temp_always = \G[TAU,TAU+N] temp_atomic; +distance warning_always = \G[TAU,TAU+N] warning_atomic; +distance stress_always = \G[TAU,TAU+N] stress_atomic; +distance until_distance = (stress_atomic < 0.3) \U[0,K] (warning_atomic > 0.1); + +perturbation p_temp_1 = ([ch_temp <- temp + R[0,1] * TEMP_OFFSET_1]@100)^N; +perturbation p_temp_15 = ([ch_temp <- temp + R[0,1] * TEMP_OFFSET_15]@100)^N; +perturbation p_temp_2 = ([ch_temp <- temp + R[0,1] * TEMP_OFFSET_2]@100)^N; +perturbation p_cool = ([cool <- (temp < 99.8 - COOL_OFFSET ? OFF : cool)]@0)^N; + +/* ROBTL formulas here (matching the original ANTLR grammar) have no + * parenthesized-grouping form, so `A && B` / `!A || B`-style composition is + * built by naming each sub-formula rather than parenthesizing inline + * (following `engine.stark`'s own `phi_5`/`phi_6`/`phi_7` convention). */ + +formula phi1_1_1_1 = \D[temp_eventually, p_temp_1] >= ETA1; +formula phi1_1_2_1 = \D[temp_always, p_temp_1] <= ETA2; +formula phi1_2_1_1 = \D[warning_always, p_temp_1] <= ETA3; +formula phi1_2_2_1 = \D[stress_always, p_temp_1] > ETA4; +formula phi1_1_lhs = phi1_1_1_1 && phi1_1_2_1; +formula phi1_1_rhs = phi1_2_1_1 && phi1_2_2_1; +formula phi1_1_impl = !phi1_1_lhs || phi1_1_rhs; +formula phi1_1 = \F[0,H] phi1_1_impl; + +formula phi1_1_1_15 = \D[temp_eventually, p_temp_15] >= ETA1; +formula phi1_1_2_15 = \D[temp_always, p_temp_15] <= ETA2; +formula phi1_2_1_15 = \D[warning_always, p_temp_15] <= ETA3; +formula phi1_2_2_15 = \D[stress_always, p_temp_15] > ETA4; +formula phi1_15_lhs = phi1_1_1_15 && phi1_1_2_15; +formula phi1_15_rhs = phi1_2_1_15 && phi1_2_2_15; +formula phi1_15_impl = !phi1_15_lhs || phi1_15_rhs; +formula phi1_15 = \F[0,H] phi1_15_impl; + +formula phi1_1_1_2 = \D[temp_eventually, p_temp_2] >= ETA1; +formula phi1_1_2_2 = \D[temp_always, p_temp_2] <= ETA2; +formula phi1_2_1_2 = \D[warning_always, p_temp_2] <= ETA3; +formula phi1_2_2_2 = \D[stress_always, p_temp_2] > ETA4; +formula phi1_2_lhs = phi1_1_1_2 && phi1_1_2_2; +formula phi1_2_rhs = phi1_2_1_2 && phi1_2_2_2; +formula phi1_2_impl = !phi1_2_lhs || phi1_2_rhs; +formula phi1_2 = \F[0,H] phi1_2_impl; + +formula phi2 = \D[until_distance, p_cool] < 1.0; +formula phi3_rhs = \D[false_negative, p_cool] <= ETA3; +formula phi3 = phi2 \U[0,K] phi3_rhs; diff --git a/examples/stark/envzompr.stark b/examples/stark/envzompr.stark new file mode 100644 index 00000000..4ec8d252 --- /dev/null +++ b/examples/stark/envzompr.stark @@ -0,0 +1,150 @@ +/* + * Ported from the original `envzompr` example: an + * 11-reaction, 8-species chemical reaction network (same Gillespie-SSA / + * no-`component` pattern as `isocitrate.stark` — see that + * file's header for the general approach: cumulative-weight thresholds + * against one `R[0,1]` draw standing in for Gillespie's weighted reaction + * choice, continuous reaction time not tracked). + * + * The original's extra `if (state.get(i) < r_input[j][i]) weight = 0` + * safeguard (don't let a reaction fire without enough reactant) is provably + * redundant here and so isn't ported: every reaction's stoichiometric input + * is 0 or 1, so a depleted reactant (count 0) already zeroes that reaction's + * weight through ordinary multiplication. + * + * As in `isocitrate.stark`, the six `addXY`/distance/robustness-formula + * variants differ only in perturbation parameters and an empirically-derived + * normalisation constant (`max/min of sampled YP` from an actual simulation + * run, which has no static equivalent) — only one representative + * perturbation/distance/formula is ported. + */ + +param H = 600; +param ETA = 0.15; +/* Placeholder: the original computes this as + max(sampled YP)*1.1 - min(sampled YP)*0.9 after running the simulation; + there's no static equivalent here. */ +param NORMALISATION = 50.0; + +global variables { + real X = 25.0; + real Y = 150.0; + real XT = 0.0; + real XP = 0.0; + real XPY = 0.0; + real YP = 10.0; + real XDYP = 0.0; + real XD = 50.0; +} + +environment { + let + w1 = 0.5 * XD + and + w2 = 0.5 * X + and + w3 = 0.5 * XT + and + w4 = 0.5 * X + and + w5 = 0.1 * XT + and + w6 = 0.02 * Y * XP + and + w7 = 0.5 * XPY + and + w8 = 0.5 * XPY + and + w9 = 0.02 * YP * XD + and + w10 = 0.5 * XDYP + and + w11 = 0.1 * XDYP + and + total = w1+w2+w3+w4+w5+w6+w7+w8+w9+w10+w11 + and + threshold = R[0,1] * total + in { + if (total > 0) { + if (threshold <= w1) { + /* r1: XD -> X */ + XD' = XD - 1; + X' = X + 1; + } else { + if (threshold <= w1+w2) { + /* r2: X -> XD */ + X' = X - 1; + XD' = XD + 1; + } else { + if (threshold <= w1+w2+w3) { + /* r3: XT -> X */ + XT' = XT - 1; + X' = X + 1; + } else { + if (threshold <= w1+w2+w3+w4) { + /* r4: X -> XT */ + X' = X - 1; + XT' = XT + 1; + } else { + if (threshold <= w1+w2+w3+w4+w5) { + /* r5: XT -> XP */ + XT' = XT - 1; + XP' = XP + 1; + } else { + if (threshold <= w1+w2+w3+w4+w5+w6) { + /* r6: Y + XP -> XPY */ + Y' = Y - 1; + XP' = XP - 1; + XPY' = XPY + 1; + } else { + if (threshold <= w1+w2+w3+w4+w5+w6+w7) { + /* r7: XPY -> Y + XP */ + XPY' = XPY - 1; + Y' = Y + 1; + XP' = XP + 1; + } else { + if (threshold <= w1+w2+w3+w4+w5+w6+w7+w8) { + /* r8: XPY -> X + YP */ + XPY' = XPY - 1; + X' = X + 1; + YP' = YP + 1; + } else { + if (threshold <= w1+w2+w3+w4+w5+w6+w7+w8+w9) { + /* r9: YP + XD -> XDYP */ + YP' = YP - 1; + XD' = XD - 1; + XDYP' = XDYP + 1; + } else { + if (threshold <= w1+w2+w3+w4+w5+w6+w7+w8+w9+w10) { + /* r10: XDYP -> YP + XD */ + XDYP' = XDYP - 1; + YP' = YP + 1; + XD' = XD + 1; + } else { + /* r11: XDYP -> Y + XD */ + XDYP' = XDYP - 1; + Y' = Y + 1; + XD' = XD + 1; + } + } + } + } + } + } + } + } + } + } + } + } +} + +penalty rho_yp = YP / NORMALISATION + +distance atomic_yp = < rho_yp; +distance distance_yp = \G[200, 600] atomic_yp; + +perturbation p_XY = [X <- 10, Y <- 50]@0; + +formula robustness = \D[distance_yp, p_XY] <= ETA; +formula always_robustness = \G[0,300] robustness; diff --git a/examples/stark/isocitrate.stark b/examples/stark/isocitrate.stark new file mode 100644 index 00000000..e4b789a9 --- /dev/null +++ b/examples/stark/isocitrate.stark @@ -0,0 +1,122 @@ +/* + * Ported from the original `Isocitrate` example: + * the isocitrate dehydrogenase regulatory network (IDHKPIDH) of E. Coli, + * simulated as a chemical reaction network via the Gillespie stochastic + * simulation algorithm (SSA), asking whether species I is robust to + * perturbing the initial amounts of E and Ip. + * + * The original is a timed system with no decision-making controller at + * all, tracking continuous reaction time between events; this grammar has + * no continuous-time concept and no way to omit a controller, only a + * discrete + * step per `environment` block, so each step here is one Gillespie reaction + * event and the real-valued time-between-reactions is not tracked — this + * matches `random_walk.stark`'s pattern of a spec with no `component` at + * all, just `variables` + `environment`. + * + * Gillespie's weighted reaction choice (pick reaction `j` with probability + * proportional to its propensity `lambda[j]`) has no direct STARK construct, + * so it's built from cumulative-weight thresholds compared against one + * `R[0,1]` draw — nested `if`/`else` narrowing down which reaction fired, + * each branch applying only that reaction's net stoichiometry change (an + * unassigned variable keeps its previous value, exactly as a variable the + * original leaves out of a step's update list does). + * + * The original draws each species' initial amount randomly + * (a uniform draw scaled to 100); ported as fixed values since variable + * initializers can't be random here either. + * + * The original evaluates six perturbations (`pertEandIp` with six different + * (x,y) pairs), each against its own empirically-derived normalisation + * constant (`max(sampled I values) * 1.1`, computed by actually running the + * simulation first) — there is no static equivalent for "run a simulation + * and observe its max" without an evaluator, so only one representative + * perturbation/distance/formula is ported, with a placeholder normalisation + * constant, rather than guessing at five more empirical constants. + */ + +param THRESHOLD = 0.03; +param LEFT_BOUND = 400; +param RIGHT_BOUND = 1000; +/* Placeholder: the original computes this as max(sampled I values) * 1.1 + after running the simulation; there's no static equivalent here. */ +param NORMALISATION = 100.0; + +global variables { + real E = 50.0; + real I = 50.0; + real Ip = 50.0; + real EIp = 50.0; + real EIpI = 50.0; +} + +environment { + let + w1 = 0.02 * E * Ip + and + w2 = 0.5 * EIp + and + w3 = 0.5 * EIp + and + w4 = 0.02 * I * EIp + and + w5 = 0.5 * EIpI + and + w6 = 0.1 * EIpI + and + total = w1 + w2 + w3 + w4 + w5 + w6 + and + threshold = R[0,1] * total + in { + if (total > 0) { + if (threshold <= w1) { + /* r1: E + Ip -> EIp */ + E' = E - 1; + Ip' = Ip - 1; + EIp' = EIp + 1; + } else { + if (threshold <= w1 + w2) { + /* r2: EIp -> E + Ip */ + EIp' = EIp - 1; + E' = E + 1; + Ip' = Ip + 1; + } else { + if (threshold <= w1 + w2 + w3) { + /* r3: EIp -> E + I */ + EIp' = EIp - 1; + E' = E + 1; + I' = I + 1; + } else { + if (threshold <= w1 + w2 + w3 + w4) { + /* r4: I + EIp -> EIpI */ + I' = I - 1; + EIp' = EIp - 1; + EIpI' = EIpI + 1; + } else { + if (threshold <= w1 + w2 + w3 + w4 + w5) { + /* r5: EIpI -> I + EIp */ + EIpI' = EIpI - 1; + I' = I + 1; + EIp' = EIp + 1; + } else { + /* r6: EIpI -> Ip + EIp */ + EIpI' = EIpI - 1; + Ip' = Ip + 1; + EIp' = EIp + 1; + } + } + } + } + } + } + } +} + +penalty rho_I = I / NORMALISATION + +distance atomic_I = < rho_I; +distance distance_I = \G[LEFT_BOUND, RIGHT_BOUND] atomic_I; + +perturbation p_E_Ip = [E <- 0.001, Ip <- 100]@0; + +formula rob_E_Ip = \D[distance_I, p_E_Ip] <= THRESHOLD; diff --git a/examples/stark/lotka.stark b/examples/stark/lotka.stark new file mode 100644 index 00000000..46de1b7a --- /dev/null +++ b/examples/stark/lotka.stark @@ -0,0 +1,73 @@ +/* + * Ported from the original `lotka` example: the + * classic Lotka autocatalytic reactions (same Gillespie-SSA pattern as + * `isocitrate.stark`/`envzompr.stark` — see `isocitrate.stark`'s header for + * the general approach): + * X + Y1 --c1--> 2Y1 + * Y1 + Y2 --c2--> 2Y2 + * Y2 --c3--> Z + * X's count never actually changes (net stoichiometry 0 in the only + * reaction it takes part in) — matching the original's comment that X's + * depletion is treated as insignificant — so `X` needs no update at all in + * the environment block. + * + * The original computes raw distance *values* for plotting, without ever + * declaring an actual formula or threshold, so there's no + * formula to port either — just the reaction network, one representative + * perturbation (`pertY1`, halving Y1's population partway through), and the + * two atomic distances it measures (again with a placeholder normalisation + * constant in place of the original's empirically-sampled one). + */ + +param N = 300; + +/* Placeholder: the original computes these as max(sampled Y1/Y2)*1.1 after + running the simulation; there's no static equivalent here. */ +param NORMALISATION_Y1 = 1200.0; +param NORMALISATION_Y2 = 1200.0; + +global variables { + real X = 1000.0; + real Y1 = 1000.0; + real Y2 = 1000.0; + real Z = 0.0; +} + +environment { + let + w1 = 0.01 * X * Y1 + and + w2 = 0.01 * Y1 * Y2 + and + w3 = 10.0 * Y2 + and + total = w1 + w2 + w3 + and + threshold = R[0,1] * total + in { + if (total > 0) { + if (threshold <= w1) { + /* r1: X + Y1 -> 2Y1 (X's count is unchanged) */ + Y1' = Y1 + 1; + } else { + if (threshold <= w1 + w2) { + /* r2: Y1 + Y2 -> 2Y2 */ + Y1' = Y1 - 1; + Y2' = Y2 + 1; + } else { + /* r3: Y2 -> Z */ + Y2' = Y2 - 1; + Z' = Z + 1; + } + } + } + } +} + +penalty rho_Y1 = Y1 / NORMALISATION_Y1 +penalty rho_Y2 = Y2 / NORMALISATION_Y2 + +distance atomic_Y1 = < rho_Y1; +distance atomic_Y2 = < rho_Y2; + +perturbation p_cut_Y1 = [Y1 <- Y1 * 0.5]@(N/2); diff --git a/examples/stark/monitoring.stark b/examples/stark/monitoring.stark new file mode 100644 index 00000000..7b8257da --- /dev/null +++ b/examples/stark/monitoring.stark @@ -0,0 +1,39 @@ +/* + * Ported from the original `monitoring` example. + * + * The original demonstrates STARK's *online monitoring* framework (the + * online-monitoring framework of the original library): it builds a + * unbounded-until monitoring formula ("eventually the observed x gets within 0 of a moving + * target") and evaluates it directly against sampled observations of a + * single running system. That is a different verification approach from + * this grammar's `formula`/`distance`/`perturbation` (ROBTL) declarations, + * which compare a *nominal* evolution sequence against a *perturbed* one via + * a distance metric — there is no textual-STARK equivalent for such a + * monitor, so only the underlying stochastic process model is ported here. + * + * The original also draws x's initial value randomly + * (`myGaussian.apply(rg)`); STARK variable initializers can't be random (no + * example in this grammar's own corpus needs that either), so `x` starts at + * the distribution's mean (0.5) instead. + */ + +global variables { + real t = 0; + real x = 0.5; +} + +component Monitor { + variables { } + controller { + state Ctrl { + step Ctrl; + } + } + init Ctrl +} + +environment { + t' = t + 1; + /* myGaussian = rg.nextGaussian()/3 + 0.5, i.e. N(mean=0.5, variance=(1/3)^2). */ + x' = N[0.5, (1.0/3.0)^2] + (1 - 1/(t+1)); +} diff --git a/examples/stark/multiscler.stark b/examples/stark/multiscler.stark new file mode 100644 index 00000000..73ead92e --- /dev/null +++ b/examples/stark/multiscler.stark @@ -0,0 +1,145 @@ +/* + * Ported from the original `multiScler` example: an + * ODE-based model (explicit Euler integration, step size `delta_t`) of + * effector/regulatory T-cell dynamics in multiple sclerosis, with a + * controller that injects resting regulatory T cells when the + * effector/regulatory ratio exceeds 10. + * + * The original's monitor-based robustness analysis (everything past the + * CSV export in its entry point) is already commented out upstream — dead + * code, not just untranslatable — so nothing working is being left out by + * omitting it here. + * + * The original runs three variants (healthy `alphaR=alphaRH`, sick + * `alphaR=alphaRS` with the controller active, and sick with no controller + * for comparison); only the controlled "sick" variant + * (`systemS5`, `var=5`) is ported, since it's the one that actually + * exercises the controller logic. + * + * uniform noise in `[-var, var]` is + * simplified to the equivalent `R[-VAR, VAR]` rather than spelling out the + * scaling from `R[0,1]`. + */ + +param ETA = 0.01; +param DELTA = 1.0; +param BETA = 0.01; +param ALPHA_E = 2.0; +param ALPHA_R_HEALTHY = 1.0; +param ALPHA_R_SICK = 0.25; +param GAMMA_E = 0.2; +param GAMMA_R = 0.2; +param K_E = 1000.0; +param K_R = 200.0; +param D1 = 1.0; +param D2 = 0.02; +param RECOVERY = 0.1; +param A = 22800.0; +param E_INIT = 1000.0; +param R_INIT = 200.0; +param HILL = 5.0; +param DELTA_T = 0.0001; +param VAR = 5; + +global variables { + real E = E_INIT; + real Er = 0.0; + /* Named `Ra` (active Treg), not `R` — `R` is this grammar's random-value + keyword and can't be used as an identifier. */ + real Ra = R_INIT; + real Rr = 0.0; + real Ea = (E_INIT / A)^2; + real l = 0.0; + real L = 0.0; + real ratioER = E_INIT / R_INIT; + real v_eta = ETA; + real v_delta = DELTA; + real v_beta = BETA; + real v_gammaE = GAMMA_E; + real v_gammaR = GAMMA_R; + real v_d1 = D1; + real v_d2 = D2; + real v_r = RECOVERY; + real alphaR = ALPHA_R_SICK; + real timer = 0.0; + real uncertainty = 1.0; + real flag = 0.0; + real flag2 = 0.0; + real Rgen = 0.0; + real wait_month = 0.0; + real wait_week = 0.0; +} + +component MS { + variables { } + controller { + state Ctrl { + /* GAP (approximation, not a grammar limitation): in the original's controller + each branch sets `wait_month=1` / `wait_week=1`, and `selectTime` reads + those flags to call `ds.setCtrlGranularity(30)` (a month after an + injection) or `ds.setCtrlGranularity(7)` (a week otherwise), i.e. the + controller re-evaluates every 30 vs 7 environment time-units depending + on the branch taken. That adaptive controller granularity is expressible + in this grammar via the `Expression # step` form (`30 # step Ctrl;` / + `7 # step Ctrl;`) but was NOT ported: both branches use a plain + `step Ctrl` (granularity 1). Consequently the `wait_month'`/`wait_week'` + assignments below set variables that nothing ever reads (dead), and the + nominal spec injects/steps the controller far more often than the + original does. */ + if (ratioER > 10) { + Rr' = Rr + 1000 + R[-10,10]; + flag' = flag + 1; + flag2' = flag2 + 1; + wait_month' = 1; + step Ctrl; + } else { + flag2' = flag2 + 1; + wait_week' = 1; + step Ctrl; + } + } + } + init Ctrl +} + +environment { + let + ie = (R[0,1] < 100*DELTA_T/365.0 ? 100.0/DELTA_T : 0.0) + and + ir = (R[0,1] < 100*DELTA_T/365.0 ? 100.0/DELTA_T : 0.0) + and + new_Er = Er + (ie - Er*v_delta - Er*v_beta + E*v_eta) * DELTA_T + and + new_Rr = Rr + (ir - Rr*v_delta - Rr*v_beta + Ra*v_eta) * DELTA_T + and + new_E = E + (Er*v_delta - E*v_eta + E*(ALPHA_E*K_R^HILL - v_gammaE*Ra^HILL)/(K_R^HILL+Ra^HILL)) * DELTA_T + and + new_R = Ra + (Rr*v_delta - Ra*v_eta + Ra*alphaR*E^HILL/(K_E^HILL+E^HILL) - Ra*v_gammaR) * DELTA_T + and + new_Ea = (E/A)^2 + and + new_l = l + (new_Ea*v_d1 - l*v_r - l*v_d2) * DELTA_T + and + new_L = L + l*v_d2*DELTA_T + in { + Er' = new_Er; + Rr' = new_Rr; + E' = new_E; + Ra' = new_R; + Ea' = new_Ea; + l' = new_l; + L' = new_L; + ratioER' = new_E / new_R; + if (timer >= 1) { + timer' = 0.0; + v_eta' = v_eta + R[-VAR,VAR]*v_eta/100.0; + v_delta' = v_delta + R[-VAR,VAR]*v_delta/100.0; + v_beta' = v_beta + R[-VAR,VAR]*v_beta/100.0; + v_d1' = v_d1 + R[-VAR,VAR]*v_d1/100.0; + v_d2' = v_d2 + R[-VAR,VAR]*v_d2/100.0; + v_r' = v_r + R[-VAR,VAR]*v_r/100.0; + } else { + timer' = timer + DELTA_T; + } + } +} diff --git a/examples/stark/polistil.stark b/examples/stark/polistil.stark new file mode 100644 index 00000000..ef3b4d6a --- /dev/null +++ b/examples/stark/polistil.stark @@ -0,0 +1,234 @@ +/* + * Ported from the original `polistil` example: a + * car navigating a curved figure-eight-style track by waypoint quadrant + * (`wp_i % 4`), choosing a random speed when not braking into a turn, with + * a "gone off-track, wait, then recover" state. + * + * The original models a second car (`your_*`/`wp_j`/`EnvironmentRace`, + * mirroring car 1's exact logic with a `SHIFT_X` offset) and a "siblings" + * variant (the same per-car mechanic with a fixed `CURVE` speed instead of + * the random choice). Both are structurally identical extensions of the + * single-car mechanic ported here, so only one car (`EnvironmentSingle`) is + * ported, to avoid mechanically duplicating ~150 lines of quadrant branching + * a second time for the same mechanic. + * + * The original's nested probabilistic choice picks A with probability 1/3, + * else B or C with probability 1/2 of the remaining 2/3 each — i.e. a + * uniform choice among three options — so it maps directly to + * `U[2.7, 2.8, 2.9]` rather than needing probabilistic-choice combinators + * this grammar doesn't have. + * + * The robustness analysis at the end of the original (`phi_out_1`, + * `phi_out_2`, `phi_speed_1`, `phi_speed_2`) is the same untranslatable + * online-monitoring formalism discussed in `monitoring.stark`, and is not + * ported. + * + * The original sets `out' <- 1.0` inside the quadrant branch when the car is + * going too fast into a turn, but *also* unconditionally sets `out' <- 0.0` + * at the very end whenever `back == 0` (checked against the *pre-step* + * value) — whichever assignment an evaluator applies last for the same + * variable in one step wins, and it's genuinely ambiguous from reading the + * original alone which is intended to take precedence in the step where the + * car first goes out of bounds. Ported as-written (same assignments, same + * order) rather than guessing at a fix. + */ + +param PI = 3.141592653589793; +param FULL = 4.0; +param CURVE = 3.0; +param MINIMAL = 2.0; +param NEUTRAL = 0.0; +param TIMER = 0.5*PI/(9*CURVE); +param BACK_ON_TRACK = 5; +param INIT_X = 0.0; +param INIT_Y = 0.0; +param INIT_THETA = (3.0/4)*PI; +param CX = 0.0; +param CY = sqrt(2.0); +param RAD = 180/PI; +param H = 500; + +global variables { + real my_x = INIT_X; + real my_y = INIT_Y; + real my_theta = INIT_THETA; + real my_speed = NEUTRAL; + real curve_theta = (5.0/4)*PI; + int wp_i = 0; + real out = 0.0; + real back = 0.0; +} + +component Car { + variables { } + controller { + state Ctrl { + if (out == 1.0) { + my_speed' = NEUTRAL; + back' = BACK_ON_TRACK; + step Stop; + } else { + if (wp_i % 4 == 0 || wp_i % 4 == 2) { + my_speed' = max(MINIMAL, min(FULL, (CY/2 - abs(my_x)) / (TIMER * abs(cos(my_theta))))); + step Ctrl; + } else { + my_speed' = U[2.7, 2.8, 2.9]; + step Ctrl; + } + } + } + state Stop { + if (back > 0.0) { + step Stop; + } else { + /* BUG FIXED: was `step Ctrl;`. the original's `Stop` is + conditional: while `back > 0` it idles back into `Stop`, otherwise + it continues into `Ctrl`. That else branch is a bare reference — a + same-tick `exec`, not a `step` (which added a spurious idle round + before the car resumed control once it was back on track). */ + exec Ctrl; + } + } + } + init Ctrl +} + +environment { + let + speed = (my_speed == FULL || my_speed == NEUTRAL) ? my_speed : max(0.0, min(FULL, my_speed + R[0,1]*0.1 - 0.05)) + in { + my_speed' = speed; + if (wp_i % 4 == 0) { + let + partial_x = my_x + speed*TIMER*cos(my_theta) + and + partial_y = my_y + speed*TIMER*sin(my_theta) + in { + if (partial_x < -CY/2) { + let + extra = abs(partial_x) - CY/2 + and + new_timer = abs(extra / (speed * cos(my_theta))) + and + extra_theta = speed * new_timer + and + new_theta = curve_theta - extra_theta + in { + my_x' = cos(new_theta) + CX; + my_y' = sin(new_theta) + CY; + curve_theta' = new_theta; + wp_i' = wp_i + 1; + my_theta' = (5.0/4)*PI; + if (speed > CURVE) { + out' = 1.0; + } + } + } else { + my_x' = partial_x; + my_y' = partial_y; + curve_theta' = curve_theta; + } + } + } else { + if (wp_i % 4 == 2) { + let + partial_x = my_x + speed*TIMER*cos(my_theta) + and + partial_y = my_y + speed*TIMER*sin(my_theta) + in { + if (partial_x < -CY/2) { + let + extra = abs(partial_x) - CY/2 + and + new_timer = abs(extra / (speed * cos(my_theta))) + and + extra_theta = speed * new_timer + and + new_theta = curve_theta + extra_theta + in { + my_x' = cos(new_theta) + CX; + my_y' = sin(new_theta) - CY; + curve_theta' = new_theta; + wp_i' = wp_i + 1; + my_theta' = (3.0/4)*PI; + if (speed > CURVE) { + out' = 1.0; + } + } + } else { + my_x' = partial_x; + my_y' = partial_y; + curve_theta' = curve_theta; + } + } + } else { + if (wp_i % 4 == 1) { + if (speed > CURVE) { + out' = 1.0; + } + let + partial_theta = curve_theta - speed*TIMER + and + partial_x = cos(partial_theta) + CX + and + partial_y = sin(partial_theta) + CY + in { + if (partial_x >= 0.0 && partial_x < CY/2 && partial_y < CY/2) { + let + extra_theta = abs(partial_theta) - PI/4 + and + new_timer = extra_theta / (speed * RAD) + and + done_theta = curve_theta - speed*TIMER + extra_theta + in { + my_x' = cos(done_theta) + CX + speed*new_timer*cos(my_theta); + my_y' = sin(done_theta) + CY + speed*new_timer*sin(my_theta); + curve_theta' = (3.0/4)*PI; + wp_i' = wp_i + 1; + } + } else { + my_x' = partial_x; + my_y' = partial_y; + curve_theta' = partial_theta; + } + } + } else { + if (speed > CURVE) { + out' = 1.0; + } + let + partial_theta = curve_theta + speed*TIMER + and + partial_x = cos(partial_theta) + CX + and + partial_y = sin(partial_theta) - CY + in { + if (partial_x >= 0.0 && partial_x < CY/2 && partial_y > -CY/2) { + let + extra_theta = abs(PI/4 - partial_theta) + and + new_timer = extra_theta / (speed * RAD) + and + done_theta = curve_theta + speed*TIMER - extra_theta + in { + my_x' = cos(done_theta) + CX + speed*new_timer*cos(my_theta); + my_y' = sin(done_theta) - CY + speed*new_timer*sin(my_theta); + curve_theta' = (5.0/4)*PI; + wp_i' = wp_i + 1; + } + } else { + my_x' = partial_x; + my_y' = partial_y; + curve_theta' = partial_theta; + } + } + } + } + } + if (back == 0.0) { + out' = 0.0; + } else { + back' = back - 1; + } + } +} diff --git a/examples/stark/polistil_race.stark b/examples/stark/polistil_race.stark new file mode 100644 index 00000000..ba7c08de --- /dev/null +++ b/examples/stark/polistil_race.stark @@ -0,0 +1,435 @@ +/* + * Ported from the original `polistil` example's + * `EnvironmentRace`/`getCar_1`/`getCar_2` (the "race" scenario): the same + * curved-track car from `polistil.stark`, run as two structurally identical, + * fully independent cars side by side (`my_*`/car 1 and `your_*`/car 2, the + * second shifted by `SHIFT_X` along the track) — there is no actual + * car-to-car interaction anywhere in `EnvironmentRace` despite the "race" + * name, so this really is the "just a bigger copy" case, unlike + * `abz2025_one_lane_three_cars.stark`'s genuine 3-way chained interaction. + * + * `EnvironmentRace` is *not* byte-for-byte identical to `EnvironmentSingle` + * (the function ported as `polistil.stark`), though: it checks `out`/`back` + * *first* and skips all physics entirely while a car is recovering (in + * `EnvironmentSingle`, the car keeps moving even while `out == 1`, since the + * physics block runs unconditionally and the `out'`/`back'` reset only + * happens at the very end) — and, when a car is going too fast at a + * quadrant boundary, `EnvironmentRace` freezes it at the track wall (or in + * place, for the two non-corner quadrants) rather than still computing the + * smooth curve-entry position alongside setting `out' = 1`. This actually + * *resolves* the ambiguity `polistil.stark`'s header flags for + * `EnvironmentSingle` (which assignment wins when both `out' <- 1` and a + * moved position are set in the same step) — `EnvironmentRace` picks + * "freeze, don't move" — but that's independent, later-written code in the + * original, not a fix applied to `EnvironmentSingle` itself, so + * `polistil.stark` is left as-is; this file follows `EnvironmentRace`'s own + * (different, but internally consistent) behaviour. + * + * The "siblings" scenario (`sib_1`/`sib_2`, reusing `EnvironmentRace`) is a + * genuinely different controller, not a scale variant: it replaces the + * random `U[2.7,2.8,2.9]` curve speed with a fixed deterministic `CURVE` + * speed. It isn't ported here. + */ + +param PI = 3.141592653589793; +param FULL = 4.0; +param CURVE = 3.0; +param MINIMAL = 2.0; +param NEUTRAL = 0.0; +param TIMER = 0.5*PI/(9*CURVE); +param BACK_ON_TRACK = 5; +param INIT_X = 0.0; +param INIT_Y = 0.0; +param INIT_THETA = (3.0/4)*PI; +param CX = 0.0; +param CY = sqrt(2.0); +param RAD = 180/PI; +param SHIFT_X = 0.3; +param SHIFT_Y = 0.0; +param H = 500; + +global variables { + real my_x = INIT_X; + real my_y = INIT_Y; + real my_theta = INIT_THETA; + real my_speed = NEUTRAL; + real curve_theta = (5.0/4)*PI; + int wp_i = 0; + real out = 0.0; + real back = 0.0; + + real your_x = INIT_X + SHIFT_X; + real your_y = INIT_Y + SHIFT_Y; + real your_theta = INIT_THETA; + real your_speed = NEUTRAL; + real your_curve_theta = (5.0/4)*PI; + int wp_j = 0; + real you_out = 0.0; + real you_back = 0.0; +} + +component Car1 { + variables { } + controller { + state Ctrl { + if (out == 1.0) { + my_speed' = NEUTRAL; + back' = BACK_ON_TRACK; + step Stop; + } else { + if (wp_i % 4 == 0 || wp_i % 4 == 2) { + my_speed' = max(MINIMAL, min(FULL, (CY/2 - abs(my_x)) / (TIMER * abs(cos(my_theta))))); + step Ctrl; + } else { + my_speed' = U[2.7, 2.8, 2.9]; + step Ctrl; + } + } + } + state Stop { + if (back > 0.0) { + step Stop; + } else { + /* BUG FIXED: was `step Ctrl;`. the original's first car `Stop` is + conditional: while `back > 0` it idles back into `Stop`, otherwise + it continues into `Ctrl`. That else branch is a bare reference — a + same-tick `exec`, not a time-consuming `step`. */ + exec Ctrl; + } + } + } + init Ctrl +} + +component Car2 { + variables { } + controller { + state Ctrl2 { + if (you_out == 1.0) { + your_speed' = NEUTRAL; + you_back' = BACK_ON_TRACK; + step Stop2; + } else { + if (wp_j % 4 == 0 || wp_j % 4 == 2) { + your_speed' = max(MINIMAL, min(FULL, (CY/2 - SHIFT_X - abs(your_x)) / (TIMER * abs(cos(your_theta))))); + step Ctrl2; + } else { + your_speed' = U[2.7, 2.8, 2.9]; + step Ctrl2; + } + } + } + state Stop2 { + if (you_back > 0.0) { + step Stop2; + } else { + /* BUG FIXED: was `step Ctrl2;`. the original's second car `Stop2` else is a bare + `reference("Ctrl2")` = same-tick `exec`, not a `step`. */ + exec Ctrl2; + } + } + } + init Ctrl2 +} + +environment { + if (out == 1.0) { + if (back - 1 == 0) { + out' = 0.0; + } + back' = back - 1; + } else { + let + speed = (my_speed == FULL || my_speed == NEUTRAL) ? my_speed : max(0.0, min(FULL, my_speed + R[0,1]*0.1 - 0.05)) + in { + my_speed' = speed; + if (wp_i % 4 == 0) { + let + partial_x = my_x + speed*TIMER*cos(my_theta) + and + partial_y = my_y + speed*TIMER*sin(my_theta) + in { + if (partial_x < -CY/2) { + if (speed > CURVE) { + my_x' = -CY/2; + my_y' = CY/2; + wp_i' = wp_i + 1; + my_theta' = (5.0/4)*PI; + out' = 1.0; + } else { + let + extra = abs(partial_x) - CY/2 + and + new_timer = abs(extra / (speed * cos(my_theta))) + and + extra_theta = speed * new_timer + and + new_theta = curve_theta - extra_theta + in { + my_x' = cos(new_theta) + CX; + my_y' = sin(new_theta) + CY; + curve_theta' = new_theta; + wp_i' = wp_i + 1; + my_theta' = (5.0/4)*PI; + } + } + } else { + my_x' = partial_x; + my_y' = partial_y; + } + } + } else { + if (wp_i % 4 == 2) { + let + partial_x = my_x + speed*TIMER*cos(my_theta) + and + partial_y = my_y + speed*TIMER*sin(my_theta) + in { + if (partial_x < -CY/2) { + if (speed > CURVE) { + my_x' = -CY/2; + my_y' = -CY/2; + wp_i' = wp_i + 1; + my_theta' = (3.0/4)*PI; + out' = 1.0; + } else { + let + extra = abs(partial_x) - CY/2 + and + new_timer = abs(extra / (speed * cos(my_theta))) + and + extra_theta = speed * new_timer + and + new_theta = curve_theta + extra_theta + in { + my_x' = cos(new_theta) + CX; + my_y' = sin(new_theta) - CY; + curve_theta' = new_theta; + wp_i' = wp_i + 1; + my_theta' = (3.0/4)*PI; + } + } + } else { + my_x' = partial_x; + my_y' = partial_y; + } + } + } else { + if (wp_i % 4 == 1) { + if (speed > CURVE) { + out' = 1.0; + } else { + let + partial_theta = curve_theta - speed*TIMER + and + partial_x = cos(partial_theta) + CX + and + partial_y = sin(partial_theta) + CY + in { + if (partial_x >= 0.0 && partial_x < CY/2 && partial_y < CY/2) { + let + extra_theta = abs(partial_theta) - PI/4 + and + new_timer = extra_theta / (speed * RAD) + and + done_theta = curve_theta - speed*TIMER + extra_theta + in { + my_x' = cos(done_theta) + CX + speed*new_timer*cos(my_theta); + my_y' = sin(done_theta) + CY + speed*new_timer*sin(my_theta); + curve_theta' = (3.0/4)*PI; + wp_i' = wp_i + 1; + } + } else { + my_x' = partial_x; + my_y' = partial_y; + curve_theta' = partial_theta; + } + } + } + } else { + if (speed > CURVE) { + out' = 1.0; + } else { + let + partial_theta = curve_theta + speed*TIMER + and + partial_x = cos(partial_theta) + CX + and + partial_y = sin(partial_theta) - CY + in { + if (partial_x >= 0.0 && partial_x < CY/2 && partial_y > -CY/2) { + let + extra_theta = abs(PI/4 - partial_theta) + and + new_timer = extra_theta / (speed * RAD) + and + done_theta = curve_theta + speed*TIMER - extra_theta + in { + my_x' = cos(done_theta) + CX + speed*new_timer*cos(my_theta); + my_y' = sin(done_theta) - CY + speed*new_timer*sin(my_theta); + curve_theta' = (5.0/4)*PI; + wp_i' = wp_i + 1; + } + } else { + my_x' = partial_x; + my_y' = partial_y; + curve_theta' = partial_theta; + } + } + } + } + } + } + } + } + + if (you_out == 1.0) { + if (you_back - 1 == 0) { + you_out' = 0.0; + } + you_back' = you_back - 1; + } else { + let + y_speed = (your_speed == FULL || your_speed == NEUTRAL) ? your_speed : max(0.0, min(FULL, your_speed + R[0,1]*0.1 - 0.05)) + in { + your_speed' = y_speed; + if (wp_j % 4 == 0) { + let + partial_x = your_x + y_speed*TIMER*cos(your_theta) + and + partial_y = your_y + y_speed*TIMER*sin(your_theta) + in { + if (partial_x < -CY/2 + SHIFT_X) { + if (y_speed > CURVE) { + your_x' = -CY/2 + SHIFT_X; + your_y' = CY/2 + SHIFT_Y; + wp_j' = wp_j + 1; + your_theta' = (5.0/4)*PI; + you_out' = 1.0; + } else { + let + extra = abs(partial_x) - CY/2 + SHIFT_X + and + new_timer = abs(extra / (y_speed * cos(your_theta))) + and + extra_theta = y_speed * new_timer + and + new_theta = your_curve_theta - extra_theta + in { + your_x' = cos(new_theta) + CX + SHIFT_X; + your_y' = sin(new_theta) + CY + SHIFT_Y; + your_curve_theta' = new_theta; + wp_j' = wp_j + 1; + your_theta' = (5.0/4)*PI; + } + } + } else { + your_x' = partial_x; + your_y' = partial_y; + } + } + } else { + if (wp_j % 4 == 2) { + let + partial_x = your_x + y_speed*TIMER*cos(your_theta) + and + partial_y = your_y + y_speed*TIMER*sin(your_theta) + in { + if (partial_x < -CY/2 + SHIFT_X) { + if (y_speed > CURVE) { + your_x' = -CY/2 + SHIFT_X; + your_y' = -CY/2 + SHIFT_Y; + wp_j' = wp_j + 1; + your_theta' = (3.0/4)*PI; + you_out' = 1.0; + } else { + let + extra = abs(partial_x) - CY/2 + SHIFT_X + and + new_timer = abs(extra / (y_speed * cos(your_theta))) + and + extra_theta = y_speed * new_timer + and + new_theta = your_curve_theta + extra_theta + in { + your_x' = cos(new_theta) + CX + SHIFT_X; + your_y' = sin(new_theta) - CY + SHIFT_Y; + your_curve_theta' = new_theta; + wp_j' = wp_j + 1; + your_theta' = (3.0/4)*PI; + } + } + } else { + your_x' = partial_x; + your_y' = partial_y; + } + } + } else { + if (wp_j % 4 == 1) { + if (y_speed > CURVE) { + you_out' = 1.0; + } else { + let + partial_theta = your_curve_theta - y_speed*TIMER + and + partial_x = cos(partial_theta) + CX + SHIFT_X + and + partial_y = sin(partial_theta) + CY + SHIFT_Y + in { + if (partial_x >= 0.0 + SHIFT_X && partial_x < CY/2 + SHIFT_X && partial_y < CY/2 + SHIFT_Y) { + let + extra_theta = abs(partial_theta) - PI/4 + and + new_timer = extra_theta / (y_speed * RAD) + and + done_theta = your_curve_theta - y_speed*TIMER + extra_theta + in { + your_x' = cos(done_theta) + CX + SHIFT_X + y_speed*new_timer*cos(your_theta); + your_y' = sin(done_theta) + CY + SHIFT_Y + y_speed*new_timer*sin(your_theta); + your_curve_theta' = (3.0/4)*PI; + wp_j' = wp_j + 1; + } + } else { + your_x' = partial_x; + your_y' = partial_y; + your_curve_theta' = partial_theta; + } + } + } + } else { + if (y_speed > CURVE) { + you_out' = 1.0; + } else { + let + partial_theta = your_curve_theta + y_speed*TIMER + and + partial_x = cos(partial_theta) + CX + SHIFT_X + and + partial_y = sin(partial_theta) - CY + SHIFT_Y + in { + if (partial_x >= 0.0 + SHIFT_X && partial_x < CY/2 + SHIFT_X && partial_y > -CY/2 + SHIFT_Y) { + let + extra_theta = abs(PI/4 - partial_theta) + and + new_timer = extra_theta / (y_speed * RAD) + and + done_theta = your_curve_theta + y_speed*TIMER - extra_theta + in { + your_x' = cos(done_theta) + CX + SHIFT_X + y_speed*new_timer*cos(your_theta); + your_y' = sin(done_theta) - CY + SHIFT_Y + y_speed*new_timer*sin(your_theta); + your_curve_theta' = (5.0/4)*PI; + wp_j' = wp_j + 1; + } + } else { + your_x' = partial_x; + your_y' = partial_y; + your_curve_theta' = partial_theta; + } + } + } + } + } + } + } + } +} diff --git a/examples/stark/random_walk.stark b/examples/stark/random_walk.stark new file mode 100644 index 00000000..3e8096f3 --- /dev/null +++ b/examples/stark/random_walk.stark @@ -0,0 +1,11 @@ +const test = 10; + +variables { + real x range [0, 100] = 50; + real y range [0, 100] = 50; +} + +environment { + x' = x + U[-1,0,1]; + y' = y + U[-1,0,1]; +} \ No newline at end of file diff --git a/examples/stark/reactionsystems_lacoperon.stark b/examples/stark/reactionsystems_lacoperon.stark new file mode 100644 index 00000000..3263cdad --- /dev/null +++ b/examples/stark/reactionsystems_lacoperon.stark @@ -0,0 +1,156 @@ +/* + * Ported from the original `reactionsystems` example's lac-operon model: + * the lac operon gene-regulatory network, modeled as a reaction system per + * Corolli, Maja, Marini, Besozzi, Mauri, "An excursion in reaction systems: + * From computer science to biology" (2012) — ten boolean-valued reactions + * (r1..r10) under the same "no permanency" principle as + * `reactionsystems_running.stark`, driven by a context controller that (a) + * keeps the "always present" genomic elements/proteins (`lac`, `lacI`, `I`, + * `cya`, `cAMP`, `crp`, `CAP`) supplied every round, and (b) cycles through a + * fixed 40-step schedule of glucose/lactose availability (`glucose_N`/ + * `lactose_N`), matching this grammar's controller-then-environment + * per-round order (context supplies entities, then the reactions read them + * that same round). + * + * The original runs `DefaultCondition` and the `Glucose5` chain in + * parallel, which maps directly to this grammar's `init A || B` + * parallel-state-composition syntax — one `component` whose controller has + * both the always-on + * `DefaultCondition` self-loop and the 40-state context cycle running side + * by side. `Start` and the standalone `Tick` state are dead code in the + * original (defined but never targeted by any transition), so they aren't + * ported. + * + * `r4` (`IOP`) and `r9` (`cAMPCAP`) read `lactose`/`glucose`, not + * `lactose_N`/`glucose_N` directly — since only the environment (never the + * controller) writes `lactose`/`glucose` (via the last two reactions + * below), and this grammar's `let`-block reads are simultaneous against the + * round's starting values, this reproduces the original's one-round lag + * exactly: context supplies `lactose_N`/`glucose_N` this round, the + * reaction system converts that to `lactose`/`glucose` this round, and + * `IOP`/`cAMPCAP` next round see the round-old `lactose`/`glucose` — matching + * `state.get(lactose)` reading the pre-call `state` in the original's + * `applyReactions`, exactly as ported. + * + * The original's robustness queries are all online-monitoring ones — + * the same untranslatable formalism already documented in + * `monitoring.stark`/`crates/stark/plan.md` — so only the reaction + * system and its context controller are ported, no `distance`/`formula`. + */ + +global variables { + int lac = 1; + int Z = 0; + int Y = 0; + int A = 0; + int lacI = 1; + int I = 1; + int IOP = 0; + int cya = 1; + int cAMP = 1; + int crp = 1; + int CAP = 1; + int cAMPCAP = 0; + int lactose = 0; + int glucose = 0; + int lactose_N = 0; + int glucose_N = 0; +} + +component Context { + variables { } + controller { + state DefaultCondition { + lac' = 1; lacI' = 1; I' = 1; cya' = 1; cAMP' = 1; crp' = 1; CAP' = 1; + step DefaultCondition; + } + + state Glucose5 { glucose_N' = 1; lactose_N' = 0; step Glucose4; } + state Glucose4 { glucose_N' = 1; lactose_N' = 0; step Glucose3; } + state Glucose3 { glucose_N' = 1; lactose_N' = 0; step Glucose2; } + state Glucose2 { glucose_N' = 1; lactose_N' = 0; step Glucose1; } + state Glucose1 { glucose_N' = 1; lactose_N' = 0; step GlucoseLactose5; } + + state GlucoseLactose5 { glucose_N' = 1; lactose_N' = 1; step GlucoseLactose4; } + state GlucoseLactose4 { glucose_N' = 1; lactose_N' = 1; step GlucoseLactose3; } + state GlucoseLactose3 { glucose_N' = 1; lactose_N' = 1; step GlucoseLactose2; } + state GlucoseLactose2 { glucose_N' = 1; lactose_N' = 1; step GlucoseLactose1; } + state GlucoseLactose1 { glucose_N' = 1; lactose_N' = 1; step Lactose5; } + + state Lactose5 { glucose_N' = 0; lactose_N' = 1; step Lactose4; } + state Lactose4 { glucose_N' = 0; lactose_N' = 1; step Lactose3; } + state Lactose3 { glucose_N' = 0; lactose_N' = 1; step Lactose2; } + state Lactose2 { glucose_N' = 0; lactose_N' = 1; step Lactose1; } + state Lactose1 { glucose_N' = 0; lactose_N' = 1; step Tick5; } + + state Tick5 { glucose_N' = 0; lactose_N' = 0; step Tick4; } + state Tick4 { glucose_N' = 0; lactose_N' = 0; step Tick3; } + state Tick3 { glucose_N' = 0; lactose_N' = 0; step Tick2; } + state Tick2 { glucose_N' = 0; lactose_N' = 0; step Tick1; } + state Tick1 { glucose_N' = 0; lactose_N' = 0; step Lact5; } + + state Lact5 { glucose_N' = 0; lactose_N' = 1; step Lact4; } + state Lact4 { glucose_N' = 0; lactose_N' = 1; step Lact3; } + state Lact3 { glucose_N' = 0; lactose_N' = 1; step Lact2; } + state Lact2 { glucose_N' = 0; lactose_N' = 1; step Lact1; } + state Lact1 { glucose_N' = 0; lactose_N' = 1; step GlucLact5; } + + state GlucLact5 { glucose_N' = 1; lactose_N' = 1; step GlucLact4; } + state GlucLact4 { glucose_N' = 1; lactose_N' = 1; step GlucLact3; } + state GlucLact3 { glucose_N' = 1; lactose_N' = 1; step GlucLact2; } + state GlucLact2 { glucose_N' = 1; lactose_N' = 1; step GlucLact1; } + state GlucLact1 { glucose_N' = 1; lactose_N' = 1; step Lac5; } + + state Lac5 { glucose_N' = 0; lactose_N' = 1; step Lac4; } + state Lac4 { glucose_N' = 0; lactose_N' = 1; step Lac3; } + state Lac3 { glucose_N' = 0; lactose_N' = 1; step Lac2; } + state Lac2 { glucose_N' = 0; lactose_N' = 1; step Lac1; } + state Lac1 { glucose_N' = 0; lactose_N' = 1; step Glu5; } + + state Glu5 { glucose_N' = 1; lactose_N' = 0; step Glu4; } + state Glu4 { glucose_N' = 1; lactose_N' = 0; step Glu3; } + state Glu3 { glucose_N' = 1; lactose_N' = 0; step Glu2; } + state Glu2 { glucose_N' = 1; lactose_N' = 0; step Glu1; } + state Glu1 { glucose_N' = 1; lactose_N' = 0; step Glucose5; } + } + init DefaultCondition || Glucose5 +} + +environment { + let + r1 = (lac == 1) + and + r2 = (lacI == 1) + and + r3 = (lacI == 1) + and + r4 = (I == 1 && lactose == 0) + and + r5 = (cya == 1) + and + r6 = (cya == 1) + and + r7 = (crp == 1) + and + r8 = (crp == 1) + and + r9 = (cAMP == 1 && CAP == 1 && glucose == 0) + and + r10 = (cAMPCAP == 1 && lac == 1 && IOP == 0) + in { + lac' = (r1 ? 1 : 0); + lacI' = (r2 ? 1 : 0); + I' = (r3 ? 1 : 0); + IOP' = (r4 ? 1 : 0); + cya' = (r5 ? 1 : 0); + cAMP' = (r6 ? 1 : 0); + crp' = (r7 ? 1 : 0); + CAP' = (r8 ? 1 : 0); + cAMPCAP' = (r9 ? 1 : 0); + Z' = (r10 ? 1 : 0); + Y' = (r10 ? 1 : 0); + A' = (r10 ? 1 : 0); + lactose' = lactose_N; + glucose' = glucose_N; + } +} diff --git a/examples/stark/reactionsystems_running.stark b/examples/stark/reactionsystems_running.stark new file mode 100644 index 00000000..4e26b615 --- /dev/null +++ b/examples/stark/reactionsystems_running.stark @@ -0,0 +1,125 @@ +/* + * Ported from the original `reactionsystems` example's running example: + * the "running example" reaction system from the reaction-systems literature + * — four boolean-valued entities (`a`,`b`,`c`,`d`) governed by two reaction + * rules under the "no permanency" principle (an entity reverts to absent + * unless some enabled reaction, or the context, produces it again this + * round), driven by an external context sequence (`Ag0`..`Ag7`) that + * supplies entities into the system before the reaction rules fire each + * round — exactly matching this grammar's controller-then-environment + * per-round order. + * + * The original's "plain" scenario (no controller at all and a different fixed + * initial state, showing the two reaction rules oscillate on their own with + * no context) exercises the same environment block with no controller at + * all, so it isn't ported as a separate file, matching the + * `turtle.stark`/`polistil.stark` precedent of porting one representative + * scenario per model. + * + * `Ag5`'s `C5_count' = 1` (not an increment) means `Ag5rep`'s counter loop + * always repeats exactly once before moving on to `Ag7` — ported as + * written, not simplified, since that's a property of the original model, + * not an approximation on this end. + * + * The original's context/perturbation sequence (`p_cont_seq`) is a chain of + * zero-delay atomic perturbations composed + * in sequence, plus one no-op step and one single-iteration repeat of `p5` + * (apply once, same as `p5` + * alone) — ported directly via this grammar's `;` sequencing and `nil` + * primary. + * + * The original has no `distance`/`formula`/robustness query at all (just + * data collection), so none is ported here either — only the reaction + * system, its context controller, and its context perturbation sequence. + */ + +global variables { + int a = 0; + int b = 0; + int c = 0; + int d = 0; +} + +component Context { + variables { int c5_count = 0; } + controller { + state Ag0 { + a' = 1; + c' = 1; + d' = 1; + step Ag1; + } + state Ag1 { + b' = 1; + step Ag2; + } + state Ag2 { + b' = 1; + c' = 1; + step Ag3; + } + state Ag3 { + b' = 1; + step Ag4; + } + state Ag4 { + /* BUG FIXED: was `exec Ag5;`. In the original's context sequence, Ag4 + idles into `Ag5`, which is time-consuming: an empty-context round, + then `Ag5` the round after. The textual language spells that `step` + — not `exec`, which is a same-tick, transparent jump into the + target's block. The original `exec Ag5` collapsed the + empty-context round the reaction-systems "running example" has at + position 4, desynchronising the whole context sequence by one round. + Corrected to `step Ag5;` per this port's own documented convention + (assign-or-idle-then-continue -> step; bare reference -> exec, see + engine_full.stark). */ + step Ag5; + } + state Ag5 { + a' = 1; + d' = 1; + c5_count' = 1; + step Ag5rep; + } + state Ag5rep { + if (c5_count > 0) { + a' = 1; + d' = 1; + c5_count' = c5_count - 1; + step Ag5rep; + } else { + /* BUG FIXED: was `step Ag7;`. In the original, `Ag5rep` is + conditional: while `C5_count > 0` it assigns and continues into + `Ag5rep`, otherwise it continues into `Ag7`. + The else branch is a *bare* `reference("Ag7")` returned by the + if-then-else, so Ag7 runs in the SAME round (transparent), which the + textual language spells `exec`. `step Ag7` wrongly inserted an extra + empty round before Ag7's `d'=1`. Corrected to `exec Ag7;`. */ + exec Ag7; + } + } + state Ag7 { + d' = 1; + step Idle; + } + state Idle { + exec Idle; + } + } + init Ag0 +} + +environment { + let + a1_enabled = (a == 1 && d == 1 && b == 0) + and + a2_enabled = (b == 1 && c == 0) + in { + a' = (a1_enabled || a2_enabled ? 1 : 0); + b' = (a1_enabled ? 1 : 0); + c' = 0; + d' = (a2_enabled ? 1 : 0); + } +} + +perturbation p_context = [a<-1,c<-1,d<-1]@0 ; [b<-1]@0 ; [b<-1,c<-1]@0 ; [b<-1]@0 ; nil ; [a<-1,d<-1]@0 ; [d<-1]@0; diff --git a/examples/stark/reactionsystems_synapse.stark b/examples/stark/reactionsystems_synapse.stark new file mode 100644 index 00000000..fa90b1e4 --- /dev/null +++ b/examples/stark/reactionsystems_synapse.stark @@ -0,0 +1,130 @@ +/* + * Ported from the original `reactionsystems` example's two-neuron model: + * a reaction-system model of synaptic signalling between two neurons + * (calcium influx, calcium-ligand binding, vesicle exocytosis, + * neurotransmitter release/decay over a 3-step delay line, neuroreceptor + * opening/closing), again under the "no permanency" principle established + * in `reactionsystems_running.stark`/`reactionsystems_lacoperon.stark`. + * + * The same example's three-neuron model scales the identical mechanism + * up (28 variables instead of this file's 21, with an extra + * neuroreceptor pair for the second incoming synapse on neuron 3), so it + * isn't ported separately — same precedent as `turtle.stark`/ + * `repressilator.stark` porting one representative scale. + * + * The system has no controller (none in the original either, like + * `isocitrate.stark`/`envzompr.stark`/`lotka.stark`): `e2` (neuron 2's + * neuroreceptor effectiveness) is never written by the reactions + * themselves, only read, so it simply isn't assigned in `environment` and + * keeps its initial value forever, matching "an unassigned variable keeps + * its previous value" exactly as the original does by reading `e2` without + * ever assigning it. + * + * `c2`'s and `o20`/`o21`/`o22`'s updates share one random draw + * (one uniform draw compared against `e2`) within the same reaction step — unlike the + * "no `let` inside perturbations" gap documented for `vehicle_full.stark`/ + * `turtle.stark`, this shared draw lives in the regular `environment` block + * (not a perturbation), where a `let` binding is available, so it's ported + * exactly rather than approximated. + * + * The perturbation reduces `e2` for `w1` steps then restores it, repeated + * `replica` times — the same "bump then revert, iterated" pattern as + * `repressilator.stark`'s `p_transl_rate`. As there, only one representative + * threshold is ported for the robustness formula (the original sweeps ten), + * and the two `Ca1`/`Ca2` normalisation constants are the original's fixed + * `/20` (not empirically derived, so no placeholder-constant caveat is + * needed here, unlike the other Gillespie-style ports). + */ + +param ED = 0.01; + +global variables { + /* first neuron */ + real Ca1 = 1.0; + real X1 = 10.0; + real XStar1 = 0.0; + real Ve1 = 5.0; + real VeStar1 = 0.0; + real T10 = 0.0; + real T11 = 0.0; + real T12 = 0.0; + real c1 = 1.0; + real o1 = 0.0; + + /* second neuron */ + real Ca2 = 0.0; + real X2 = 10.0; + real XStar2 = 0.0; + real Ve2 = 5.0; + real VeStar2 = 0.0; + real T2 = 0.0; + real c2 = 1.0; + real o20 = 0.0; + real o21 = 0.0; + real o22 = 0.0; + + /* effectiveness of neuron 2's neuroreceptor (from neuron 1), in [0,1] */ + real e2 = 1.0; +} + +environment { + let + w2 = (R[0,1] < e2) + in { + /* Ca1: postsynaptic activity (receptor open) sets it to 1; otherwise + presynaptic activity doubles it until the threshold 10, then it decays. */ + Ca1' = (o1 == 1 ? 1 : (Ca1 > 0 && Ca1 < 10 ? Ca1 * 2 : 0)); + Ca2' = (o20 == 1 && T10 == 1 && Ca2 > 0 ? Ca2 + 3 + : (o20 == 1 && T10 == 1 && Ca2 == 0 ? 1 + : (Ca2 > 0 && Ca2 < 10 ? Ca2 * 2 : 0))); + + /* calcium ligand: persists once bound, until enough calcium forms the complex */ + X1' = (XStar1 == 10 && Ve1 > 0 ? 10 : (X1 > 0 && XStar1 == 0 ? X1 : 0)); + X2' = (XStar2 == 10 && Ve2 > 0 ? 10 : (X2 > 0 && XStar2 == 0 ? X2 : 0)); + + /* vesicles before exocytosis: persist, or are replenished once emptied */ + Ve1' = (Ve1 > 0 && VeStar1 == 0 ? Ve1 : (VeStar1 > 0 ? VeStar1 : 0)); + Ve2' = (Ve2 > 0 && VeStar2 == 0 ? Ve2 : (VeStar2 > 0 ? VeStar2 : 0)); + + /* calcium-ligand complex forms once enough calcium and ligand are present */ + XStar1' = (Ca1 >= 10 && X1 >= 10 ? X1 : 0); + XStar2' = (Ca2 >= 10 && X2 >= 10 ? X2 : 0); + + /* vesicles release their neurotransmitter once the complex has formed */ + VeStar1' = (XStar1 == 10 && Ve1 > 0 ? Ve1 : 0); + VeStar2' = (XStar2 == 10 && Ve2 > 0 ? Ve2 : 0); + + /* neurotransmitter from neuron 1, available immediately and for the next + two steps (a 3-step delay line); neuron 2's has no delay. */ + T12' = (VeStar1 > 0 ? 1 : 0); + T11' = (VeStar1 > 0 ? 1 : (T12 == 1 ? 1 : 0)); + T10' = (VeStar1 > 0 ? 1 : (T11 == 1 ? 1 : 0)); + T2' = (VeStar2 > 0 ? 1 : 0); + + /* neuroreceptor of neuron 1: opens when neuron 2's neurotransmitter is + present, closes otherwise or once open. */ + c1' = ((c1 > 0 && T2 == 0) || o1 > 0 ? 1 : 0); + o1' = (T2 > 0 ? 1 : 0); + + /* neuroreceptor of neuron 2: opens (with probability e2, via the shared + draw w2) when neuron 1's delayed neurotransmitter T12 is present, then + stays open for two more steps (o22 -> o21 -> o20) like T1's delay line. */ + c2' = ((c2 > 0 && T10 == 0) || (c2 > 0 && T10 == 1 && !w2) || (o20 > 0 && o21 == 0 && o22 == 0) ? 1 : 0); + o22' = (T12 > 0 && w2 ? 1 : 0); + o21' = (T12 > 0 && w2 ? 1 : (o22 == 1 ? 1 : 0)); + o20' = (T12 > 0 && w2 ? 1 : (o21 == 1 ? 1 : 0)); + } +} + +penalty rho_ca1 = Ca1 / 20 +penalty rho_ca2 = Ca2 / 20 + +distance atomic_ca1 = < rho_ca1; +distance atomic_ca2 = < rho_ca2; +distance max_ca12 = max(atomic_ca1, atomic_ca2); +distance max_interval_ca12 = \G[0,1000] max_ca12; + +/* Reduces e2 by ED for 50 steps, then restores it for 50 steps, repeated 5 times. */ +perturbation p_e2 = ([e2 <- max(0.0, e2 - ED)]@50 ; [e2 <- max(0.0, e2 + ED)]@50)^5; + +formula robust_synapse = \D[max_interval_ca12, p_e2] <= 0.10; diff --git a/examples/stark/reactionsystems_synapse_3neuron.stark b/examples/stark/reactionsystems_synapse_3neuron.stark new file mode 100644 index 00000000..55e049cb --- /dev/null +++ b/examples/stark/reactionsystems_synapse_3neuron.stark @@ -0,0 +1,144 @@ +/* + * Ported from the original `reactionsystems` example's three-neuron model: + * the same synaptic-signalling reaction system as + * `reactionsystems_synapse.stark` (calcium influx, calcium-ligand binding, + * vesicle exocytosis, neuroreceptor opening/closing), scaled from 2 to 3 + * neurons — but the topology is genuinely richer, not a plain copy: neuron 3 + * receives synaptic input from *both* neuron 1 and neuron 2 (two separate + * receptor pairs, `c31`/`o31` and `c32`/`o32`, each gated by its own + * effectiveness `e31`/`e32`), while neurons 1 and 2 each unconditionally + * receive neuron 3's neurotransmitter `T3` back (no effectiveness gating on + * `c1`/`o1`/`c2`/`o2`, unlike neuron 3's receptors). + * + * This version also has *no* multi-step neurotransmitter delay line: `T1`, + * `T2`, `T3` all become available and decay in a single step (unlike + * `reactionsystems_synapse.stark`'s `T10`/`T11`/`T12` 3-step delay for + * neuron 1's output) — a genuine structural difference from the 2-neuron + * file, not just a bigger copy of the same model. + * + * The original's `upd_e31_e32` sets *both* `e31` and `e32` from `e31`'s old + * value (`state.get(e31) + x` for both updates — `e32`'s own old value is + * never read), which looks like a copy-paste slip; it's harmless in + * practice since `e31`/`e32` start equal and are always bumped identically, + * so they stay numerically identical throughout — ported as written + * (`e31_new`/`e32_new` below are the same expression), matching this + * session's convention of preserving benign original quirks rather than + * silently "fixing" behaviour that was never actually observably wrong. + */ + +param ED = 0.01; + +global variables { + /* first neuron */ + real Ca1 = 1.0; + real X1 = 10.0; + real XStar1 = 0.0; + real Ve1 = 5.0; + real VeStar1 = 0.0; + real T1 = 0.0; + real c1 = 1.0; + real o1 = 0.0; + + /* second neuron */ + real Ca2 = 0.0; + real X2 = 10.0; + real XStar2 = 0.0; + real Ve2 = 5.0; + real VeStar2 = 0.0; + real T2 = 0.0; + real c2 = 1.0; + real o2 = 0.0; + + /* third neuron, receiving from both neuron 1 and neuron 2 */ + real Ca3 = 0.0; + real X3 = 10.0; + real XStar3 = 0.0; + real Ve3 = 5.0; + real VeStar3 = 0.0; + real T3 = 0.0; + real c31 = 1.0; + real o31 = 0.0; + real c32 = 1.0; + real o32 = 0.0; + + /* effectiveness of neuron 3's two neuroreceptors (from neurons 1 and 2), in [0,1] */ + real e31 = 1.0; + real e32 = 1.0; +} + +environment { + let + w31 = (R[0,1] < e31) + and + w32 = (R[0,1] < e32) + in { + /* Ca1: postsynaptic activity (receptor open) sets it to 1; otherwise + presynaptic activity doubles it until the threshold 10, then it decays. */ + Ca1' = (o1 == 1 ? 1 : (Ca1 > 0 && Ca1 < 10 ? Ca1 * 2 : 0)); + Ca2' = (o2 == 1 ? 1 : (Ca2 > 0 && Ca2 < 10 ? Ca2 * 2 : 0)); + /* Ca3: both receptors open -> 4; exactly one open -> 1; else presynaptic doubling/decay. */ + Ca3' = (o31 == 1 && o32 == 1 ? 4 + : ((o31 == 1 || o32 == 1) ? 1 + : (Ca3 > 0 && Ca3 < 10 ? Ca3 * 2 : 0))); + + /* calcium ligand: persists once bound, until enough calcium forms the complex */ + X1' = (XStar1 == 10 && Ve1 > 0 ? 10 : (X1 > 0 && XStar1 == 0 ? X1 : 0)); + X2' = (XStar2 == 10 && Ve2 > 0 ? 10 : (X2 > 0 && XStar2 == 0 ? X2 : 0)); + X3' = (XStar3 == 10 && Ve3 > 0 ? 10 : (X3 > 0 && XStar3 == 0 ? X3 : 0)); + + /* vesicles before exocytosis: persist, or are replenished once emptied */ + Ve1' = (Ve1 > 0 && VeStar1 == 0 ? Ve1 : (VeStar1 > 0 ? VeStar1 : 0)); + Ve2' = (Ve2 > 0 && VeStar2 == 0 ? Ve2 : (VeStar2 > 0 ? VeStar2 : 0)); + Ve3' = (Ve3 > 0 && VeStar3 == 0 ? Ve3 : (VeStar3 > 0 ? VeStar3 : 0)); + + /* calcium-ligand complex forms once enough calcium and ligand are present */ + XStar1' = (Ca1 >= 10 && X1 >= 10 ? X1 : 0); + XStar2' = (Ca2 >= 10 && X2 >= 10 ? X2 : 0); + XStar3' = (Ca3 >= 10 && X3 >= 10 ? X3 : 0); + + /* vesicles release their neurotransmitter once the complex has formed */ + VeStar1' = (XStar1 == 10 && Ve1 > 0 ? Ve1 : 0); + VeStar2' = (XStar2 == 10 && Ve2 > 0 ? Ve2 : 0); + VeStar3' = (XStar3 == 10 && Ve3 > 0 ? Ve3 : 0); + + /* neurotransmitter: available immediately for one step, no delay line here */ + T1' = (VeStar1 > 0 ? 1 : 0); + T2' = (VeStar2 > 0 ? 1 : 0); + T3' = (VeStar3 > 0 ? 1 : 0); + + /* neuroreceptors of neurons 1 and 2: open when neuron 3's neurotransmitter + is present, closed otherwise or once open. No effectiveness gating. */ + c1' = ((c1 > 0 && T3 == 0) || o1 > 0 ? 1 : 0); + o1' = (T3 > 0 ? 1 : 0); + c2' = ((c2 > 0 && T3 == 0) || o2 > 0 ? 1 : 0); + o2' = (T3 > 0 ? 1 : 0); + + /* neuron 3's receptor from neuron 1: opens (with probability e31, via the + shared draw w31) when T1 is present, closes otherwise or once open. */ + c31' = ((c31 > 0 && T1 == 0) || (c31 > 0 && T1 == 1 && !w31) || o31 == 1 ? 1 : 0); + o31' = (T1 > 0 && w31 ? 1 : 0); + + /* neuron 3's receptor from neuron 2: same shape, gated by e32/w32. */ + c32' = ((c32 > 0 && T2 == 0) || (c32 > 0 && T2 == 1 && !w32) || o32 == 1 ? 1 : 0); + o32' = (T2 > 0 && w32 ? 1 : 0); + } +} + +penalty rho_ca1 = Ca1 / 20 +penalty rho_ca2 = Ca2 / 20 +penalty rho_ca3 = Ca3 / 20 + +distance atomic_ca1 = < rho_ca1; +distance atomic_ca2 = < rho_ca2; +distance atomic_ca3 = < rho_ca3; +distance max_ca12 = max(atomic_ca1, atomic_ca2); +distance max_ca123 = max(max_ca12, atomic_ca3); +distance max_interval_ca123 = \G[0,1000] max_ca123; + +/* Reduces e31 and e32 (identically, see file header) by ED for 50 steps, + then restores them for 50 steps, repeated 5 times. */ +perturbation p_e31_e32 = + ([e31 <- max(0.0, e31 - ED), e32 <- max(0.0, e31 - ED)]@50 + ; [e31 <- max(0.0, e31 + ED), e32 <- max(0.0, e31 + ED)]@50)^5; + +formula robust_synapse = \D[max_interval_ca123, p_e31_e32] <= 0.15; diff --git a/examples/stark/repressilator.stark b/examples/stark/repressilator.stark new file mode 100644 index 00000000..c3c43e0d --- /dev/null +++ b/examples/stark/repressilator.stark @@ -0,0 +1,292 @@ +/* + * Ported from the original `repressilator` example: + * the classic repressilator, a synthetic 3-gene cyclic negative-feedback + * oscillator (gene 3 represses gene 1, gene 1 represses gene 2, gene 2 + * represses gene 3), simulated as a "two-state model" chemical reaction + * network with 18 reactions via Gillespie's stochastic simulation algorithm + * (SSA) — same no-`component` shape and cumulative-weight + * `if`/`else` reaction-selection pattern established in + * `isocitrate.stark`/`envzompr.stark`/`lotka.stark`. + * + * The same example also simulates the identical model with a different + * (Skorokhod-representation) numerical integration scheme, so it isn't + * ported separately, matching the `turtle.stark` precedent of porting one + * representative scenario rather than every alternate implementation. + * + * Each gene `i` has 4 species: `Gi`/`AGi` (inactive/active promoter, always + * `Gi+AGi = 1`), `Xi` (mRNA count), `Zi` (protein count), and 6 reactions: + * activation (`Gi --koni--> AGi`), deactivation (`AGi --koffi--> Gi`), + * transcription (`AGi --s0i--> AGi+Xi`), translation (`Xi --s1i--> Xi+Zi`), + * mRNA degradation (`Xi --d0i-->`), protein degradation (`Zi --d1i-->`). + * + * The novel part beyond the by-now-familiar SSA template: after *every* + * reaction (not just the ones that touch protein levels), `kon1`/`kon2`/ + * `kon3` — the burst-frequency/activation-rate variables, not fixed + * constants — are recomputed from the protein levels via a Hill/sigmoid + * response curve (`kon_rate` below) and a fixed interaction matrix + * `THETAij` (gene `j`'s influence on gene `i`'s activation), which is what + * encodes the repressive topology. The original's `selectAndApplyReaction` + * computes this recompute *once*, unconditionally, from `state.get(Z1)`/ + * `state.get(Z2)`/`state.get(Z3)` — i.e. the *pre-reaction* protein levels, + * regardless of which reaction (if any) just changed a `Zi` — so a single + * `kon1'`/`kon2'`/`kon3'` computed from the current, not-yet-updated + * `Z1`/`Z2`/`Z3` before the reaction-selection `if`/`else` chain is exactly + * right for every branch; no branch needs (or should use) an adjusted + * `Zi + 1`/`Zi - 1`. (An earlier version of this file *did* recompute + * `kon1'`/`kon2'`/`kon3'` from the post-reaction `Zi` in the six branches + * that change one — cross-checked and confirmed wrong against both + * of the original's two variants, which read the identical + * `state.get(Zi)`; fixed here by dropping those six redundant, incorrect + * recomputes.) + * + * The original evaluates 20 thresholds (for plotting a robustness curve); + * only one representative threshold is ported here, matching the + * `isocitrate.stark`/`envzompr.stark` precedent of not guessing at + * empirically-swept values. The normalisation constants are likewise + * placeholders: the original computes them as + * `max(sampled Zi value across both traces) * 1.1` after actually running + * the simulation, which has no static equivalent here. + */ + +param K01 = 0.0; +param K11 = 2.0; +param BETA1 = 5.0; +param K02 = 0.0; +param K12 = 2.0; +param BETA2 = 5.0; +param K03 = 0.0; +param K13 = 2.0; +param BETA3 = 5.0; + +/* THETAij: interaction weight of gene j's protein on gene i's activation. */ +param THETA11 = 0.0; +param THETA21 = 0.0; +param THETA31 = -10.0; +param THETA12 = -10.0; +param THETA22 = 0.0; +param THETA32 = 0.0; +param THETA13 = 0.0; +param THETA23 = -10.0; +param THETA33 = 0.0; + +/* Initial burst frequencies, evaluated at Z1 = Z2 = Z3 = 0. */ +param INIT_KON1 = (K01 + K11 * exp(BETA1)) / (1 + exp(BETA1)); +param INIT_KON2 = (K02 + K12 * exp(BETA2)) / (1 + exp(BETA2)); +param INIT_KON3 = (K03 + K13 * exp(BETA3)) / (1 + exp(BETA3)); + +function kon_rate(real k0, real k1, real beta, real z1, real theta1, real z2, real theta2, real z3, real theta3) { + let e = exp(beta + theta1*z1 + theta2*z2 + theta3*z3) in + return (k0 + k1*e) / (1 + e); +} + +global variables { + /* the system starts with all promoters inactive, no mRNA and no protein */ + real G1 = 1.0; + real AG1 = 0.0; + real X1 = 0.0; + real Z1 = 0.0; + + real G2 = 1.0; + real AG2 = 0.0; + real X2 = 0.0; + real Z2 = 0.0; + + real G3 = 1.0; + real AG3 = 0.0; + real X3 = 0.0; + real Z3 = 0.0; + + real kon1 = INIT_KON1; + real koff1 = 5.0; + real s01 = 250.0; + real s11 = 7.0; + real d01 = 1.0; + real d11 = 0.1; + + real kon2 = INIT_KON2; + real koff2 = 5.0; + real s02 = 250.0; + real s12 = 7.0; + real d02 = 1.0; + real d12 = 0.1; + + real kon3 = INIT_KON3; + real koff3 = 5.0; + real s03 = 250.0; + real s13 = 7.0; + real d03 = 1.0; + real d13 = 0.1; +} + +environment { + let + w1 = kon1 * G1 + and + w2 = koff1 * AG1 + and + w3 = s01 * AG1 + and + w4 = s11 * X1 + and + w5 = d01 * X1 + and + w6 = d11 * Z1 + and + w7 = kon2 * G2 + and + w8 = koff2 * AG2 + and + w9 = s02 * AG2 + and + w10 = s12 * X2 + and + w11 = d02 * X2 + and + w12 = d12 * Z2 + and + w13 = kon3 * G3 + and + w14 = koff3 * AG3 + and + w15 = s03 * AG3 + and + w16 = s13 * X3 + and + w17 = d03 * X3 + and + w18 = d13 * Z3 + and + c1 = w1 + and + c2 = c1 + w2 + and + c3 = c2 + w3 + and + c4 = c3 + w4 + and + c5 = c4 + w5 + and + c6 = c5 + w6 + and + c7 = c6 + w7 + and + c8 = c7 + w8 + and + c9 = c8 + w9 + and + c10 = c9 + w10 + and + c11 = c10 + w11 + and + c12 = c11 + w12 + and + c13 = c12 + w13 + and + c14 = c13 + w14 + and + c15 = c14 + w15 + and + c16 = c15 + w16 + and + c17 = c16 + w17 + and + c18 = c17 + w18 + and + threshold = R[0,1] * c18 + in { + /* default: recompute kon1/kon2/kon3 from the current (unchanged) + protein levels; overridden below in the branches that change one. */ + kon1' = kon_rate(K01, K11, BETA1, Z1, THETA11, Z2, THETA21, Z3, THETA31); + kon2' = kon_rate(K02, K12, BETA2, Z1, THETA12, Z2, THETA22, Z3, THETA32); + kon3' = kon_rate(K03, K13, BETA3, Z1, THETA13, Z2, THETA23, Z3, THETA33); + if (c18 > 0) { + if (threshold <= c1) { + /* r1: G1 -[kon1]-> AG1 */ + G1' = G1 - 1; + AG1' = AG1 + 1; + } else { if (threshold <= c2) { + /* r2: AG1 -[koff1]-> G1 */ + AG1' = AG1 - 1; + G1' = G1 + 1; + } else { if (threshold <= c3) { + /* r3: AG1 -[s01]-> AG1 + X1 */ + X1' = X1 + 1; + } else { if (threshold <= c4) { + /* r4: X1 -[s11]-> X1 + Z1 */ + Z1' = Z1 + 1; + } else { if (threshold <= c5) { + /* r5: X1 -[d01]-> nil */ + X1' = X1 - 1; + } else { if (threshold <= c6) { + /* r6: Z1 -[d11]-> nil */ + Z1' = Z1 - 1; + } else { if (threshold <= c7) { + /* r7: G2 -[kon2]-> AG2 */ + G2' = G2 - 1; + AG2' = AG2 + 1; + } else { if (threshold <= c8) { + /* r8: AG2 -[koff2]-> G2 */ + AG2' = AG2 - 1; + G2' = G2 + 1; + } else { if (threshold <= c9) { + /* r9: AG2 -[s02]-> AG2 + X2 */ + X2' = X2 + 1; + } else { if (threshold <= c10) { + /* r10: X2 -[s12]-> X2 + Z2 */ + Z2' = Z2 + 1; + } else { if (threshold <= c11) { + /* r11: X2 -[d02]-> nil */ + X2' = X2 - 1; + } else { if (threshold <= c12) { + /* r12: Z2 -[d12]-> nil */ + Z2' = Z2 - 1; + } else { if (threshold <= c13) { + /* r13: G3 -[kon3]-> AG3 */ + G3' = G3 - 1; + AG3' = AG3 + 1; + } else { if (threshold <= c14) { + /* r14: AG3 -[koff3]-> G3 */ + AG3' = AG3 - 1; + G3' = G3 + 1; + } else { if (threshold <= c15) { + /* r15: AG3 -[s03]-> AG3 + X3 */ + X3' = X3 + 1; + } else { if (threshold <= c16) { + /* r16: X3 -[s13]-> X3 + Z3 */ + Z3' = Z3 + 1; + } else { if (threshold <= c17) { + /* r17: X3 -[d03]-> nil */ + X3' = X3 - 1; + } else { + /* r18: Z3 -[d13]-> nil */ + Z3' = Z3 - 1; + }}}}}}}}}}}}}}}}} + } + } +} + +/* Placeholder: the original computes these as max(sampled Zi value across + both the unperturbed and perturbed traces) * 1.1 after running the + simulation; there's no static equivalent here. */ +param NORMALISATION_Z1 = 100.0; +param NORMALISATION_Z2 = 100.0; +param NORMALISATION_Z3 = 100.0; +/* x: increment applied to gene 1's translation rate s11 by the perturbation. */ +param PERT_X = -3.0; + +penalty rho_z1 = Z1 / NORMALISATION_Z1 +penalty rho_z2 = Z2 / NORMALISATION_Z2 +penalty rho_z3 = Z3 / NORMALISATION_Z3 + +distance atomic_z1 = < rho_z1; +distance atomic_z2 = < rho_z2; +distance atomic_z3 = < rho_z3; +distance max_z1_z2 = max(atomic_z1, atomic_z2); +distance max_z1_z2_z3 = max(max_z1_z2, atomic_z3); +distance max_interval_z1_z2_z3 = \G[800,900] max_z1_z2_z3; + +/* Bumps s11 (gene 1's translation rate) by PERT_X for 50 steps, then reverts + it for the next 50 steps, repeated 5 times. */ +perturbation p_transl_rate = ([s11 <- max(0.0, s11 + PERT_X)]@50 ; [s11 <- max(0.0, s11 - PERT_X)]@50)^5; + +formula robust_repr = \D[max_interval_z1_z2_z3, p_transl_rate] <= 0.10; diff --git a/examples/stark/single_vehicle.stark b/examples/stark/single_vehicle.stark new file mode 100644 index 00000000..224a707d --- /dev/null +++ b/examples/stark/single_vehicle.stark @@ -0,0 +1,196 @@ +param A = 1.0; +param B = 2.0; +param V = 0.0; +param TIMER = 5; +param INIT_SPEED = 25.0; +param MAX_SPEED = 40.0; +param INIT_DISTANCE = 10000.0; +param SAFETY_DISTANCE = 200.0; + +param MAX_OFFSET_02 = 0.2; +param MAX_OFFSET_03 = 0.3; +param MAX_OFFSET_04 = 0.4; +param MAX_OFFSET_05 = 0.5; +param ETA_slow = 0.1; +param H = 300; + +param OK = 0; +param DANGER = 1; + + + +function new_s_speed (real speed, real acc, real token) { + if (token < 0.5) { + return min(MAX_SPEED, max(0, speed + acc + 0.3)); + } else { + return min(MAX_SPEED, max(0, speed + acc - 0.3)); + } +} + +function eval_bd(real speed){ + return (speed^2 + (A + B) * (A * TIMER^2 + 2 * speed * TIMER)) / (2 * B); +} + +function eval_rd(real speed){ + return eval_bd(speed) + SAFETY_DISTANCE; +} + +function crash_probability(real dist){ + if (dist > 0){ + return 0.0; + } else { + return 1.0; + } +} + +function slow_speed(real speed, real offs){ + return max(0.0, speed - offs); +} + +function IDS_guard(bool dist, bool acc1, bool acc2, bool speed){ + return dist && (acc1 || (acc2 && speed)); +} + + + +global variables{ + real p_speed range [0,MAX_SPEED] = INIT_SPEED; + real p_distance range [0,INIT_DISTANCE] = INIT_DISTANCE; + real gap range [0, INIT_DISTANCE] = INIT_DISTANCE - eval_rd(INIT_SPEED); + real offset_speed = 0.0; + real token = 1.0; + real s_speed range [0,MAX_SPEED] = INIT_SPEED; + real accel range [-B,A] = V; + int counter range [0,TIMER] = 0; +} + + + +component Vehicle { + variables{ + int warning = OK; + } + controller { + state Ctrl { + if (s_speed > 0) { + if (gap > 0) { + accel' = A; + counter' = TIMER; + step Accelerate; + } else { + accel' = -B; + counter' = TIMER; + step Decelerate; + } + } else { + accel' = V; + counter' = TIMER; + step Stop; + } + } + state Accelerate { + if (counter > 0) { + step Accelerate; + } else { + exec Ctrl; + } + } + state Decelerate { + if (counter > 0) { + step Decelerate; + } else { + exec Ctrl; + } + } + state Stop { + if (counter > 0) { + step Stop; + } else { + if (warning == DANGER) { + accel' = -B; + counter' = TIMER; + step Decelerate; + } else { + counter' = TIMER; + step Stop; + } + } + } + state IDS { + if (IDS_guard(p_distance <= 2*TIMER*SAFETY_DISTANCE, accel == A, accel == V, p_speed > 0.0)) { + warning' = DANGER; + step IDS; + } else { + warning' = OK; + step IDS; + } + } + } + init Ctrl || IDS +} + +environment { + token' = R[0,1]; + counter' = counter-1; + p_speed' = min(MAX_SPEED, max(0, p_speed + accel)); + p_distance' = p_distance - (accel/2 + p_speed); + s_speed' = new_s_speed(p_speed,accel,token); + if (counter-1 == 0) { + gap' = p_distance - (accel/2 + new_s_speed(p_speed,accel,token)) - eval_rd(new_s_speed(p_speed,accel,token)); + } +} + + + +penalty rho_crash = crash_probability(p_distance) + + + +distance exp_crash = \G[250,300] < rho_crash; + + + +perturbation p_slow_02 = [s_speed <- slow_speed(s_speed,offset_speed), + gap <- p_distance - eval_rd(slow_speed(s_speed,offset_speed)), + offset_speed <- p_speed * MAX_OFFSET_02 * R[0,1]]@(TIMER-1); + +perturbation p_ItSlow_02 = ([offset_speed <- p_speed * MAX_OFFSET_02 * R[0,1]]@0); (p_slow_02)^50; + + +perturbation p_slow_03 = [s_speed <- slow_speed(s_speed,offset_speed), + gap <- p_distance - eval_rd(slow_speed(s_speed,offset_speed)), + offset_speed <- p_speed * MAX_OFFSET_03 * R[0,1]]@(TIMER-1); + +perturbation p_ItSlow_03 = ([offset_speed <- p_speed * MAX_OFFSET_03 * R[0,1]]@0); (p_slow_03)^50; + +perturbation p_slow_04 = [s_speed <- slow_speed(s_speed,offset_speed), + gap <- p_distance - eval_rd(slow_speed(s_speed,offset_speed)), + offset_speed <- p_speed * MAX_OFFSET_04 * R[0,1]]@(TIMER-1); + +perturbation p_ItSlow_04 = ([offset_speed <- p_speed * MAX_OFFSET_04 * R[0,1]]@0); (p_slow_04)^50; + +perturbation p_slow_05 = [s_speed <- slow_speed(s_speed,offset_speed), + gap <- p_distance - eval_rd(slow_speed(s_speed,offset_speed)), + offset_speed <- p_speed * MAX_OFFSET_05 * R[0,1]]@(TIMER-1); + +perturbation p_ItSlow_05 = ([offset_speed <- p_speed * MAX_OFFSET_05 * R[0,1]]@0); (p_slow_05)^50; + + + +formula phi_slow_02 = \D[exp_crash,p_ItSlow_02] <= ETA_slow; + +formula phi_slow_03 = \D[exp_crash,p_ItSlow_03] <= ETA_slow; + +formula phi_slow_04 = \D[exp_crash,p_ItSlow_04] <= ETA_slow; + +formula phi_slow_05 = \D[exp_crash,p_ItSlow_05] <= ETA_slow; + +formula always_slow_02 = \G[0,H] phi_slow_02; + +formula always_slow_03 = \G[0,H] phi_slow_03; + +formula always_slow_04 = \G[0,H] phi_slow_04; + +formula always_slow_05 = \G[0,H] phi_slow_05; + + diff --git a/examples/stark/toll.stark b/examples/stark/toll.stark new file mode 100644 index 00000000..ae1488c7 --- /dev/null +++ b/examples/stark/toll.stark @@ -0,0 +1,110 @@ +param A = 0.25; +param B = 2.0; +param N = 0.0; +param TIMER = 1; +param INIT_SPEED = 25.0; +param MAX_SPEED = 40.0; +param INIT_DISTANCE = 10000.0; +param H = 350; + +function eval_bd(real speed) { + return (speed^2 + (A + B) * (A * TIMER^2 + 2 * speed * TIMER)) / (2 * B); +} + +function new_speed (real speed, real acc) { + if (acc == N) { + return max(0.0, speed - A); + } else { + return min(MAX_SPEED, max(0.0, speed + acc)); + } +} + +function new_s_speed (real speed, real acc, real token) { + if (token < 0.5) { + return new_speed(speed, acc) + R[0,0.5]; + } else { + return new_speed(speed, acc) - R[0,0.5]; + } +} + +global variables { + real p_speed range [0,MAX_SPEED] = INIT_SPEED; + real p_distance range [0,INIT_DISTANCE] = INIT_DISTANCE; + real braking_distance range [0, INIT_DISTANCE] = eval_bd(INIT_SPEED); + real gap range [0, INIT_DISTANCE] = INIT_DISTANCE - eval_bd(INIT_SPEED); +} + +component vehicle { + variables{ + real s_speed range [0,MAX_SPEED] = 25.0; + real accel range [-B,A]= N; + int timer_V range [0,TIMER] = 0; + } + controller { + state Ctrl { + if (s_speed > 0) { + if (gap > 0) { + accel' = A; + timer_V' = TIMER; + step Accelerate; + } else { + accel' = - B; + timer_V' = TIMER; + step Decelerate; + } + } else { + if (gap > 0) { + accel' = A; + timer_V' = TIMER; + step Accelerate; + } else { + accel' = N; + timer_V' = TIMER; + step Stop; + } + } + } + state Accelerate { + if (timer_V > 0) { + step Accelerate; + } else { + exec Ctrl; + } + } + state Decelerate { + if (timer_V > 0) { + step Decelerate; + } else { + exec Ctrl; + } + } + state Stop { + if (timer_V > 0) { + step Stop; + } else { + timer_V' = TIMER; + step Stop; + } + } + } + init Ctrl +} + +environment{ + let + travel = max(0.0, accel/2 + p_speed) + and + token = R[0,1] + and + new_sens_speed = new_s_speed(p_speed, accel, token) + in { + timer_V' = timer_V - 1; + p_speed' = new_speed(p_speed, accel); + s_speed' = new_sens_speed; + p_distance' = p_distance - travel; + if (timer_V - 1 == 0) { + braking_distance' = eval_bd(new_sens_speed); + gap' = p_distance - travel - eval_bd(new_sens_speed); + } + } +} \ No newline at end of file diff --git a/examples/stark/tollbooth.stark b/examples/stark/tollbooth.stark new file mode 100644 index 00000000..00e305fa --- /dev/null +++ b/examples/stark/tollbooth.stark @@ -0,0 +1,129 @@ +/* + * Ported from the original `tollbooth` example. + * + * The vehicle dynamics here (variables, controller states, environment + * update) are the same model already ported in `toll.stark`/`two_vehicles.stark` + * — this is the same scenario under a different name upstream, just with the + * parameters spelled out (`ACCELERATION`/`BRAKE`/`NEUTRAL`) instead of + * `toll.stark`'s shorthand (`A`/`B`/`N`). + * + * What's specific to this example are the four `penalty` declarations + * (`rho_100`..`rho_350`, each just `p_distance` scaled by a different + * constant) — those map directly to this grammar's `penalty` declarations. + * What's built on top of them (the original's compositional penalties, and + * the online-monitoring robustness properties over them) is the same + * formalism discussed in `monitoring.stark` — a different + * verification approach from this grammar's `distance`/`perturbation`/ + * `formula` (ROBTL) declarations, with no textual-STARK equivalent, so it + * isn't ported. + */ + +param ACCELERATION = 0.25; +param BRAKE = 2.0; +param NEUTRAL = 0.0; +param TIMER = 1; +param INIT_SPEED = 25.0; +param MAX_SPEED = 40.0; +param INIT_DISTANCE = 10000.0; +param H = 350; + +global variables { + real p_speed = INIT_SPEED; + real s_speed = INIT_SPEED; + real p_distance = INIT_DISTANCE; + real accel = NEUTRAL; + int timer_V = 0; + real braking_distance = (INIT_SPEED * INIT_SPEED + (ACCELERATION + BRAKE) * (ACCELERATION * TIMER * TIMER + 2 * INIT_SPEED * TIMER)) / (2 * BRAKE); + real gap = INIT_DISTANCE - (INIT_SPEED * INIT_SPEED + (ACCELERATION + BRAKE) * (ACCELERATION * TIMER * TIMER + 2 * INIT_SPEED * TIMER)) / (2 * BRAKE); +} + +component Vehicle { + variables { } + controller { + state Ctrl { + if (s_speed > 0) { + if (gap > 0) { + accel' = ACCELERATION; + timer_V' = TIMER; + step Accelerate; + } else { + accel' = -BRAKE; + timer_V' = TIMER; + step Decelerate; + } + } else { + if (gap > 0) { + accel' = ACCELERATION; + timer_V' = TIMER; + step Accelerate; + } else { + accel' = NEUTRAL; + timer_V' = TIMER; + step Stop; + } + } + } + state Accelerate { + if (timer_V > 0) { + step Accelerate; + } else { + /* BUG FIXED: was `step Ctrl;`. The original's `Accelerate` is + conditional: while `timer_V > 0` it idles back into `Accelerate`, + otherwise it continues into `Ctrl`. That else branch is a *bare* + controller reference, which is a same-tick jump (`exec`), not a + time-consuming `step`. `step Ctrl` inserted a spurious + idle round before Ctrl re-planned. Matches the identical model in the + pre-existing toll.stark/two_vehicles.stark, which correctly use + `exec Ctrl`. */ + exec Ctrl; + } + } + state Decelerate { + if (timer_V > 0) { + step Decelerate; + } else { + /* BUG FIXED: was `step Ctrl;` — same fix as Accelerate above (the original's else + branch is a bare `reference("Ctrl")` = same-tick `exec`). */ + exec Ctrl; + } + } + state Stop { + if (timer_V > 0) { + step Stop; + } else { + timer_V' = TIMER; + step Stop; + } + } + } + init Ctrl +} + +environment { + let + travel = max(accel/2 + p_speed, 0.0) + and + new_p_speed = (accel == NEUTRAL ? max(0.0, p_speed - ACCELERATION) : min(MAX_SPEED, max(0.0, p_speed + accel))) + and + token = R[0,1] + and + new_s_speed = (token < 0.5 ? new_p_speed + R[0,1] * 0.5 : new_p_speed - R[0,1] * 0.5) + in { + timer_V' = timer_V - 1; + p_speed' = new_p_speed; + p_distance' = p_distance - travel; + if (timer_V - 1 == 0) { + s_speed' = new_s_speed; + braking_distance' = (new_s_speed * new_s_speed + (ACCELERATION + BRAKE) * (ACCELERATION * TIMER * TIMER + 2 * new_s_speed * TIMER)) / (2 * BRAKE); + gap' = (p_distance - travel) - (new_s_speed * new_s_speed + (ACCELERATION + BRAKE) * (ACCELERATION * TIMER * TIMER + 2 * new_s_speed * TIMER)) / (2 * BRAKE); + } + } +} + +penalty rho_100 = p_distance / INIT_DISTANCE + +penalty rho_200 = p_distance / 7000 + +penalty rho_275 = p_distance / 2500 + +penalty rho_350 = p_distance / 10 diff --git a/examples/stark/turtle.stark b/examples/stark/turtle.stark new file mode 100644 index 00000000..195b30d3 --- /dev/null +++ b/examples/stark/turtle.stark @@ -0,0 +1,201 @@ +/* + * Ported from the original `turtle` example's industrial-plant scenario: + * a robot navigating a sequence of waypoints, with a speed/acceleration + * controller cycle (`SetDir` picks a heading, `Ctrl`/`Accelerate`/ + * `Decelerate`/`Stop` manage speed) essentially identical to + * `toll.stark`/`vehicle_full.stark`'s vehicle-following cycle, applied here + * to waypoint tracking instead of gap-keeping. + * + * `turtle`'s second scenario, the smart hospital, is the same + * waypoint-following-robot mechanic (same controller shape, same feedback + * system) applied to a different environment/waypoint list, so it isn't + * ported separately. + * + * STARK has no array type, so the original's `WPx`/`WPy` waypoint arrays + * become `wp_x`/`wp_y` functions doing the lookup via nested ternaries (7 + * waypoints, so 7-way nested `?:`) — the natural STARK equivalent of a fixed + * lookup table. + * + * The original also builds a `FeedbackSystem`/`PersistentFeedback` + * (comparing the running system against the mean of its own nominal + * evolution sequence, correcting speed/waypoint drift): the feedback + * framework is available only from the original library, with no + * textual-STARK construct at all, so it isn't ported — same as the + * online-monitoring gap in `monitoring.stark`. + * + * The original's perturbation is a persistent one (applied at + * *every* step, indefinitely); approximated here with a large but finite + * iteration count (`^300`), since this grammar's `^` always takes a + * concrete count. As in `vehicle_full.stark`, the perturbation draws its own + * `R[0,1]` for each assignment that needs the same random offset + * (`s_speed`/`gap` both depend on one `fake_speed` in the original), which + * is a known fidelity gap — see `crates/stark/plan.md`. + */ + +param PI = 3.141592653589793; +param ACCELERATION = 0.05; +param BRAKE = 0.40; +param NEUTRAL = 0.0; +param TIMER = 1; +param INIT_SPEED = 0.0; +param MAX_SPEED = 3.0; +param MAX_SPEED_OFFSET = 0.15; +param INIT_X = 0.0; +param INIT_Y = 0.0; +param INIT_THETA = PI/2; +param FINAL_X = 35.0; +param FINAL_Y = 30.0; +param INIT_DISTANCE = sqrt((1.0-INIT_X)^2 + (3.0-INIT_Y)^2); +param LAST_WAYPOINT = 6; + +function wp_x(int i) { + return (i==0 ? 1.0 : (i==1 ? 13.0 : (i==2 ? 7.0 : (i==3 ? 23.0 : (i==4 ? 20.0 : (i==5 ? 32.0 : FINAL_X)))))); +} + +function wp_y(int i) { + return (i==0 ? 3.0 : (i==1 ? 3.0 : (i==2 ? 7.0 : (i==3 ? 14.0 : (i==4 ? 31.0 : (i==5 ? 40.0 : FINAL_Y)))))); +} + +function heading_to(int wp, real x, real y) { + /* BUG FIXED: the original computes + (WPx[wp]==x) ? 0 : ((WPx[wp] 0) { + theta' = heading_to(currentWP, x, y); + step Ctrl; + } else { + if (currentWP == LAST_WAYPOINT) { + timer_V' = TIMER; + step Stop; + } else { + currentWP' = currentWP + 1; + theta' = heading_to(currentWP + 1, x, y); + step Ctrl; + } + } + } + state Ctrl { + if (s_speed > 0) { + if (gap > 0) { + accel' = ACCELERATION; + timer_V' = TIMER; + step Accelerate; + } else { + accel' = -BRAKE; + timer_V' = TIMER; + step Decelerate; + } + } else { + if (gap > 0) { + accel' = ACCELERATION; + timer_V' = TIMER; + step Accelerate; + } else { + accel' = NEUTRAL; + timer_V' = TIMER; + step SetDir; + } + } + } + state Accelerate { + if (timer_V > 0) { + step Accelerate; + } else { + /* BUG FIXED: was `step Ctrl;`. the original's `Accelerate` is + conditional: while `timer_V > 0` it idles back into `Accelerate`, + otherwise it continues into `Ctrl`. That else branch is a bare + reference — a same-tick `exec`, not a time-consuming `step`. */ + exec Ctrl; + } + } + state Decelerate { + if (timer_V > 0) { + step Decelerate; + } else { + /* BUG FIXED: was `step Ctrl;` — same as Accelerate (the original's else is a bare + `reference("Ctrl")` = same-tick `exec`). */ + exec Ctrl; + } + } + state Stop { + if (timer_V > 0) { + step Stop; + } else { + timer_V' = TIMER; + step Stop; + } + } + } + init SetDir +} + +environment { + let + new_p_speed = (accel == NEUTRAL ? max(0.0, p_speed - ACCELERATION) : min(MAX_SPEED, max(0.0, p_speed + accel))) + and + newX = x + cos(theta) * new_p_speed + and + newY = y + sin(theta) * new_p_speed + and + new_p_distance = sqrt((wp_x(currentWP) - newX)^2 + (wp_y(currentWP) - newY)^2) + and + new_gap = new_p_distance - braking_distance(new_p_speed) + in { + x' = newX; + y' = newY; + timer_V' = timer_V - 1; + p_speed' = new_p_speed; + p_distance' = new_p_distance; + s_speed' = new_p_speed; + gap' = new_gap; + } +} + +/* Placeholder: the original computes this as max(sampled Euclidean + distance-to-waypoint) after running the simulation; there's no static + equivalent here. */ +param NORMALISATION = 40.0; + +penalty rho_p2p = sqrt((x - wp_x(currentWP))^2 + (y - wp_y(currentWP))^2) / NORMALISATION + +distance dist_p2p = < rho_p2p; +distance max_dist_p2p = \G[0,200] dist_p2p; + +perturbation p_slower = ([ + s_speed <- max(0.0, p_speed - R[0,1]*MAX_SPEED_OFFSET), + gap <- p_distance - braking_distance(max(0.0, p_speed - R[0,1]*MAX_SPEED_OFFSET)) +]@0)^300; + +formula robust_p2p = \D[max_dist_p2p, p_slower] <= 0.3; diff --git a/examples/stark/turtle_hospital.stark b/examples/stark/turtle_hospital.stark new file mode 100644 index 00000000..7a07dfd4 --- /dev/null +++ b/examples/stark/turtle_hospital.stark @@ -0,0 +1,224 @@ +/* + * Ported from the original `turtle` example's smart-hospital scenario: + * the same waypoint-following robot controller/environment shape as + * `turtle.stark` (the industrial plant) — `SetDir`/`Ctrl`/`Accelerate`/ + * `Decelerate`/`Stop`, gap-vs-braking-distance speed control — but *not* + * just a different waypoint list: this scenario adds a medicine-delivery + * task layered on top (`get_medicine`: 0 not carrying, 1 carrying, -1 + * dropped; `fail`: delivery failed) that genuinely changes the environment + * logic, so it's ported as its own file rather than folded into + * `turtle.stark`. + * + * Medicine is picked up at waypoint 2, delivered at waypoint 8, and dropped + * if the robot turns more than PI/9 while carrying it above + * `MAX_SPEED_WITH_MED` — delivery also fails if the robot reaches waypoint 7 + * without carrying the medicine, or waypoint 11 without having delivered it. + * All four conditions read the *same* pre-round state in the original + * (`state.get(get_medicine)` never reflects an update queued earlier in the + * same call), and the "drop" check is written *after* the delivery check in + * `getEnvironmentUpdates`, so it takes priority when both could fire in the + * same round (reaching waypoint 8 while also over-turning) — ported by + * checking the drop condition first in `new_get_medicine`'s ternary chain, + * matching this grammar's "later assignment overrides" semantics used + * throughout this session (e.g. `polistil.stark`). + * + * The original's `FeedbackSystem`/`PersistentFeedback` (comparing the + * running system to the mean of its own nominal evolution, correcting + * heading/waypoint drift) is the same feedback extension, available only + * already documented as untranslatable in `turtle.stark`'s header — not + * ported here either. + * + * The `ChangeDir` perturbation has *two* parts: an unconditional heading + * jitter (`theta <- theta + R[-0.05,0.05]`, ported below) applied every + * step, and a periodic speed boost gated on `state.getStep() % k == 0` — the + * absolute simulation-step counter has no equivalent expression in this + * grammar (there is no `step`/"current round index" primitive available to + * `Expression`), so the speed-boost half of the perturbation cannot be + * expressed at all. This is a new, previously undocumented gap; see + * `crates/stark/plan.md`. As in `turtle.stark`, the original's persistent + * perturbation (applied at *every* step, forever) is approximated with a + * large but finite iteration count (`^300`). + * + * The original sweeps thresholds `eta` in [0.05, 0.15] across three `off` + * values (1.25, 1.5, 1.75) for its `\G[0,14] \D[...] <= eta` robustness + * query; only one representative combination (`off = 1.5`, `eta = 0.10`) is + * ported, matching the `isocitrate.stark`/`envzompr.stark` precedent of not + * guessing at every swept value. + */ + +param PI = 3.141592653589793; +param ACCELERATION = 0.05; +param BRAKE = 0.1; +param NEUTRAL = 0.0; +param TIMER = 1; +param INIT_SPEED = 0.0; +param MAX_SPEED = 1.0; +param MAX_SPEED_WITH_MED = 0.5; +param MAX_THETA_OFFSET = 0.1; +param INIT_X = 15.0; +param INIT_Y = 6.0; +param INIT_THETA = PI/2; +param FINAL_X = 15.0; +param FINAL_Y = 6.0; +param INIT_DISTANCE = sqrt((13.0-INIT_X)^2 + (6.0-INIT_Y)^2); +param LAST_WAYPOINT = 11; + +function wp_x(int i) { + return (i==0 ? 13.0 : (i==1 ? 13.0 : (i==2 ? 13.0 : (i==3 ? 6.0 : (i==4 ? 6.0 : (i==5 ? 2.0 : + (i==6 ? 6.0 : (i==7 ? 6.0 : (i==8 ? 2.0 : (i==9 ? 6.0 : (i==10 ? 6.0 : FINAL_X))))))))))); +} + +function wp_y(int i) { + return (i==0 ? 6.0 : (i==1 ? 1.0 : (i==2 ? 6.0 : (i==3 ? 6.0 : (i==4 ? 2.0 : (i==5 ? 2.0 : + (i==6 ? 2.0 : (i==7 ? 7.0 : (i==8 ? 7.0 : (i==9 ? 7.0 : (i==10 ? 6.0 : FINAL_Y))))))))))); +} + +function heading_to(int wp, real x, real y) { + /* BUG FIXED: the original's ternary `?:` binds looser than `+`, so in + (WPx[wp]==x) ? 0 : ((WPx[wp] 0) { + previous_theta' = theta; + theta' = heading_to(currentWP, x, y); + step Ctrl; + } else { + if (currentWP == LAST_WAYPOINT) { + timer_V' = TIMER; + step Stop; + } else { + previous_theta' = theta; + currentWP' = currentWP + 1; + theta' = heading_to(currentWP + 1, x, y); + step Ctrl; + } + } + } + state Ctrl { + if (s_speed > 0) { + if (gap > 0) { + accel' = ACCELERATION; + timer_V' = TIMER; + step Accelerate; + } else { + accel' = -BRAKE; + timer_V' = TIMER; + step Decelerate; + } + } else { + if (gap > 0) { + accel' = ACCELERATION; + timer_V' = TIMER; + step Accelerate; + } else { + accel' = NEUTRAL; + timer_V' = TIMER; + step SetDir; + } + } + } + state Accelerate { + if (timer_V > 0) { + step Accelerate; + } else { + /* BUG FIXED: was `step Ctrl;`. the original's `Accelerate` is + conditional: while `timer_V > 0` it idles back into `Accelerate`, + otherwise it continues into `Ctrl`. That else branch is a bare + reference — a same-tick `exec`, not a time-consuming `step` (same + as turtle.stark). */ + exec Ctrl; + } + } + state Decelerate { + if (timer_V > 0) { + step Decelerate; + } else { + /* BUG FIXED: was `step Ctrl;` — same as Accelerate (bare `reference("Ctrl")` + in the original, else = same-tick `exec`). */ + exec Ctrl; + } + } + state Stop { + if (timer_V > 0) { + step Stop; + } else { + timer_V' = TIMER; + step Stop; + } + } + } + init SetDir +} + +environment { + let + new_p_speed = (accel == NEUTRAL ? max(0.0, p_speed - ACCELERATION) : min(MAX_SPEED, max(0.0, p_speed + accel))) + and + newX = x + cos(theta) * new_p_speed + and + newY = y + sin(theta) * new_p_speed + and + new_p_distance = sqrt((wp_x(currentWP) - newX)^2 + (wp_y(currentWP) - newY)^2) + and + new_gap = new_p_distance - braking_distance(new_p_speed) + and + dropped = (abs(theta - previous_theta) > PI/9 && get_medicine == 1 && new_p_speed > MAX_SPEED_WITH_MED) + and + delivered = (currentWP == 8 && get_medicine == 1) + and + picked_up = (currentWP == 2 && get_medicine == 0) + in { + x' = newX; + y' = newY; + timer_V' = timer_V - 1; + p_speed' = new_p_speed; + p_distance' = new_p_distance; + s_speed' = new_p_speed; + gap' = new_gap; + get_medicine' = (dropped ? -1 : (delivered ? 0 : (picked_up ? 1 : get_medicine))); + fail' = (dropped || (currentWP == 7 && !(get_medicine == 1)) || (currentWP == 11 && !(get_medicine == 0)) ? 1 : fail); + flag' = (get_medicine == 1 && flag == 0 ? 1 : flag); + previous_theta' = theta; + } +} + +penalty rho_fail = fail + +distance atomic_fail = < rho_fail; +distance fail_interval = \G[31,236] atomic_fail; + +/* Only the unconditional heading jitter is portable — see file header for + the periodic speed-boost gap. */ +perturbation p_changedir = ([theta <- theta + R[-0.05,0.05]]@0)^300; + +formula phi_fail = \G[0,14] \D[fail_interval, p_changedir] <= 0.10; diff --git a/examples/stark/two_vehicles.stark b/examples/stark/two_vehicles.stark new file mode 100644 index 00000000..97153fa9 --- /dev/null +++ b/examples/stark/two_vehicles.stark @@ -0,0 +1,421 @@ +param ACCELERATION = 1.0; +param BRAKE = 2.0; +param NEUTRAL = 0.0; +param TIMER_INIT = 5; +param DANGER = 1; +param OK = 0; +param INIT_SPEED_V1 = 25.0; +param INIT_SPEED_V2 = 25.0; +param MAX_SPEED = 40.0; +param MAX_SPEED_OFFSET_02 = 0.2; +param MAX_SPEED_OFFSET_03 = 0.3; +param MAX_SPEED_OFFSET_04 = 0.4; +param INIT_DISTANCE_OBS_V1 = 10000.0; +param INIT_DISTANCE_V1_V2 = 5000.0; +param MAX_DISTANCE_OFFSET = 1.0; +param SAFETY_DISTANCE = 200.0; +param ETA_fast = 0.05; +param ETA_slow = 0.1; +param ETA_comb = 0.1; +param ETA_crash_speed = 0.05; +param ETA_crash_speed_bis = 0.1; +param ETA_crash_speed_ter = 0.15; + + +function new_timer(int timer){ + return timer - 1; +} + +function travelled(real speed, real accel){ + return accel/2 + speed; +} + +function new_speed(real speed, real accel){ + return min(MAX_SPEED, max(0, speed + accel)); +} + +function new_distance(real dist, real travel){ + return dist - travel; +} + +function eval_bd(real speed){ + return (speed^2 + (ACCELERATION + BRAKE) * (ACCELERATION * TIMER_INIT^2 + 2 * speed * TIMER_INIT)) / (2 * BRAKE); +} + +function eval_rd(real speed){ + return eval_bd(speed) + SAFETY_DISTANCE; +} + + +function effectOf(real v) { + if (v == ACCELERATION) { + return 1.0; + } else { + if (v == NEUTRAL) { + return 0.0; + } else { + return -1.0; + } + } +} + +function crash_probability(real dist){ + if (dist > 0){ + return 0.0; + } else { + return 1.0; + } +} + +function crash_speed(int collision, real dist_object, real dist_vehicles, real speed){ + if (collision == 0 && (dist_object <=0 || dist_vehicles <=0)){ + return speed/MAX_SPEED; + } else { + return 0.0; + } +} + +function slow_speed(real speed, real offs){ + return max(0, speed - offs); +} + +function fast_speed(real speed, real offs){ + return min(MAX_SPEED, speed + offs); +} + +function controller_guard(bool gap1, bool gap2, bool light, bool dist){ + return gap1 && (light || dist) && gap2; +} + +function IDS_guard(bool dist, bool acc1, bool acc2, bool speed){ + return dist && (acc1 || (acc2 && speed)); +} + + + + +global variables{ + real p_speed_V1 range [0,MAX_SPEED] = INIT_SPEED_V1; + real p_distance_V1 range [0,INIT_DISTANCE_OBS_V1] = INIT_DISTANCE_OBS_V1; + int timer_V1 range [0,TIMER_INIT] = 0; + real braking_distance_V1 range [0, INIT_DISTANCE_OBS_V1] = eval_bd(INIT_SPEED_V1); + real required_distance_V1 range [0, INIT_DISTANCE_OBS_V1] = eval_rd(INIT_SPEED_V1); + real safety_gap_V1 range [0, INIT_DISTANCE_OBS_V1] = INIT_DISTANCE_OBS_V1 - eval_rd(INIT_SPEED_V1); + real p_speed_V2 range [0,MAX_SPEED] = INIT_SPEED_V2; + real p_distance_V2 range [0,INIT_DISTANCE_OBS_V1 + INIT_DISTANCE_V1_V2] = INIT_DISTANCE_OBS_V1 + INIT_DISTANCE_V1_V2; + real p_distance_V1_V2 range [0,INIT_DISTANCE_V1_V2 + INIT_DISTANCE_OBS_V1] = INIT_DISTANCE_V1_V2; + int timer_V2 range [0,TIMER_INIT] = 0; + real braking_distance_V2 range [0, INIT_DISTANCE_V1_V2 + INIT_DISTANCE_OBS_V1] = eval_bd(INIT_SPEED_V2); + real required_distance_V2 range [0, INIT_DISTANCE_V1_V2 + + INIT_DISTANCE_OBS_V1] = eval_rd(INIT_SPEED_V2); + real safety_gap_V2 range [0, INIT_DISTANCE_OBS_V1 + INIT_DISTANCE_V1_V2] = INIT_DISTANCE_OBS_V1 + INIT_DISTANCE_V1_V2 - eval_rd(INIT_SPEED_V2); + real safety_gap_V1_V2 range [0, INIT_DISTANCE_OBS_V1 + INIT_DISTANCE_V1_V2] = INIT_DISTANCE_V1_V2 - eval_rd(INIT_SPEED_V2); + real offset_speed_V1 range [0,MAX_SPEED*MAX_SPEED_OFFSET_04] = 0.0; + real offset_speed_V2 range [0,MAX_SPEED*MAX_SPEED_OFFSET_04] = 0.0; + real offset_distance range [0,INIT_DISTANCE_V1_V2*MAX_DISTANCE_OFFSET] = 0.0; +} + + +component Vehicle1 { + variables{ + real s_speed_V1 range [0,MAX_SPEED] = 25.0; + real s_distance_V1 range [0,INIT_DISTANCE_OBS_V1] = INIT_DISTANCE_OBS_V1; + real accel_V1 range [-BRAKE,ACCELERATION]= NEUTRAL; + int warning_V1 range [0,1] = 0; + int brake_light_V1 range [0,1] = 0; + int crashed_V1 range [0,1] = 0; + } + controller { + state Ctrl_V1 { + if (s_speed_V1 > 0) { + if (safety_gap_V1 > 0) { + accel_V1' = ACCELERATION; + timer_V1' = TIMER_INIT; + brake_light_V1' = 0; + step Accelerate_V1; + } else { + accel_V1' = - BRAKE; + timer_V1' = TIMER_INIT; + brake_light_V1' = 1; + step Decelerate_V1; + } + } else { + accel_V1' = NEUTRAL; + timer_V1' = TIMER_INIT; + step Stop_V1; + } + } + state Accelerate_V1 { + if (timer_V1 > 0) { + step Accelerate_V1; + } else { + exec Ctrl_V1; + } + } + state Decelerate_V1 { + if (timer_V1 > 0) { + step Decelerate_V1; + } else { + exec Ctrl_V1; + } + } + state Stop_V1 { + if (timer_V1 > 0) { + step Stop_V1; + } else { + if (warning_V1 == DANGER) { + accel_V1' = -BRAKE; + timer_V1' = TIMER_INIT; + brake_light_V1' = 1; + step Decelerate_V1; + } else { + timer_V1' = TIMER_INIT; + step Stop_V1; + } + } + } + state IDS_V1 { + if (IDS_guard(p_distance_V1 <= 2*TIMER_INIT*SAFETY_DISTANCE, accel_V1 == ACCELERATION, accel_V1 == NEUTRAL, p_speed_V1 > 0.0)) { + warning_V1' = DANGER; + step IDS_V1; + } else { + warning_V1' = OK; + step IDS_V1; + } + } + } + init Ctrl_V1 || IDS_V1 +} + +component Vehicle2 { + variables{ + real s_speed_V2 range [0,MAX_SPEED] = 25.0; + real s_distance_V2 range [0,INIT_DISTANCE_OBS_V1 + INIT_DISTANCE_V1_V2] = INIT_DISTANCE_OBS_V1 + INIT_DISTANCE_V1_V2; + real s_distance_V1_V2 range [0,INIT_DISTANCE_OBS_V1 + INIT_DISTANCE_V1_V2] = INIT_DISTANCE_V1_V2; + real accel_V2 range [-BRAKE,ACCELERATION] = NEUTRAL; + int warning_V2 range [0,1] = 0; + int brake_light_V2 range [0,1] = 0; + int crashed_V2 range [0,1] = 0; + } + controller { + state Ctrl_V2 { + if (s_speed_V2 > 0) { + if (controller_guard(safety_gap_V2 > 0,safety_gap_V1_V2 > 0,brake_light_V1 == 0,s_distance_V1_V2 > 300)) { + accel_V2' = ACCELERATION; + timer_V2' = TIMER_INIT; + brake_light_V2' = 0; + step Accelerate_V2; + } else { + accel_V2' = - BRAKE; + timer_V2' = TIMER_INIT; + brake_light_V2' = 1; + step Decelerate_V2; + } + } else { + accel_V2' = NEUTRAL; + timer_V2' = TIMER_INIT; + step Stop_V2; + } + } + state Accelerate_V2 { + if (timer_V2 > 0) { + step Accelerate_V2; + } else { + exec Ctrl_V2; + } + } + state Decelerate_V2 { + if (timer_V2 > 0) { + step Decelerate_V2; + } else { + exec Ctrl_V2; + } + } + state Stop_V2 { + if (timer_V2 > 0) { + step Stop_V2; + } else { + if (warning_V2 == DANGER) { + accel_V2' = -BRAKE; + timer_V2' = TIMER_INIT; + brake_light_V2' = 1; + step Decelerate_V2; + } else { + timer_V2' = TIMER_INIT; + step Stop_V2; + } + } + } + state IDS_V2 { + if (IDS_guard(p_distance_V2 <= 2*TIMER_INIT*SAFETY_DISTANCE, accel_V2 == ACCELERATION, accel_V2 == NEUTRAL, p_speed_V2 > 0.0)) { + warning_V2' = DANGER; + step IDS_V2; + } else { + warning_V2' = OK; + step IDS_V2; + } + } + } + init Ctrl_V2 || IDS_V2 +} + +environment { + timer_V1' = new_timer(timer_V1); + p_speed_V1' = new_speed(p_speed_V1,accel_V1); + p_distance_V1' = new_distance(p_distance_V1, travelled(p_speed_V1,accel_V1)); + timer_V2' = new_timer(timer_V2); + p_speed_V2' = new_speed(p_speed_V2,accel_V2); + p_distance_V2' = new_distance(p_distance_V2, travelled(p_speed_V2,accel_V2)); + p_distance_V1_V2' = new_distance(p_distance_V1_V2, travelled(p_speed_V2,accel_V2) - travelled(p_speed_V1,accel_V1)); + if (new_timer(timer_V1) == 0) { + braking_distance_V1' = eval_bd(new_speed(p_speed_V1,accel_V1)); + required_distance_V1' = eval_rd(new_speed(p_speed_V1,accel_V1)); + safety_gap_V1' = new_distance(p_distance_V1, travelled(p_speed_V1,accel_V1)) - eval_rd(new_speed(p_speed_V1,accel_V1)); + s_speed_V1' = new_speed(p_speed_V1,accel_V1); + s_distance_V1' = new_distance(p_distance_V1, travelled(p_speed_V1,accel_V1)); + } + if (new_timer(timer_V2) == 0) { + braking_distance_V2' = eval_bd(new_speed(p_speed_V2,accel_V2)); + required_distance_V2' = eval_rd(new_speed(p_speed_V2,accel_V2)); + safety_gap_V2' = new_distance(p_distance_V2, travelled(p_speed_V2,accel_V2)) - eval_rd(new_speed(p_speed_V2,accel_V2)); + safety_gap_V1_V2' = new_distance(p_distance_V1_V2, travelled(p_speed_V2,accel_V2) - travelled(p_speed_V1,accel_V1)) - eval_rd(new_speed(p_speed_V2,accel_V2)); + s_speed_V2' = new_speed(p_speed_V2,accel_V2); + s_distance_V2' = new_distance(p_distance_V2, travelled(p_speed_V2,accel_V2)); + s_distance_V1_V2' = new_distance(p_distance_V1_V2, travelled(p_speed_V2,accel_V2) - travelled(p_speed_V1,accel_V1)); + } + when (p_distance_V1 <= 0) + crashed_V1' = 1; + when (p_distance_V2 <= 0 || p_distance_V1_V2 <=0) + crashed_V2' = 1; +} + +penalty rho_crash = crash_probability(p_distance_V1_V2) + +penalty rho_crash_speed = crash_speed(crashed_V2, p_distance_V2, p_distance_V1_V2, p_speed_V2) + +distance exp_crash = \G[350,450] < rho_crash; + +distance exp_crash_speed = \G[10,400] < rho_crash_speed; + +perturbation p_slow_02 = [s_speed_V2 <- slow_speed(p_speed_V2,offset_speed_V2), + required_distance_V2 <- eval_rd(slow_speed(p_speed_V2,offset_speed_V2)), + safety_gap_V1_V2 <- p_distance_V1_V2 - eval_rd(slow_speed(p_speed_V2,offset_speed_V2)), + offset_speed_V2 <- p_speed_V2 * MAX_SPEED_OFFSET_02 * R[0,1]]@(TIMER_INIT-1); + +perturbation p_ItSlow_02 = ([offset_speed_V2 <- p_speed_V2 * MAX_SPEED_OFFSET_02 * R[0,1]]@0); (p_slow_02)^150; + + +perturbation p_fast_02 = [s_speed_V1 <- fast_speed(p_speed_V1,offset_speed_V1), + required_distance_V1 <- eval_rd(fast_speed(p_speed_V1,offset_speed_V1)), + safety_gap_V1 <- p_distance_V1 - eval_rd(fast_speed(p_speed_V1,offset_speed_V1)), + offset_speed_V1 <- p_speed_V1 * MAX_SPEED_OFFSET_02 * R[0,1]]@(TIMER_INIT-1); + +perturbation p_ItFast_02 = ([offset_speed_V1 <- p_speed_V1 * MAX_SPEED_OFFSET_02 * R[0,1]]@0);(p_fast_02)^150; + + +perturbation p_comb_02 = ([offset_speed_V1 <- p_speed_V1 * MAX_SPEED_OFFSET_02 * R[0,1], + offset_speed_V2 <- p_speed_V2 * MAX_SPEED_OFFSET_02 * R[0,1]]@0); + ((p_fast_02)^3;(p_slow_02)^3)^50; + + +perturbation p_slow_03 = [s_speed_V2 <- slow_speed(p_speed_V2,offset_speed_V2), + required_distance_V2 <- eval_rd(slow_speed(p_speed_V2,offset_speed_V2)), + safety_gap_V1_V2 <- p_distance_V1_V2 - eval_rd(slow_speed(p_speed_V2,offset_speed_V2)), + offset_speed_V2 <- p_speed_V2 * MAX_SPEED_OFFSET_03 * R[0,1]]@(TIMER_INIT-1); + +perturbation p_ItSlow_03 = ([offset_speed_V2 <- p_speed_V2 * MAX_SPEED_OFFSET_03 * R[0,1]]@0); (p_slow_03)^150; + + +perturbation p_fast_03 = [s_speed_V1 <- fast_speed(p_speed_V1,offset_speed_V1), + required_distance_V1 <- eval_rd(fast_speed(p_speed_V1,offset_speed_V1)), + safety_gap_V1 <- p_distance_V1 - eval_rd(fast_speed(p_speed_V1,offset_speed_V1)), + offset_speed_V1 <- p_speed_V1 * MAX_SPEED_OFFSET_03 * R[0,1]]@(TIMER_INIT-1); + +perturbation p_ItFast_03 = ([offset_speed_V1 <- p_speed_V1 * MAX_SPEED_OFFSET_03 * R[0,1]]@0);(p_fast_03)^150; + + +perturbation p_comb_03 = ([offset_speed_V1 <- p_speed_V1 * MAX_SPEED_OFFSET_03 * R[0,1], + offset_speed_V2 <- p_speed_V2 * MAX_SPEED_OFFSET_03 * R[0,1]]@0); + ((p_fast_03)^3;(p_slow_03)^3)^50; + + +perturbation p_slow_04 = [s_speed_V2 <- slow_speed(p_speed_V2,offset_speed_V2), + required_distance_V2 <- eval_rd(slow_speed(p_speed_V2,offset_speed_V2)), + safety_gap_V1_V2 <- p_distance_V1_V2 - eval_rd(slow_speed(p_speed_V2,offset_speed_V2)), + offset_speed_V2 <- p_speed_V2 * MAX_SPEED_OFFSET_04 * R[0,1]]@(TIMER_INIT-1); + +perturbation p_ItSlow_04 = ([offset_speed_V2 <- p_speed_V2 * MAX_SPEED_OFFSET_04 * R[0,1]]@0); (p_slow_04)^150; + + +perturbation p_fast_04 = [s_speed_V1 <- fast_speed(p_speed_V1,offset_speed_V1), + required_distance_V1 <- eval_rd(fast_speed(p_speed_V1,offset_speed_V1)), + safety_gap_V1 <- p_distance_V1 - eval_rd(fast_speed(p_speed_V1,offset_speed_V1)), + offset_speed_V1 <- p_speed_V1 * MAX_SPEED_OFFSET_04 * R[0,1]]@(TIMER_INIT-1); + +perturbation p_ItFast_04 = ([offset_speed_V1 <- p_speed_V1 * MAX_SPEED_OFFSET_04 * R[0,1]]@0);(p_fast_04)^150; + + +perturbation p_comb_04 = ([offset_speed_V1 <- p_speed_V1 * MAX_SPEED_OFFSET_04 * R[0,1], + offset_speed_V2 <- p_speed_V2 * MAX_SPEED_OFFSET_04 * R[0,1]]@0); + ((p_fast_04)^3;(p_slow_04)^3)^50; + + +perturbation p_distSens = [s_distance_V1_V2 <- p_distance_V1_V2 * (1 + offset_distance), + s_distance_V2 <- p_distance_V2 * (1 + offset_distance), + safety_gap_V1_V2 <- p_distance_V1_V2 * (1 + offset_distance) - eval_rd(p_speed_V2), + safety_gap_V2 <- p_distance_V2 * (1 + offset_distance) - eval_rd(p_speed_V2), + offset_distance <- R[0,1] * MAX_DISTANCE_OFFSET]@(TIMER_INIT-1); + +perturbation p_ItDistSens = ([offset_distance <- R[0,1] * MAX_DISTANCE_OFFSET]@0); (p_distSens)^300; + + +formula phi_slow_02 = \D[exp_crash,p_ItSlow_02] <= ETA_slow; + +formula phi_fast_02 = \D[exp_crash,p_ItFast_02] <= ETA_fast; + +formula phi_comb_02 = \D[exp_crash,p_comb_02] <= ETA_comb; + +formula always_slow_02 = \G[0,450] \D[exp_crash,p_ItSlow_02] <= ETA_slow; + +formula always_fast_02 = \G[0,450] \D[exp_crash,p_ItFast_02] <= ETA_fast; + +formula always_comb_02 = \G[0,450]\D[exp_crash,p_comb_02] <= ETA_comb; + + +formula phi_slow_03 = \D[exp_crash,p_ItSlow_03] <= ETA_slow; + +formula phi_fast_03 = \D[exp_crash,p_ItFast_03] <= ETA_fast; + +formula phi_comb_03 = \D[exp_crash,p_comb_03] <= ETA_comb; + +formula always_slow_03 = \G[0,450] \D[exp_crash,p_ItSlow_03] <= ETA_slow; + +formula always_fast_03 = \G[0,450] \D[exp_crash,p_ItFast_03] <= ETA_fast; + +formula always_comb_03 = \G[0,450] \D[exp_crash,p_comb_03] <= ETA_comb; + + +formula phi_slow_04 = \D[exp_crash,p_ItSlow_04] <= ETA_slow; + +formula phi_fast_04 = \D[exp_crash,p_ItFast_04] <= ETA_fast; + +formula phi_comb_04 = \D[exp_crash,p_comb_04] <= ETA_comb; + +formula always_slow_04 = \G[0,450] \D[exp_crash,p_ItSlow_04] <= ETA_slow; + +formula always_fast_04 = \G[0,450] \D[exp_crash,p_ItFast_04] <= ETA_fast; + +formula always_comb_04 = \G[0,450] \D[exp_crash,p_comb_04] <= ETA_comb; + + +formula phi_crash_speed = \D[exp_crash_speed,p_ItDistSens] <= ETA_crash_speed; + +formula phi_crash_speed_bis = \D[exp_crash_speed,p_ItDistSens] <= ETA_crash_speed_bis; + +formula phi_crash_speed_ter = \D[exp_crash_speed,p_ItDistSens] <= ETA_crash_speed_ter; + +formula always_crash_speed = \G[0,450] \D[exp_crash_speed,p_ItDistSens] <= ETA_crash_speed; + +formula always_crash_speed_bis = \G[0,450] \D[exp_crash_speed,p_ItDistSens] <= ETA_crash_speed_bis; + +formula always_crash_speed_ter = \G[0,450] \D[exp_crash_speed,p_ItDistSens] <= ETA_crash_speed_ter; + diff --git a/examples/stark/vehicle_full.stark b/examples/stark/vehicle_full.stark new file mode 100644 index 00000000..de1992d9 --- /dev/null +++ b/examples/stark/vehicle_full.stark @@ -0,0 +1,335 @@ +/* + * Ported from the original `vehicle` example: a + * richer two-vehicle model than `two_vehicles.stark`/`toll.stark` — V1 + * follows a fixed obstacle, V2 follows V1, each with its own IDS + * (intrusion-detection-style warning) state, brake lights, and crash flags. + * + * Perturbation assignments have no `let`, so a computation shared across + * several assignments in the same `[...]@time` block (the original computes + * one random `offset` and reuses it for a fake speed, a fake required + * distance, and a fake safety gap) can't be drawn once and shared — each + * assignment below draws its own `R[0,1]`, so the three "sensor" values are + * no longer derived from exactly the same sample. This is a real fidelity + * gap, not a stylistic choice — see `crates/stark/plan.md`. + * + * The original's one-step-delayed perturbation (wait one step before the + * repeating perturbation starts) is dropped — folding it into the atomic + * perturbation's own `@time` would only shift the start by one tick and + * doesn't change the perturbation strategy under test. + * The original's combined perturbation (three "faster" applications + * sequenced with three "slower" ones, that pair repeated 20 times) maps + * directly to + * this grammar's `;` (sequence) and `^` (iteration) perturbation operators. + */ + +param ACCELERATION = 1.0; +param BRAKE = 2.0; +param NEUTRAL = 0.0; +param TIMER_INIT = 5; +param DANGER = 1; +param OK = 0; +param MAX_SPEED_OFFSET = 0.3; +param INIT_SPEED_V1 = 25.0; +param INIT_SPEED_V2 = 25.0; +param MAX_SPEED = 40.0; +param INIT_DISTANCE_OBS_V1 = 10000.0; +param INIT_DISTANCE_V1_V2 = 5000.0; +param SAFETY_DISTANCE = 200.0; +param ETA_comb = 0.1; +param ETA_fast = 0.05; +param ETA_slow = 0.1; +param H = 450; +param MAX_DISTANCE_OFFSET = 1.0; +param ETA_CRASH_SPEED = 0.05; + +function required_distance(real speed) { + return (speed*speed + (ACCELERATION+BRAKE)*(ACCELERATION*TIMER_INIT*TIMER_INIT + 2*speed*TIMER_INIT))/(2*BRAKE) + SAFETY_DISTANCE; +} + +function faster_speed(real speed, real token) { + return min(MAX_SPEED, speed + speed * token * MAX_SPEED_OFFSET); +} + +function slower_speed(real speed, real token) { + return max(0.0, speed - speed * token * MAX_SPEED_OFFSET); +} + +global variables { + real p_speed_V1 = INIT_SPEED_V1; + real s_speed_V1 = INIT_SPEED_V1; + real p_distance_V1 = INIT_DISTANCE_OBS_V1; + real s_distance_V1 = INIT_DISTANCE_OBS_V1; + real accel_V1 = NEUTRAL; + int timer_V1 = 0; + int warning_V1 = OK; + real braking_distance_V1 = required_distance(INIT_SPEED_V1) - SAFETY_DISTANCE; + real required_distance_V1 = required_distance(INIT_SPEED_V1); + real safety_gap_V1 = INIT_DISTANCE_OBS_V1 - required_distance(INIT_SPEED_V1); + int brake_light_V1 = 0; + + real p_speed_V2 = INIT_SPEED_V2; + real s_speed_V2 = INIT_SPEED_V2; + real p_distance_V2 = INIT_DISTANCE_V1_V2 + INIT_DISTANCE_OBS_V1; + real s_distance_V2 = INIT_DISTANCE_V1_V2 + INIT_DISTANCE_OBS_V1; + real p_distance_V1_V2 = INIT_DISTANCE_V1_V2; + real s_distance_V1_V2 = INIT_DISTANCE_V1_V2; + real accel_V2 = NEUTRAL; + int timer_V2 = 0; + int warning_V2 = OK; + real braking_distance_V2 = required_distance(INIT_SPEED_V2) - SAFETY_DISTANCE; + real required_distance_V2 = required_distance(INIT_SPEED_V2); + real safety_gap_V1_V2 = INIT_DISTANCE_V1_V2 - required_distance(INIT_SPEED_V2); + real safety_gap_V2 = INIT_DISTANCE_V1_V2 + INIT_DISTANCE_OBS_V1 - required_distance(INIT_SPEED_V2); + int brake_light_V2 = 0; + + int crashed_V1 = 0; + int crashed_V2 = 0; +} + +component Vehicle1 { + variables { } + controller { + state Ctrl_V1 { + if (s_speed_V1 > 0) { + if (safety_gap_V1 > 0) { + accel_V1' = ACCELERATION; + timer_V1' = TIMER_INIT; + brake_light_V1' = 0; + step Accelerate_V1; + } else { + accel_V1' = -BRAKE; + timer_V1' = TIMER_INIT; + brake_light_V1' = 1; + step Decelerate_V1; + } + } else { + accel_V1' = NEUTRAL; + timer_V1' = TIMER_INIT; + step Stop_V1; + } + } + state Accelerate_V1 { + if (timer_V1 > 0) { + step Accelerate_V1; + } else { + /* BUG FIXED: was `step Ctrl_V1;`. the original's `Accelerate_V1` is + conditional: while `timer_V1 > 0` it idles back into + `Accelerate_V1`, otherwise it continues into `Ctrl_V1`. That else + branch is a bare reference — a same-tick `exec`, not a + time-consuming `step` (which added a spurious idle round before + Ctrl re-planned). + Matches the toll.stark/two_vehicles.stark timer idiom. */ + exec Ctrl_V1; + } + } + state Decelerate_V1 { + if (timer_V1 > 0) { + step Decelerate_V1; + } else { + /* BUG FIXED: was `step Ctrl_V1;` — same as Accelerate_V1 (bare reference + in the original, else = same-tick `exec`). */ + exec Ctrl_V1; + } + } + state Stop_V1 { + if (timer_V1 > 0) { + step Stop_V1; + } else { + if (warning_V1 == DANGER) { + accel_V1' = -BRAKE; + timer_V1' = TIMER_INIT; + brake_light_V1' = 1; + step Decelerate_V1; + } else { + timer_V1' = TIMER_INIT; + step Stop_V1; + } + } + } + state IDS_V1 { + if (p_distance_V1 <= 2*TIMER_INIT*SAFETY_DISTANCE && (accel_V1 == ACCELERATION || (accel_V1 == NEUTRAL && p_speed_V1 > 0.0))) { + warning_V1' = DANGER; + step IDS_V1; + } else { + warning_V1' = OK; + step IDS_V1; + } + } + } + init Ctrl_V1 || IDS_V1 +} + +component Vehicle2 { + variables { } + controller { + state Ctrl_V2 { + if (s_speed_V2 > 0) { + if (safety_gap_V1_V2 > 0 && (brake_light_V1 == 0 || s_distance_V1_V2 >= 300) && safety_gap_V2 > 0) { + accel_V2' = ACCELERATION; + timer_V2' = TIMER_INIT; + brake_light_V2' = 0; + step Accelerate_V2; + } else { + accel_V2' = -BRAKE; + timer_V2' = TIMER_INIT; + brake_light_V2' = 1; + step Decelerate_V2; + } + } else { + accel_V2' = NEUTRAL; + timer_V2' = TIMER_INIT; + step Stop_V2; + } + } + state Accelerate_V2 { + if (timer_V2 > 0) { + step Accelerate_V2; + } else { + /* BUG FIXED: was `step Ctrl_V2;` — the original's else is a bare + `reference("Ctrl_V2")` = same-tick `exec`, not a `step`. */ + exec Ctrl_V2; + } + } + state Decelerate_V2 { + if (timer_V2 > 0) { + step Decelerate_V2; + } else { + /* BUG FIXED: was `step Ctrl_V2;` — same as Accelerate_V2. */ + exec Ctrl_V2; + } + } + state Stop_V2 { + if (timer_V2 > 0) { + step Stop_V2; + } else { + if (warning_V2 == DANGER) { + accel_V2' = -BRAKE; + timer_V2' = TIMER_INIT; + brake_light_V2' = 1; + step Decelerate_V2; + } else { + timer_V2' = TIMER_INIT; + step Stop_V2; + } + } + } + state IDS_V2 { + if (p_distance_V2 <= 2*TIMER_INIT*SAFETY_DISTANCE && (accel_V2 == ACCELERATION || (accel_V2 == NEUTRAL && p_speed_V2 > 0.0))) { + warning_V2' = DANGER; + step IDS_V2; + } else { + warning_V2' = OK; + step IDS_V2; + } + } + } + init Ctrl_V2 || IDS_V2 +} + +environment { + let + travel_V1 = accel_V1/2 + p_speed_V1 + and + new_timer_V1 = timer_V1 - 1 + and + new_p_speed_V1 = min(MAX_SPEED, max(0.0, p_speed_V1 + accel_V1)) + and + new_p_distance_V1 = p_distance_V1 - travel_V1 + and + travel_V2 = accel_V2/2 + p_speed_V2 + and + new_timer_V2 = timer_V2 - 1 + and + new_p_speed_V2 = min(MAX_SPEED, max(0.0, p_speed_V2 + accel_V2)) + and + new_p_distance_V1_V2 = p_distance_V1_V2 - travel_V2 + travel_V1 + and + new_p_distance_V2 = p_distance_V2 - travel_V2 + in { + timer_V1' = new_timer_V1; + p_speed_V1' = new_p_speed_V1; + p_distance_V1' = new_p_distance_V1; + timer_V2' = new_timer_V2; + p_speed_V2' = new_p_speed_V2; + p_distance_V2' = new_p_distance_V2; + p_distance_V1_V2' = new_p_distance_V1_V2; + if (new_timer_V1 == 0) { + let + new_bd_V1 = (new_p_speed_V1*new_p_speed_V1 + (ACCELERATION+BRAKE)*(ACCELERATION*TIMER_INIT*TIMER_INIT + 2*new_p_speed_V1*TIMER_INIT))/(2*BRAKE) + and + new_rd_V1 = new_bd_V1 + SAFETY_DISTANCE + and + new_sg_V1 = new_p_distance_V1 - new_rd_V1 + in { + s_speed_V1' = new_p_speed_V1; + braking_distance_V1' = new_bd_V1; + required_distance_V1' = new_rd_V1; + safety_gap_V1' = new_sg_V1; + s_distance_V1' = new_p_distance_V1; + } + } + if (new_timer_V2 == 0) { + let + new_bd_V2 = (new_p_speed_V2*new_p_speed_V2 + (ACCELERATION+BRAKE)*(ACCELERATION*TIMER_INIT*TIMER_INIT + 2*new_p_speed_V2*TIMER_INIT))/(2*BRAKE) + and + new_rd_V2 = new_bd_V2 + SAFETY_DISTANCE + and + new_sg_V1_V2 = new_p_distance_V1_V2 - new_rd_V2 + and + new_sg_V2 = new_p_distance_V2 - new_rd_V2 + in { + s_speed_V2' = new_p_speed_V2; + braking_distance_V2' = new_bd_V2; + required_distance_V2' = new_rd_V2; + safety_gap_V1_V2' = new_sg_V1_V2; + safety_gap_V2' = new_sg_V2; + s_distance_V2' = new_p_distance_V2; + s_distance_V1_V2' = new_p_distance_V1_V2; + } + } + if (p_distance_V2 <= 0 || p_distance_V1_V2 <= 0) { + crashed_V2' = 1; + } + if (p_distance_V1 <= 0) { + crashed_V1' = 1; + } + } +} + +penalty rho_crash_probability = (p_distance_V1_V2 > 0 ? 0.0 : 1.0) +penalty rho_crash_speed = ((crashed_V2 == 0 && (p_distance_V2 <= 0 || p_distance_V1_V2 <= 0)) ? p_speed_V2 / MAX_SPEED : 0.0) + +distance crash_probability = < rho_crash_probability; +distance crash_dist = \G[350,450] crash_probability; + +distance crash_speed = < rho_crash_speed; +distance crash_speed_dist = \G[10,400] crash_speed; + +perturbation p_faster = ([ + s_speed_V1 <- faster_speed(p_speed_V1, R[0,1]), + required_distance_V1 <- required_distance(faster_speed(p_speed_V1, R[0,1])), + safety_gap_V1 <- p_distance_V1 - required_distance(faster_speed(p_speed_V1, R[0,1])) +]@(TIMER_INIT - 1))^3; + +perturbation p_slower = ([ + s_speed_V2 <- slower_speed(p_speed_V2, R[0,1]), + required_distance_V2 <- required_distance(slower_speed(p_speed_V2, R[0,1])), + safety_gap_V1_V2 <- p_distance_V1_V2 - required_distance(slower_speed(p_speed_V2, R[0,1])) +]@(TIMER_INIT - 1))^3; + +perturbation p_combined = (p_faster ; p_slower)^20; + +perturbation p_distance_sensors = ([ + safety_gap_V1_V2 <- p_distance_V1_V2 * (1 + R[0,1] * MAX_DISTANCE_OFFSET) - required_distance_V2, + safety_gap_V2 <- p_distance_V1 * (1 + R[0,1] * MAX_DISTANCE_OFFSET) - required_distance_V2, + s_distance_V1_V2 <- p_distance_V1_V2 * (1 + R[0,1] * MAX_DISTANCE_OFFSET), + s_distance_V2 <- p_distance_V1 * (1 + R[0,1] * MAX_DISTANCE_OFFSET) +]@(TIMER_INIT - 1))^100; + +formula phi_fast = \G[0,H] \D[crash_dist, p_faster] <= ETA_fast; +formula phi_slow = \G[0,H] \D[crash_dist, p_slower] <= ETA_slow; +formula phi_comb = \G[0,H] \D[crash_dist, p_combined] <= ETA_comb; +formula phi_crash_lhs = phi_fast && phi_slow; +formula phi_crash = !phi_crash_lhs || phi_comb; + +formula phi_crash_speed = \G[0,H] \D[crash_speed_dist, p_distance_sensors] <= ETA_CRASH_SPEED; diff --git a/examples/stark/ventilator.stark b/examples/stark/ventilator.stark new file mode 100644 index 00000000..18a1be86 --- /dev/null +++ b/examples/stark/ventilator.stark @@ -0,0 +1,1310 @@ +/* + * Ported from the original `mechanicallungventilator` example + * (5167 lines): a mechanical lung ventilator, modelled as three parallel + * components — the main ventilation-mode controller (`Ventilator`, states + * `P`..`P_failSafeI`: power-on self-test, PCV/PSV breathing cycles with + * inspiratory-pause/recruitment-manoeuvre/expiratory-pause sub-phases, and + * fail-safe), an alarm monitor (`Alarm`, states `P_alarms`/`Idle_Alarms`/ + * `P_Alarms_final`), and the mode-switch handshake (`Switch`, state + * `P_switch`) — sharing 90 global variables (sensor/actuator values, GUI + * requests, timers and counters) with one `environment` block modelling + * sensor noise, battery drain, and the physical pressure/flow response. + * + * This is by far the largest model ported this session; every declaration + * below is a direct, line-by-line translation of the corresponding original + * (a state read becomes `x`, an update becomes `x' = v`), not a + * re-derivation, so the header notes below focus on *how* constructs that + * don't exist verbatim in this grammar were encoded, not on the ventilator + * domain itself. + * + * All 90 variables are declared `real`, including the many that only ever + * hold 0/1 (`b_powerOn`, `conn_patient`, ...): the original mixes these + * freely with continuous arithmetic (timers, pressures) in the same + * expressions, and this grammar's `int`/`real` type lattice is asymmetric + * (an `int` doesn't freely combine with a `real`), so `real` throughout + * avoids a combinatorial type-mismatch problem for no loss of fidelity — + * the original stores every variable as a double anyway. + * + * Some ~1600 lines of the original's entry point build online-monitoring + * queries — the same untranslatable formalism already documented in + * `monitoring.stark`/`crates/stark/plan.md` — so none of those are ported; + * only the genuinely-ROBTL formula, distance and perturbation queries near + * the top of it are (see the bottom of this file). + * + * In the original, "perform these assignments, then continue as `next`" and + * "idle a round, then continue as `next`" are the *same* underlying + * construct — idling is just the assignment form with a no-op update — and + * it *always* consumes exactly one simulation round, with a full + * environment step in between, `next` becoming the behaviour for the round + * after. So every one of them is ported as `assignments; step next;`, with + * `step` used regardless of whether `next` is a bare reference or an inline + * node, and regardless of whether there is an accompanying assignment. + * + * An if-then-else, by contrast, recurses into whichever branch immediately, + * within the *same* round — it never consumes a round on its own, so a + * chain of nested conditionals (as most states below have) collapses into + * one `if`/`else` in a single STARK state at no round cost. The one place + * this grammar's `exec X;` is the right translation is a conditional branch + * that is a *bare* reference to `X`, with no assignment or idle wrapping + * it: that branch, taken, continues into `X` in the same round. No state in + * this file has that shape, so `exec` doesn't appear here — every leaf + * below is reached through an assignment or an idle, hence `step`. (An + * earlier version of this file had the `step`/`exec` distinction backwards; + * every controller state has been corrected.) + * + * An assignment whose `next` is itself a fresh, *inline* conditional (not a + * bare reference) needs an **extra STARK state**, not just a `step`, to be + * faithful: the assignment consumes its own round before that inline branch + * is even reached, so the branch's condition is evaluated one round (and one + * environment step) later than a flattened single-state translation would + * imply. Three states have exactly this shape and are split into two STARK + * states each below — `P_start_up`/`P_start_up_check_sensors`, + * `P_self_test`/`P_self_test_check`, `P_VentOff`/`P_VentOff_check`. Every + * other controller state in this file was checked against the original + * line-by-line and confirmed to only ever wrap a *bare* reference, never an + * inline branch, so no further splits are needed. + * + * `P_Alarms_final`'s self-transition references `P_alarms_final` (lower-case + * `a`) instead of the actually-registered `P_Alarms_final` — a latent typo + * upstream, since an unregistered name would fail to resolve at runtime. + * It is ported as the evidently-intended self-loop (`step P_Alarms_final;`, + * since the original idles into it), matching the precedent of fixing clear + * upstream typos (e.g. `toll.stark`'s `pen_stress`/`accel==N` fixes) rather + * than reproducing them. + */ + +param PRM = 20.0; +param RM_TIME = 10.0; +param RR_PCV = 12.0; +param IE_PCV = 0.5; +param P_INSP_PCV = 15.0; +param ITS_PCV = 3.0; +param P_INSP_PSV = 15.0; +param ITS_PSV = 3.0; +param ETS = 30.0; +param T_APNEALAG = 30.0; +param RR_AP = 12.0; +param P_INSP_AP = 12.0; +param IE_AP = 0.5; +param MAX_P_INSP = 40.0; +param MIN_P_INSP = 50.0; /* 50% of P_insp, divided by 100 where used */ +param MAX_V_E = 80.0; +param MIN_V_E = 2.0; +param MIN_RR = 4.0; +param MAX_RR = 50.0; +param MIN_PEEP = 5.0; +param MAX_PEEP = 15.0; +param MAX_T_IP = 40.0; +param MAX_T_EP = 60.0; +param TRIGGER_WINDOW_DELAY = 0.7; +param MAX_INSP_TIME_PSV = 7.0; +param PM_A_GB_PRESSURE = 4500.0; +param PM_A_GB_FiO2 = 50.0; +param PM_A_PEEP_VALVE = 8.0; +param HIGH_FLOW = 60.0; +param H = 450.0; + +global variables { + real p_GB_pressure = PM_A_GB_PRESSURE; + real s_GB_pressure = PM_A_GB_PRESSURE; + real p_PS_ins_pressure = 0.0; + real s_PS_ins_pressure = 0.0; + real p_PS_exp_pressure = 0.0; + real s_PS_exp_pressure = 0.0; + real p_OS = PM_A_GB_FiO2; + real s_OS = PM_A_GB_FiO2; + real p_Fl1_flow = 0.0; + real s_Fl1_flow = 0.0; + real p_Fl2_flow = 0.0; + real s_Fl2_flow = 0.0; + real p_temp = 37.0; + real s_temp = 37.0; + real p_power_source = 0.0; + real s_power_source = 0.0; + real p_fan = 0.0; + real s_fan = 0.0; + real s_battery_level = 100.0; + real a_IN_valve = 0.0; + real a_OUT_valve = 0.0; + real a_LED = 0.0; + real RR_ms = 0.0; + real peak_P_insp = 0.0; + real V_tidal = 0.0; + real V_E = 0.0; + real t_RM_remaining = 0.0; + real Status = 0.0; + real IE_ms = 0.0; + real b_powerOn = 1.0; + real conn_power_source = 1.0; + real conn_air_supply = 1.0; + real conn_patient = 0.0; + real conn_breathing = 1.0; + real comm_sens_valves_ok = 1.0; + real comm_memory = 1.0; + real comm_cont_gui_ok = 1.0; + real init_succ = 0.0; + real conn_failToPowerOn = 0.0; + real sys_out_of_service = 0.0; + real selfTest_fail = 0.0; + real gui_req_res_ven = 0.0; + real power_switch_ok = 1.0; + real no_leaks_breathing_circuit = 1.0; + real out_valve_ok = 1.0; + real alarms_ok = 1.0; + real nr_of_retries = 0.0; + real nr_of_retries_p = 0.0; + real gui_req_change_mode_PCV = 1.0; + real gui_req_change_mode_PSV = 0.0; + real gui_req_stop_vent = 0.0; + real timer_PCV_insp = 0.0; + real timer_PSV_insp = 0.0; + real timer_PCV_exp = 0.0; + real drop_PAW = 0.0; + real gui_req_IP = 0.0; + real gui_req_RM = 0.0; + real gui_req_EP = 0.0; + real timer_IP = 0.0; + real timer_EP = 0.0; + real timer_RM = 0.0; + real timer_triggerDelay = 0.0; + real min_exp_time_psv = 0.4; + real b_powerOff = 0.0; + real gui_param_psv_ok = 0.0; + real phase = 0.0; + real phase_changed = 0.0; + real IE_toolow_counter = 0.0; + real timer_insp = 0.0; + real timer_exp = 0.0; + real cycle_done = 0.0; + real fs = 0.0; + real previous_PAW = 0.0; + real peak_flow = 0.0; + real timer_PSV_exp = 0.0; + real V_tidal_prev = 0.0; + real rr_pcv = 12.0; + real p_insp_pcv = 15.0; + real ie_pcv = 0.5; + real ind_var = 0.0; + real alarm_counter = -1.0; + real counter_cycles = 0.0; + real switch_ready = 0.0; + real req_counter = -1.0; + real on_counter = -1.0; + real test_counter = -1.0; + real test_per = 0.0; + real p_drop_PAW = 0.0; + real p_peak_flow = 0.0; + real psv_param_counter = -1.0; +} + +environment { + let + new_pressure_in = + (Status == 1 || Status == 2 + ? (!(a_IN_valve == 0) ? a_IN_valve + PM_A_PEEP_VALVE + : (!(phase == 2) && !(phase == 4) ? max(PM_A_PEEP_VALVE, p_PS_ins_pressure - 0.25*peak_P_insp) + : p_PS_ins_pressure)) + : max(0.0, p_PS_ins_pressure*0.7 - 0.5)) + and + new_pressure_out = + (Status == 1 || Status == 2 + ? (a_OUT_valve == 0 ? PM_A_PEEP_VALVE + : (timer_exp == 0 ? max(PM_A_PEEP_VALVE, PM_A_PEEP_VALVE + 0.2*peak_P_insp) + : max(PM_A_PEEP_VALVE, p_PS_exp_pressure - 0.15*peak_P_insp))) + : max(0.0, p_PS_exp_pressure*0.7 - 0.5)) + and + new_flow_in = + (Status == 1 || Status == 2 + ? (timer_insp == 0 ? HIGH_FLOW : (phase == 1 ? max(0, 0.85*p_Fl1_flow - 0.5) : 0)) + : 0.0) + and + new_flow_out = + (Status == 1 || Status == 2 + ? (timer_exp == 0 ? -HIGH_FLOW : (phase == 3 ? min(0, 0.7*p_Fl2_flow + 0.5) : 0)) + : min(0.0, p_Fl2_flow*0.7 + 0.5)) + and + noise_gb = R[-1,1] + and + noise_ps_ins = R[-1,1] + and + noise_ps_exp = R[-1,1] + and + noise_os = R[-1,1] + and + noise_fl2 = R[-0.3,0.3] + and + noise_fl1 = R[-0.3,0.3] + and + noise_temp = R[-0.1,0.1] + and + new_counter_cycles = (cycle_done == 1 ? counter_cycles + 1 : counter_cycles) + and + new_RR = (cycle_done == 1 ? 60/(timer_insp + timer_exp) : (!(Status == 1) && !(Status == 2) ? 0 : RR_ms)) + and + new_IE = (cycle_done == 1 ? timer_insp/timer_exp : (!(Status == 1) && !(Status == 2) ? 0 : IE_ms)) + and + new_IE_counter = + (cycle_done == 1 && timer_insp/timer_exp < 0.01 ? IE_toolow_counter + 1 + : (cycle_done == 1 ? 0 : IE_toolow_counter)) + and + new_V_tidal = + (Status == 1 || Status == 2 + ? (phase == 1 || phase == 2 ? V_tidal + (new_flow_in + noise_fl1)*1 + : (phase_changed == 1 && phase == 3 ? 0 : V_tidal)) + : 0.0) + and + new_V_tidal_prev = + (Status == 1 || Status == 2 + ? (phase == 1 || phase == 2 ? V_tidal_prev + : (phase_changed == 1 && phase == 3 ? V_tidal : V_tidal_prev)) + : V_tidal) + and + new_V_E = + (cycle_done == 1 ? (V_tidal_prev*60/(timer_insp + timer_exp))/1000 + : (!(Status == 1) && !(Status == 2) ? 0 : V_E)) + and + new_peak_flow = + (peak_flow < s_Fl1_flow ? s_Fl1_flow + : ((phase_changed == 1 && phase == 1) || (!(Status == 1) && !(Status == 2)) ? 0 : peak_flow)) + and + new_peak_flow_p = + (p_peak_flow < p_Fl1_flow ? p_Fl1_flow + : ((phase_changed == 1 && phase == 1) || (!(Status == 1) && !(Status == 2)) ? 0 : p_peak_flow)) + and + new_peak_P_insp = + (peak_P_insp < s_PS_ins_pressure ? s_PS_ins_pressure + : ((phase_changed == 1 && phase == 1) || (!(Status == 1) && !(Status == 2)) ? 0 : peak_P_insp)) + and + new_b_powerOn = + (b_powerOn == 1 ? 0 + : (b_powerOff == 1 && on_counter == -1 ? 0 + : (on_counter > 0 ? 0 + : ((p_PS_ins_pressure == 0 && (Status == 0 || Status == 7) && R[0,1] < 0.3) ? 1 : b_powerOn)))) + and + new_b_powerOff = (b_powerOff == 1 ? 0 : ((Status == 6 && R[0,1] < 0.1) ? 1 : b_powerOff)) + and + new_alarm_counter = + (a_LED == 0 ? alarm_counter + : (alarm_counter == -1 ? ceil(R[0,1]*10) : (alarm_counter > 0 ? alarm_counter - 1 : -1))) + and + new_a_LED = ((a_LED == 1 && alarm_counter == 0) ? 0 : a_LED) + and + new_gui_req_stop_vent = (((Status == 1 || Status == 2) && R[0,1] < 0.01) ? 1 : gui_req_stop_vent) + and + choose_mode = R[0,1] + and + new_gui_req_change_mode_PCV_step = + (Status == 5 ? (choose_mode < 0.5 ? 1 : 0) : gui_req_change_mode_PCV) + and + new_gui_req_change_mode_PSV_step0 = (Status == 7 ? 0 : gui_req_change_mode_PSV) + and + new_gui_req_change_mode_PSV_step1 = + ((Status == 1 && R[0,1] < 0.04) ? 1 : new_gui_req_change_mode_PSV_step0) + and + new_gui_req_change_mode_PSV_step2 = + (Status == 5 ? (choose_mode < 0.5 ? 0 : (choose_mode < 0.83 ? 1 : 0)) : new_gui_req_change_mode_PSV_step1) + and + req_counter_runs_out = (gui_req_change_mode_PSV == 1 && req_counter == 0) + and + new_gui_req_change_mode_PSV = (req_counter_runs_out ? 0 : new_gui_req_change_mode_PSV_step2) + and + new_gui_param_psv_ok = + (gui_req_change_mode_PSV == 0 ? 0 + : (psv_param_counter == -1 ? gui_param_psv_ok + : (psv_param_counter > 0 ? gui_param_psv_ok : 1))) + and + new_psv_param_counter = + (gui_req_change_mode_PSV == 0 ? psv_param_counter + : (psv_param_counter == -1 ? ceil(R[0,1]*5) + 1 + : (psv_param_counter > 0 ? psv_param_counter - 1 : -1))) + and + new_req_counter = + (gui_req_change_mode_PSV == 1 && req_counter == -1 ? ceil(R[0,1]*10) + 5 + : (gui_req_change_mode_PSV == 1 && req_counter > 0 ? req_counter - 1 + : (req_counter_runs_out ? -1 : req_counter))) + and + new_on_counter = + (b_powerOff == 1 && on_counter == -1 ? ceil(R[0,1]*2) + 6 + : (on_counter > 0 ? on_counter - 1 : (on_counter == 0 ? -1 : on_counter))) + in { + s_battery_level' = (!(Status == 1) && !(Status == 2) ? s_battery_level - 0.1 : s_battery_level - 0.5); + p_PS_ins_pressure' = new_pressure_in; + p_PS_exp_pressure' = new_pressure_out; + p_GB_pressure' = PM_A_GB_PRESSURE; + p_OS' = PM_A_GB_FiO2; + p_Fl1_flow' = new_flow_in; + p_Fl2_flow' = new_flow_out; + previous_PAW' = s_PS_ins_pressure; + + s_GB_pressure' = (p_GB_pressure == 0 ? 0 : p_GB_pressure + noise_gb); + s_PS_ins_pressure' = (new_pressure_in == 0 ? 0 : new_pressure_in + noise_ps_ins); + drop_PAW' = s_PS_ins_pressure - (new_pressure_in + noise_ps_ins); + s_PS_exp_pressure' = (new_pressure_out == 0 ? 0 : new_pressure_out + noise_ps_exp); + s_OS' = (p_OS == 0 ? 0 : p_OS + noise_os); + s_Fl2_flow' = (new_flow_out == 0 ? 0 : new_flow_out + noise_fl2); + s_Fl1_flow' = (new_flow_in == 0 ? 0 : new_flow_in + noise_fl1); + s_temp' = p_temp + noise_temp; + s_power_source' = p_power_source; + s_fan' = p_fan; + + conn_patient' = (Status == 7 ? 0 : (init_succ == 1 ? 1 : conn_patient)); + + counter_cycles' = new_counter_cycles; + RR_ms' = new_RR; + IE_ms' = new_IE; + IE_toolow_counter' = new_IE_counter; + V_tidal' = new_V_tidal; + V_tidal_prev' = new_V_tidal_prev; + V_E' = new_V_E; + peak_flow' = new_peak_flow; + p_peak_flow' = new_peak_flow_p; + peak_P_insp' = new_peak_P_insp; + phase_changed' = (phase_changed == 1 ? 0 : phase_changed); + p_drop_PAW' = p_PS_ins_pressure - new_pressure_in; + + b_powerOn' = new_b_powerOn; + b_powerOff' = new_b_powerOff; + alarm_counter' = new_alarm_counter; + a_LED' = new_a_LED; + gui_req_stop_vent' = new_gui_req_stop_vent; + gui_req_change_mode_PCV' = new_gui_req_change_mode_PCV_step; + gui_req_change_mode_PSV' = new_gui_req_change_mode_PSV; + gui_param_psv_ok' = new_gui_param_psv_ok; + psv_param_counter' = new_psv_param_counter; + req_counter' = new_req_counter; + on_counter' = new_on_counter; + } +} + +component Ventilator { + variables { } + controller { + state P { + if (b_powerOn == 1) { + Status' = 3; + b_powerOn' = 0; + step P_checkcond; + } else { + step P; + } + } + + state P_checkcond { + if (conn_breathing == 1) { step P_checkcond1; } else { step P_RepNotConnBreath; } + } + state P_checkcond1 { + if (conn_air_supply == 1) { step P_checkcond2; } else { step P_RepNotConnAir; } + } + state P_checkcond2 { + if (conn_power_source == 1) { step P_checkcond3; } else { step P_RepNotConnPower; } + } + state P_checkcond3 { + if (conn_patient == 0) { step P_start_up; } else { step P_RepConnPatient; } + } + + state P_RepNotConnBreath { conn_failToPowerOn' = 1; step P; } + state P_RepNotConnAir { conn_failToPowerOn' = 2; step P; } + state P_RepNotConnPower { conn_failToPowerOn' = 3; step P; } + state P_RepConnPatient { conn_failToPowerOn' = 4; step P; } + + /* renamed from "P_start-up" (hyphens are not valid identifiers here) */ + state P_start_up { + if (b_powerOff == 1) { + step P_final; + } else { + /* BUG FIXED: the original wraps this assignment's continuation in a + fresh inline conditional rather than a bare reference, so it is a + SECOND round (with a full environment step in between) before + `comm_sens_valves_ok` is even read — not the same round. Split + into `P_start_up`/`P_start_up_check_sensors` to match. */ + rr_pcv' = RR_PCV; + ie_pcv' = IE_PCV; + p_insp_pcv' = P_INSP_PCV; + step P_start_up_check_sensors; + } + } + + state P_start_up_check_sensors { + if (comm_sens_valves_ok == 1) { + nr_of_retries' = 0; + step P_start_up1; + } else { + nr_of_retries' = 1 + nr_of_retries; + step P_retrySensor; + } + } + + state P_retrySensor { + if (nr_of_retries >= 5) { + nr_of_retries' = 0; + step P_failSafe; + } else { + step P_start_up; + } + } + + state P_start_up1 { + if (b_powerOff == 1) { + step P_final; + } else { + if (conn_power_source == 1) { + nr_of_retries_p' = 0; + step P_start_up2; + } else { + nr_of_retries_p' = 1 + nr_of_retries_p; + step P_retryPower; + } + } + } + + state P_retryPower { + if (nr_of_retries_p >= 5) { + nr_of_retries_p' = 0; + step P_failSafe; + } else { + step P_start_up1; + } + } + + state P_start_up2 { + if (b_powerOff == 1) { + step P_final; + } else { + if (comm_memory == 1 && comm_cont_gui_ok == 1) { + init_succ' = 1; + step P_self_test; + } else { + step P_start_upFail; + } + } + } + + state P_start_upFail { + init_succ' = -1; + sys_out_of_service' = 1; + a_IN_valve' = 0; + a_OUT_valve' = 1; + step P_failSafe; + } + + /* Self-test mode */ + state P_self_test { + /* BUG FIXED: the original wraps this assignment's continuation in a + fresh inline conditional rather than a bare reference, so + `b_powerOff` etc. are read a full round later (with an environment + step in between), not the same round. Split into + `P_self_test`/`P_self_test_check` to match. */ + init_succ' = 0; + Status' = 4; + step P_self_test_check; + } + + state P_self_test_check { + if (b_powerOff == 1) { + step P_final; + } else { + if ((gui_req_res_ven == 1 || + (power_switch_ok == 1 && no_leaks_breathing_circuit == 1 && out_valve_ok == 1 && alarms_ok == 1)) + && !(fs == 1)) { + step P_VentOff; + } else { + selfTest_fail' = 1; + sys_out_of_service' = 1; + step P_failSafe; + } + } + } + + /* Ventilation off mode */ + state P_VentOff { + /* BUG FIXED: same pattern as `P_start_up`/`P_self_test` above — the + original wraps this assignment's continuation in a fresh inline + conditional, so `b_powerOff` etc. are read a full round later. Split + into `P_VentOff`/`P_VentOff_check` to match. */ + a_IN_valve' = 0; + a_OUT_valve' = 1; + Status' = 5; + phase' = 0; + timer_insp' = 0; + timer_exp' = 0; + step P_VentOff_check; + } + + state P_VentOff_check { + if (b_powerOff == 1) { + step P_final; + } else { + if (fs == 1) { + a_IN_valve' = 0; + a_OUT_valve' = 0; + step P_failSafe; + } else { + if (conn_patient == 0) { + ind_var' = 1; + step P_VentOff; + } else { + if (gui_req_change_mode_PCV == 1) { + step P_PCV; + } else { + if (gui_req_change_mode_PSV == 1) { step P_PSV; } else { step P_VentOff; } + } + } + } + } + } + + /* PCV general breathing mode */ + state P_PCV { + if (b_powerOff == 1) { + cycle_done' = 0; + step P_final; + } else { + if (fs == 1) { + a_IN_valve' = 0; + a_OUT_valve' = 1; + step P_failSafe; + } else { + timer_insp' = 0; + timer_PCV_insp' = (60.0*ie_pcv)/(rr_pcv*(1+ie_pcv)); + a_IN_valve' = p_insp_pcv; + a_OUT_valve' = 0; + Status' = 1; + phase' = 1; + phase_changed' = 1; + cycle_done' = 0; + step P_PCV_insp; + } + } + } + + state P_PCV_insp { + if (b_powerOff == 1) { + timer_insp' = timer_insp + 1; + step P_final; + } else { + if (fs == 1) { + a_IN_valve' = 0; + a_OUT_valve' = 1; + step P_failSafe; + } else { + if (gui_req_stop_vent == 1) { + timer_insp' = timer_insp + 1; + step P_VentOff; + } else { + if (s_PS_ins_pressure > MAX_P_INSP) { + timer_insp' = timer_insp + 1; + step P_PCV_exp0; + } else { + if (timer_PCV_insp > 0) { + timer_insp' = timer_insp + 1; + timer_PCV_insp' = timer_PCV_insp - 1; + step P_PCV_insp; + } else { + if (gui_req_IP == 1) { + a_IN_valve' = 0; + a_OUT_valve' = 0; + timer_IP' = MAX_T_IP; + phase' = 2; + timer_insp' = timer_insp + 1; + step P_IP_PCV; + } else { + if (gui_req_RM == 1) { + timer_RM' = RM_TIME; + a_IN_valve' = PRM; + a_OUT_valve' = 0; + phase' = 5; + timer_insp' = timer_insp + 1; + step P_RM; + } else { + if (switch_ready == 1) { + timer_insp' = timer_insp + 1; + switch_ready' = 0; + gui_req_change_mode_PSV' = 0; + step P_PSV_exp0; + } else { + timer_insp' = timer_insp + 1; + step P_PCV_exp0; + } + } + } + } + } + } + } + } + } + + state P_IP_PCV { + if (b_powerOff == 1) { + step P_final; + } else { + if (fs == 1) { + a_IN_valve' = 0; + a_OUT_valve' = 1; + step P_failSafe; + } else { + if (gui_req_IP == 0) { + if (switch_ready == 1) { + timer_insp' = timer_insp + 1; + switch_ready' = 0; + gui_req_change_mode_PSV' = 0; + step P_PSV_exp0; + } else { + timer_insp' = timer_insp + 1; + step P_PCV_exp0; + } + } else { + if (timer_IP > 0) { + timer_IP' = timer_IP - 1; + timer_insp' = timer_insp + 1; + step P_IP_PCV; + } else { + if (switch_ready == 1) { + timer_insp' = timer_insp + 1; + switch_ready' = 0; + gui_req_change_mode_PSV' = 0; + step P_PSV_exp0; + } else { + timer_insp' = timer_insp + 1; + step P_PCV_exp0; + } + } + } + } + } + } + + state P_RM { + if (b_powerOff == 1) { + step P_final; + } else { + if (fs == 1) { + a_IN_valve' = 0; + a_OUT_valve' = 1; + step P_failSafe; + } else { + if (gui_req_RM == 0) { + if (switch_ready == 1) { + switch_ready' = 0; + gui_req_change_mode_PSV' = 0; + timer_insp' = timer_insp + 1; + step P_PSV_exp0; + } else { + timer_insp' = timer_insp + 1; + step P_PCV_exp0; + } + } else { + if (timer_RM > 0) { + timer_RM' = timer_RM - 1; + timer_insp' = timer_insp - 1; + step P_RM; + } else { + if (switch_ready == 1) { + switch_ready' = 0; + gui_req_change_mode_PSV' = 0; + timer_insp' = timer_insp + 1; + step P_PSV_exp0; + } else { + timer_insp' = timer_insp + 1; + step P_PCV_exp0; + } + } + } + } + } + } + + state P_PCV_exp0 { + if (b_powerOff == 1) { + step P_final; + } else { + if (fs == 1) { + a_IN_valve' = 0; + a_OUT_valve' = 0; + step P_failSafe; + } else { + timer_PCV_exp' = 60/(rr_pcv*(1+ie_pcv)); + a_IN_valve' = 0; + a_OUT_valve' = 1; + timer_triggerDelay' = TRIGGER_WINDOW_DELAY; + phase' = 3; + phase_changed' = 1; + timer_exp' = 0; + step P_PCV_exp; + } + } + } + + state P_PCV_exp { + if (b_powerOff == 1) { + step P_final; + } else { + if (fs == 1) { + a_IN_valve' = 0; + a_OUT_valve' = 1; + step P_failSafe; + } else { + if (gui_req_stop_vent == 1) { + timer_exp' = timer_exp + 1; + step P_VentOff; + } else { + if (timer_triggerDelay > 0) { + if (timer_PCV_exp > 0) { + timer_PCV_exp' = timer_PCV_exp - 1; + timer_triggerDelay' = timer_triggerDelay - 1; + timer_exp' = timer_exp + 1; + step P_PCV_exp; + } else { + if (gui_req_EP == 1) { + a_IN_valve' = 0; + a_OUT_valve' = 0; + timer_EP' = MAX_T_EP; + phase' = 4; + timer_exp' = timer_exp + 1; + step P_EP_PCV; + } else { + timer_exp' = timer_exp + 1; + cycle_done' = 1; + step P_PCV; + } + } + } else { + if (drop_PAW > ITS_PCV) { + timer_exp' = timer_exp + 1; + cycle_done' = 1; + step P_PCV; + } else { + if (timer_PCV_exp > 0) { + timer_PCV_exp' = timer_PCV_exp - 1; + timer_exp' = timer_exp + 1; + step P_PCV_exp; + } else { + if (gui_req_EP == 1) { + a_IN_valve' = 0; + a_OUT_valve' = 1; + timer_EP' = MAX_T_EP; + phase' = 4; + timer_exp' = timer_exp + 1; + step P_EP_PCV; + } else { + timer_exp' = timer_exp + 1; + cycle_done' = 1; + step P_PCV; + } + } + } + } + } + } + } + } + + state P_EP_PCV { + if (b_powerOff == 1) { + step P_final; + } else { + if (fs == 1) { + a_IN_valve' = 0; + a_OUT_valve' = 1; + step P_failSafe; + } else { + if (gui_req_EP == 0) { + timer_exp' = timer_exp + 1; + cycle_done' = 1; + step P_PCV; + } else { + if (timer_EP > 0) { + timer_EP' = timer_EP - 1; + timer_exp' = timer_exp + 1; + step P_EP_PCV; + } else { + timer_exp' = timer_exp + 1; + cycle_done' = 1; + step P_PCV; + } + } + } + } + } + + /* PSV general breathing mode (mirrors PCV above) */ + state P_PSV { + if (b_powerOff == 1) { + cycle_done' = 0; + step P_final; + } else { + if (fs == 1) { + a_IN_valve' = 0; + a_OUT_valve' = 1; + step P_failSafe; + } else { + timer_insp' = 0; + timer_PSV_insp' = MAX_INSP_TIME_PSV; + a_IN_valve' = P_INSP_PSV; + a_OUT_valve' = 0; + Status' = 2; + phase' = 1; + phase_changed' = 1; + cycle_done' = 0; + step P_PSV_insp; + } + } + } + + state P_PSV_insp { + if (b_powerOff == 1) { + timer_insp' = timer_insp + 1; + step P_final; + } else { + if (fs == 1) { + a_IN_valve' = 0; + a_OUT_valve' = 1; + step P_failSafe; + } else { + if (gui_req_stop_vent == 1) { + timer_insp' = timer_insp + 1; + step P_VentOff; + } else { + if (s_PS_ins_pressure > MAX_P_INSP || s_Fl1_flow <= peak_flow*ETS/100) { + timer_insp' = timer_insp + 1; + step P_PSV_exp0; + } else { + if (timer_PSV_insp > 0) { + timer_insp' = timer_insp + 1; + timer_PSV_insp' = timer_PSV_insp - 1; + step P_PSV_insp; + } else { + if (gui_req_IP == 1) { + a_IN_valve' = 0; + a_OUT_valve' = 0; + timer_IP' = MAX_T_IP; + phase' = 2; + timer_insp' = timer_insp + 1; + step P_IP_PSV; + } else { + if (gui_req_RM == 1) { + timer_RM' = RM_TIME; + a_IN_valve' = PRM; + a_OUT_valve' = 0; + phase' = 5; + timer_insp' = timer_insp + 1; + step P_RM_PSV; + } else { + timer_insp' = timer_insp + 1; + step P_PSV_exp0; + } + } + } + } + } + } + } + } + + state P_IP_PSV { + if (b_powerOff == 1) { + step P_final; + } else { + if (fs == 1) { + a_IN_valve' = 0; + a_OUT_valve' = 1; + step P_failSafe; + } else { + if (gui_req_IP == 0) { + timer_insp' = timer_insp + 1; + step P_PSV_exp0; + } else { + if (timer_IP > 0) { + timer_IP' = timer_IP - 1; + timer_insp' = timer_insp + 1; + step P_IP_PSV; + } else { + timer_insp' = timer_insp + 1; + step P_PSV_exp0; + } + } + } + } + } + + state P_RM_PSV { + if (b_powerOff == 1) { + step P_final; + } else { + if (fs == 1) { + a_IN_valve' = 0; + a_OUT_valve' = 1; + step P_failSafe; + } else { + if (gui_req_RM == 0) { + timer_insp' = timer_insp + 1; + step P_PSV_exp0; + } else { + if (timer_RM > 0) { + timer_RM' = timer_RM - 1; + timer_insp' = timer_insp - 1; + step P_RM_PSV; + } else { + timer_insp' = timer_insp + 1; + step P_PSV_exp0; + } + } + } + } + } + + state P_PSV_exp0 { + if (b_powerOff == 1) { + step P_final; + } else { + if (fs == 1) { + a_IN_valve' = 0; + a_OUT_valve' = 0; + step P_failSafe; + } else { + timer_PSV_exp' = T_APNEALAG; + a_IN_valve' = 0; + a_OUT_valve' = 1; + timer_triggerDelay' = 0.5*timer_insp; + phase' = 3; + phase_changed' = 1; + timer_exp' = 0; + Status' = 2; + step P_PSV_exp; + } + } + } + + state P_PSV_exp { + if (b_powerOff == 1) { + step P_final; + } else { + if (fs == 1) { + a_IN_valve' = 0; + a_OUT_valve' = 1; + step P_failSafe; + } else { + if (gui_req_stop_vent == 1) { + timer_exp' = timer_exp + 1; + step P_VentOff; + } else { + if (timer_triggerDelay > 0) { + if (timer_PSV_exp > 0) { + timer_PSV_exp' = timer_PSV_exp - 1; + timer_triggerDelay' = timer_triggerDelay - 1; + timer_exp' = timer_exp + 1; + step P_PSV_exp; + } else { + if (gui_req_EP == 1) { + a_IN_valve' = 0; + a_OUT_valve' = 0; + timer_EP' = MAX_T_EP; + phase' = 4; + timer_exp' = timer_exp + 1; + step P_EP_PSV; + } else { + timer_exp' = timer_exp + 1; + cycle_done' = 1; + rr_pcv' = RR_AP; + p_insp_pcv' = P_INSP_AP; + ie_pcv' = IE_AP; + step P_PCV; + } + } + } else { + if (drop_PAW > ITS_PSV) { + timer_exp' = timer_exp + 1; + cycle_done' = 1; + step P_PSV; + } else { + if (timer_PSV_exp > 0) { + timer_PSV_exp' = timer_PSV_exp - 1; + timer_exp' = timer_exp + 1; + step P_PSV_exp; + } else { + if (gui_req_EP == 1) { + a_IN_valve' = 0; + a_OUT_valve' = 1; + timer_EP' = MAX_T_EP; + phase' = 4; + timer_exp' = timer_exp + 1; + step P_EP_PSV; + } else { + timer_exp' = timer_exp + 1; + cycle_done' = 1; + rr_pcv' = RR_AP; + p_insp_pcv' = P_INSP_AP; + ie_pcv' = IE_AP; + step P_PCV; + } + } + } + } + } + } + } + } + + state P_EP_PSV { + if (b_powerOff == 1) { + step P_final; + } else { + if (fs == 1) { + a_IN_valve' = 0; + a_OUT_valve' = 1; + step P_failSafe; + } else { + if (gui_req_EP == 0) { + timer_exp' = timer_exp + 1; + cycle_done' = 1; + step P_PSV; + } else { + if (timer_EP > 0) { + timer_EP' = timer_EP - 1; + timer_exp' = timer_exp + 1; + step P_EP_PSV; + } else { + timer_exp' = timer_exp + 1; + cycle_done' = 1; + rr_pcv' = RR_AP; + p_insp_pcv' = P_INSP_AP; + ie_pcv' = IE_AP; + step P_PCV; + } + } + } + } + } + + /* Final mode */ + state P_final { + Status' = 7; + phase' = 0; + counter_cycles' = 0; + step P; + } + + /* Fail-safe mode */ + state P_failSafe { + a_IN_valve' = 0; + a_OUT_valve' = 1; + Status' = 6; + init_succ' = 0; + selfTest_fail' = 0; + phase' = 0; + phase_changed' = 1; + fs' = 1; + step P_failSafeI; + } + + state P_failSafeI { + if (b_powerOff == 1) { step P_final; } else { step P_failSafeI; } + } + } + init P +} + +component Alarm { + variables { } + controller { + state P_alarms { + if (Status == 1 || Status == 2) { + if (s_temp > 75 || s_PS_ins_pressure > MAX_P_INSP || s_PS_exp_pressure > MAX_PEEP || IE_toolow_counter > 4 + || (phase_changed == 1 && phase == 3 && !(a_IN_valve == 0)) + || (phase_changed == 1 && phase == 1 && a_IN_valve == 0) + || (phase_changed == 1 && phase == 1 && !(a_OUT_valve == 0)) + || (phase_changed == 1 && phase == 3 && !(a_OUT_valve == 1)) + || conn_air_supply == 0) { + fs' = 1; + a_LED' = 1; + a_IN_valve' = 0; + a_OUT_valve' = 1; + step Idle_Alarms; + } else { + if (nr_of_retries_p >= 5) { + fs' = 1; + a_LED' = 1; + a_IN_valve' = 0; + a_OUT_valve' = 1; + nr_of_retries_p' = 0; + step Idle_Alarms; + } else { + if (conn_power_source == 0) { + nr_of_retries_p' = nr_of_retries_p + 1; + step P_alarms; + } else { + if (counter_cycles > 0) { + if (Status == 1) { + if (s_PS_ins_pressure < (MIN_P_INSP/100*p_insp_pcv) || comm_sens_valves_ok == 0 + || V_E < MIN_V_E || s_PS_exp_pressure < MIN_PEEP + || RR_ms > MAX_RR || (RR_ms < MIN_RR && !(RR_ms == 0)) || timer_PSV_exp < 0 + || comm_cont_gui_ok == 0 || V_E > MAX_V_E + || s_OS > PM_A_GB_FiO2 + 3 || s_OS < PM_A_GB_FiO2 - 3) { + a_LED' = 1; + step P_alarms; + } else { + step P_alarms; + } + } else { + if (s_PS_ins_pressure < (MIN_P_INSP/100*P_INSP_PSV) || comm_sens_valves_ok == 0 + || V_E < MIN_V_E || s_PS_exp_pressure < MIN_PEEP + || RR_ms > MAX_RR || (RR_ms < MIN_RR && !(RR_ms == 0)) || timer_PSV_exp < 0 + || comm_cont_gui_ok == 0 || V_E > MAX_V_E + || s_OS > PM_A_GB_FiO2 + 3 || s_OS < PM_A_GB_FiO2 - 3) { + a_LED' = 1; + step P_alarms; + } else { + step P_alarms; + } + } + } else { + if (Status == 1) { + if (s_PS_ins_pressure < (MIN_P_INSP/100*p_insp_pcv) || comm_sens_valves_ok == 0 + || s_PS_exp_pressure < MIN_PEEP || timer_PSV_exp < 0 + || comm_cont_gui_ok == 0 || s_OS > PM_A_GB_FiO2 + 3 || s_OS < PM_A_GB_FiO2 - 3) { + a_LED' = 1; + step P_alarms; + } else { + step P_alarms; + } + } else { + if (s_PS_ins_pressure < (MIN_P_INSP/100*P_INSP_PSV) || comm_sens_valves_ok == 0 + || s_PS_exp_pressure < MIN_PEEP || timer_PSV_exp < 0 + || comm_cont_gui_ok == 0 || s_OS > PM_A_GB_FiO2 + 3 || s_OS < PM_A_GB_FiO2 - 3) { + a_LED' = 1; + step P_alarms; + } else { + step P_alarms; + } + } + } + } + } + } + } else { + if (s_temp > 75 || conn_air_supply == 0) { + fs' = 1; + a_LED' = 1; + a_IN_valve' = 0; + a_OUT_valve' = 1; + step Idle_Alarms; + } else { + if (nr_of_retries_p >= 5) { + fs' = 1; + a_LED' = 1; + a_IN_valve' = 0; + a_OUT_valve' = 1; + nr_of_retries_p' = 0; + step Idle_Alarms; + } else { + if (conn_power_source == 0) { + nr_of_retries_p' = nr_of_retries_p + 1; + step P_alarms; + } else { + if (comm_sens_valves_ok == 0 || comm_cont_gui_ok == 0) { + a_LED' = 1; + step P_alarms; + } else { + step P_alarms; + } + } + } + } + } + } + + state Idle_Alarms { + if (b_powerOff == 1) { step P_Alarms_final; } else { step Idle_Alarms; } + } + + state P_Alarms_final { + if (b_powerOn == 1) { + Status' = 0; + phase' = 0; + a_LED' = 0; + b_powerOn' = 0; + step P_alarms; + } else { + Status' = 7; + phase' = 0; + a_LED' = 0; + step P_Alarms_final; + } + } + } + init P_alarms +} + +component Switch { + variables { } + controller { + state P_switch { + if (gui_req_change_mode_PSV == 1) { + if (gui_param_psv_ok == 1) { + switch_ready' = 1; + step P_switch; + } else { + switch_ready' = 0; + step P_switch; + } + } else { + switch_ready' = 0; + step P_switch; + } + } + } + init P_switch +} + +/* Robustness queries. The original evaluates 7 of these (out of ~70 more + built on the untranslatable online-monitoring formalism, not ported — + see the file header); eta_sav_6/eta_sav_16 are the original's local + entry-point constants, ported as params. */ +param ETA_SAV_6 = 0.1; +param ETA_SAV_16 = 0.1; + +penalty rho_sav_6 = (RR_ms < MIN_RR && a_LED == 0 && !(RR_ms == 0) ? 1.0 : 0.0) +penalty rho_sav_6_penal_no_alarm = (RR_ms < MIN_RR && !(RR_ms == 0) ? 1.0 : 0.0) +penalty rho_sav_6_alarm = (!(a_LED == 1) ? 1.0 : 0.0) +penalty rho_sav_16 = (V_E < MIN_V_E && !(V_E == 0) && a_LED == 0 ? 1.0 : 0.0) +penalty rho_sav_16_penal_no_alarm = (V_E < MIN_V_E && !(V_E == 0) && test_per == 1 ? 1.0 : 0.0) +penalty rho_cont_15 = (nr_of_retries >= 5 && !(Status == 6) ? 1.0 : 0.0) +penalty rho_cont_15_penal_no_alarm = (nr_of_retries >= 5 ? 1.0 : 0.0) +penalty rho_basic_test = (!(comm_sens_valves_ok == 1) ? 1.0 : 0.0) + +distance atomic_sav_6 = < rho_sav_6; +distance sav_6_dist = \F[0,2] atomic_sav_6; +distance atomic_sav_6_penal_no_alarm = < rho_sav_6_penal_no_alarm; +distance sav_6_dist_penal_no_alarm = \G[0,2] atomic_sav_6_penal_no_alarm; +distance atomic_sav_6_led = < rho_sav_6_alarm; +distance sav_6_dist_led = \G[0,2] atomic_sav_6_led; + +distance atomic_sav_16 = < rho_sav_16; +distance sav_16_dist = \F[0,3] atomic_sav_16; +distance atomic_sav_16_penal_no_alarm = < rho_sav_16_penal_no_alarm; +distance sav_16_dist_penal_no_alarm = \G[0,2] atomic_sav_16_penal_no_alarm; + +distance basic_test = < rho_basic_test; + +distance atomic_cont_15 = < rho_cont_15; +distance cont_15_dist = \F[13,15] atomic_cont_15; +distance atomic_cont_15_penal_no_alarm = < rho_cont_15_penal_no_alarm; +distance cont_15_dist_penal_no_alarm = \G[13,14] atomic_cont_15_penal_no_alarm; + +/* Forces RR_ms below MIN_RR (unless it's currently 0, i.e. no breathing rate + established yet). */ +perturbation p_sav_6 = [RR_ms <- (!(RR_ms == 0) ? MIN_RR - 1 : RR_ms)]@0; +/* Forces V_E below MIN_V_E and marks the test as active. */ +perturbation p_sav_16 = [V_E <- 1.5, test_per <- 1]@0; +/* Simulates the sensor-valve communication failing. */ +perturbation p_cont_15 = [comm_sens_valves_ok <- 0]@0; +perturbation p_basic_test = [comm_sens_valves_ok <- 0]@0; + +formula phi_sav_6_t3_2 = \D[sav_6_dist_penal_no_alarm, p_sav_6] >= 1.0; +formula phi_sav_6 = \G[0,H] \D[sav_6_dist, p_sav_6] <= ETA_SAV_6; +formula phi_sav_6_alarm = \G[0,0] \D[sav_6_dist_led, p_sav_6] >= 1; +formula phi_sav_16_per = \D[sav_16_dist_penal_no_alarm, p_sav_16] >= 1.0; +formula phi_sav_16 = \G[0,9] \D[sav_16_dist, p_sav_16] <= ETA_SAV_16; +formula phi_cont_15 = \D[cont_15_dist, p_cont_15] <= 0.1; +formula phi_cont_15_per = \D[cont_15_dist_penal_no_alarm, p_cont_15] >= 1.0; +formula phi_basic_test = \D[basic_test, p_basic_test] >= 1.0; diff --git a/tools/mcrl2/crates/mcrl2/src/atermpp/aterm.rs b/tools/mcrl2/crates/mcrl2/src/atermpp/aterm.rs index 480a56a1..3c2476b6 100644 --- a/tools/mcrl2/crates/mcrl2/src/atermpp/aterm.rs +++ b/tools/mcrl2/crates/mcrl2/src/atermpp/aterm.rs @@ -262,7 +262,7 @@ impl ATerm { THREAD_TERM_POOL.with_borrow(|tp| tp.from_string(s)) } - /// Constructs an ATerm from a UniquePtr. Note that we still do the + /// Constructs an ATerm from a `UniquePtr`. Note that we still do the /// protection here, so the term is copied into the thread local term pool. pub(crate) fn from_unique_ptr(term: UniquePtr) -> Self { debug_assert!(!term.is_null(), "Cannot create ATerm from null unique ptr"); diff --git a/tools/stark/Cargo.toml b/tools/stark/Cargo.toml new file mode 100644 index 00000000..7cf0e61e --- /dev/null +++ b/tools/stark/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "merc-stark" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +merc_stark.workspace = true +merc_tools.workspace = true +merc_utilities.workspace = true + +clap.workspace = true +env_logger.workspace = true +log.workspace = true diff --git a/tools/stark/src/main.rs b/tools/stark/src/main.rs new file mode 100644 index 00000000..cfe70b34 --- /dev/null +++ b/tools/stark/src/main.rs @@ -0,0 +1,437 @@ +use std::fs::read_to_string; +use std::path::Path; +use std::path::PathBuf; +use std::process::ExitCode; + +use clap::Parser; +use clap::Subcommand; +use log::info; + +use log::trace; +use merc_stark::DefKind; +use merc_stark::Diagnostics; +use merc_stark::StarkSpecification; +use merc_stark::UntypedStarkSpecification; +use merc_stark::eval::Analysis; +use merc_stark::eval::AnalysisOptions; +use merc_stark::eval::RecordingObserver; +use merc_stark::eval::Simulation; +use merc_stark::eval::TruthValue; +use merc_stark::ir::IrProgram; +use merc_stark::ir::SlotId; +use merc_stark::value::Value; +use merc_tools::VerbosityFlag; +use merc_tools::Version; +use merc_tools::VersionFlag; +use merc_tools::report_error; +use merc_utilities::MercError; +use merc_utilities::Timing; + +/// A command line tool for STARK specifications. +#[derive(clap::Parser, Debug)] +#[command(arg_required_else_help = true)] +struct Cli { + #[command(flatten)] + version: VersionFlag, + + #[command(flatten)] + verbosity: VerbosityFlag, + + #[command(subcommand)] + commands: Option, + + #[arg(long, global = true)] + timings: bool, +} + +/// Defines the subcommands for this tool. +#[derive(Debug, Subcommand)] +enum Commands { + /// Parses, resolves and type checks the given STARK specification, reporting every problem found. + Check(CheckArgs), + /// Runs a single trajectory of the specification for a fixed number of steps under a fixed seed. + Simulate(SimulateArgs), + /// Verifies the specification's `formula` declarations by robustness analysis, reporting each verdict. + Verify(VerifyArgs), +} + +#[derive(clap::Args, Debug)] +struct CheckArgs { + /// The STARK specification to check. + #[arg(value_name = "SPEC")] + specification: PathBuf, + + /// Print every declaration in the specification with its inferred type. + #[arg(long)] + print_symbols: bool, + + /// Lower the checked specification to its IR program and print a summary of it. + #[arg(long)] + lower: bool, +} + +#[derive(clap::Args, Debug)] +struct SimulateArgs { + /// The STARK specification to simulate. + #[arg(value_name = "SPEC")] + specification: PathBuf, + + /// The number of macro-steps to run. + #[arg(long, default_value_t = 50)] + steps: u64, + + /// The seed the run is reproducible from. + #[arg(long, default_value_t = 0)] + seed: u64, + + /// Print the state after every step, rather than only the final state. + #[arg(long)] + trajectory: bool, + + /// Decimal places to round real-valued state to when printing. + #[arg(long, default_value_t = 2)] + precision: usize, +} + +#[derive(clap::Args, Debug)] +struct VerifyArgs { + /// The STARK specification to verify. + #[arg(value_name = "SPEC")] + specification: PathBuf, + + /// The seed the analysis is reproducible from. + #[arg(long, default_value_t = 0)] + seed: u64, + + /// The time step at which each formula is evaluated. + #[arg(long, default_value_t = 0)] + step: usize, + + /// Samples per step in the reference evolution sequence — larger is more accurate and linearly more expensive. + #[arg(long, default_value_t = AnalysisOptions::default().sample_size)] + samples: usize, + + /// Perturbed samples drawn per reference sample. + #[arg(long, default_value_t = AnalysisOptions::default().scale)] + scale: usize, + + /// Bootstrap replicas behind the three-valued confidence interval. + #[arg(long = "bootstrap-replicas", default_value_t = AnalysisOptions::default().bootstrap_replicas)] + bootstrap_replicas: usize, + + /// The standard-normal quantile the confidence interval spans. + #[arg(long, default_value_t = AnalysisOptions::default().quantile)] + quantile: f64, + + /// Use the two-valued boolean semantics instead of the three-valued one. + #[arg(long)] + boolean: bool, +} + +fn main() -> ExitCode { + let cli = Cli::parse(); + + env_logger::Builder::new() + .filter_level(cli.verbosity.log_level_filter()) + .parse_default_env() + .init(); + + if cli.version.into() { + eprintln!("{}", Version); + return ExitCode::SUCCESS; + } + + let timing = Timing::new(); + let result = handle_command(cli.commands, &timing); + + if cli.timings { + timing.print(); + } + + report_error(result) +} + +fn handle_command(commands: Option, timing: &Timing) -> Result<(), MercError> { + if let Some(command) = commands { + match command { + Commands::Check(args) => check(args, timing)?, + Commands::Simulate(args) => simulate(args, timing)?, + Commands::Verify(args) => verify(args, timing)?, + } + } + + Ok(()) +} + +/// Checks a specification, optionally printing its symbols and/or a summary of +/// its lowered IR program. +fn check(args: CheckArgs, timing: &Timing) -> Result<(), MercError> { + let source = read_source(&args.specification)?; + let spec = check_specification(&source, &args.specification, timing)?; + + if args.print_symbols { + print_symbols(&spec); + } + + if args.lower { + let program = lower_specification(&spec, &source, &args.specification, timing)?; + print_ir_summary(&program); + } + + info!("{} is a valid STARK specification", args.specification.display()); + Ok(()) +} + +/// Runs one trajectory of the specification and prints its state. +fn simulate(args: SimulateArgs, timing: &Timing) -> Result<(), MercError> { + let source = read_source(&args.specification)?; + let spec = check_specification(&source, &args.specification, timing)?; + let program = lower_specification(&spec, &source, &args.specification, timing)?; + + let mut simulation = Simulation::new(&program, args.seed) + .map_err(|err| MercError::from(format!("cannot initialise simulation: {err}")))?; + let mut observer = RecordingObserver::default(); + + if let Err(err) = timing.measure("simulation", || simulation.run(args.steps, &mut observer)) { + return Err(MercError::from(format!( + "simulation failed at step {}: {err}", + simulation.step_count() + 1 + ))); + } + + print_trajectory(&program, &observer.trajectory, args.trajectory, args.precision); + Ok(()) +} + +/// Verifies every `formula` declaration by robustness analysis, printing each +/// formula's verdict. +fn verify(args: VerifyArgs, timing: &Timing) -> Result<(), MercError> { + let source = read_source(&args.specification)?; + let spec = check_specification(&source, &args.specification, timing)?; + let program = lower_specification(&spec, &source, &args.specification, timing)?; + + if program.formula_decls().is_empty() { + info!("{} declares no formulas to verify", args.specification.display()); + return Ok(()); + } + + let options = AnalysisOptions { + sample_size: args.samples, + scale: args.scale, + bootstrap_replicas: args.bootstrap_replicas, + quantile: args.quantile, + }; + let mut analysis = Analysis::new(&program, args.seed, options) + .map_err(|err| MercError::from(format!("cannot initialise analysis: {err}")))?; + + let mut sequence = timing + .measure("sampling", || analysis.sample()) + .map_err(|err| MercError::from(format!("cannot sample the system: {err}")))?; + + // One sequence is sampled once and reused across every formula — see the + // module doc comment on `eval::Analysis`. + for decl in program.formula_decls() { + let verdict = timing.measure("verification", || { + if args.boolean { + analysis + .check_boolean(&mut sequence, args.step, decl.root) + .map(|value| if value { "true" } else { "false" }.to_string()) + } else { + analysis + .check(&mut sequence, args.step, decl.root) + .map(|verdict| describe_truth(verdict).to_string()) + } + }); + + match verdict { + Ok(verdict) => println!("{}: {verdict}", decl.name), + Err(err) => return Err(MercError::from(format!("verifying `{}` failed: {err}", decl.name))), + } + } + + Ok(()) +} + +/// Reads `path` into memory, turning an I/O error into a [MercError]. +fn read_source(path: &Path) -> Result { + read_to_string(path).map_err(|err| MercError::from(format!("cannot read {}: {err}", path.display()))) +} + +/// Parses and checks `source` into a [StarkSpecification]. +/// +/// Diagnostics are rendered against the source text here rather than being +/// propagated as a plain error, since a bare `Diagnostics` has no way to show +/// the offending lines — the whole point of the spans it carries. +fn check_specification(source: &str, path: &Path, timing: &Timing) -> Result { + let untyped = timing.measure("parsing", || UntypedStarkSpecification::parse(source))?; + trace!("AST: {:#?}", untyped); + + timing + .measure("resolving and type checking", || StarkSpecification::from_untyped(untyped)) + .map_err(|diagnostics| render_diagnostics(&diagnostics, source, path)) +} + +/// Lowers a checked specification to its [IrProgram], rendering any lowering +/// diagnostic against the source the same way [check_specification] does. +fn lower_specification( + spec: &StarkSpecification, + source: &str, + path: &Path, + timing: &Timing, +) -> Result { + timing + .measure("lowering", || IrProgram::from_spec(spec)) + .map_err(|diagnostics| render_diagnostics(&diagnostics, source, path)) +} + +/// Renders a [Diagnostics] against `source` into the error a command returns. +fn render_diagnostics(diagnostics: &Diagnostics, source: &str, path: &Path) -> MercError { + let count = diagnostics.items().len(); + let plural = if count == 1 { "error" } else { "errors" }; + + MercError::from(format!( + "{count} {plural} in {}\n\n{}", + path.display(), + diagnostics.render(source) + )) +} + +/// Prints every top-level declaration with the type checker's verdict on it. +fn print_symbols(spec: &StarkSpecification) { + for (index, def) in spec.symbols().defs.iter().enumerate() { + let id = merc_stark::DefId::new(index); + + // Functions carry a signature rather than a single type, and kinds like + // components have neither, so what is worth printing differs per kind. + if let Some(signature) = spec.types().signature_of(id) { + let arguments = signature + .arguments + .iter() + .map(|argument| argument.to_string()) + .collect::>() + .join(", "); + + println!("{}: ({arguments}) -> {}", def.name, signature.return_type); + } else if let Some(ty) = spec.types().type_of(id) { + println!("{}: {ty}", def.name); + } else { + println!("{}: {}", def.name, describe(&def.kind)); + } + } +} + +/// Prints the size of a lowered IR program — how many slots, and how many of +/// each kind of declaration the evaluator will drive. +fn print_ir_summary(program: &IrProgram) { + println!("variables: {}", program.variables().len()); + println!("globals: {}", program.globals().len()); + println!("functions: {}", program.functions().len()); + println!("penalties: {}", program.penalties().len()); + println!("components: {}", program.components().len()); + println!("perturbations: {}", program.perturbation_decls().len()); + println!("distances: {}", program.distance_decls().len()); + println!("formulas: {}", program.formula_decls().len()); + println!("total slots: {}", program.n_slots()); +} + +/// Prints a simulation's state as an aligned table: one column per variable, +/// and either every step's row (`full`) or only the final one. Reals are +/// rounded to `precision` decimals, since a full `f64` rendering makes the +/// columns far wider than a table meant for eyeballing trends needs. +fn print_trajectory(program: &IrProgram, trajectory: &[Vec], full: bool, precision: usize) { + let names: Vec<&str> = (0..program.n_variables()) + .map(|index| program.slot(SlotId::new(index)).name.as_str()) + .collect(); + + // Column widths accommodate the header name and every value printed under + // it, so the columns line up regardless of how wide the values grow. + let rows: &[Vec] = if full || trajectory.is_empty() { + trajectory + } else { + &trajectory[trajectory.len() - 1..] + }; + + // Values are rendered to a `String` before being padded: `Value`'s + // `Display` writes straight through with `write!`, so it ignores the + // formatter's width flag and `{value:>width$}` would not pad at all. + let cells: Vec> = rows + .iter() + .map(|row| row.iter().map(|value| render(value, precision)).collect()) + .collect(); + + let mut widths: Vec = names.iter().map(|name| name.len()).collect(); + for row in &cells { + for (column, value) in row.iter().enumerate() { + widths[column] = widths[column].max(value.len()); + } + } + + // The step column is only meaningful when more than one state is shown; + // for the final state alone it would be a column of one. + let step_width = if full { + rows.len().to_string().len().max("step".len()) + } else { + 0 + }; + let step_column = |label: &str| -> String { + if full { + format!("{label:>step_width$} ") + } else { + String::new() + } + }; + + let header = names + .iter() + .enumerate() + .map(|(column, name)| format!("{name:>width$}", width = widths[column])) + .collect::>() + .join(" "); + println!("{}{header}", step_column("step")); + + for (step, row) in cells.iter().enumerate() { + let line = row + .iter() + .enumerate() + .map(|(column, value)| format!("{value:>width$}", width = widths[column])) + .collect::>() + .join(" "); + println!("{}{line}", step_column(&(step + 1).to_string())); + } +} + +/// Renders a state value for the trajectory table, rounding a real to +/// `precision` decimals. Only reals are reformatted — an integer state +/// variable is exact, and padding it with decimals would suggest otherwise. +fn render(value: &Value, precision: usize) -> String { + match value { + Value::Real(real) => format!("{real:.precision$}"), + other => other.to_string(), + } +} + +/// A human readable name for the kinds that have no type of their own. +fn describe(kind: &DefKind) -> &'static str { + match kind { + DefKind::Constant => "constant", + DefKind::Parameter => "parameter", + DefKind::Variable { .. } => "variable", + DefKind::Function { .. } => "function", + DefKind::Penalty => "penalty", + DefKind::Component => "component", + DefKind::TypeElement { .. } => "type element", + DefKind::Type => "type", + DefKind::Perturbation => "perturbation", + DefKind::Distance => "distance", + DefKind::Formula => "formula", + } +} + +/// A human readable rendering of a three-valued verdict. +fn describe_truth(verdict: TruthValue) -> &'static str { + match verdict { + TruthValue::True => "true", + TruthValue::False => "false", + TruthValue::Unknown => "unknown", + } +}