From 96f9cf9253639c90566329b9c93584cd7c400dcc Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Wed, 17 Dec 2025 00:30:16 +0100 Subject: [PATCH 01/50] Added an initial grammar for the Stark language --- crates/stark/Cargo.toml | 15 ++ crates/stark/src/lib.rs | 4 + crates/stark/stark_grammar.pest | 279 ++++++++++++++++++++++++++++++++ 3 files changed, 298 insertions(+) create mode 100644 crates/stark/Cargo.toml create mode 100644 crates/stark/src/lib.rs create mode 100644 crates/stark/stark_grammar.pest diff --git a/crates/stark/Cargo.toml b/crates/stark/Cargo.toml new file mode 100644 index 000000000..3f0662c06 --- /dev/null +++ b/crates/stark/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "merc_stark" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +merc_utilities.workspace = true + +pest.workspace = true +pest_derive.workspace = true +pest_consume.workspace = true \ No newline at end of file diff --git a/crates/stark/src/lib.rs b/crates/stark/src/lib.rs new file mode 100644 index 000000000..b4bf1e993 --- /dev/null +++ b/crates/stark/src/lib.rs @@ -0,0 +1,4 @@ +mod ast; +mod parse; +mod consume; +mod precedence; \ No newline at end of file diff --git a/crates/stark/stark_grammar.pest b/crates/stark/stark_grammar.pest new file mode 100644 index 000000000..3869800c6 --- /dev/null +++ b/crates/stark/stark_grammar.pest @@ -0,0 +1,279 @@ +// JSpearSpecificationLanguage + +WHITESPACE = _{ " " | "\t" | "\r" | "\n" | "\u{000C}" } +COMMENT = _{ "/*" ~ (!"*/" ~ ANY)* ~ "*/" } + +// Identifiers and literals +DIGIT = _{ '0'..'9' } +LETTER = _{ 'a'..'z' | 'A'..'Z' | "_" } + +ID = @{ LETTER ~ (LETTER | DIGIT)* } +NEXT_ID = @{ ID ~ "'" } +INTEGER = @{ DIGIT+ } +REAL = @{ ((DIGIT* ~ "." ~ DIGIT+) | (DIGIT+ ~ ".")) ~ (("E" | "e") ~ "-"? ~ DIGIT+)? } + +// Entry point +StarkSpecification = { SOI ~ Element* ~ EOI } + +Element = _{ + DeclarationConstant + | DeclarationParameter + | DeclarationVariables + | DeclarationType + | DeclarationEnvironment + | DeclarationPenalty + | DeclarationFunction + | DeclarationComponent + | DeclarationPerturbation + | DeclarationDistance + | DeclarationFormula +} + +// Declarations +DeclarationFormula = _{ "formula" ~ ID ~ "=" ~ RobtlFormula ~ ";" } + +DeclarationDistance = _{ "distance" ~ ID ~ "=" ~ DistanceExpression ~ ";" } + +DeclarationPerturbation = _{ "perturbation" ~ ID ~ "=" ~ PerturbationExpression ~ ";" } + +// Functions +DeclarationFunction = _{ + "function" ~ ID ~ "(" ~ (FunctionArgument ~ ("," ~ FunctionArgument)*)? ~ ")" ~ FunctionBlockStatement +} + +FunctionStatement = _{ + FunctionReturnStatement + | FunctionIfThenElseStatement + | FunctionBlockStatement + | FunctionLetStatement +} + +FunctionLetStatement = _{ "let" ~ ID ~ "=" ~ Expression ~ "in" ~ FunctionStatement } + +FunctionIfThenElseStatement = _{ + "if" ~ "(" ~ Expression ~ ")" ~ FunctionStatement ~ ("else" ~ FunctionStatement)? +} + +FunctionReturnStatement = _{ "return" ~ Expression ~ ";" } + +FunctionBlockStatement = _{ "{" ~ FunctionStatement ~ "}" } + +FunctionArgument = _{ Ty ~ ID } + +// Components and controllers +DeclarationComponent = _{ + "component" ~ ID ~ "{" ~ + "variables" ~ "{" ~ VariableDeclaration* ~ "}" ~ + "controller" ~ "{" ~ ControllerStateDeclaration* ~ "}" ~ + "init" ~ ControllerExpression ~ + "}" +} + +ControllerStateDeclaration = _{ "aiState" ~ ID ~ ControllerBlockBehaviour } + +ControllerBlockBehaviour = _{ "{" ~ ControllerCommand* ~ "}" } + +ControllerSequentialBehaviour = _{ ControllerVariableAssignment* ~ ControllerTerminalStatement } + +ControllerCommand = _{ + ControllerStepAtion + | ControllerExecAction + | ControllerLetAssignment + | ControllerVariableAssignment + | ControllerIfThenElseBehaviour + | ControllerBlockBehaviour +} + +ControllerTerminalStatement = _{ + ControllerStepAtion + | ControllerExecAction + | ControllerLetAssignment + | ControllerIfThenElseBehaviour +} + +ControllerCaseStatment = _{ "case" ~ "(" ~ Expression ~ ")" ~ ControllerBlockBehaviour } + +ControllerExpression = _{ ID ~ ("||" ~ ID)* } + +DeclarationPenalty = _{ "penalty" ~ ID ~ "=" ~ Expression } + +ControllerLetAssignment = _{ "let" ~ ID ~ "=" ~ Expression ~ "in" ~ ControllerBlockBehaviour } + +ControllerVariableAssignment = _{ ("when" ~ Expression)? ~ VarExpression ~ "=" ~ Expression ~ ";" } + +ControllerExecAction = _{ "exec" ~ ID ~ ";" } + +ControllerStepAtion = _{ (Expression ~ "#")? ~ "step" ~ ID ~ ";" } + +ControllerIfThenElseBehaviour = _{ "if" ~ "(" ~ Expression ~ ")" ~ ControllerBlockBehaviour ~ ("else" ~ ControllerBlockBehaviour)? } + +// Environment +DeclarationEnvironment = _{ "environment" ~ EnvironmentBlock } + +EnvironmentBlock = _{ "{" ~ EnvironmentCommand* ~ "}" } + +EnvironmentCommand = _{ + EnvironmentLetCommand + | EnvironmentIfThenElse + | EnvironmentAssignment + | EnvironmentBlock +} + +EnvironmentAssignment = _{ VariableAssignment } + +EnvironmentIfThenElse = _{ "if" ~ "(" ~ Expression ~ ")" ~ EnvironmentCommand ~ ("else" ~ EnvironmentCommand)? } + +EnvironmentLetCommand = _{ + "let" ~ LocalVariable ~ ("and" ~ LocalVariable)* ~ "in" ~ EnvironmentCommand +} + +VariableAssignment = _{ ("when" ~ Expression)? ~ VarExpression ~ "=" ~ Expression ~ ";" } + +VarExpression = _{ NEXT_ID } + +LocalVariable = _{ ID ~ "=" ~ Expression } + +// Types and variables +DeclarationType = _{ "type" ~ ID ~ "=" ~ TypeElementDeclaration ~ ("|" ~ TypeElementDeclaration)* ~ ";" } + +TypeElementDeclaration = _{ ID } + +DeclarationVariables = _{ ("global")? ~ "variables" ~ "{" ~ VariableDeclaration* ~ "}" } + +VariableDeclaration = _{ Ty ~ ID ~ ("range" ~ "[" ~ Expression ~ "," ~ Expression ~ "]")? ~ "=" ~ Expression ~ ";" } + +Ty = _{ + "int" + | "real" + | "bool" + | ID +} + +DeclarationParameter = _{ "param" ~ ID ~ "=" ~ Expression ~ ";" } + +DeclarationConstant = _{ "const" ~ ID ~ "=" ~ Expression ~ ";" } + +// Expressions using prefix-primary-postfix with infix chaining +Expression = _{ ExpressionPrefix* ~ Primary ~ ExpressionPostfix* ~ (ExpressionInfix ~ ExpressionPrefix* ~ Primary ~ ExpressionPostfix*)* } + +// Prefix operators for Expression +ExpressionPrefix = _{ ExpressionNot | ExpressionUnaryPlus | ExpressionUnaryMinus } + ExpressionNot = { "!" } + ExpressionUnaryPlus = { "+" } + ExpressionUnaryMinus = { "-" } + +// Infix operators for Expression +ExpressionInfix = _{ + ExpressionPow + | ExpressionMult + | ExpressionDiv + | ExpressionIntDiv + | ExpressionAdd + | ExpressionSubtract + | ExpressionMod + | ExpressionLess + | ExpressionLeq + | ExpressionEq + | ExpressionGeq + | ExpressionGreater + | ExpressionBitAnd + | ExpressionAnd + | ExpressionBitOr + | ExpressionOr +} + ExpressionPow = { "^" } + ExpressionMult = { "*" } + ExpressionDiv = { "/" } + ExpressionIntDiv = { "//" } + ExpressionAdd = { "+" } + ExpressionSubtract = { "-" } + ExpressionMod = { "%" } + ExpressionLess = { "<" } + ExpressionLeq = { "<=" } + ExpressionEq = { "==" } + ExpressionGeq = { ">=" } + ExpressionGreater = { ">" } + ExpressionBitAnd = { "&" } + ExpressionAnd = { "&&" } + ExpressionBitOr = { "|" } + ExpressionOr = { "||" } + +// Postfix operators for Expression +ExpressionPostfix = _{ ExpressionCall | ExpressionAggregate } + ExpressionCall = { "(" ~ (Expression ~ ("," ~ Expression)*)? ~ ")" } + ExpressionAggregate = { "." ~ ExpressionAggregateOp ~ "(" ~ (Expression)? ~ ")" } + ExpressionAggregateOp = { "count" | "min" | "max" | "mean" } + +Primary = _{ + "(" ~ Expression ~ ")" + | "false" + | "true" + | "N" ~ "[" ~ Expression ~ "," ~ Expression ~ "]" + | "U" ~ "[" ~ Expression ~ ("," ~ Expression)* ~ "]" + | "R" ~ ("[" ~ Expression ~ "," ~ Expression ~ "]")? + | "it" + | INTEGER + | REAL + | ID +} + +// Perturbation expressions +PerturbationExpression = _{ PerturbationSequence } + +PerturbationSequence = _{ PerturbationIter ~ (";" ~ PerturbationIter)* } + +PerturbationIter = _{ PerturbationPrimary ~ ("^" ~ Expression)? } + +PerturbationPrimary = _{ + "nil" + | "(" ~ PerturbationExpression ~ ")" + | "[" ~ PerturbationAssignment ~ ("," ~ PerturbationAssignment)* ~ "]" ~ "@" ~ Expression + | ID +} + +PerturbationAssignment = _{ ID ~ "<-" ~ Expression } + +// Distance expressions +DistanceExpression = _{ DistanceExprThreshold } + +DistanceExprThreshold = _{ DistanceExprUntil ~ (("<" | "<=" | ">=" | ">") ~ Expression)? } + +DistanceExprUntil = _{ DistanceExprPrefix ~ ("\\U" ~ "[" ~ Expression ~ "," ~ Expression ~ "]" ~ DistanceExprPrefix)* } + +DistanceExprPrefix = _{ + "F" ~ "[" ~ Expression ~ "," ~ Expression ~ "]" ~ DistanceExprPrefix + | "G" ~ "[" ~ Expression ~ "," ~ Expression ~ "]" ~ DistanceExprPrefix + | DistanceExprPrimary +} + +DistanceExprPrimary = _{ + "<" ~ ID + | ">" ~ ID + | "(" ~ DistanceExpression ~ ")" + | ID + | "min" ~ "(" ~ DistanceExpression ~ "," ~ DistanceExpression ~ ")" + | "max" ~ "(" ~ DistanceExpression ~ "," ~ DistanceExpression ~ ")" + | Expression ~ "*" ~ DistanceExpression ~ ("+" ~ Expression ~ "*" ~ DistanceExpression)* +} + +// ROBTL formulas +RobtlFormula = _{ RobtlPrefix* ~ RobtlPrimary ~ (RobtlInfix ~ RobtlPrefix* ~ RobtlPrimary)* } + +RobtlPrefix = _{ + "!" + | "G" ~ "[" ~ Expression ~ "," ~ Expression ~ "]" + | "F" ~ "[" ~ Expression ~ "," ~ Expression ~ "]" +} + +RobtlInfix = _{ + "&&" + | "||" + | "U" ~ "[" ~ Expression ~ "," ~ Expression ~ "]" +} + +RobtlPrimary = _{ + "true" + | "false" + | "D" ~ "[" ~ ID ~ "," ~ ID ~ "]" ~ ("<=" | "<" | "==" | ">=" | ">") ~ Expression + | ID +} \ No newline at end of file From beac98c0035814d55bc4ed73bdd13c9e8c00d5b4 Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Wed, 17 Dec 2025 00:31:05 +0100 Subject: [PATCH 02/50] Added several .stark specifications from the Stark toolset --- examples/stark/engine.stark | 194 ++++++++++++++++++++++++++ examples/stark/random_walk.stark | 11 ++ examples/stark/single_vehicle.stark | 207 ++++++++++++++++++++++++++++ examples/stark/toll.stark | 109 +++++++++++++++ examples/stark/two_vehicles.stark | 109 +++++++++++++++ 5 files changed, 630 insertions(+) create mode 100644 examples/stark/engine.stark create mode 100644 examples/stark/random_walk.stark create mode 100644 examples/stark/single_vehicle.stark create mode 100644 examples/stark/toll.stark create mode 100644 examples/stark/two_vehicles.stark diff --git a/examples/stark/engine.stark b/examples/stark/engine.stark new file mode 100644 index 000000000..8f55cd71a --- /dev/null +++ b/examples/stark/engine.stark @@ -0,0 +1,194 @@ +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; + +/*type speed_value = LOW|HALF|FULL; +type warning_value = OK|HOT;*/ + +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; +} + + +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 pen_wrn (int warning) { + if (warning == HOT) { + return 1.0; + } else { + return 0.0; + } +} + +function get_stress (real stress_value) { + return stress_value; +} + + +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 { + aiState Ctrl { + if (ch_temp >= 99.8) { + cool' = true; + step Cooling; + } else { + exec Check; + } + } + aiState Check { + if (ch_speed == LOW) { + speed' = LOW; + cool' = false; + step Ctrl; + } else { + speed' = ch_in; + cool' = false; + step Ctrl; + } + } + aiState Cooling { + 4#step Check; + } + aiState 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 { + let + deltaTemp = temperatureUpdateInOneStep(cool, speed) + in + temp' = temp + deltaTemp; + ch_temp' = ch_temp + deltaTemp; + 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 = pen_wrn(ch_wrn) + +penalty rho_stress = pen_stress(stress) + + + +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; + + + +perturbation fake_temperature = [ch_temp <- temp * TEMP_OFFSET * R[0,1]]@0; + +perturbation it_fake_temperature = fake_temperature^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; \ No newline at end of file diff --git a/examples/stark/random_walk.stark b/examples/stark/random_walk.stark new file mode 100644 index 000000000..3e8096f36 --- /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/single_vehicle.stark b/examples/stark/single_vehicle.stark new file mode 100644 index 000000000..b5b4b76f4 --- /dev/null +++ b/examples/stark/single_vehicle.stark @@ -0,0 +1,207 @@ +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; + + + +/*type IDSmsg = OK|DANGER;*/ + + +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(boolean dist, boolean acc1, boolean acc2, boolean 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 { + aiState 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; + } + } + aiState Accelerate { + if (counter > 0) { + step Accelerate; + } else { + exec Ctrl; + } + } + aiState Decelerate { + if (counter > 0) { + step Decelerate; + } else { + exec Ctrl; + } + } + aiState Stop { + if (counter > 0) { + step Stop; + } else { + if (warning == DANGER) { + accel' = -B; + counter' = TIMER; + step Decelerate; + } else { + counter' = TIMER; + step Stop; + } + } + } + aiState 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); + if (counter-1 == 0) { + s_speed' = new_s_speed(p_speed,accel,token); + 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) + +penalty physical_dist = p_distance + +penalty sensed_speed = s_speed + +penalty physical_speed = p_speed + +penalty rho_token = token + +penalty rho_offset = offset_speed + + + +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] \D[exp_crash,p_ItSlow_02] <= ETA_slow; + +formula always_slow_03 = \G[0,H] \D[exp_crash,p_ItSlow_03] <= ETA_slow; + +formula always_slow_04 = \G[0,H] \D[exp_crash,p_ItSlow_04] <= ETA_slow; + +formula always_slow_05 = \G[0,H] \D[exp_crash,p_ItSlow_05] <= ETA_slow; \ No newline at end of file diff --git a/examples/stark/toll.stark b/examples/stark/toll.stark new file mode 100644 index 000000000..eb7adb7f7 --- /dev/null +++ b/examples/stark/toll.stark @@ -0,0 +1,109 @@ +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 (accel == 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_V1; + 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_V1; + } + } + } + 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/two_vehicles.stark b/examples/stark/two_vehicles.stark new file mode 100644 index 000000000..eb7adb7f7 --- /dev/null +++ b/examples/stark/two_vehicles.stark @@ -0,0 +1,109 @@ +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 (accel == 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_V1; + 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_V1; + } + } + } + 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 From 3c91719734691de8e783904cd1ee47cd948d978a Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Wed, 17 Dec 2025 00:32:22 +0100 Subject: [PATCH 03/50] Added specifications as tests --- crates/stark/src/parse.rs | 65 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 crates/stark/src/parse.rs diff --git a/crates/stark/src/parse.rs b/crates/stark/src/parse.rs new file mode 100644 index 000000000..209a6cccc --- /dev/null +++ b/crates/stark/src/parse.rs @@ -0,0 +1,65 @@ + +use pest::Parser; +use pest_derive::Parser; + +use merc_utilities::MercError; + +use crate::ast::StarkSpecification; + +#[derive(Parser)] +#[grammar = "stark_grammar.pest"] +pub struct StarkParser; + + +impl StarkSpecification { + /// Parse the given stark specification into an AST. + pub fn parse(input: &str) -> Result { + let pairs = StarkParser::parse(Rule::StarkSpecification, input)?; + + Ok(Self { + controllers: Default::default(), + }) + } +} + + +#[cfg(test)] +mod tests { + use crate::ast::StarkSpecification; + + #[test] + fn test_parse_engine_stark() { + if let Err(x) = StarkSpecification::parse(include_str!("../../../examples/stark/engine.stark")) { + panic!("Failed to parse: {}", x); + } + } + + #[test] + fn test_parse_random_walk_stark() { + if let Err(x) = StarkSpecification::parse(include_str!("../../../examples/stark/random_walk.stark")) { + panic!("Failed to parse: {}", x); + } + } + + #[test] + fn test_parse_single_vehicle_stark() { + if let Err(x) = StarkSpecification::parse(include_str!("../../../examples/stark/single_vehicle.stark")) { + panic!("Failed to parse: {}", x); + + } + } + + #[test] + fn test_parse_toll_stark() { + if let Err(x) = StarkSpecification::parse(include_str!("../../../examples/stark/toll.stark")) { + panic!("Failed to parse: {}", x); + } + } + + #[test] + fn test_parse_two_vehicles_stark() { + if let Err(x) = StarkSpecification::parse(include_str!("../../../examples/stark/two_vehicles.stark")) { + panic!("Failed to parse: {}", x); + } + } +} \ No newline at end of file From d49eeaaf6675a9ca51ca6f04fdd227437edf1f31 Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Wed, 17 Dec 2025 00:46:31 +0100 Subject: [PATCH 04/50] Started the AST --- crates/stark/src/ast.rs | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 crates/stark/src/ast.rs diff --git a/crates/stark/src/ast.rs b/crates/stark/src/ast.rs new file mode 100644 index 000000000..641ac7f28 --- /dev/null +++ b/crates/stark/src/ast.rs @@ -0,0 +1,33 @@ +use std::collections::HashMap; + +use merc_utilities::MercError; + +use crate::parse::StarkParser; + + +/// Represents the AST of a controller. +pub enum Controller { + Action(Vec, Box), + Assignemnt(Vec, Box), + Effect(), + /// Executes another controller. + Exec(usize), + Choice(Box, Box), + IfThenElse(Expression, Box, Box), + Nil, + Paralellel(Box, Box), + Interleave(f64, Box, Box), + Step(Box), +} + +pub enum Expression { + +} + +pub struct StarkSpecification { + pub controllers: HashMap, +} + +struct Update { + +} \ No newline at end of file From 216555d733fe0d040dfab85c5f9e3f3dd0bb1534 Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Wed, 14 Jan 2026 15:29:25 +0100 Subject: [PATCH 05/50] Added expressions, and the stark license. --- crates/stark/src/ast.rs | 54 ++++++++++- examples/stark/LICENSE | 201 +++++++++++++++++++++++++++++++++++++++ examples/stark/README.md | 3 + 3 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 examples/stark/LICENSE create mode 100644 examples/stark/README.md diff --git a/crates/stark/src/ast.rs b/crates/stark/src/ast.rs index 641ac7f28..a0b865363 100644 --- a/crates/stark/src/ast.rs +++ b/crates/stark/src/ast.rs @@ -10,18 +10,68 @@ pub enum Controller { Action(Vec, Box), Assignemnt(Vec, Box), Effect(), - /// Executes another controller. Exec(usize), Choice(Box, Box), IfThenElse(Expression, Box, Box), - Nil, Paralellel(Box, Box), Interleave(f64, Box, Box), Step(Box), + Nil, } +/// Represents an expression in the AST. pub enum Expression { + // Literals + False, + True, + Integer(i64), + Real(f64), + Identifier(String), + Iterator, + + // Distributions + Normal { mean: Box, std_dev: Box }, + Uniform { values: Vec }, + Range { min: Option>, max: Option> }, + + // Prefix operators + Not(Box), + UnaryPlus(Box), + UnaryMinus(Box), + + // Binary operators + Binary(BinaryOp, Box, Box), + + // Postfix operators + Call { function: Box, arguments: Vec }, + Aggregate { target: Box, op: AggregateOp, argument: Option> }, +} + +pub enum BinaryOp { + Pow, + Mult, + Div, + IntDiv, + Add, + Subtract, + Mod, + Less, + Leq, + Eq, + Geq, + Greater, + BitAnd, + And, + BitOr, + Or, +} + +pub enum AggregateOp { + Count, + Min, + Max, + Mean, } pub struct StarkSpecification { diff --git a/examples/stark/LICENSE b/examples/stark/LICENSE new file mode 100644 index 000000000..8f3d380fb --- /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 000000000..67c0fafdd --- /dev/null +++ b/examples/stark/README.md @@ -0,0 +1,3 @@ +# Overview + +These examples are taken from the [Stark](https://github.com/mlaveaux/STARK.git) repository. \ No newline at end of file From 5f0c192e6ffe291cef25f08bf664c68a3a3067e1 Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Tue, 7 Apr 2026 22:14:09 +0200 Subject: [PATCH 06/50] Made some small changes to the grammar --- Cargo.lock | 10 +++ Cargo.toml | 2 + crates/stark/Cargo.toml | 4 +- crates/stark/src/ast.rs | 135 ++++++++++++++++++++++++++++---- crates/stark/stark_grammar.pest | 26 +++--- 5 files changed, 147 insertions(+), 30 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9f89ee39d..1f5f31d0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1381,6 +1381,16 @@ dependencies = [ "rand", ] +[[package]] +name = "merc_stark" +version = "1.0.0" +dependencies = [ + "merc_pest_consume", + "merc_utilities", + "pest", + "pest_derive", +] + [[package]] name = "merc_symbolic" version = "3.0.0" diff --git a/Cargo.toml b/Cargo.toml index 66dabed59..9736f5624 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ members = [ "crates/lts/benchmarks", "crates/macros", "crates/number", + "crates/preorder", "crates/rec-tests", "crates/reduction", "crates/refinement", @@ -38,6 +39,7 @@ members = [ "crates/sabre/benchmarks", "crates/sharedmutex", "crates/sharedmutex/benchmarks", + "crates/stark", "crates/symbolic", "crates/syntax", "crates/tools", diff --git a/crates/stark/Cargo.toml b/crates/stark/Cargo.toml index 3f0662c06..7f27d64a2 100644 --- a/crates/stark/Cargo.toml +++ b/crates/stark/Cargo.toml @@ -5,11 +5,9 @@ edition.workspace = true license.workspace = true rust-version.workspace = true -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - [dependencies] merc_utilities.workspace = true pest.workspace = true pest_derive.workspace = true -pest_consume.workspace = true \ No newline at end of file +merc_pest_consume.workspace = true \ No newline at end of file diff --git a/crates/stark/src/ast.rs b/crates/stark/src/ast.rs index a0b865363..80d1b4f95 100644 --- a/crates/stark/src/ast.rs +++ b/crates/stark/src/ast.rs @@ -1,19 +1,14 @@ use std::collections::HashMap; -use merc_utilities::MercError; - -use crate::parse::StarkParser; - - /// Represents the AST of a controller. pub enum Controller { Action(Vec, Box), - Assignemnt(Vec, Box), + Assignment(Vec, Box), Effect(), Exec(usize), Choice(Box, Box), IfThenElse(Expression, Box, Box), - Paralellel(Box, Box), + Parallel(Box, Box), Interleave(f64, Box, Box), Step(Box), Nil, @@ -30,9 +25,17 @@ pub enum Expression { Iterator, // Distributions - Normal { mean: Box, std_dev: Box }, - Uniform { values: Vec }, - Range { min: Option>, max: Option> }, + Normal { + mean: Box, + std_dev: Box, + }, + Uniform { + values: Vec, + }, + Range { + min: Option>, + max: Option>, + }, // Prefix operators Not(Box), @@ -43,8 +46,15 @@ pub enum Expression { Binary(BinaryOp, Box, Box), // Postfix operators - Call { function: Box, arguments: Vec }, - Aggregate { target: Box, op: AggregateOp, argument: Option> }, + Call { + function: Box, + arguments: Vec, + }, + Aggregate { + target: Box, + op: AggregateOp, + argument: Option>, + }, } pub enum BinaryOp { @@ -66,7 +76,6 @@ pub enum BinaryOp { Or, } - pub enum AggregateOp { Count, Min, @@ -78,6 +87,104 @@ pub struct StarkSpecification { pub controllers: HashMap, } -struct Update { +pub struct Update { + identifier: Identifier, + value: Expression, +} + +pub struct Variable { + global: bool, + ty: Ty, + id: Identifier, + range: Option, + initial_value: Expression, +} + +pub struct Constant { + global: bool, + ty: Option, + id: Identifier, + range: Option, + initial_value: Expression, +} + +impl Constant { + pub fn new(id: Identifier, value: Expression) -> Self { + Constant { + global: true, + ty: None, + id, + range: None, + initial_value: value, + } + } +} + +impl Variable { + pub fn new( + global: bool, + ty: Ty, + id: Identifier, + range: Option, + initial_value: Expression, + ) -> Self { + Variable { + global, + ty, + id, + range, + initial_value, + } + } +} + +pub struct Range { + min: Option, + max: Option, +} + +impl Range { + pub fn new(min: Option, max: Option) -> Self { + Range { min, max } + } +} + +pub enum Ty { + Real, + Integer, + Boolean, +} + +pub struct Identifier { + name: String, + span: Span, +} + +impl Identifier { + pub fn new(name: String, span: Span) -> Self { + Identifier { name, span } + } +} + +pub enum Command { + Let, + IfThenElse, + Assignment, + Block, +} + +/// Source location information, spanning from start to end in the source text. +#[derive(Clone, 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(), + } + } } \ No newline at end of file diff --git a/crates/stark/stark_grammar.pest b/crates/stark/stark_grammar.pest index 3869800c6..c873f0bd7 100644 --- a/crates/stark/stark_grammar.pest +++ b/crates/stark/stark_grammar.pest @@ -154,7 +154,7 @@ DeclarationParameter = _{ "param" ~ ID ~ "=" ~ Expression ~ ";" } DeclarationConstant = _{ "const" ~ ID ~ "=" ~ Expression ~ ";" } // Expressions using prefix-primary-postfix with infix chaining -Expression = _{ ExpressionPrefix* ~ Primary ~ ExpressionPostfix* ~ (ExpressionInfix ~ ExpressionPrefix* ~ Primary ~ ExpressionPostfix*)* } +Expression = _{ ExpressionPrefix* ~ ExpressionPrimary ~ ExpressionPostfix* ~ (ExpressionInfix ~ ExpressionPrefix* ~ ExpressionPrimary ~ ExpressionPostfix*)* } // Prefix operators for Expression ExpressionPrefix = _{ ExpressionNot | ExpressionUnaryPlus | ExpressionUnaryMinus } @@ -166,37 +166,37 @@ ExpressionPrefix = _{ ExpressionNot | ExpressionUnaryPlus | ExpressionUnaryMinus ExpressionInfix = _{ ExpressionPow | ExpressionMult - | ExpressionDiv | ExpressionIntDiv + | ExpressionDiv | ExpressionAdd | ExpressionSubtract | ExpressionMod - | ExpressionLess | ExpressionLeq + | ExpressionLess | ExpressionEq | ExpressionGeq | ExpressionGreater - | ExpressionBitAnd | ExpressionAnd - | ExpressionBitOr + | ExpressionBitAnd | ExpressionOr + | ExpressionBitOr } ExpressionPow = { "^" } ExpressionMult = { "*" } - ExpressionDiv = { "/" } ExpressionIntDiv = { "//" } + ExpressionDiv = { "/" } ExpressionAdd = { "+" } ExpressionSubtract = { "-" } ExpressionMod = { "%" } - ExpressionLess = { "<" } ExpressionLeq = { "<=" } + ExpressionLess = { "<" } ExpressionEq = { "==" } ExpressionGeq = { ">=" } ExpressionGreater = { ">" } - ExpressionBitAnd = { "&" } ExpressionAnd = { "&&" } - ExpressionBitOr = { "|" } + ExpressionBitAnd = { "&" } ExpressionOr = { "||" } + ExpressionBitOr = { "|" } // Postfix operators for Expression ExpressionPostfix = _{ ExpressionCall | ExpressionAggregate } @@ -204,7 +204,7 @@ ExpressionPostfix = _{ ExpressionCall | ExpressionAggregate } ExpressionAggregate = { "." ~ ExpressionAggregateOp ~ "(" ~ (Expression)? ~ ")" } ExpressionAggregateOp = { "count" | "min" | "max" | "mean" } -Primary = _{ +ExpressionPrimary = _{ "(" ~ Expression ~ ")" | "false" | "true" @@ -212,8 +212,8 @@ Primary = _{ | "U" ~ "[" ~ Expression ~ ("," ~ Expression)* ~ "]" | "R" ~ ("[" ~ Expression ~ "," ~ Expression ~ "]")? | "it" - | INTEGER | REAL + | INTEGER | ID } @@ -241,8 +241,8 @@ DistanceExprThreshold = _{ DistanceExprUntil ~ (("<" | "<=" | ">=" | ">") ~ Expr DistanceExprUntil = _{ DistanceExprPrefix ~ ("\\U" ~ "[" ~ Expression ~ "," ~ Expression ~ "]" ~ DistanceExprPrefix)* } DistanceExprPrefix = _{ - "F" ~ "[" ~ Expression ~ "," ~ Expression ~ "]" ~ DistanceExprPrefix - | "G" ~ "[" ~ Expression ~ "," ~ Expression ~ "]" ~ DistanceExprPrefix + "\\F" ~ "[" ~ Expression ~ "," ~ Expression ~ "]" ~ DistanceExprPrefix + | "\\G" ~ "[" ~ Expression ~ "," ~ Expression ~ "]" ~ DistanceExprPrefix | DistanceExprPrimary } From 5bd29e8c87a3336df9cdd0c6b1991fd2647e2bc2 Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Thu, 16 Apr 2026 15:54:44 +0200 Subject: [PATCH 07/50] Made progress on the operator precedence. --- crates/stark/src/consume.rs | 160 ++++++++++++++++++++ crates/stark/src/lib.rs | 9 +- crates/stark/src/parse.rs | 17 +-- crates/stark/src/precedence.rs | 252 ++++++++++++++++++++++++++++++++ crates/stark/stark_grammar.pest | 31 ++-- 5 files changed, 444 insertions(+), 25 deletions(-) create mode 100644 crates/stark/src/consume.rs create mode 100644 crates/stark/src/precedence.rs diff --git a/crates/stark/src/consume.rs b/crates/stark/src/consume.rs new file mode 100644 index 000000000..3ca1eb4ad --- /dev/null +++ b/crates/stark/src/consume.rs @@ -0,0 +1,160 @@ +#![allow(clippy::result_large_err)] + +use std::collections::HashMap; + +use merc_pest_consume::Error; +use merc_pest_consume::match_nodes; + +use crate::StarkParser; +use crate::ast::{Constant, Expression, Identifier, Range, StarkSpecification, Ty, Variable}; +use crate::parse::Rule; +use crate::precedence::{ + parse_distance_expression, parse_expression, parse_perturbation_expression, parse_robtl_formula, +}; + +/// Type alias for Errors resulting parsing. +pub(crate) type ParseResult = std::result::Result>; +pub(crate) type ParseNode<'i> = merc_pest_consume::Node<'i, Rule, ()>; + +#[merc_pest_consume::parser] +impl StarkParser { + pub fn StarkSpecification(input: ParseNode) -> ParseResult { + let controllers = HashMap::new(); + + for child in input.into_children() { + match child.as_rule() { + Rule::DeclarationComponent => { + // TODO: component parsing is not implemented yet. + } + Rule::DeclarationParameter => {} + Rule::DeclarationConstant => { + let _ = Self::DeclarationConstant(child)?; + } + Rule::DeclarationVariables => { + let _ = Self::DeclarationVariables(child)?; + } + Rule::DeclarationType => {} + Rule::DeclarationEnvironment => { + Self::DeclarationEnvironment(child)?; + } + Rule::DeclarationPenalty => {} + Rule::DeclarationFunction => {} + Rule::DeclarationPerturbation => { + Self::DeclarationPerturbation(child)?; + } + Rule::DeclarationDistance => { + Self::DeclarationDistance(child)?; + } + Rule::DeclarationFormula => { + Self::DeclarationFormula(child)?; + } + Rule::EOI => {} + _ => unreachable!(), + } + } + + Ok(StarkSpecification { controllers }) + } + + fn DeclarationConstant(input: ParseNode) -> ParseResult { + match_nodes!(input.into_children(); + [ID(id), Expression(expr)] => { + Ok(Constant::new(id, expr)) + } + ) + } + + fn ID(input: ParseNode) -> ParseResult { + let span = input.as_span(); + Ok(Identifier::new(input.as_str().to_string(), span.into())) + } + + fn DeclarationVariables(input: ParseNode) -> ParseResult> { + match_nodes!(input.into_children(); + [VariableDeclaration(vars)..] => { + Ok(vars.collect()) + } + ) + } + + fn VariableDeclaration(input: ParseNode) -> ParseResult { + match_nodes!(input.into_children(); + [Ty(ty), ID(id), Expression(min), Expression(max), Expression(initial)] => { + Ok(Variable::new( + false, + ty, + id, + Some(Range::new(Some(min), Some(max))), + initial, + )) + }, + [Ty(ty), ID(id), Expression(initial)] => { + Ok(Variable::new(false, ty, id, None, initial)) + } + ) + } + + fn DeclarationEnvironment(input: ParseNode) -> ParseResult<()> { + match_nodes!(input.into_children(); + [EnvironmentBlock(block)] => { + let _ = block; + Ok(()) + } + ) + } + + fn DeclarationPerturbation(input: ParseNode) -> ParseResult<()> { + match_nodes!(input.into_children(); + [ID(_), PerturbationExpression(_)] => Ok(()) + ) + } + + fn DeclarationDistance(input: ParseNode) -> ParseResult<()> { + match_nodes!(input.into_children(); + [ID(_), DistanceExpression(_)] => Ok(()) + ) + } + + fn DeclarationFormula(input: ParseNode) -> ParseResult<()> { + match_nodes!(input.into_children(); + [ID(_), RobtlFormula(_)] => Ok(()) + ) + } + + fn EnvironmentBlock(input: ParseNode) -> ParseResult<()> { + for child in input.into_children() { + Self::EnvironmentCommand(child)?; + } + Ok(()) + } + + fn EnvironmentCommand(_input: ParseNode) -> ParseResult<()> { + Ok(()) + } + + fn Ty(input: ParseNode) -> ParseResult { + Ok(match input.as_str() { + "int" => Ty::Integer, + "real" => Ty::Real, + "bool" => Ty::Boolean, + // User-defined types are not yet represented in the AST type enum. + _ => Ty::Integer, + }) + } + + pub(crate) fn Expression(input: ParseNode) -> ParseResult { + parse_expression(input.children().as_pairs().clone()) + } + + fn PerturbationExpression(input: ParseNode) -> ParseResult<()> { + parse_perturbation_expression(input.children().as_pairs().clone()) + } + + fn DistanceExpression(input: ParseNode) -> ParseResult<()> { + parse_distance_expression(input.children().as_pairs().clone()) + } + + fn RobtlFormula(input: ParseNode) -> ParseResult<()> { + parse_robtl_formula(input.children().as_pairs().clone()) + } +} \ No newline at end of file diff --git a/crates/stark/src/lib.rs b/crates/stark/src/lib.rs index b4bf1e993..55113655b 100644 --- a/crates/stark/src/lib.rs +++ b/crates/stark/src/lib.rs @@ -1,4 +1,9 @@ mod ast; -mod parse; mod consume; -mod precedence; \ No newline at end of file +mod parse; +mod precedence; + +pub use ast::*; +pub use consume::*; +pub use parse::*; +pub use precedence::*; \ No newline at end of file diff --git a/crates/stark/src/parse.rs b/crates/stark/src/parse.rs index 209a6cccc..0ab78a25e 100644 --- a/crates/stark/src/parse.rs +++ b/crates/stark/src/parse.rs @@ -1,32 +1,28 @@ - use pest::Parser; use pest_derive::Parser; use merc_utilities::MercError; use crate::ast::StarkSpecification; +use crate::consume::ParseNode; #[derive(Parser)] #[grammar = "stark_grammar.pest"] pub struct StarkParser; - impl StarkSpecification { /// Parse the given stark specification into an AST. pub fn parse(input: &str) -> Result { - let pairs = StarkParser::parse(Rule::StarkSpecification, input)?; - - Ok(Self { - controllers: Default::default(), - }) + let mut result = StarkParser::parse(Rule::StarkSpecification, input)?; + let root = result.next().expect("Could not parse STARK specification"); + Ok(StarkParser::StarkSpecification(ParseNode::new(root))?) } } - #[cfg(test)] mod tests { use crate::ast::StarkSpecification; - + #[test] fn test_parse_engine_stark() { if let Err(x) = StarkSpecification::parse(include_str!("../../../examples/stark/engine.stark")) { @@ -45,7 +41,6 @@ mod tests { fn test_parse_single_vehicle_stark() { if let Err(x) = StarkSpecification::parse(include_str!("../../../examples/stark/single_vehicle.stark")) { panic!("Failed to parse: {}", x); - } } @@ -62,4 +57,4 @@ mod tests { panic!("Failed to parse: {}", x); } } -} \ No newline at end of file +} diff --git a/crates/stark/src/precedence.rs b/crates/stark/src/precedence.rs new file mode 100644 index 000000000..c25553389 --- /dev/null +++ b/crates/stark/src/precedence.rs @@ -0,0 +1,252 @@ +use std::sync::LazyLock; + +use pest::iterators::Pair; +use pest::iterators::Pairs; +use pest::pratt_parser::Assoc; +use pest::pratt_parser::Op; +use pest::pratt_parser::PrattParser; + +use crate::ast::AggregateOp; +use crate::ast::BinaryOp; +use crate::ast::Expression; +use crate::parse::Rule; +use crate::consume::ParseResult; + +pub static EXPRESSION_PRATT_PARSER: LazyLock> = LazyLock::new(|| { + // Precedence is defined lowest to highest + 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) | Op::postfix(Rule::ExpressionAggregate)) +}); + +#[allow(clippy::result_large_err)] +fn parse_expression_primary(primary: Pair<'_, Rule>) -> ParseResult { + match primary.as_rule() { + Rule::Expression => parse_expression(primary.into_inner()), + Rule::INTEGER => Ok(Expression::Integer( + primary.as_str().parse::().expect("INTEGER token should parse as i64"), + )), + Rule::REAL => Ok(Expression::Real( + primary.as_str().parse::().expect("REAL token should parse as f64"), + )), + Rule::ID => Ok(Expression::Identifier(primary.as_str().to_string())), + _ => { + let text = primary.as_str(); + if text == "false" { + return Ok(Expression::False); + } + if text == "true" { + return Ok(Expression::True); + } + if text == "it" { + return Ok(Expression::Iterator); + } + + let args: Vec = primary + .clone() + .into_inner() + .filter(|pair| pair.as_rule() == Rule::Expression) + .map(|pair| parse_expression(pair.into_inner())) + .collect::, _>>()?; + + if text.starts_with("N[") && args.len() == 2 { + let mut args = args.into_iter(); + return Ok(Expression::Normal { + mean: Box::new(args.next().expect("normal distribution requires mean")), + std_dev: Box::new(args.next().expect("normal distribution requires std_dev")), + }); + } + + if text.starts_with("U[") { + return Ok(Expression::Uniform { values: args }); + } + + if text.starts_with('R') { + let mut args = args.into_iter(); + return Ok(Expression::Range { + min: args.next().map(Box::new), + max: args.next().map(Box::new), + }); + } + + Ok(Expression::Identifier(text.to_string())) + } + } +} + +#[allow(clippy::result_large_err)] +pub fn parse_expression(pairs: Pairs) -> ParseResult { + EXPRESSION_PRATT_PARSER + .map_primary(parse_expression_primary) + .map_prefix(|op, rhs| match op.as_rule() { + Rule::ExpressionNot => Ok(Expression::Not(Box::new(rhs?))), + Rule::ExpressionUnaryPlus => Ok(Expression::UnaryPlus(Box::new(rhs?))), + Rule::ExpressionUnaryMinus => Ok(Expression::UnaryMinus(Box::new(rhs?))), + _ => unimplemented!("Unexpected expression prefix operator: {:?}", op.as_rule()), + }) + .map_infix(|lhs, op, rhs| { + 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, + _ => unimplemented!("Unexpected expression binary operator: {:?}", op.as_rule()), + }; + + Ok(Expression::Binary(op, Box::new(lhs?), Box::new(rhs?))) + }) + .map_postfix(|target, postfix| match postfix.as_rule() { + Rule::ExpressionCall => { + let arguments = postfix + .into_inner() + .filter(|pair| pair.as_rule() == Rule::Expression) + .map(|pair| parse_expression(pair.into_inner())) + .collect::, _>>()?; + + Ok(Expression::Call { + function: Box::new(target?), + arguments, + }) + } + Rule::ExpressionAggregate => { + let mut children = postfix.into_inner(); + let op = match children + .next() + .expect("ExpressionAggregate should always contain an op") + .as_str() + { + "count" => AggregateOp::Count, + "min" => AggregateOp::Min, + "max" => AggregateOp::Max, + "mean" => AggregateOp::Mean, + x => unimplemented!("Unknown aggregate op: {x}"), + }; + + let argument = children + .find(|pair| pair.as_rule() == Rule::Expression) + .map(|pair| parse_expression(pair.into_inner())) + .transpose()? + .map(Box::new); + + Ok(Expression::Aggregate { + target: Box::new(target?), + op, + argument, + }) + } + _ => unimplemented!("Unexpected expression postfix operator: {:?}", postfix.as_rule()), + }) + .parse(pairs) +} + +pub static PERTURBATION_PRATT_PARSER: LazyLock> = LazyLock::new(|| { + PrattParser::new() + .op(Op::postfix(Rule::PerturbationPostfix)) +}); + +#[allow(clippy::result_large_err)] +pub fn parse_perturbation_expression(pairs: Pairs) -> ParseResult<()> { + PERTURBATION_PRATT_PARSER + .map_primary(|primary| match primary.as_rule() { + Rule::PerturbationExpression => parse_perturbation_expression(primary.into_inner()), + Rule::PerturbationPrimary => Ok(()), + _ => Ok(()), + }) + .map_postfix(|expr, _| { + expr?; + Ok(()) + }) + .parse(pairs) +} + +pub static DISTANCE_PRATT_PARSER: LazyLock> = LazyLock::new(|| { + PrattParser::new() + .op(Op::postfix(Rule::DistancePostfix)) + .op(Op::infix(Rule::DistanceInfix, Assoc::Left)) + .op(Op::prefix(Rule::DistancePrefix)) +}); + +#[allow(clippy::result_large_err)] +pub fn parse_distance_expression(pairs: Pairs) -> ParseResult<()> { + DISTANCE_PRATT_PARSER + .map_primary(|primary| match primary.as_rule() { + Rule::DistanceExpression => parse_distance_expression(primary.into_inner()), + Rule::DistancePrimary => Ok(()), + _ => Ok(()), + }) + .map_prefix(|_, expr| { + expr?; + Ok(()) + }) + .map_postfix(|expr, _| { + expr?; + Ok(()) + }) + .map_infix(|lhs, _, rhs| { + lhs?; + rhs?; + Ok(()) + }) + .parse(pairs) +} + +pub static ROBTL_PRATT_PARSER: LazyLock> = LazyLock::new(|| { + PrattParser::new() + .op(Op::infix(Rule::RobtlInfix, Assoc::Left)) + .op(Op::prefix(Rule::RobtlPrefix)) +}); + +#[allow(clippy::result_large_err)] +pub fn parse_robtl_formula(pairs: Pairs) -> ParseResult<()> { + ROBTL_PRATT_PARSER + .map_primary(|primary| match primary.as_rule() { + Rule::RobtlFormula => parse_robtl_formula(primary.into_inner()), + Rule::RobtlPrimary => Ok(()), + _ => Ok(()), + }) + .map_prefix(|_, expr| { + expr?; + Ok(()) + }) + .map_infix(|lhs, _, rhs| { + lhs?; + rhs?; + Ok(()) + }) + .parse(pairs) +} diff --git a/crates/stark/stark_grammar.pest b/crates/stark/stark_grammar.pest index c873f0bd7..ba487989c 100644 --- a/crates/stark/stark_grammar.pest +++ b/crates/stark/stark_grammar.pest @@ -218,11 +218,15 @@ ExpressionPrimary = _{ } // Perturbation expressions -PerturbationExpression = _{ PerturbationSequence } +PerturbationExpression = _{ PerturbationPrimary ~ PerturbationPostfix* ~ (PerturbationInfix ~ PerturbationPrimary ~ PerturbationPostfix*)* } -PerturbationSequence = _{ PerturbationIter ~ (";" ~ PerturbationIter)* } +// Infix operators for PerturbationExpression +PerturbationInfix = _{ PerturbationSemicolon } + PerturbationSemicolon = { ";" } -PerturbationIter = _{ PerturbationPrimary ~ ("^" ~ Expression)? } +// Postfix operators for PerturbationExpression +PerturbationPostfix = _{ PerturbationPow } + PerturbationPow = { "^" ~ Expression } PerturbationPrimary = _{ "nil" @@ -234,19 +238,22 @@ PerturbationPrimary = _{ PerturbationAssignment = _{ ID ~ "<-" ~ Expression } // Distance expressions -DistanceExpression = _{ DistanceExprThreshold } +DistanceExpression = _{ DistancePrefix* ~ DistancePrimary ~ DistancePostfix* ~ (DistanceInfix ~ DistancePrefix* ~ DistancePrimary ~ DistancePostfix*)* } -DistanceExprThreshold = _{ DistanceExprUntil ~ (("<" | "<=" | ">=" | ">") ~ Expression)? } +// Prefix operators for DistanceExpression +DistancePrefix = _{ DistancePrefixF | DistancePrefixG } + DistancePrefixF = { "\\F" ~ "[" ~ Expression ~ "," ~ Expression ~ "]" } + DistancePrefixG = { "\\G" ~ "[" ~ Expression ~ "," ~ Expression ~ "]" } -DistanceExprUntil = _{ DistanceExprPrefix ~ ("\\U" ~ "[" ~ Expression ~ "," ~ Expression ~ "]" ~ DistanceExprPrefix)* } +// Infix operators for DistanceExpression +DistanceInfix = _{ DistanceInfixUntil } + DistanceInfixUntil = { "\\U" ~ "[" ~ Expression ~ "," ~ Expression ~ "]" } -DistanceExprPrefix = _{ - "\\F" ~ "[" ~ Expression ~ "," ~ Expression ~ "]" ~ DistanceExprPrefix - | "\\G" ~ "[" ~ Expression ~ "," ~ Expression ~ "]" ~ DistanceExprPrefix - | DistanceExprPrimary -} +// Postfix operators for DistanceExpression +DistancePostfix = _{ DistancePostfixThreshold } + DistancePostfixThreshold = { ("<=" | "<" | ">=" | ">") ~ Expression } -DistanceExprPrimary = _{ +DistancePrimary = _{ "<" ~ ID | ">" ~ ID | "(" ~ DistanceExpression ~ ")" From de081f1ec0ce80acc8a78ea3d499095f73feded6 Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Thu, 16 Apr 2026 16:01:12 +0200 Subject: [PATCH 08/50] Fixed rebase errors. --- Cargo.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 9736f5624..349f8a5b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,7 +29,6 @@ members = [ "crates/lts/benchmarks", "crates/macros", "crates/number", - "crates/preorder", "crates/rec-tests", "crates/reduction", "crates/refinement", From a1a2a4426795622044ce20520957b0ef3bc2f608 Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Fri, 17 Jul 2026 15:26:33 +0200 Subject: [PATCH 09/50] Updated the AST and consumption --- crates/stark/Cargo.toml | 4 +- crates/stark/LICENSE | 201 +++++++++ crates/stark/src/ast.rs | 484 ++++++++++++++++----- crates/stark/src/consume.rs | 479 +++++++++++++++++---- crates/stark/src/lib.rs | 8 +- crates/stark/src/precedence.rs | 678 +++++++++++++++++++++++------- crates/stark/stark_grammar.pest | 333 ++++++++------- examples/stark/toll.stark | 10 +- examples/stark/two_vehicles.stark | 10 +- 9 files changed, 1696 insertions(+), 511 deletions(-) create mode 100644 crates/stark/LICENSE diff --git a/crates/stark/Cargo.toml b/crates/stark/Cargo.toml index 7f27d64a2..c925a1d01 100644 --- a/crates/stark/Cargo.toml +++ b/crates/stark/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "merc_stark" -version.workspace = true +license = "APACHE-2.0" +version = "1.0.0" edition.workspace = true -license.workspace = true rust-version.workspace = true [dependencies] diff --git a/crates/stark/LICENSE b/crates/stark/LICENSE new file mode 100644 index 000000000..8f3d380fb --- /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/src/ast.rs b/crates/stark/src/ast.rs index 80d1b4f95..54c535a4f 100644 --- a/crates/stark/src/ast.rs +++ b/crates/stark/src/ast.rs @@ -1,20 +1,323 @@ -use std::collections::HashMap; - -/// Represents the AST of a controller. -pub enum Controller { - Action(Vec, Box), - Assignment(Vec, Box), - Effect(), - Exec(usize), - Choice(Box, Box), - IfThenElse(Expression, Box, Box), - Parallel(Box, Box), - Interleave(f64, Box, Box), - Step(Box), +//! Abstract syntax tree for the STARK specification language. +//! +//! This mirrors the structure of the original STARK ANTLR grammar +//! (`StarkSpecificationLanguage.g4`). The tree is produced by `consume.rs` +//! (structural declarations) together with the Pratt parsers in `precedence.rs` +//! (expressions and the perturbation / distance / ROBTL sub-languages). + +/// A complete parsed STARK specification: the ordered list of every top-level +/// declaration in the source. +#[derive(Clone, Debug, Default)] +pub struct StarkSpecification { + 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 StarkSpecification { + pub fn new() -> Self { + Self::default() + } +} + +// --------------------------------------------------------------------------- +// Top-level declarations +// --------------------------------------------------------------------------- + +/// `const name = value;` +#[derive(Clone, Debug)] +pub struct Constant { + pub id: Identifier, + pub value: Expression, +} + +/// `param name = value;` +#[derive(Clone, Debug)] +pub struct Parameter { + pub id: 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 global: bool, + pub ty: Ty, + pub id: Identifier, + pub range: Option, + pub initial_value: Expression, +} + +/// `type name = A | B | C;` +#[derive(Clone, Debug)] +pub struct TypeDeclaration { + pub id: Identifier, + pub elements: Vec, +} + +/// `penalty name = expr` +#[derive(Clone, Debug)] +pub struct Penalty { + pub id: Identifier, + pub value: Expression, +} + +/// `function name(args) { body }` +#[derive(Clone, Debug)] +pub struct Function { + pub id: Identifier, + pub arguments: Vec, + pub body: FunctionStatement, +} + +#[derive(Clone, Debug)] +pub struct FunctionArgument { + pub ty: Ty, + pub id: Identifier, +} + +#[derive(Clone, Debug)] +pub enum FunctionStatement { + Return(Expression), + IfThenElse { + guard: Expression, + then_branch: Box, + else_branch: Option>, + }, + Let { + id: Identifier, + value: Expression, + body: Box, + }, + Block(Box), +} + +// --------------------------------------------------------------------------- +// Components and controllers +// --------------------------------------------------------------------------- + +/// `component name { variables { .. } controller { .. } init .. }` +#[derive(Clone, Debug)] +pub struct Component { + pub id: Identifier, + pub variables: Vec, + pub states: Vec, + /// The `init` expression: the parallel composition of state references. + pub init: Vec, +} + +/// `aiState name { .. }` +#[derive(Clone, Debug)] +pub struct ControllerState { + pub id: Identifier, + pub body: Vec, +} + +#[derive(Clone, Debug)] +pub enum ControllerCommand { + /// `[steps #] step target;` + Step { + steps: Option, + target: Identifier, + }, + /// `exec target;` + Exec(Identifier), + /// `let id = value in body` + Let { + id: 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: 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 `'`). +#[derive(Clone, Debug)] +pub struct Update { + pub guard: Option, + pub target: Identifier, + pub value: Expression, +} + +// --------------------------------------------------------------------------- +// Robustness sub-languages (perturbation / distance / ROBTL) +// --------------------------------------------------------------------------- + +/// `perturbation name = expr;` +#[derive(Clone, Debug)] +pub struct Perturbation { + pub id: Identifier, + pub value: PerturbationExpression, +} + +#[derive(Clone, Debug)] +pub enum PerturbationExpression { Nil, + Reference(Identifier), + /// `[ v1 <- e1, v2 <- e2 ] @ time` + Atomic { + assignments: Vec, + time: Expression, + }, + /// `left ; right` + Sequence(Box, Box), + /// `argument ^ iterations` + Iteration { + argument: Box, + iterations: Expression, + }, } -/// Represents an expression in the AST. +#[derive(Clone, Debug)] +pub struct PerturbationAssignment { + pub id: Identifier, + pub value: Expression, +} + +/// `distance name = expr;` +#[derive(Clone, Debug)] +pub struct Distance { + pub id: Identifier, + pub value: DistanceExpression, +} + +#[derive(Clone, Debug)] +pub enum DistanceExpression { + Reference(Identifier), + /// `< penalty` + AtomicLeft(Identifier), + /// `> penalty` + AtomicRight(Identifier), + /// `\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: Identifier, + pub value: RobtlFormula, +} + +#[derive(Clone, Debug)] +pub enum RobtlFormula { + True, + False, + Reference(Identifier), + /// `\D[distance, perturbation] op value` + Distance { + distance: Identifier, + perturbation: Identifier, + 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, + }, +} + +// --------------------------------------------------------------------------- +// Expressions +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug)] pub enum Expression { // Literals False, @@ -22,9 +325,10 @@ pub enum Expression { Integer(i64), Real(f64), Identifier(String), + /// The `it` lambda parameter used inside aggregate/perturbation contexts. Iterator, - // Distributions + // Distributions / random values Normal { mean: Box, std_dev: Box, @@ -32,6 +336,7 @@ pub enum Expression { Uniform { values: Vec, }, + /// `R` or `R[min,max]`. Range { min: Option>, max: Option>, @@ -45,18 +350,27 @@ pub enum Expression { // Binary operators Binary(BinaryOp, Box, Box), - // Postfix operators + // `guard ? then : else` + Ternary { + guard: Box, + then_branch: Box, + else_branch: Box, + }, + + /// A user-defined function application `name(args)`. Call { function: Box, arguments: Vec, }, - Aggregate { - target: Box, - op: AggregateOp, - argument: Option>, + + /// 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, @@ -76,88 +390,73 @@ pub enum BinaryOp { Or, } -pub enum AggregateOp { - Count, - Min, - Max, - Mean, -} - -pub struct StarkSpecification { - pub controllers: HashMap, -} - -pub struct Update { - identifier: Identifier, - value: Expression, -} - -pub struct Variable { - global: bool, - ty: Ty, - id: Identifier, - range: Option, - initial_value: Expression, +/// 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, } -pub struct Constant { - global: bool, - ty: Option, - id: Identifier, - range: Option, - initial_value: Expression, -} - -impl Constant { - pub fn new(id: Identifier, value: Expression) -> Self { - Constant { - global: true, - ty: None, - id, - range: None, - initial_value: value, - } - } +/// 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, } -impl Variable { - pub fn new( - global: bool, - ty: Ty, - id: Identifier, - range: Option, - initial_value: Expression, - ) -> Self { - Variable { - global, - ty, - id, - range, - initial_value, - } - } -} +// --------------------------------------------------------------------------- +// Shared leaf types +// --------------------------------------------------------------------------- +/// A `range [min, max]` bound on a variable declaration. +#[derive(Clone, Debug)] pub struct Range { - min: Option, - max: Option, -} - -impl Range { - pub fn new(min: Option, max: Option) -> Self { - Range { min, max } - } + 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 { - name: String, - span: Span, + pub name: String, + pub span: Span, } impl Identifier { @@ -166,15 +465,8 @@ impl Identifier { } } -pub enum Command { - Let, - IfThenElse, - Assignment, - Block, -} - /// Source location information, spanning from start to end in the source text. -#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Hash)] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Hash)] pub struct Span { pub start: usize, pub end: usize, @@ -187,4 +479,4 @@ impl From> for Span { end: span.end(), } } -} \ No newline at end of file +} diff --git a/crates/stark/src/consume.rs b/crates/stark/src/consume.rs index 3ca1eb4ad..2300f1abf 100644 --- a/crates/stark/src/consume.rs +++ b/crates/stark/src/consume.rs @@ -1,160 +1,471 @@ #![allow(clippy::result_large_err)] -use std::collections::HashMap; - use merc_pest_consume::Error; use merc_pest_consume::match_nodes; use crate::StarkParser; -use crate::ast::{Constant, Expression, Identifier, Range, StarkSpecification, Ty, Variable}; +use crate::ast::Component; +use crate::ast::Constant; +use crate::ast::ControllerCommand; +use crate::ast::ControllerState; +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::StarkSpecification; +use crate::ast::Ty; +use crate::ast::TypeDeclaration; +use crate::ast::Update; +use crate::ast::Variable; use crate::parse::Rule; -use crate::precedence::{ - parse_distance_expression, parse_expression, parse_perturbation_expression, parse_robtl_formula, -}; +use crate::precedence::parse_distance_expression; +use crate::precedence::parse_expression; +use crate::precedence::parse_perturbation_expression; +use crate::precedence::parse_robtl_formula; -/// Type alias for Errors resulting parsing. +/// 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, ()>; +// --------------------------------------------------------------------------- +// Dispatch helpers for silent alternation groups. +// +// The grammar's `FunctionStatement`, `ControllerCommand` and `EnvironmentCommand` +// rules are silent, so their concrete variant nodes appear directly as children. +// These helpers route a variant node 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:?}"), + } +} + +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:?}"), + } +} + +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(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 { pub fn StarkSpecification(input: ParseNode) -> ParseResult { - let controllers = HashMap::new(); + let mut spec = StarkSpecification::new(); for child in input.into_children() { match child.as_rule() { - Rule::DeclarationComponent => { - // TODO: component parsing is not implemented yet. - } - Rule::DeclarationParameter => {} - Rule::DeclarationConstant => { - let _ = Self::DeclarationConstant(child)?; - } - Rule::DeclarationVariables => { - let _ = Self::DeclarationVariables(child)?; - } - Rule::DeclarationType => {} - Rule::DeclarationEnvironment => { - Self::DeclarationEnvironment(child)?; - } - Rule::DeclarationPenalty => {} - Rule::DeclarationFunction => {} - Rule::DeclarationPerturbation => { - Self::DeclarationPerturbation(child)?; - } - Rule::DeclarationDistance => { - Self::DeclarationDistance(child)?; - } - Rule::DeclarationFormula => { - Self::DeclarationFormula(child)?; - } + 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 => {} - _ => unreachable!(), + rule => unreachable!("unexpected top-level declaration: {rule:?}"), } } - Ok(StarkSpecification { controllers }) + 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(input.children().as_pairs().clone()) + } + + 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(id), Expression(expr)] => { - Ok(Constant::new(id, expr)) - } + [ID(id), Expression(value)] => Ok(Constant { id, value }) ) } - fn ID(input: ParseNode) -> ParseResult { - let span = input.as_span(); - Ok(Identifier::new(input.as_str().to_string(), span.into())) + fn DeclarationParameter(input: ParseNode) -> ParseResult { + match_nodes!(input.into_children(); + [ID(id), Expression(value)] => Ok(Parameter { id, value }) + ) } - fn DeclarationVariables(input: ParseNode) -> ParseResult> { + fn DeclarationPenalty(input: ParseNode) -> ParseResult { match_nodes!(input.into_children(); - [VariableDeclaration(vars)..] => { - Ok(vars.collect()) - } + [ID(id), Expression(value)] => Ok(Penalty { id, value }) + ) + } + + fn DeclarationType(input: ParseNode) -> ParseResult { + match_nodes!(input.into_children(); + [ID(id), TypeElement(elements)..] => Ok(TypeDeclaration { id, 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(id), Expression(min), Expression(max), Expression(initial)] => { - Ok(Variable::new( - false, - ty, - id, - Some(Range::new(Some(min), Some(max))), - initial, - )) + [Ty(ty), ID(id), VariableRange(range), Expression(initial_value)] => { + Ok(Variable { global: false, ty, id, range: Some(range), initial_value }) }, - [Ty(ty), ID(id), Expression(initial)] => { - Ok(Variable::new(false, ty, id, None, initial)) + [Ty(ty), ID(id), Expression(initial_value)] => { + Ok(Variable { global: false, ty, id, range: None, initial_value }) } ) } - fn DeclarationEnvironment(input: ParseNode) -> ParseResult<()> { + fn VariableRange(input: ParseNode) -> ParseResult { match_nodes!(input.into_children(); - [EnvironmentBlock(block)] => { - let _ = block; - Ok(()) - } + [Expression(min), Expression(max)] => Ok(Range { min, max }) ) } - fn DeclarationPerturbation(input: ParseNode) -> ParseResult<()> { + // --- Functions --------------------------------------------------------- + + fn DeclarationFunction(input: ParseNode) -> ParseResult { + let mut id = None; + let mut arguments = Vec::new(); + let mut body = None; + + for child in input.into_children() { + match child.as_rule() { + Rule::ID => id = 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: id.expect("function requires a name"), + arguments, + body: body.expect("function requires a body"), + }) + } + + fn FunctionArgument(input: ParseNode) -> ParseResult { match_nodes!(input.into_children(); - [ID(_), PerturbationExpression(_)] => Ok(()) + [Ty(ty), ID(id)] => Ok(FunctionArgument { ty, id }) ) } - fn DeclarationDistance(input: ParseNode) -> ParseResult<()> { + fn FunctionReturn(input: ParseNode) -> ParseResult { match_nodes!(input.into_children(); - [ID(_), DistanceExpression(_)] => Ok(()) + [Expression(value)] => Ok(FunctionStatement::Return(value)) ) } - fn DeclarationFormula(input: ParseNode) -> ParseResult<()> { + fn FunctionLet(input: ParseNode) -> ParseResult { + let mut children = input.into_children(); + let id = 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, + 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 id = 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 => id = 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: id.expect("component requires a name"), + variables, + states, + init, + }) + } + + fn ControllerExpression(input: ParseNode) -> ParseResult> { match_nodes!(input.into_children(); - [ID(_), RobtlFormula(_)] => Ok(()) + [ID(states)..] => Ok(states.collect()) ) } - fn EnvironmentBlock(input: ParseNode) -> ParseResult<()> { + fn ControllerState(input: ParseNode) -> ParseResult { + let mut children = input.into_children(); + let id = Self::ID(children.next().expect("state name"))?; + let body = Self::ControllerBlock(children.next().expect("state body"))?; + Ok(ControllerState { id, 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() { - Self::EnvironmentCommand(child)?; + match child.as_rule() { + Rule::Expression => steps = Some(Self::Expression(child)?), + Rule::ID => target = Some(Self::ID(child)?), + rule => unreachable!("unexpected step child: {rule:?}"), + } } - Ok(()) + Ok(ControllerCommand::Step { + steps, + target: target.expect("step requires a target"), + }) } - fn EnvironmentCommand(_input: ParseNode) -> ParseResult<()> { - Ok(()) + fn ControllerExec(input: ParseNode) -> ParseResult { + match_nodes!(input.into_children(); + [ID(target)] => Ok(ControllerCommand::Exec(target)) + ) } - fn Ty(input: ParseNode) -> ParseResult { - Ok(match input.as_str() { - "int" => Ty::Integer, - "real" => Ty::Real, - "bool" => Ty::Boolean, - // User-defined types are not yet represented in the AST type enum. - _ => Ty::Integer, + fn ControllerLet(input: ParseNode) -> ParseResult { + let mut children = input.into_children(); + let id = 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, 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, }) } - pub(crate) fn Expression(input: ParseNode) -> ParseResult { - parse_expression(input.children().as_pairs().clone()) + // --- 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 PerturbationExpression(input: ParseNode) -> ParseResult<()> { + 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(id), Expression(value)] => Ok(LocalVariable { id, value }) + ) + } + + // --- Robustness sub-languages ----------------------------------------- + + fn DeclarationPerturbation(input: ParseNode) -> ParseResult { + let mut children = input.into_children(); + let id = Self::ID(children.next().expect("perturbation name"))?; + let value = Self::PerturbationExpression(children.next().expect("perturbation value"))?; + Ok(Perturbation { id, value }) + } + + fn PerturbationExpression(input: ParseNode) -> ParseResult { parse_perturbation_expression(input.children().as_pairs().clone()) } - fn DistanceExpression(input: ParseNode) -> ParseResult<()> { + fn DeclarationDistance(input: ParseNode) -> ParseResult { + let mut children = input.into_children(); + let id = Self::ID(children.next().expect("distance name"))?; + let value = Self::DistanceExpression(children.next().expect("distance value"))?; + Ok(Distance { id, value }) + } + + fn DistanceExpression(input: ParseNode) -> ParseResult { parse_distance_expression(input.children().as_pairs().clone()) } - fn RobtlFormula(input: ParseNode) -> ParseResult<()> { + fn DeclarationFormula(input: ParseNode) -> ParseResult { + let mut children = input.into_children(); + let id = Self::ID(children.next().expect("formula name"))?; + let value = Self::RobtlFormula(children.next().expect("formula value"))?; + Ok(Formula { id, value }) + } + + fn RobtlFormula(input: ParseNode) -> ParseResult { parse_robtl_formula(input.children().as_pairs().clone()) } -} \ No newline at end of file +} diff --git a/crates/stark/src/lib.rs b/crates/stark/src/lib.rs index 55113655b..ff6adb38a 100644 --- a/crates/stark/src/lib.rs +++ b/crates/stark/src/lib.rs @@ -3,7 +3,7 @@ mod consume; mod parse; mod precedence; -pub use ast::*; -pub use consume::*; -pub use parse::*; -pub use precedence::*; \ No newline at end of file +pub(crate) use ast::*; +pub(crate) use consume::*; +pub(crate) use parse::*; +pub(crate) use precedence::*; diff --git a/crates/stark/src/precedence.rs b/crates/stark/src/precedence.rs index c25553389..32f2869d2 100644 --- a/crates/stark/src/precedence.rs +++ b/crates/stark/src/precedence.rs @@ -1,99 +1,188 @@ +//! 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`. + 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 crate::ast::AggregateOp; +use merc_pest_consume::Error; + use crate::ast::BinaryOp; +use crate::ast::ComparisonOp; +use crate::ast::DistanceExpression; use crate::ast::Expression; -use crate::parse::Rule; +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(), + )) +} + +/// Parse an `Expression` node's children with the expression Pratt parser. +#[allow(clippy::result_large_err)] +fn parse_expression_node(pair: Pair<'_, Rule>) -> ParseResult { + parse_expression(pair.into_inner()) +} + +/// 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() +} + +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 to highest + // 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::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::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) | Op::postfix(Rule::ExpressionAggregate)) + .op(Op::prefix(Rule::ExpressionNot) + | Op::prefix(Rule::ExpressionUnaryPlus) + | Op::prefix(Rule::ExpressionUnaryMinus)) + .op(Op::postfix(Rule::ExpressionCall) | Op::postfix(Rule::ExpressionTernary)) }); #[allow(clippy::result_large_err)] fn parse_expression_primary(primary: Pair<'_, Rule>) -> ParseResult { match primary.as_rule() { - Rule::Expression => parse_expression(primary.into_inner()), - Rule::INTEGER => Ok(Expression::Integer( - primary.as_str().parse::().expect("INTEGER token should parse as i64"), - )), - Rule::REAL => Ok(Expression::Real( - primary.as_str().parse::().expect("REAL token should parse as f64"), - )), + // Parenthesized sub-expression. + Rule::Expression => parse_expression_node(primary), + Rule::INTEGER => match primary.as_str().parse::() { + Ok(value) => Ok(Expression::Integer(value)), + Err(_) => 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) => Ok(Expression::Real(value)), + Err(_) => error(&primary, format!("invalid real literal `{}`", primary.as_str())), + }, Rule::ID => Ok(Expression::Identifier(primary.as_str().to_string())), - _ => { - let text = primary.as_str(); - if text == "false" { - return Ok(Expression::False); - } - if text == "true" { - return Ok(Expression::True); - } - if text == "it" { - return Ok(Expression::Iterator); - } - - let args: Vec = primary - .clone() - .into_inner() - .filter(|pair| pair.as_rule() == Rule::Expression) - .map(|pair| parse_expression(pair.into_inner())) - .collect::, _>>()?; - - if text.starts_with("N[") && args.len() == 2 { - let mut args = args.into_iter(); - return Ok(Expression::Normal { - mean: Box::new(args.next().expect("normal distribution requires mean")), - std_dev: Box::new(args.next().expect("normal distribution requires std_dev")), - }); - } - - if text.starts_with("U[") { - return Ok(Expression::Uniform { values: args }); - } - - if text.starts_with('R') { - let mut args = args.into_iter(); - return Ok(Expression::Range { - min: args.next().map(Box::new), - max: args.next().map(Box::new), - }); - } - - Ok(Expression::Identifier(text.to_string())) + Rule::ExpressionTrue => Ok(Expression::True), + Rule::ExpressionFalse => Ok(Expression::False), + Rule::ExpressionIterator => Ok(Expression::Iterator), + Rule::ExpressionNormal => { + let mut args = expression_arguments(primary)?.into_iter(); + Ok(Expression::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 => Ok(Expression::Uniform { + values: expression_arguments(primary)?, + }), + Rule::ExpressionRandom => { + let mut args = expression_arguments(primary)?.into_iter(); + Ok(Expression::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::>>()?; + Ok(Expression::MathCall { function, arguments }) + } + rule => unreachable!("unexpected expression primary: {rule:?}"), } } @@ -105,7 +194,7 @@ pub fn parse_expression(pairs: Pairs) -> ParseResult { Rule::ExpressionNot => Ok(Expression::Not(Box::new(rhs?))), Rule::ExpressionUnaryPlus => Ok(Expression::UnaryPlus(Box::new(rhs?))), Rule::ExpressionUnaryMinus => Ok(Expression::UnaryMinus(Box::new(rhs?))), - _ => unimplemented!("Unexpected expression prefix operator: {:?}", op.as_rule()), + rule => unreachable!("unexpected expression prefix operator: {rule:?}"), }) .map_infix(|lhs, op, rhs| { let op = match op.as_rule() { @@ -125,128 +214,413 @@ pub fn parse_expression(pairs: Pairs) -> ParseResult { Rule::ExpressionAnd => BinaryOp::And, Rule::ExpressionBitOr => BinaryOp::BitOr, Rule::ExpressionOr => BinaryOp::Or, - _ => unimplemented!("Unexpected expression binary operator: {:?}", op.as_rule()), + rule => unreachable!("unexpected expression binary operator: {rule:?}"), }; - Ok(Expression::Binary(op, Box::new(lhs?), Box::new(rhs?))) }) .map_postfix(|target, postfix| match postfix.as_rule() { - Rule::ExpressionCall => { - let arguments = postfix - .into_inner() - .filter(|pair| pair.as_rule() == Rule::Expression) - .map(|pair| parse_expression(pair.into_inner())) - .collect::, _>>()?; - - Ok(Expression::Call { - function: Box::new(target?), - arguments, + Rule::ExpressionCall => Ok(Expression::Call { + function: Box::new(target?), + arguments: expression_arguments(postfix)?, + }), + Rule::ExpressionTernary => { + let mut branches = expression_arguments(postfix)?.into_iter(); + Ok(Expression::Ternary { + guard: Box::new(target?), + then_branch: Box::new(branches.next().expect("ternary requires a then branch")), + else_branch: Box::new(branches.next().expect("ternary requires an else branch")), }) } - Rule::ExpressionAggregate => { - let mut children = postfix.into_inner(); - let op = match children - .next() - .expect("ExpressionAggregate should always contain an op") - .as_str() - { - "count" => AggregateOp::Count, - "min" => AggregateOp::Min, - "max" => AggregateOp::Max, - "mean" => AggregateOp::Mean, - x => unimplemented!("Unknown aggregate op: {x}"), - }; - - let argument = children - .find(|pair| pair.as_rule() == Rule::Expression) - .map(|pair| parse_expression(pair.into_inner())) - .transpose()? - .map(Box::new); - - Ok(Expression::Aggregate { - target: Box::new(target?), - op, - argument, - }) - } - _ => unimplemented!("Unexpected expression postfix operator: {:?}", postfix.as_rule()), + rule => unreachable!("unexpected expression postfix operator: {rule:?}"), }) .parse(pairs) } +// --------------------------------------------------------------------------- +// Perturbation expressions +// --------------------------------------------------------------------------- + pub static PERTURBATION_PRATT_PARSER: LazyLock> = LazyLock::new(|| { PrattParser::new() - .op(Op::postfix(Rule::PerturbationPostfix)) + .op(Op::infix(Rule::PerturbationSemicolon, Assoc::Left)) + .op(Op::postfix(Rule::PerturbationPow)) }); #[allow(clippy::result_large_err)] -pub fn parse_perturbation_expression(pairs: Pairs) -> ParseResult<()> { +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(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 id = identifier(&inner.next().expect("assignment target")); + let value = parse_expression_node(inner.next().expect("assignment value"))?; + assignments.push(PerturbationAssignment { id, 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(|primary| match primary.as_rule() { - Rule::PerturbationExpression => parse_perturbation_expression(primary.into_inner()), - Rule::PerturbationPrimary => Ok(()), - _ => Ok(()), + .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(|expr, _| { - expr?; - Ok(()) + .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::postfix(Rule::DistancePostfix)) - .op(Op::infix(Rule::DistanceInfix, Assoc::Left)) - .op(Op::prefix(Rule::DistancePrefix)) + .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)] -pub fn parse_distance_expression(pairs: Pairs) -> ParseResult<()> { +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(identifier( + &primary.into_inner().next().expect("penalty reference"), + ))), + Rule::DistanceAtomicRight => Ok(DistanceExpression::AtomicRight(identifier( + &primary.into_inner().next().expect("penalty reference"), + ))), + Rule::ID => Ok(DistanceExpression::Reference(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(|primary| match primary.as_rule() { - Rule::DistanceExpression => parse_distance_expression(primary.into_inner()), - Rule::DistancePrimary => Ok(()), - _ => Ok(()), - }) - .map_prefix(|_, expr| { - expr?; - Ok(()) + .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_postfix(|expr, _| { - expr?; - Ok(()) + .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_infix(|lhs, _, rhs| { - lhs?; - rhs?; - Ok(()) + .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::RobtlInfix, Assoc::Left)) - .op(Op::prefix(Rule::RobtlPrefix)) + .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)] -pub fn parse_robtl_formula(pairs: Pairs) -> ParseResult<()> { +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(identifier(&primary))), + Rule::RobtlDistance => { + let mut children = primary.into_inner(); + let distance = identifier(&children.next().expect("distance reference")); + let perturbation = 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(|primary| match primary.as_rule() { - Rule::RobtlFormula => parse_robtl_formula(primary.into_inner()), - Rule::RobtlPrimary => Ok(()), - _ => Ok(()), - }) - .map_prefix(|_, expr| { - expr?; - Ok(()) + .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, _, rhs| { - lhs?; - rhs?; - Ok(()) + .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::Expression; + use crate::ast::MathFunction; + use crate::ast::PerturbationExpression; + use crate::ast::RobtlFormula; + use crate::ast::StarkSpecification; + use crate::ast::Ty; + + /// Parse `const c = ;` and return the parsed expression. + fn expr(src: &str) -> Expression { + let spec = StarkSpecification::parse(&format!("const c = {src};")).expect("should parse"); + spec.constants.into_iter().next().expect("one constant").value + } + + #[test] + fn arithmetic_precedence() { + // `1 + 2 * 3` must group as `1 + (2 * 3)`. + match expr("1 + 2 * 3") { + Expression::Binary(BinaryOp::Add, lhs, rhs) => { + assert!(matches!(*lhs, Expression::Integer(1))); + assert!(matches!(*rhs, Expression::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") { + Expression::Binary(BinaryOp::Pow, lhs, rhs) => { + assert!(matches!(*lhs, Expression::Integer(2))); + assert!(matches!(*rhs, Expression::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") { + Expression::Binary(BinaryOp::BitAnd, lhs, _) => { + assert!(matches!(*lhs, Expression::Binary(BinaryOp::Greater, _, _))); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn unary_minus_and_not() { + assert!(matches!(expr("-x"), Expression::UnaryMinus(_))); + assert!(matches!(expr("!x"), Expression::Not(_))); + } + + #[test] + fn math_calls_and_user_calls() { + match expr("max(1, 2)") { + Expression::MathCall { + function: MathFunction::Max, + arguments, + } => assert_eq!(arguments.len(), 2), + other => panic!("unexpected: {other:?}"), + } + match expr("abs(x)") { + Expression::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. + assert!(matches!(expr("eval_bd(x)"), Expression::Call { .. })); + } + + #[test] + fn identifiers_starting_with_keyword_prefixes() { + // `italic`/`Rate` must be identifiers, not `it` / `R` followed by junk. + assert!(matches!(expr("italic"), Expression::Identifier(name) if name == "italic")); + assert!(matches!(expr("Rate"), Expression::Identifier(name) if name == "Rate")); + } + + #[test] + fn ternary() { + assert!(matches!(expr("a ? b : c"), Expression::Ternary { .. })); + } + + #[test] + fn distributions() { + assert!(matches!(expr("N[0, 1]"), Expression::Normal { .. })); + assert!(matches!(expr("U[1, 2, 3]"), Expression::Uniform { values } if values.len() == 3)); + assert!(matches!( + expr("R[0, 1]"), + Expression::Range { + min: Some(_), + max: Some(_) + } + )); + assert!(matches!(expr("R"), Expression::Range { min: None, max: None })); + } + + #[test] + fn integer_overflow_is_an_error() { + assert!(StarkSpecification::parse("const c = 99999999999999999999999;").is_err()); + } + + #[test] + fn variable_with_range_and_type() { + let spec = + StarkSpecification::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.id.name, "counter"); + } + + #[test] + fn perturbation_sequence_and_iteration() { + let spec = StarkSpecification::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 = StarkSpecification::parse("distance d = \\G[0, 10] < rho;\nformula f = \\D[d, p] <= 5;") + .expect("should parse"); + assert!(matches!(spec.distances[0].value, DistanceExpression::Globally { .. })); + assert!(matches!(spec.formulas[0].value, RobtlFormula::Distance { .. })); + } +} diff --git a/crates/stark/stark_grammar.pest b/crates/stark/stark_grammar.pest index ba487989c..b2623f163 100644 --- a/crates/stark/stark_grammar.pest +++ b/crates/stark/stark_grammar.pest @@ -1,13 +1,32 @@ -// JSpearSpecificationLanguage +// 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' | "_" } +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" | "aiState" | "init" | "when" | "step" | "exec" + | "let" | "in" | "and" | "if" | "else" | "return" | "range" + | "int" | "real" | "bool" | "true" | "false" | "nil" ) + ~ !(LETTER | DIGIT) +} -ID = @{ LETTER ~ (LETTER | DIGIT)* } +ID = @{ !KEYWORD ~ LETTER ~ (LETTER | DIGIT)* } NEXT_ID = @{ ID ~ "'" } INTEGER = @{ DIGIT+ } REAL = @{ ((DIGIT* ~ "." ~ DIGIT+) | (DIGIT+ ~ ".")) ~ (("E" | "e") ~ "-"? ~ DIGIT+)? } @@ -30,139 +49,104 @@ Element = _{ } // Declarations -DeclarationFormula = _{ "formula" ~ ID ~ "=" ~ RobtlFormula ~ ";" } +DeclarationConstant = { "const" ~ ID ~ "=" ~ Expression ~ ";" } +DeclarationParameter = { "param" ~ ID ~ "=" ~ Expression ~ ";" } +DeclarationPenalty = { "penalty" ~ ID ~ "=" ~ Expression } + +DeclarationFormula = { "formula" ~ ID ~ "=" ~ RobtlFormula ~ ";" } +DeclarationDistance = { "distance" ~ ID ~ "=" ~ DistanceExpression ~ ";" } +DeclarationPerturbation = { "perturbation" ~ ID ~ "=" ~ PerturbationExpression ~ ";" } -DeclarationDistance = _{ "distance" ~ ID ~ "=" ~ DistanceExpression ~ ";" } +// Types and variables +DeclarationType = { "type" ~ ID ~ "=" ~ TypeElement ~ ("|" ~ TypeElement)* ~ ";" } +TypeElement = { ID } -DeclarationPerturbation = _{ "perturbation" ~ ID ~ "=" ~ PerturbationExpression ~ ";" } +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)*)? ~ ")" ~ FunctionBlockStatement +DeclarationFunction = { + "function" ~ ID ~ "(" ~ (FunctionArgument ~ ("," ~ FunctionArgument)*)? ~ ")" ~ FunctionBlock } +FunctionArgument = { Ty ~ ID } FunctionStatement = _{ - FunctionReturnStatement - | FunctionIfThenElseStatement - | FunctionBlockStatement - | FunctionLetStatement -} - -FunctionLetStatement = _{ "let" ~ ID ~ "=" ~ Expression ~ "in" ~ FunctionStatement } - -FunctionIfThenElseStatement = _{ - "if" ~ "(" ~ Expression ~ ")" ~ FunctionStatement ~ ("else" ~ FunctionStatement)? + FunctionReturn + | FunctionIfThenElse + | FunctionBlock + | FunctionLet } - -FunctionReturnStatement = _{ "return" ~ Expression ~ ";" } - -FunctionBlockStatement = _{ "{" ~ FunctionStatement ~ "}" } - -FunctionArgument = _{ Ty ~ ID } +FunctionLet = { "let" ~ ID ~ "=" ~ Expression ~ "in" ~ FunctionStatement } +FunctionIfThenElse = { "if" ~ "(" ~ Expression ~ ")" ~ FunctionStatement ~ ("else" ~ FunctionStatement)? } +FunctionReturn = { "return" ~ Expression ~ ";" } +FunctionBlock = { "{" ~ FunctionStatement ~ "}" } // Components and controllers -DeclarationComponent = _{ +DeclarationComponent = { "component" ~ ID ~ "{" ~ - "variables" ~ "{" ~ VariableDeclaration* ~ "}" ~ - "controller" ~ "{" ~ ControllerStateDeclaration* ~ "}" ~ - "init" ~ ControllerExpression ~ + "variables" ~ "{" ~ VariableDeclaration* ~ "}" ~ + "controller" ~ "{" ~ ControllerState* ~ "}" ~ + "init" ~ ControllerExpression ~ "}" } -ControllerStateDeclaration = _{ "aiState" ~ ID ~ ControllerBlockBehaviour } - -ControllerBlockBehaviour = _{ "{" ~ ControllerCommand* ~ "}" } - -ControllerSequentialBehaviour = _{ ControllerVariableAssignment* ~ ControllerTerminalStatement } +ControllerState = { "aiState" ~ ID ~ ControllerBlock } +ControllerBlock = { "{" ~ ControllerCommand* ~ "}" } ControllerCommand = _{ - ControllerStepAtion - | ControllerExecAction - | ControllerLetAssignment - | ControllerVariableAssignment - | ControllerIfThenElseBehaviour - | ControllerBlockBehaviour + ControllerStep + | ControllerExec + | ControllerLet + | ControllerAssignment + | ControllerIfThenElse + | ControllerBlock } -ControllerTerminalStatement = _{ - ControllerStepAtion - | ControllerExecAction - | ControllerLetAssignment - | ControllerIfThenElseBehaviour -} - -ControllerCaseStatment = _{ "case" ~ "(" ~ Expression ~ ")" ~ ControllerBlockBehaviour } - -ControllerExpression = _{ ID ~ ("||" ~ ID)* } +ControllerStep = { (Expression ~ "#")? ~ "step" ~ ID ~ ";" } +ControllerExec = { "exec" ~ ID ~ ";" } +ControllerLet = { "let" ~ ID ~ "=" ~ Expression ~ "in" ~ ControllerBlock } +ControllerAssignment = { WhenGuard? ~ NEXT_ID ~ "=" ~ Expression ~ ";" } +ControllerIfThenElse = { "if" ~ "(" ~ Expression ~ ")" ~ ControllerBlock ~ ("else" ~ ControllerBlock)? } -DeclarationPenalty = _{ "penalty" ~ ID ~ "=" ~ Expression } +// `init` expression: a parallel composition of controller state references. +ControllerExpression = { ID ~ ("||" ~ ID)* } -ControllerLetAssignment = _{ "let" ~ ID ~ "=" ~ Expression ~ "in" ~ ControllerBlockBehaviour } - -ControllerVariableAssignment = _{ ("when" ~ Expression)? ~ VarExpression ~ "=" ~ Expression ~ ";" } - -ControllerExecAction = _{ "exec" ~ ID ~ ";" } - -ControllerStepAtion = _{ (Expression ~ "#")? ~ "step" ~ ID ~ ";" } - -ControllerIfThenElseBehaviour = _{ "if" ~ "(" ~ Expression ~ ")" ~ ControllerBlockBehaviour ~ ("else" ~ ControllerBlockBehaviour)? } +WhenGuard = { "when" ~ Expression } // Environment -DeclarationEnvironment = _{ "environment" ~ EnvironmentBlock } - -EnvironmentBlock = _{ "{" ~ EnvironmentCommand* ~ "}" } +DeclarationEnvironment = { "environment" ~ EnvironmentBlock } +EnvironmentBlock = { "{" ~ EnvironmentCommand* ~ "}" } EnvironmentCommand = _{ - EnvironmentLetCommand + EnvironmentLet | EnvironmentIfThenElse | EnvironmentAssignment | EnvironmentBlock } -EnvironmentAssignment = _{ VariableAssignment } +EnvironmentAssignment = { WhenGuard? ~ NEXT_ID ~ "=" ~ Expression ~ ";" } +EnvironmentIfThenElse = { "if" ~ "(" ~ Expression ~ ")" ~ EnvironmentCommand ~ ("else" ~ EnvironmentCommand)? } +EnvironmentLet = { "let" ~ LocalVariable ~ ("and" ~ LocalVariable)* ~ "in" ~ EnvironmentCommand } +LocalVariable = { ID ~ "=" ~ Expression } -EnvironmentIfThenElse = _{ "if" ~ "(" ~ Expression ~ ")" ~ EnvironmentCommand ~ ("else" ~ EnvironmentCommand)? } +// Expressions: a flat prefix* primary postfix* (infix ...)* stream for the Pratt parser. +Expression = { ExpressionPrefix* ~ ExpressionPrimary ~ ExpressionPostfix* ~ (ExpressionInfix ~ ExpressionPrefix* ~ ExpressionPrimary ~ ExpressionPostfix*)* } -EnvironmentLetCommand = _{ - "let" ~ LocalVariable ~ ("and" ~ LocalVariable)* ~ "in" ~ EnvironmentCommand -} - -VariableAssignment = _{ ("when" ~ Expression)? ~ VarExpression ~ "=" ~ Expression ~ ";" } - -VarExpression = _{ NEXT_ID } - -LocalVariable = _{ ID ~ "=" ~ Expression } - -// Types and variables -DeclarationType = _{ "type" ~ ID ~ "=" ~ TypeElementDeclaration ~ ("|" ~ TypeElementDeclaration)* ~ ";" } - -TypeElementDeclaration = _{ ID } - -DeclarationVariables = _{ ("global")? ~ "variables" ~ "{" ~ VariableDeclaration* ~ "}" } - -VariableDeclaration = _{ Ty ~ ID ~ ("range" ~ "[" ~ Expression ~ "," ~ Expression ~ "]")? ~ "=" ~ Expression ~ ";" } - -Ty = _{ - "int" - | "real" - | "bool" - | ID -} - -DeclarationParameter = _{ "param" ~ ID ~ "=" ~ Expression ~ ";" } - -DeclarationConstant = _{ "const" ~ ID ~ "=" ~ Expression ~ ";" } - -// Expressions using prefix-primary-postfix with infix chaining -Expression = _{ ExpressionPrefix* ~ ExpressionPrimary ~ ExpressionPostfix* ~ (ExpressionInfix ~ ExpressionPrefix* ~ ExpressionPrimary ~ ExpressionPostfix*)* } - -// Prefix operators for Expression +// Prefix operators ExpressionPrefix = _{ ExpressionNot | ExpressionUnaryPlus | ExpressionUnaryMinus } - ExpressionNot = { "!" } - ExpressionUnaryPlus = { "+" } + ExpressionNot = { "!" } + ExpressionUnaryPlus = { "+" } ExpressionUnaryMinus = { "-" } -// Infix operators for Expression +// Infix operators (multi-character variants listed before their prefixes) ExpressionInfix = _{ ExpressionPow | ExpressionMult @@ -181,106 +165,129 @@ ExpressionInfix = _{ | ExpressionOr | ExpressionBitOr } - ExpressionPow = { "^" } - ExpressionMult = { "*" } - ExpressionIntDiv = { "//" } - ExpressionDiv = { "/" } - ExpressionAdd = { "+" } + ExpressionPow = { "^" } + ExpressionMult = { "*" } + ExpressionIntDiv = { "//" } + ExpressionDiv = { "/" } + ExpressionAdd = { "+" } ExpressionSubtract = { "-" } - ExpressionMod = { "%" } - ExpressionLeq = { "<=" } - ExpressionLess = { "<" } - ExpressionEq = { "==" } - ExpressionGeq = { ">=" } - ExpressionGreater = { ">" } - ExpressionAnd = { "&&" } - ExpressionBitAnd = { "&" } - ExpressionOr = { "||" } - ExpressionBitOr = { "|" } - -// Postfix operators for Expression -ExpressionPostfix = _{ ExpressionCall | ExpressionAggregate } - ExpressionCall = { "(" ~ (Expression ~ ("," ~ Expression)*)? ~ ")" } - ExpressionAggregate = { "." ~ ExpressionAggregateOp ~ "(" ~ (Expression)? ~ ")" } - ExpressionAggregateOp = { "count" | "min" | "max" | "mean" } + ExpressionMod = { "%" } + ExpressionLeq = { "<=" } + ExpressionLess = { "<" } + ExpressionEq = { "==" } + ExpressionGeq = { ">=" } + ExpressionGreater = { ">" } + ExpressionAnd = { "&&" } + ExpressionBitAnd = { "&" } + ExpressionOr = { "||" } + ExpressionBitOr = { "|" } + +// Postfix operators +ExpressionPostfix = _{ ExpressionCall | ExpressionTernary } + ExpressionCall = { "(" ~ (Expression ~ ("," ~ Expression)*)? ~ ")" } + ExpressionTernary = { "?" ~ Expression ~ ":" ~ Expression } ExpressionPrimary = _{ "(" ~ Expression ~ ")" - | "false" - | "true" - | "N" ~ "[" ~ Expression ~ "," ~ Expression ~ "]" - | "U" ~ "[" ~ Expression ~ ("," ~ Expression)* ~ "]" - | "R" ~ ("[" ~ Expression ~ "," ~ Expression ~ "]")? - | "it" + | 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*)* } +PerturbationExpression = { PerturbationPrimary ~ PerturbationPostfix* ~ (PerturbationInfix ~ PerturbationPrimary ~ PerturbationPostfix*)* } -// Infix operators for PerturbationExpression PerturbationInfix = _{ PerturbationSemicolon } PerturbationSemicolon = { ";" } -// Postfix operators for PerturbationExpression PerturbationPostfix = _{ PerturbationPow } PerturbationPow = { "^" ~ Expression } PerturbationPrimary = _{ - "nil" - | "(" ~ PerturbationExpression ~ ")" - | "[" ~ PerturbationAssignment ~ ("," ~ PerturbationAssignment)* ~ "]" ~ "@" ~ Expression - | ID + PerturbationNil + | "(" ~ PerturbationExpression ~ ")" + | PerturbationAtomic + | ID } + PerturbationNil = @{ "nil" ~ !(LETTER | DIGIT) } + PerturbationAtomic = { "[" ~ PerturbationAssignment ~ ("," ~ PerturbationAssignment)* ~ "]" ~ "@" ~ Expression } -PerturbationAssignment = _{ ID ~ "<-" ~ Expression } +PerturbationAssignment = { ID ~ "<-" ~ Expression } // Distance expressions -DistanceExpression = _{ DistancePrefix* ~ DistancePrimary ~ DistancePostfix* ~ (DistanceInfix ~ DistancePrefix* ~ DistancePrimary ~ DistancePostfix*)* } +DistanceExpression = { DistancePrefix* ~ DistancePrimary ~ DistancePostfix* ~ (DistanceInfix ~ DistancePrefix* ~ DistancePrimary ~ DistancePostfix*)* } -// Prefix operators for DistanceExpression DistancePrefix = _{ DistancePrefixF | DistancePrefixG } DistancePrefixF = { "\\F" ~ "[" ~ Expression ~ "," ~ Expression ~ "]" } DistancePrefixG = { "\\G" ~ "[" ~ Expression ~ "," ~ Expression ~ "]" } -// Infix operators for DistanceExpression DistanceInfix = _{ DistanceInfixUntil } DistanceInfixUntil = { "\\U" ~ "[" ~ Expression ~ "," ~ Expression ~ "]" } -// Postfix operators for DistanceExpression DistancePostfix = _{ DistancePostfixThreshold } - DistancePostfixThreshold = { ("<=" | "<" | ">=" | ">") ~ Expression } + DistancePostfixThreshold = { DistanceThresholdOp ~ Expression } + DistanceThresholdOp = { "<=" | "<" | ">=" | ">" } DistancePrimary = _{ - "<" ~ ID - | ">" ~ ID + DistanceMin + | DistanceMax + | DistanceAtomicLeft + | DistanceAtomicRight | "(" ~ DistanceExpression ~ ")" + | DistanceLinearCombination | ID - | "min" ~ "(" ~ DistanceExpression ~ "," ~ DistanceExpression ~ ")" - | "max" ~ "(" ~ DistanceExpression ~ "," ~ DistanceExpression ~ ")" - | Expression ~ "*" ~ DistanceExpression ~ ("+" ~ Expression ~ "*" ~ DistanceExpression)* } + 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)* } +RobtlFormula = { RobtlPrefix* ~ RobtlPrimary ~ (RobtlInfix ~ RobtlPrefix* ~ RobtlPrimary)* } -RobtlPrefix = _{ - "!" - | "G" ~ "[" ~ Expression ~ "," ~ Expression ~ "]" - | "F" ~ "[" ~ Expression ~ "," ~ Expression ~ "]" -} +RobtlPrefix = _{ RobtlNot | RobtlGlobally | RobtlEventually } + RobtlNot = { "!" } + RobtlGlobally = { "\\G" ~ "[" ~ Expression ~ "," ~ Expression ~ "]" } + RobtlEventually = { "\\F" ~ "[" ~ Expression ~ "," ~ Expression ~ "]" } -RobtlInfix = _{ - "&&" - | "||" - | "U" ~ "[" ~ Expression ~ "," ~ Expression ~ "]" -} +RobtlInfix = _{ RobtlAnd | RobtlOr | RobtlUntil } + RobtlAnd = { "&&" } + RobtlOr = { "||" } + RobtlUntil = { "\\U" ~ "[" ~ Expression ~ "," ~ Expression ~ "]" } RobtlPrimary = _{ - "true" - | "false" - | "D" ~ "[" ~ ID ~ "," ~ ID ~ "]" ~ ("<=" | "<" | "==" | ">=" | ">") ~ Expression + RobtlTrue + | RobtlFalse + | RobtlDistance | ID -} \ No newline at end of file +} + RobtlTrue = @{ "true" ~ !(LETTER | DIGIT) } + RobtlFalse = @{ "false" ~ !(LETTER | DIGIT) } + RobtlDistance = { "\\D" ~ "[" ~ ID ~ "," ~ ID ~ "]" ~ RobtlComparison ~ Expression } + RobtlComparison = { "<=" | "<" | "==" | ">=" | ">" } diff --git a/examples/stark/toll.stark b/examples/stark/toll.stark index eb7adb7f7..c91c2b12d 100644 --- a/examples/stark/toll.stark +++ b/examples/stark/toll.stark @@ -41,7 +41,7 @@ component vehicle { int timer_V range [0,TIMER] = 0; } controller { - state Ctrl { + aiState Ctrl { if (s_speed > 0) { if (gap > 0) { accel' = A; @@ -64,21 +64,21 @@ component vehicle { } } } - state Accelerate { + aiState Accelerate { if (timer_V > 0) { step Accelerate; } else { exec Ctrl; } } - state Decelerate { + aiState Decelerate { if (timer_V > 0) { step Decelerate; } else { exec Ctrl; } } - state Stop { + aiState Stop { if (timer_V > 0) { step Stop; } else { @@ -104,6 +104,6 @@ environment{ 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); + gap' = p_distance - travel - eval_bd(new_sens_speed); } } \ No newline at end of file diff --git a/examples/stark/two_vehicles.stark b/examples/stark/two_vehicles.stark index eb7adb7f7..c91c2b12d 100644 --- a/examples/stark/two_vehicles.stark +++ b/examples/stark/two_vehicles.stark @@ -41,7 +41,7 @@ component vehicle { int timer_V range [0,TIMER] = 0; } controller { - state Ctrl { + aiState Ctrl { if (s_speed > 0) { if (gap > 0) { accel' = A; @@ -64,21 +64,21 @@ component vehicle { } } } - state Accelerate { + aiState Accelerate { if (timer_V > 0) { step Accelerate; } else { exec Ctrl; } } - state Decelerate { + aiState Decelerate { if (timer_V > 0) { step Decelerate; } else { exec Ctrl; } } - state Stop { + aiState Stop { if (timer_V > 0) { step Stop; } else { @@ -104,6 +104,6 @@ environment{ 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); + gap' = p_distance - travel - eval_bd(new_sens_speed); } } \ No newline at end of file From 5699889cc13edc1cc0a4bb44e938a47673a97d11 Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Sat, 18 Jul 2026 14:41:14 +0200 Subject: [PATCH 10/50] Add a general span for use in ASTs --- crates/utilities/src/lib.rs | 4 + crates/utilities/src/span.rs | 231 +++++++++++++++++++++++++++++++++++ 2 files changed, 235 insertions(+) create mode 100644 crates/utilities/src/span.rs diff --git a/crates/utilities/src/lib.rs b/crates/utilities/src/lib.rs index aa56665a7..b30cfc52f 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 000000000..b07c392ff --- /dev/null +++ b/crates/utilities/src/span.rs @@ -0,0 +1,231 @@ +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 | ^"); + } +} From b555e7690919660a9d4d1db0258d1e56cd98a3b4 Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Sat, 18 Jul 2026 18:07:00 +0200 Subject: [PATCH 11/50] Updated parsing with spans, and some general improvements --- crates/stark/src/ast.rs | 268 ++++++++++++++++++----------- crates/stark/src/parse.rs | 20 +-- crates/stark/src/precedence.rs | 301 +++++++++++++++++++++++---------- 3 files changed, 389 insertions(+), 200 deletions(-) diff --git a/crates/stark/src/ast.rs b/crates/stark/src/ast.rs index 54c535a4f..567fa318a 100644 --- a/crates/stark/src/ast.rs +++ b/crates/stark/src/ast.rs @@ -4,11 +4,82 @@ //! (`StarkSpecificationLanguage.g4`). The tree is produced by `consume.rs` //! (structural declarations) together with the Pratt parsers in `precedence.rs` //! (expressions and the perturbation / distance / ROBTL sub-languages). +//! +//! Declarations carry an `id: Option` (or `Option` for +//! controller states) that is `None` after parsing and filled in by name +//! resolution (`resolve.rs`). Every place a declared name is *referenced* +//! (rather than declared) uses [DefRef], [StateRef] or, inside expressions, +//! [Binding] — all `None`/absent until resolution runs. + +pub use merc_utilities::Span; +pub use merc_utilities::Spanned; +use merc_utilities::TagIndex; + +/// A unique tag for top-level declarations: constants, parameters, variables, +/// functions, penalties, components, custom types, perturbations, distances +/// and formulas all share this single namespace, mirroring the original +/// STARK `SymbolTable`'s single `symbols` map. +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` together with the source span it was parsed from. +pub type SpannedExpression = Spanned; + +/// A reference to a top-level declaration (variable, constant, parameter, +/// function, penalty, distance, perturbation, formula or component), +/// resolved to a [DefId] by name resolution. `id` is `None` until then. +#[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 StarkSpecification { +pub struct UntypedStarkSpecification { pub constants: Vec, pub parameters: Vec, pub variables: Vec, @@ -22,7 +93,7 @@ pub struct StarkSpecification { pub formulas: Vec, } -impl StarkSpecification { +impl UntypedStarkSpecification { pub fn new() -> Self { Self::default() } @@ -35,67 +106,75 @@ impl StarkSpecification { /// `const name = value;` #[derive(Clone, Debug)] pub struct Constant { - pub id: Identifier, - pub value: Expression, + pub id: Option, + pub name: Identifier, + pub value: SpannedExpression, } /// `param name = value;` #[derive(Clone, Debug)] pub struct Parameter { - pub id: Identifier, - pub value: Expression, + pub id: Option, + pub name: Identifier, + pub value: SpannedExpression, } /// 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 id: Identifier, + pub name: Identifier, pub range: Option, - pub initial_value: Expression, + pub initial_value: SpannedExpression, } /// `type name = A | B | C;` #[derive(Clone, Debug)] pub struct TypeDeclaration { - pub id: Identifier, + pub id: Option, + pub name: Identifier, pub elements: Vec, } /// `penalty name = expr` #[derive(Clone, Debug)] pub struct Penalty { - pub id: Identifier, - pub value: Expression, + pub id: Option, + pub name: Identifier, + pub value: SpannedExpression, } /// `function name(args) { body }` #[derive(Clone, Debug)] pub struct Function { - pub id: Identifier, + 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 id: Identifier, + pub name: Identifier, } #[derive(Clone, Debug)] pub enum FunctionStatement { - Return(Expression), + Return(SpannedExpression), IfThenElse { - guard: Expression, + guard: SpannedExpression, then_branch: Box, else_branch: Option>, }, Let { - id: Identifier, - value: Expression, + id: Option, + name: Identifier, + value: SpannedExpression, body: Box, }, Block(Box), @@ -108,17 +187,19 @@ pub enum FunctionStatement { /// `component name { variables { .. } controller { .. } init .. }` #[derive(Clone, Debug)] pub struct Component { - pub id: Identifier, + pub id: Option, + pub name: Identifier, pub variables: Vec, pub states: Vec, /// The `init` expression: the parallel composition of state references. - pub init: Vec, + pub init: Vec, } /// `aiState name { .. }` #[derive(Clone, Debug)] pub struct ControllerState { - pub id: Identifier, + pub id: Option, + pub name: Identifier, pub body: Vec, } @@ -126,22 +207,23 @@ pub struct ControllerState { pub enum ControllerCommand { /// `[steps #] step target;` Step { - steps: Option, - target: Identifier, + steps: Option, + target: StateRef, }, /// `exec target;` - Exec(Identifier), + Exec(StateRef), /// `let id = value in body` Let { - id: Identifier, - value: Expression, + id: Option, + name: Identifier, + value: SpannedExpression, body: Vec, }, /// `[when guard] target' = value;` Assignment(Update), /// `if (guard) { .. } else { .. }` IfThenElse { - guard: Expression, + guard: SpannedExpression, then_branch: Vec, else_branch: Option>, }, @@ -165,7 +247,7 @@ pub enum EnvironmentCommand { Assignment(Update), /// `if (guard) cmd [else cmd]` IfThenElse { - guard: Expression, + guard: SpannedExpression, then_branch: Box, else_branch: Option>, }, @@ -180,17 +262,19 @@ pub enum EnvironmentCommand { #[derive(Clone, Debug)] pub struct LocalVariable { - pub id: Identifier, - pub value: Expression, + pub id: Option, + pub name: Identifier, + pub value: SpannedExpression, } /// A `[when guard] target' = value;` assignment shared by controllers and the -/// environment. `target` is the primed variable name (without the trailing `'`). +/// 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: Identifier, - pub value: Expression, + pub guard: Option, + pub target: DefRef, + pub value: SpannedExpression, } // --------------------------------------------------------------------------- @@ -200,64 +284,67 @@ pub struct Update { /// `perturbation name = expr;` #[derive(Clone, Debug)] pub struct Perturbation { - pub id: Identifier, + pub id: Option, + pub name: Identifier, pub value: PerturbationExpression, } #[derive(Clone, Debug)] pub enum PerturbationExpression { Nil, - Reference(Identifier), + Reference(DefRef), /// `[ v1 <- e1, v2 <- e2 ] @ time` Atomic { assignments: Vec, - time: Expression, + time: SpannedExpression, }, /// `left ; right` Sequence(Box, Box), /// `argument ^ iterations` Iteration { argument: Box, - iterations: Expression, + iterations: SpannedExpression, }, } #[derive(Clone, Debug)] pub struct PerturbationAssignment { - pub id: Identifier, - pub value: Expression, + pub target: DefRef, + pub value: SpannedExpression, } /// `distance name = expr;` #[derive(Clone, Debug)] pub struct Distance { - pub id: Identifier, + pub id: Option, + pub name: Identifier, pub value: DistanceExpression, } #[derive(Clone, Debug)] pub enum DistanceExpression { - Reference(Identifier), + /// A reference to another named `distance` declaration. + Reference(DefRef), /// `< penalty` - AtomicLeft(Identifier), + AtomicLeft(DefRef), /// `> penalty` - AtomicRight(Identifier), + AtomicRight(DefRef), /// `\F[from,to] argument` Eventually { - from: Expression, - to: Expression, + from: SpannedExpression, + to: SpannedExpression, argument: Box, }, /// `\G[from,to] argument` Globally { - from: Expression, - to: Expression, + from: SpannedExpression, + to: SpannedExpression, argument: Box, }, /// `left \U[from,to] right` Until { - from: Expression, - to: Expression, + from: SpannedExpression, + to: SpannedExpression, left: Box, right: Box, }, @@ -265,18 +352,19 @@ pub enum DistanceExpression { Threshold { op: ComparisonOp, left: Box, - threshold: Expression, + threshold: SpannedExpression, }, Min(Box, Box), Max(Box, Box), /// `w1 * d1 + w2 * d2 + ...` - LinearCombination(Vec<(Expression, DistanceExpression)>), + LinearCombination(Vec<(SpannedExpression, DistanceExpression)>), } /// `formula name = formula;` #[derive(Clone, Debug)] pub struct Formula { - pub id: Identifier, + pub id: Option, + pub name: Identifier, pub value: RobtlFormula, } @@ -284,30 +372,31 @@ pub struct Formula { pub enum RobtlFormula { True, False, - Reference(Identifier), + /// A reference to another named `formula` declaration. + Reference(DefRef), /// `\D[distance, perturbation] op value` Distance { - distance: Identifier, - perturbation: Identifier, + distance: DefRef, + perturbation: DefRef, op: ComparisonOp, - value: Expression, + value: SpannedExpression, }, Not(Box), Globally { - from: Expression, - to: Expression, + from: SpannedExpression, + to: SpannedExpression, argument: Box, }, Eventually { - from: Expression, - to: Expression, + from: SpannedExpression, + to: SpannedExpression, argument: Box, }, And(Box, Box), Or(Box, Box), Until { - from: Expression, - to: Expression, + from: SpannedExpression, + to: SpannedExpression, left: Box, right: Box, }, @@ -324,49 +413,52 @@ pub enum Expression { True, Integer(i64), Real(f64), - Identifier(String), + /// A name reference: a constant/parameter/variable, a local binding + /// (function argument, `let` binding, `it`), or (before resolution) + /// unresolved. `binding` is filled in by `resolve.rs`. + Reference { name: String, binding: Option }, /// The `it` lambda parameter used inside aggregate/perturbation contexts. Iterator, // Distributions / random values Normal { - mean: Box, - std_dev: Box, + mean: Box, + std_dev: Box, }, Uniform { - values: Vec, + values: Vec, }, /// `R` or `R[min,max]`. Range { - min: Option>, - max: Option>, + min: Option>, + max: Option>, }, // Prefix operators - Not(Box), - UnaryPlus(Box), - UnaryMinus(Box), + Not(Box), + UnaryPlus(Box), + UnaryMinus(Box), // Binary operators - Binary(BinaryOp, Box, Box), + Binary(BinaryOp, Box, Box), // `guard ? then : else` Ternary { - guard: Box, - then_branch: Box, - else_branch: Box, + guard: Box, + then_branch: Box, + else_branch: Box, }, /// A user-defined function application `name(args)`. Call { - function: Box, - arguments: Vec, + function: DefRef, + arguments: Vec, }, /// A built-in math function application, e.g. `abs(x)`, `max(a, b)`. MathCall { function: MathFunction, - arguments: Vec, + arguments: Vec, }, } @@ -439,8 +531,8 @@ pub enum MathFunction { /// A `range [min, max]` bound on a variable declaration. #[derive(Clone, Debug)] pub struct Range { - pub min: Expression, - pub max: Expression, + pub min: SpannedExpression, + pub max: SpannedExpression, } #[derive(Clone, Debug)] @@ -464,19 +556,3 @@ impl Identifier { Identifier { name, span } } } - -/// Source location information, spanning from start to end in the source text. -#[derive(Clone, Copy, 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(), - } - } -} diff --git a/crates/stark/src/parse.rs b/crates/stark/src/parse.rs index 0ab78a25e..fff837b9a 100644 --- a/crates/stark/src/parse.rs +++ b/crates/stark/src/parse.rs @@ -3,57 +3,57 @@ use pest_derive::Parser; use merc_utilities::MercError; -use crate::ast::StarkSpecification; +use crate::ast::UntypedStarkSpecification; use crate::consume::ParseNode; #[derive(Parser)] #[grammar = "stark_grammar.pest"] pub struct StarkParser; -impl StarkSpecification { +impl UntypedStarkSpecification { /// Parse the given stark specification into an AST. pub fn parse(input: &str) -> Result { - let mut result = StarkParser::parse(Rule::StarkSpecification, input)?; + let mut result = StarkParser::parse(Rule::UntypedStarkSpecification, input)?; let root = result.next().expect("Could not parse STARK specification"); - Ok(StarkParser::StarkSpecification(ParseNode::new(root))?) + Ok(StarkParser::UntypedStarkSpecification(ParseNode::new(root))?) } } #[cfg(test)] mod tests { - use crate::ast::StarkSpecification; + use crate::ast::UntypedStarkSpecification; #[test] fn test_parse_engine_stark() { - if let Err(x) = StarkSpecification::parse(include_str!("../../../examples/stark/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) = StarkSpecification::parse(include_str!("../../../examples/stark/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) = StarkSpecification::parse(include_str!("../../../examples/stark/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) = StarkSpecification::parse(include_str!("../../../examples/stark/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) = StarkSpecification::parse(include_str!("../../../examples/stark/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 index 32f2869d2..fe12b95fc 100644 --- a/crates/stark/src/precedence.rs +++ b/crates/stark/src/precedence.rs @@ -3,6 +3,11 @@ //! 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 [SpannedExpression]); 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; @@ -14,9 +19,12 @@ 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::Identifier; @@ -24,6 +32,7 @@ use crate::ast::MathFunction; use crate::ast::PerturbationAssignment; use crate::ast::PerturbationExpression; use crate::ast::RobtlFormula; +use crate::ast::SpannedExpression; use crate::consume::ParseResult; use crate::parse::Rule; @@ -44,21 +53,55 @@ fn error(pair: &Pair<'_, Rule>, message: impl Into) -> ParseResult )) } -/// Parse an `Expression` node's children with the expression Pratt parser. +/// 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)] -fn parse_expression_node(pair: Pair<'_, Rule>) -> ParseResult { - parse_expression(pair.into_inner()) +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( + Expression::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> { +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, @@ -125,48 +168,55 @@ pub static EXPRESSION_PRATT_PARSER: LazyLock> = LazyLock::new( .op(Op::prefix(Rule::ExpressionNot) | Op::prefix(Rule::ExpressionUnaryPlus) | Op::prefix(Rule::ExpressionUnaryMinus)) - .op(Op::postfix(Rule::ExpressionCall) | Op::postfix(Rule::ExpressionTernary)) + .op(Op::postfix(Rule::ExpressionCall)) }); #[allow(clippy::result_large_err)] -fn parse_expression_primary(primary: Pair<'_, Rule>) -> ParseResult { - match primary.as_rule() { - // Parenthesized sub-expression. - Rule::Expression => parse_expression_node(primary), +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) => Ok(Expression::Integer(value)), - Err(_) => error( - &primary, - format!( - "integer literal `{}` does not fit in a 64-bit integer", - primary.as_str() - ), - ), + Ok(value) => Expression::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) => Ok(Expression::Real(value)), - Err(_) => error(&primary, format!("invalid real literal `{}`", primary.as_str())), + Ok(value) => Expression::Real(value), + Err(_) => return error(&primary, format!("invalid real literal `{}`", primary.as_str())), }, - Rule::ID => Ok(Expression::Identifier(primary.as_str().to_string())), - Rule::ExpressionTrue => Ok(Expression::True), - Rule::ExpressionFalse => Ok(Expression::False), - Rule::ExpressionIterator => Ok(Expression::Iterator), + Rule::ID => Expression::Reference { + name: primary.as_str().to_string(), + binding: None, + }, + Rule::ExpressionTrue => Expression::True, + Rule::ExpressionFalse => Expression::False, + Rule::ExpressionIterator => Expression::Iterator, Rule::ExpressionNormal => { let mut args = expression_arguments(primary)?.into_iter(); - Ok(Expression::Normal { + Expression::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 => Ok(Expression::Uniform { + Rule::ExpressionUniform => Expression::Uniform { values: expression_arguments(primary)?, - }), + }, Rule::ExpressionRandom => { let mut args = expression_arguments(primary)?.into_iter(); - Ok(Expression::Range { + Expression::Range { min: args.next().map(Box::new), max: args.next().map(Box::new), - }) + } } Rule::ExpressionUnaryMathCall | Rule::ExpressionBinaryMathCall => { let mut children = primary.into_inner(); @@ -180,23 +230,32 @@ fn parse_expression_primary(primary: Pair<'_, Rule>) -> ParseResult .filter(|p| p.as_rule() == Rule::Expression) .map(parse_expression_node) .collect::>>()?; - Ok(Expression::MathCall { function, arguments }) + Expression::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 { +pub fn parse_expression(pairs: Pairs) -> ParseResult { EXPRESSION_PRATT_PARSER .map_primary(parse_expression_primary) - .map_prefix(|op, rhs| match op.as_rule() { - Rule::ExpressionNot => Ok(Expression::Not(Box::new(rhs?))), - Rule::ExpressionUnaryPlus => Ok(Expression::UnaryPlus(Box::new(rhs?))), - Rule::ExpressionUnaryMinus => Ok(Expression::UnaryMinus(Box::new(rhs?))), - rule => unreachable!("unexpected expression prefix operator: {rule:?}"), + .map_prefix(|op, rhs| { + let rhs = rhs?; + let span = cover(&op.as_span().into(), &rhs.span); + let expr = match op.as_rule() { + Rule::ExpressionNot => Expression::Not(Box::new(rhs)), + Rule::ExpressionUnaryPlus => Expression::UnaryPlus(Box::new(rhs)), + Rule::ExpressionUnaryMinus => Expression::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, @@ -216,22 +275,23 @@ pub fn parse_expression(pairs: Pairs) -> ParseResult { Rule::ExpressionOr => BinaryOp::Or, rule => unreachable!("unexpected expression binary operator: {rule:?}"), }; - Ok(Expression::Binary(op, Box::new(lhs?), Box::new(rhs?))) + Ok(Spanned::new(Expression::Binary(op, Box::new(lhs), Box::new(rhs)), span)) }) - .map_postfix(|target, postfix| match postfix.as_rule() { - Rule::ExpressionCall => Ok(Expression::Call { - function: Box::new(target?), - arguments: expression_arguments(postfix)?, - }), - Rule::ExpressionTernary => { - let mut branches = expression_arguments(postfix)?.into_iter(); - Ok(Expression::Ternary { - guard: Box::new(target?), - then_branch: Box::new(branches.next().expect("ternary requires a then branch")), - else_branch: Box::new(branches.next().expect("ternary requires an else branch")), - }) + .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 { + Expression::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(Expression::Call { function, arguments }, span)) + } + rule => unreachable!("unexpected expression postfix operator: {rule:?}"), } - rule => unreachable!("unexpected expression postfix operator: {rule:?}"), }) .parse(pairs) } @@ -251,7 +311,7 @@ fn parse_perturbation_primary(primary: Pair<'_, Rule>) -> ParseResult parse_perturbation_expression(primary.into_inner()), Rule::PerturbationNil => Ok(PerturbationExpression::Nil), - Rule::ID => Ok(PerturbationExpression::Reference(identifier(&primary))), + Rule::ID => Ok(PerturbationExpression::Reference(DefRef::new(identifier(&primary)))), Rule::PerturbationAtomic => { let mut assignments = Vec::new(); let mut time = None; @@ -259,9 +319,9 @@ fn parse_perturbation_primary(primary: Pair<'_, Rule>) -> ParseResult { let mut inner = child.into_inner(); - let id = identifier(&inner.next().expect("assignment target")); + let target = DefRef::new(identifier(&inner.next().expect("assignment target"))); let value = parse_expression_node(inner.next().expect("assignment value"))?; - assignments.push(PerturbationAssignment { id, value }); + assignments.push(PerturbationAssignment { target, value }); } Rule::Expression => time = Some(parse_expression_node(child)?), rule => unreachable!("unexpected perturbation atomic child: {rule:?}"), @@ -315,7 +375,7 @@ pub static DISTANCE_PRATT_PARSER: LazyLock> = LazyLock::new(|| /// 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)> { +fn parse_interval(pair: Pair<'_, Rule>) -> ParseResult<(SpannedExpression, SpannedExpression)> { let mut args = expression_arguments(pair)?.into_iter(); Ok(( args.next().expect("interval requires a lower bound"), @@ -327,13 +387,13 @@ fn parse_interval(pair: Pair<'_, Rule>) -> ParseResult<(Expression, Expression)> 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(identifier( + Rule::DistanceAtomicLeft => Ok(DistanceExpression::AtomicLeft(DefRef::new(identifier( &primary.into_inner().next().expect("penalty reference"), - ))), - Rule::DistanceAtomicRight => Ok(DistanceExpression::AtomicRight(identifier( + )))), + Rule::DistanceAtomicRight => Ok(DistanceExpression::AtomicRight(DefRef::new(identifier( &primary.into_inner().next().expect("penalty reference"), - ))), - Rule::ID => Ok(DistanceExpression::Reference(identifier(&primary))), + )))), + 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))) @@ -360,7 +420,9 @@ fn parse_distance_primary(primary: Pair<'_, Rule>) -> ParseResult) -> ParseResult<(DistanceExpression, DistanceExpression)> { - let mut children = pair.into_inner().filter(|p| p.as_rule() == Rule::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)) @@ -416,7 +478,9 @@ pub static ROBTL_PRATT_PARSER: LazyLock> = LazyLock::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)) + .op(Op::prefix(Rule::RobtlNot) + | Op::prefix(Rule::RobtlGlobally) + | Op::prefix(Rule::RobtlEventually)) }); #[allow(clippy::result_large_err)] @@ -425,11 +489,11 @@ fn parse_robtl_primary(primary: Pair<'_, Rule>) -> ParseResult { Rule::RobtlFormula => parse_robtl_formula(primary.into_inner()), Rule::RobtlTrue => Ok(RobtlFormula::True), Rule::RobtlFalse => Ok(RobtlFormula::False), - Rule::ID => Ok(RobtlFormula::Reference(identifier(&primary))), + Rule::ID => Ok(RobtlFormula::Reference(DefRef::new(identifier(&primary)))), Rule::RobtlDistance => { let mut children = primary.into_inner(); - let distance = identifier(&children.next().expect("distance reference")); - let perturbation = identifier(&children.next().expect("perturbation reference")); + 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 { @@ -486,19 +550,15 @@ pub fn parse_robtl_formula(pairs: Pairs) -> ParseResult { #[cfg(test)] mod tests { - use crate::ast::BinaryOp; - use crate::ast::DistanceExpression; - use crate::ast::Expression; - use crate::ast::MathFunction; - use crate::ast::PerturbationExpression; - use crate::ast::RobtlFormula; - use crate::ast::StarkSpecification; - use crate::ast::Ty; - - /// Parse `const c = ;` and return the parsed expression. + use crate::ast::{ + BinaryOp, DistanceExpression, Expression, MathFunction, PerturbationExpression, RobtlFormula, + UntypedStarkSpecification, Ty, + }; + + /// Parse `const c = ;` and return the parsed expression (span discarded). fn expr(src: &str) -> Expression { - let spec = StarkSpecification::parse(&format!("const c = {src};")).expect("should parse"); - spec.constants.into_iter().next().expect("one constant").value + let spec = UntypedStarkSpecification::parse(&format!("const c = {src};")).expect("should parse"); + spec.constants.into_iter().next().expect("one constant").value.node } #[test] @@ -506,8 +566,8 @@ mod tests { // `1 + 2 * 3` must group as `1 + (2 * 3)`. match expr("1 + 2 * 3") { Expression::Binary(BinaryOp::Add, lhs, rhs) => { - assert!(matches!(*lhs, Expression::Integer(1))); - assert!(matches!(*rhs, Expression::Binary(BinaryOp::Mult, _, _))); + assert!(matches!(lhs.node, Expression::Integer(1))); + assert!(matches!(rhs.node, Expression::Binary(BinaryOp::Mult, _, _))); } other => panic!("unexpected: {other:?}"), } @@ -518,8 +578,8 @@ mod tests { // `2 ^ 3 ^ 2` must group as `2 ^ (3 ^ 2)`. match expr("2 ^ 3 ^ 2") { Expression::Binary(BinaryOp::Pow, lhs, rhs) => { - assert!(matches!(*lhs, Expression::Integer(2))); - assert!(matches!(*rhs, Expression::Binary(BinaryOp::Pow, _, _))); + assert!(matches!(lhs.node, Expression::Integer(2))); + assert!(matches!(rhs.node, Expression::Binary(BinaryOp::Pow, _, _))); } other => panic!("unexpected: {other:?}"), } @@ -530,7 +590,7 @@ mod tests { // `a > b & c` must group as `(a > b) & c` (relations tighter than `&`). match expr("a > b & c") { Expression::Binary(BinaryOp::BitAnd, lhs, _) => { - assert!(matches!(*lhs, Expression::Binary(BinaryOp::Greater, _, _))); + assert!(matches!(lhs.node, Expression::Binary(BinaryOp::Greater, _, _))); } other => panic!("unexpected: {other:?}"), } @@ -558,15 +618,43 @@ mod tests { } => assert_eq!(arguments.len(), 1), other => panic!("unexpected: {other:?}"), } - // A non-builtin name is a user call, not a math call. - assert!(matches!(expr("eval_bd(x)"), Expression::Call { .. })); + // 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)") { + Expression::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"), Expression::Identifier(name) if name == "italic")); - assert!(matches!(expr("Rate"), Expression::Identifier(name) if name == "Rate")); + assert!(matches!( + expr("italic"), + Expression::Reference { name, .. } if name == "italic" + )); + assert!(matches!( + expr("Rate"), + Expression::Reference { name, .. } if name == "Rate" + )); + } + + #[test] + fn unresolved_reference_has_no_binding() { + assert!(matches!( + expr("x"), + Expression::Reference { binding: None, .. } + )); } #[test] @@ -590,23 +678,36 @@ mod tests { #[test] fn integer_overflow_is_an_error() { - assert!(StarkSpecification::parse("const c = 99999999999999999999999;").is_err()); + 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 = - StarkSpecification::parse("global variables { int counter range [0, 10] = 0; }").expect("should parse"); + 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.id.name, "counter"); + assert_eq!(var.name.name, "counter"); + assert!(var.id.is_none()); } #[test] fn perturbation_sequence_and_iteration() { - let spec = StarkSpecification::parse("perturbation p = ([x <- 1]@0); ([y <- 2]@0)^3;").expect("should parse"); + 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) => { @@ -618,9 +719,21 @@ mod tests { #[test] fn distance_and_formula() { - let spec = StarkSpecification::parse("distance d = \\G[0, 10] < rho;\nformula f = \\D[d, p] <= 5;") - .expect("should parse"); + 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 { .. })); - assert!(matches!(spec.formulas[0].value, RobtlFormula::Distance { .. })); + 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:?}"), + } } } From e834c7f04491b0ff1d8913dbc0eba59a3ed49bed Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Sat, 18 Jul 2026 18:07:15 +0200 Subject: [PATCH 12/50] Updated the consumption of the grammar --- crates/stark/src/consume.rs | 119 ++++++++++++++++-------------------- 1 file changed, 54 insertions(+), 65 deletions(-) diff --git a/crates/stark/src/consume.rs b/crates/stark/src/consume.rs index 2300f1abf..d485f2220 100644 --- a/crates/stark/src/consume.rs +++ b/crates/stark/src/consume.rs @@ -4,34 +4,15 @@ 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::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::StarkSpecification; -use crate::ast::Ty; -use crate::ast::TypeDeclaration; -use crate::ast::Update; -use crate::ast::Variable; +use crate::ast::{ + Component, Constant, ControllerCommand, ControllerState, DefRef, Distance, Environment, EnvironmentCommand, + Formula, Function, FunctionArgument, FunctionStatement, Identifier, LocalVariable, Parameter, Penalty, + Perturbation, Range, SpannedExpression, UntypedStarkSpecification, StateRef, Ty, TypeDeclaration, Update, Variable, +}; use crate::parse::Rule; -use crate::precedence::parse_distance_expression; -use crate::precedence::parse_expression; -use crate::precedence::parse_perturbation_expression; -use crate::precedence::parse_robtl_formula; +use crate::precedence::{ + parse_distance_expression, parse_expression_node, parse_perturbation_expression, parse_robtl_formula, +}; /// Type alias for Errors resulting from parsing. pub(crate) type ParseResult = std::result::Result>; @@ -87,7 +68,7 @@ fn assignment_update(node: ParseNode) -> ParseResult { for child in node.into_children() { match child.as_rule() { Rule::WhenGuard => guard = Some(StarkParser::WhenGuard(child)?), - Rule::NEXT_ID => target = Some(StarkParser::NEXT_ID(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:?}"), } @@ -102,8 +83,8 @@ fn assignment_update(node: ParseNode) -> ParseResult { #[merc_pest_consume::parser] impl StarkParser { - pub fn StarkSpecification(input: ParseNode) -> ParseResult { - let mut spec = StarkSpecification::new(); + pub fn UntypedStarkSpecification(input: ParseNode) -> ParseResult { + let mut spec = UntypedStarkSpecification::new(); for child in input.into_children() { match child.as_rule() { @@ -151,11 +132,11 @@ impl StarkParser { }) } - pub(crate) fn Expression(input: ParseNode) -> ParseResult { - parse_expression(input.children().as_pairs().clone()) + pub(crate) fn Expression(input: ParseNode) -> ParseResult { + parse_expression_node(input.into_pair()) } - fn WhenGuard(input: ParseNode) -> ParseResult { + fn WhenGuard(input: ParseNode) -> ParseResult { match_nodes!(input.into_children(); [Expression(guard)] => Ok(guard) ) @@ -165,25 +146,25 @@ impl StarkParser { fn DeclarationConstant(input: ParseNode) -> ParseResult { match_nodes!(input.into_children(); - [ID(id), Expression(value)] => Ok(Constant { id, value }) + [ID(name), Expression(value)] => Ok(Constant { id: None, name, value }) ) } fn DeclarationParameter(input: ParseNode) -> ParseResult { match_nodes!(input.into_children(); - [ID(id), Expression(value)] => Ok(Parameter { id, value }) + [ID(name), Expression(value)] => Ok(Parameter { id: None, name, value }) ) } fn DeclarationPenalty(input: ParseNode) -> ParseResult { match_nodes!(input.into_children(); - [ID(id), Expression(value)] => Ok(Penalty { id, value }) + [ID(name), Expression(value)] => Ok(Penalty { id: None, name, value }) ) } fn DeclarationType(input: ParseNode) -> ParseResult { match_nodes!(input.into_children(); - [ID(id), TypeElement(elements)..] => Ok(TypeDeclaration { id, elements: elements.collect() }) + [ID(name), TypeElement(elements)..] => Ok(TypeDeclaration { id: None, name, elements: elements.collect() }) ) } @@ -216,11 +197,11 @@ impl StarkParser { fn VariableDeclaration(input: ParseNode) -> ParseResult { match_nodes!(input.into_children(); - [Ty(ty), ID(id), VariableRange(range), Expression(initial_value)] => { - Ok(Variable { global: false, ty, id, range: Some(range), initial_value }) + [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(id), Expression(initial_value)] => { - Ok(Variable { global: false, ty, id, range: None, initial_value }) + [Ty(ty), ID(name), Expression(initial_value)] => { + Ok(Variable { id: None, global: false, ty, name, range: None, initial_value }) } ) } @@ -234,13 +215,13 @@ impl StarkParser { // --- Functions --------------------------------------------------------- fn DeclarationFunction(input: ParseNode) -> ParseResult { - let mut id = None; + 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 => id = Some(Self::ID(child)?), + 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:?}"), @@ -248,7 +229,8 @@ impl StarkParser { } Ok(Function { - id: id.expect("function requires a name"), + id: None, + name: name.expect("function requires a name"), arguments, body: body.expect("function requires a body"), }) @@ -256,7 +238,7 @@ impl StarkParser { fn FunctionArgument(input: ParseNode) -> ParseResult { match_nodes!(input.into_children(); - [Ty(ty), ID(id)] => Ok(FunctionArgument { ty, id }) + [Ty(ty), ID(name)] => Ok(FunctionArgument { id: None, ty, name }) ) } @@ -268,11 +250,12 @@ impl StarkParser { fn FunctionLet(input: ParseNode) -> ParseResult { let mut children = input.into_children(); - let id = Self::ID(children.next().expect("let name"))?; + 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, + id: None, + name, value, body: Box::new(body), }) @@ -299,14 +282,14 @@ impl StarkParser { // --- Components and controllers --------------------------------------- fn DeclarationComponent(input: ParseNode) -> ParseResult { - let mut id = None; + 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 => id = Some(Self::ID(child)?), + 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)?, @@ -315,24 +298,25 @@ impl StarkParser { } Ok(Component { - id: id.expect("component requires a name"), + id: None, + name: name.expect("component requires a name"), variables, states, init, }) } - fn ControllerExpression(input: ParseNode) -> ParseResult> { + fn ControllerExpression(input: ParseNode) -> ParseResult> { match_nodes!(input.into_children(); - [ID(states)..] => Ok(states.collect()) + [ID(states)..] => Ok(states.map(StateRef::new).collect()) ) } fn ControllerState(input: ParseNode) -> ParseResult { let mut children = input.into_children(); - let id = Self::ID(children.next().expect("state name"))?; + let name = Self::ID(children.next().expect("state name"))?; let body = Self::ControllerBlock(children.next().expect("state body"))?; - Ok(ControllerState { id, body }) + Ok(ControllerState { id: None, name, body }) } fn ControllerBlock(input: ParseNode) -> ParseResult> { @@ -345,7 +329,7 @@ impl StarkParser { for child in input.into_children() { match child.as_rule() { Rule::Expression => steps = Some(Self::Expression(child)?), - Rule::ID => target = Some(Self::ID(child)?), + Rule::ID => target = Some(StateRef::new(Self::ID(child)?)), rule => unreachable!("unexpected step child: {rule:?}"), } } @@ -357,16 +341,21 @@ impl StarkParser { fn ControllerExec(input: ParseNode) -> ParseResult { match_nodes!(input.into_children(); - [ID(target)] => Ok(ControllerCommand::Exec(target)) + [ID(target)] => Ok(ControllerCommand::Exec(StateRef::new(target))) ) } fn ControllerLet(input: ParseNode) -> ParseResult { let mut children = input.into_children(); - let id = Self::ID(children.next().expect("let name"))?; + 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, value, body }) + Ok(ControllerCommand::Let { + id: None, + name, + value, + body, + }) } fn ControllerAssignment(input: ParseNode) -> ParseResult { @@ -430,7 +419,7 @@ impl StarkParser { fn LocalVariable(input: ParseNode) -> ParseResult { match_nodes!(input.into_children(); - [ID(id), Expression(value)] => Ok(LocalVariable { id, value }) + [ID(name), Expression(value)] => Ok(LocalVariable { id: None, name, value }) ) } @@ -438,9 +427,9 @@ impl StarkParser { fn DeclarationPerturbation(input: ParseNode) -> ParseResult { let mut children = input.into_children(); - let id = Self::ID(children.next().expect("perturbation name"))?; + let name = Self::ID(children.next().expect("perturbation name"))?; let value = Self::PerturbationExpression(children.next().expect("perturbation value"))?; - Ok(Perturbation { id, value }) + Ok(Perturbation { id: None, name, value }) } fn PerturbationExpression(input: ParseNode) -> ParseResult { @@ -449,9 +438,9 @@ impl StarkParser { fn DeclarationDistance(input: ParseNode) -> ParseResult { let mut children = input.into_children(); - let id = Self::ID(children.next().expect("distance name"))?; + let name = Self::ID(children.next().expect("distance name"))?; let value = Self::DistanceExpression(children.next().expect("distance value"))?; - Ok(Distance { id, value }) + Ok(Distance { id: None, name, value }) } fn DistanceExpression(input: ParseNode) -> ParseResult { @@ -460,9 +449,9 @@ impl StarkParser { fn DeclarationFormula(input: ParseNode) -> ParseResult { let mut children = input.into_children(); - let id = Self::ID(children.next().expect("formula name"))?; + let name = Self::ID(children.next().expect("formula name"))?; let value = Self::RobtlFormula(children.next().expect("formula value"))?; - Ok(Formula { id, value }) + Ok(Formula { id: None, name, value }) } fn RobtlFormula(input: ParseNode) -> ParseResult { From 26b20751d93c7990edd26c0b16aee01cc1afa4a7 Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Sat, 18 Jul 2026 18:07:41 +0200 Subject: [PATCH 13/50] Added the name resolution and well typedness checks for stark --- crates/stark/src/diagnostics.rs | 141 +++++ crates/stark/src/resolve.rs | 863 ++++++++++++++++++++++++++++++ crates/stark/src/specification.rs | 107 ++++ 3 files changed, 1111 insertions(+) create mode 100644 crates/stark/src/diagnostics.rs create mode 100644 crates/stark/src/resolve.rs create mode 100644 crates/stark/src/specification.rs diff --git a/crates/stark/src/diagnostics.rs b/crates/stark/src/diagnostics.rs new file mode 100644 index 000000000..0ebadc326 --- /dev/null +++ b/crates/stark/src/diagnostics.rs @@ -0,0 +1,141 @@ +//! Diagnostics collected during name resolution and type checking. +//! +//! Ported from `parsing/ParseErrorCollector.java`: rather than failing 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. + +use std::error::Error; +use std::fmt; + +use merc_utilities::Span; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Severity { + Error, +} + +/// A single diagnostic anchored to a source [Span]. +#[derive(Clone, Debug)] +pub struct Diagnostic { + pub span: Span, + pub severity: Severity, + pub message: String, +} + +impl Diagnostic { + pub fn error(span: Span, message: impl Into) -> Self { + Diagnostic { + span, + severity: Severity::Error, + message: message.into(), + } + } + + /// Renders this diagnostic against its `source` text, in the same + /// `-->`/`|`/`^^^` style parser errors use (see [Span::render]). + pub fn render(&self, source: &str) -> String { + format!("{}\n{}", self.message, self.span.render(source)) + } +} + +/// 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, message: impl Into) { + self.items.push(Diagnostic::error(span, message)); + } + + 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 + } + + /// 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)` — the "return + /// `null` only after collecting every error" pattern from + /// `SpecificationLoader.load`, but via `Result` instead of a sentinel. + 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 { + for (index, item) in self.items.iter().enumerate() { + if index > 0 { + writeln!(f)?; + } + writeln!(f, "{}", item.message)?; + } + Ok(()) + } +} + +// Letting `Diagnostics` implement `std::error::Error` means it converts into +// `MercError` for free via that type's blanket `From` impl. +impl Error for Diagnostics {} + +#[cfg(test)] +mod tests { + use super::Diagnostics; + use merc_utilities::Span; + + #[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 }, "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 }, "first"); + diagnostics.error(Span { start: 2, end: 3 }, "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 }, "unexpected x"); + let rendered = diagnostics.render("eqn f = x;"); + assert!(rendered.contains("unexpected x")); + assert!(rendered.contains("^")); + } +} diff --git a/crates/stark/src/resolve.rs b/crates/stark/src/resolve.rs new file mode 100644 index 000000000..13c7edb0c --- /dev/null +++ b/crates/stark/src/resolve.rs @@ -0,0 +1,863 @@ +//! Name resolution: assigns every declaration a stable [DefId]/[StateId]/ +//! [LocalId] and rewrites every reference in place to point at the +//! declaration it names, mirroring `parsing/SymbolTable.java`. +//! +//! STARK has no forward references: a name is only visible to expressions +//! that come *after* its declaration in source order (this is what +//! `SpecificationLanguageValidator`'s single top-down visitor pass implies, +//! and nothing in the example specs relies on forward references either — +//! including function self-recursion, which this resolver also rejects: a +//! function's own name is registered only *after* its body has been +//! resolved). 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. +//! +//! The one exception is controller states: `step`/`exec` inside a state may +//! target a *later* state in the same component (state machines are +//! naturally mutually recursive), so each component's states are registered +//! in a first pass before any state body is resolved. +//! +//! 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). +//! +//! 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 merc_utilities::Span; + +use crate::ast::*; +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 `Expression::Reference` may resolve to this kind: + /// whether it names a *value*, as opposed to a function, penalty, + /// component, perturbation, distance, formula or type, each of which is + /// only referenceable from its own dedicated syntax (a call, a `\D[...]`, + /// …), never from a bare name in an ordinary expression. + 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] — e.g. `typecheck.rs` validating a `Ty::Named` type + /// annotation against a declared `type` (a check that has nothing to do + /// with binding an expression reference, so it isn't performed here). + 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. Always +/// returns a table — even a spec with unresolved names produces one, with +/// those references left as `None` — so `typecheck.rs` can still make +/// progress on everything that *did* resolve. +pub fn resolve(spec: &mut UntypedStarkSpecification) -> (SymbolTable, Diagnostics) { + let mut resolver = Resolver { + table: SymbolTable::default(), + scopes: Vec::new(), + diagnostics: Diagnostics::new(), + }; + resolver.resolve_specification(spec); + (resolver.table, resolver.diagnostics) +} + +struct Resolver { + table: SymbolTable, + /// Local scopes (function arguments, `let` bindings), innermost last. + scopes: Vec>, + diagnostics: Diagnostics, +} + +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_span = self.table.def(existing).span.clone(); + self.diagnostics.error( + name.span.clone(), + format!( + "duplicate definition of `{}` (first defined at {}..{})", + name.name, first_span.start, first_span.end + ), + ); + return None; + } + let id = DefId::new(self.table.defs.len()); + self.table.defs.push(DefEntry { + kind, + name: name.name.clone(), + span: name.span.clone(), + }); + self.table.names.insert(name.name.clone(), id); + 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_span = self.table.state(existing).span.clone(); + self.diagnostics.error( + name.span.clone(), + format!( + "duplicate controller state `{}` (first defined at {}..{})", + name.name, first_span.start, first_span.end + ), + ); + return None; + } + let id = StateId::new(self.table.states.len()); + 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_span = self.table.local(existing).span.clone(); + self.diagnostics.error( + name.span.clone(), + format!( + "duplicate binding `{}` (first defined at {}..{})", + name.name, first_span.start, first_span.end + ), + ); + ids.push(None); + continue; + } + let id = LocalId::new(self.table.locals.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); + ids + } + + fn pop_scope(&mut self) { + 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(), format!("unknown symbol `{}`", name.name)); + } + + /// 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: &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(), + format!("`{}` is {}, expected {}", reference.name.name, kind.describe(), 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(), format!("unknown controller state `{}`", reference.name.name)); + } + } + } + + /// 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(), format!("unknown symbol `{name}`")); + 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(), + format!( + "`{name}` is {}, expected a constant, parameter, variable or type element", + kind.describe() + ), + ); + None + } + } + + // -- Top-level walk ----------------------------------------------------- + + fn resolve_specification(&mut self, spec: &mut UntypedStarkSpecification) { + 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); + } + } + + 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); + variable.id = self.declare(&variable.name, DefKind::Variable { global: variable.global }); + } + + 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(), + format!("type `{}` cannot declare an element with the same name", ty.name.name), + ); + } 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) { + 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) { + 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 SpannedExpression) { + match &mut expr.node { + Expression::False + | Expression::True + | Expression::Integer(_) + | Expression::Real(_) + | Expression::Iterator => {} + Expression::Reference { name, binding } => { + *binding = self.resolve_reference(name, &expr.span); + } + Expression::Normal { mean, std_dev } => { + self.resolve_expression(mean); + self.resolve_expression(std_dev); + } + Expression::Uniform { values } => { + for value in values { + self.resolve_expression(value); + } + } + Expression::Range { min, max } => { + if let Some(min) = min { + self.resolve_expression(min); + } + if let Some(max) = max { + self.resolve_expression(max); + } + } + Expression::Not(inner) | Expression::UnaryPlus(inner) | Expression::UnaryMinus(inner) => { + self.resolve_expression(inner); + } + Expression::Binary(_, left, right) => { + self.resolve_expression(left); + self.resolve_expression(right); + } + Expression::Ternary { + guard, + then_branch, + else_branch, + } => { + self.resolve_expression(guard); + self.resolve_expression(then_branch); + self.resolve_expression(else_branch); + } + Expression::Call { function, arguments } => { + for argument in arguments.iter_mut() { + self.resolve_expression(argument); + } + self.resolve_def_ref(function, DefKind::is_function_kind, "a function"); + } + Expression::MathCall { arguments, .. } => { + for argument in arguments { + self.resolve_expression(argument); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::resolve; + use crate::ast::{Binding, Expression, UntypedStarkSpecification}; + + 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 { + Expression::Binary(_, lhs, _) => { + assert!(matches!(lhs.node, Expression::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.has_errors()); + } + + #[test] + fn duplicate_top_level_name_is_an_error() { + let (_spec, _table, diagnostics) = resolve_source("const a = 1;\nconst a = 2;"); + assert!(diagnostics.has_errors()); + } + + #[test] + fn calling_a_variable_is_illegal_use_of_name() { + let (_spec, _table, diagnostics) = + resolve_source("global variables { int x = 0; }\nconst c = x(1);"); + assert!(diagnostics.has_errors()); + } + + #[test] + fn referencing_a_function_as_a_value_is_illegal_use_of_name() { + let (_spec, _table, diagnostics) = + resolve_source("function f(int x) { return x; }\nconst c = f;"); + assert!(diagnostics.has_errors()); + } + + #[test] + fn function_cannot_call_itself() { + let (_spec, _table, diagnostics) = resolve_source("function f(int x) { return f(x); }"); + assert!(diagnostics.has_errors()); + } + + #[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 { + Expression::Binary(_, lhs, rhs) => { + assert!(matches!(lhs.node, Expression::Reference { binding: Some(Binding::Local(_)), .. })); + assert!(matches!(rhs.node, Expression::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.has_errors()); + } + + #[test] + fn controller_state_can_forward_reference_a_sibling_state() { + let (_spec, _table, diagnostics) = resolve_source( + "component C {\n variables { }\n controller {\n aiState A { step B; }\n aiState 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 aiState A { step B; }\n }\n init A\n}\ncomponent C2 {\n variables { }\n controller {\n aiState B { exec B; }\n }\n init B\n}", + ); + assert!(diagnostics.has_errors()); + } + + #[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, + Expression::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.has_errors()); + } + + #[test] + fn assignment_target_must_be_a_variable() { + let (_spec, _table, diagnostics) = resolve_source( + "const k = 1;\nenvironment { k' = 1; }", + ); + assert!(diagnostics.has_errors()); + } + + #[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")), + ] { + 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 000000000..0a81ac25a --- /dev/null +++ b/crates/stark/src/specification.rs @@ -0,0 +1,107 @@ +//! The checked form of a STARK specification. +//! +//! Parsing yields an [UntypedStarkSpecification]: a faithful syntax tree whose +//! references are unresolved (`DefRef::id` is `None`) and whose expressions have +//! no types yet. Running [UntypedStarkSpecification::check] — name resolution +//! followed by type checking, both from `RESOLVE_TYPECHECK_PLAN.md` — either +//! reports every problem at once through [Diagnostics] or produces a +//! [StarkSpecification], which pairs the now fully-resolved tree with the +//! [SymbolTable] and [TypeTable] that describe it. +//! +//! The type distinction is the point: only `check` can produce a +//! [StarkSpecification], so anything holding one (a future model lowering or +//! evaluator) knows resolution and type checking already succeeded and never has +//! to re-derive or re-validate that. + +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 + } +} + +impl UntypedStarkSpecification { + /// Resolves and type-checks this specification. + /// + /// Type checking runs even when resolution reported errors — unresolved + /// references simply stay `None` and the checker skips them — so a single + /// call reports the problems from both passes together rather than making + /// the caller fix all the name errors before seeing any type errors. + pub fn check(mut self) -> Result { + let (symbols, mut diagnostics) = resolve(&mut self); + let (types, type_diagnostics) = typecheck(&self, &symbols); + diagnostics.extend(type_diagnostics); + + diagnostics.into_result(StarkSpecification { + ast: self, + symbols, + types, + }) + } +} + +#[cfg(test)] +mod tests { + use crate::ast::UntypedStarkSpecification; + + #[test] + fn checks_every_example_specification() { + for (name, source) in [ + ("engine.stark", include_str!("../../../examples/stark/engine.stark")), + ("random_walk.stark", include_str!("../../../examples/stark/random_walk.stark")), + ( + "single_vehicle.stark", + include_str!("../../../examples/stark/single_vehicle.stark"), + ), + ("toll.stark", include_str!("../../../examples/stark/toll.stark")), + ( + "two_vehicles.stark", + include_str!("../../../examples/stark/two_vehicles.stark"), + ), + ("monitoring.stark", include_str!("../../../examples/stark/monitoring.stark")), + ("agriculturalDT.stark", include_str!("../../../examples/stark/agriculturalDT.stark")), + ] { + let spec = UntypedStarkSpecification::parse(source).unwrap_or_else(|e| panic!("{name} failed to parse: {e}")); + + if let Err(diagnostics) = spec.check() { + panic!("{name} failed to check:\n{}", diagnostics.render(source)); + } + } + } + + #[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 = spec.check().err().expect("should not check"); + assert!( + diagnostics.items().len() >= 2, + "expected both passes to report: {diagnostics}" + ); + } +} From b890d081111ce522a0eb20ec8b89d3964794c66a Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Sat, 18 Jul 2026 18:07:59 +0200 Subject: [PATCH 14/50] Fixed some issues in the examples found by these checks --- examples/stark/engine.stark | 5 +++-- examples/stark/single_vehicle.stark | 2 +- examples/stark/toll.stark | 9 +++++---- examples/stark/two_vehicles.stark | 9 +++++---- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/examples/stark/engine.stark b/examples/stark/engine.stark index 8f55cd71a..3cd4875c3 100644 --- a/examples/stark/engine.stark +++ b/examples/stark/engine.stark @@ -131,7 +131,7 @@ component Engine{ environment { let deltaTemp = temperatureUpdateInOneStep(cool, speed) - in + in { temp' = temp + deltaTemp; ch_temp' = ch_temp + deltaTemp; p1' = temp; @@ -143,6 +143,7 @@ environment { if (isStressed(p1,p2,p3,p4,p5,p6) > 3) { stress' = stress + STRESS_INCR; } + } } @@ -151,7 +152,7 @@ penalty rho_temperature = pen_temp(temp,ch_temp) penalty rho_warning = pen_wrn(ch_wrn) -penalty rho_stress = pen_stress(stress) +penalty rho_stress = get_stress(stress) diff --git a/examples/stark/single_vehicle.stark b/examples/stark/single_vehicle.stark index b5b4b76f4..491c62020 100644 --- a/examples/stark/single_vehicle.stark +++ b/examples/stark/single_vehicle.stark @@ -50,7 +50,7 @@ function slow_speed(real speed, real offs){ return max(0.0, speed - offs); } -function IDS_guard(boolean dist, boolean acc1, boolean acc2, boolean speed){ +function IDS_guard(bool dist, bool acc1, bool acc2, bool speed){ return dist && (acc1 || (acc2 && speed)); } diff --git a/examples/stark/toll.stark b/examples/stark/toll.stark index c91c2b12d..75a081913 100644 --- a/examples/stark/toll.stark +++ b/examples/stark/toll.stark @@ -12,7 +12,7 @@ function eval_bd(real speed) { } function new_speed (real speed, real acc) { - if (accel == N) { + if (acc == N) { return max(0.0, speed - A); } else { return min(MAX_SPEED, max(0.0, speed + acc)); @@ -28,7 +28,7 @@ function new_s_speed (real speed, real acc, real token) { } global variables { - real p_speed range [0,MAX_SPEED] = INIT_SPEED_V1; + 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); @@ -60,7 +60,7 @@ component vehicle { } else { accel' = N; timer_V' = TIMER; - step Stop_V1; + step Stop; } } } @@ -97,7 +97,7 @@ environment{ token = R[0,1] and new_sens_speed = new_s_speed(p_speed, accel, token) - in + in { timer_V' = timer_V - 1; p_speed' = new_speed(p_speed, accel); s_speed' = new_sens_speed; @@ -106,4 +106,5 @@ environment{ 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/two_vehicles.stark b/examples/stark/two_vehicles.stark index c91c2b12d..75a081913 100644 --- a/examples/stark/two_vehicles.stark +++ b/examples/stark/two_vehicles.stark @@ -12,7 +12,7 @@ function eval_bd(real speed) { } function new_speed (real speed, real acc) { - if (accel == N) { + if (acc == N) { return max(0.0, speed - A); } else { return min(MAX_SPEED, max(0.0, speed + acc)); @@ -28,7 +28,7 @@ function new_s_speed (real speed, real acc, real token) { } global variables { - real p_speed range [0,MAX_SPEED] = INIT_SPEED_V1; + 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); @@ -60,7 +60,7 @@ component vehicle { } else { accel' = N; timer_V' = TIMER; - step Stop_V1; + step Stop; } } } @@ -97,7 +97,7 @@ environment{ token = R[0,1] and new_sens_speed = new_s_speed(p_speed, accel, token) - in + in { timer_V' = timer_V - 1; p_speed' = new_speed(p_speed, accel); s_speed' = new_sens_speed; @@ -106,4 +106,5 @@ environment{ braking_distance' = eval_bd(new_sens_speed); gap' = p_distance - travel - eval_bd(new_sens_speed); } + } } \ No newline at end of file From 31f9e5ac493d3e4eced50769545a895b4c88c53c Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Sat, 18 Jul 2026 18:08:15 +0200 Subject: [PATCH 15/50] Added an initial type checking pass --- crates/stark/src/typecheck.rs | 880 ++++++++++++++++++++++++++++++++++ crates/stark/src/types.rs | 397 +++++++++++++++ 2 files changed, 1277 insertions(+) create mode 100644 crates/stark/src/typecheck.rs create mode 100644 crates/stark/src/types.rs diff --git a/crates/stark/src/typecheck.rs b/crates/stark/src/typecheck.rs new file mode 100644 index 000000000..1bb15af7a --- /dev/null +++ b/crates/stark/src/typecheck.rs @@ -0,0 +1,880 @@ +//! Type checking, ported from `types/ExpressionTypeInference.java` and +//! `types/StarkFunctionStatementTypeInference.java`. +//! +//! 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 what the Java `TypeEvaluationContext`/ +//! `LocalTypeContext` stack of scopes did — 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 Java reference: +//! `visitAndExpression`/`visitOrExpression` never propagate a `Random` +//! result there (`visitOrExpression` even computes an `isRandom` local and +//! then never uses it — reading as an unfinished path, not a deliberate +//! choice, since the very next case, `visitRelationExpression`, does +//! propagate), and `visitUnaryMathCallExpression` never propagates +//! randomness either, while the binary math-call path 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 crate::ast::*; +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. +pub struct TypeTable { + def_types: Vec>, + signatures: Vec>, +} + +impl TypeTable { + pub fn type_of(&self, id: DefId) -> Option<&StarkType> { + self.def_types[id.value()].as_ref() + } + + pub fn signature_of(&self, id: DefId) -> Option<&FunctionSignature> { + 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); + ( + 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) { + 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) { + 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(), format!("unknown type `{name}`")); + 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(), format!("expected {expected}, 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(), format!("expected a numerical type, 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(), format!("expected {left}, found {right}")); + } + } + + // -- 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())); + } + } + } + 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); + } + } + + fn check_variable(&mut self, variable: &Variable) { + let declared = self.ty_of_annotation(&variable.ty, &variable.name.span); + 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); + if let Some(id) = variable.id { + self.set_def_type(id, declared); + } + } + + fn check_function(&mut self, function: &Function) { + 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 { + self.signatures[id.value()] = Some(FunctionSignature { arguments, return_type }); + } + } + + /// Returns the type of every `return` reachable from `statement`, merged + /// together (mirrors `StarkFunctionStatementTypeInference`: 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 { + // Deterministic policy under test: no randomness here (see + // the module doc comment / plan for the justification). + 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, false); + if let Some(id) = id { + self.set_local_type(*id, value_ty); + } + self.check_controller_commands(body); + } + ControllerCommand::Assignment(update) => self.check_update(update, false), + 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: &SpannedExpression, to: &SpannedExpression) { + 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 ------------------------------------------------------ + + /// `combineToRealType` in the Java source: 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: &SpannedExpression, right: &SpannedExpression, 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 + } + } + + fn check_expression(&mut self, expr: &SpannedExpression, random_allowed: bool) -> StarkType { + match &expr.node { + Expression::False | Expression::True => StarkType::Boolean, + Expression::Integer(_) => StarkType::Integer, + Expression::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. + Expression::Iterator => StarkType::Error, + Expression::Reference { binding, .. } => match binding { + Some(Binding::Def(id)) => self.def_type(*id), + Some(Binding::Local(id)) => self.local_type(*id), + None => StarkType::Error, + }, + Expression::Normal { mean, std_dev } => { + if !random_allowed { + self.diagnostics + .error(expr.span.clone(), "random expressions are not allowed here".to_string()); + 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) + } + } + Expression::Uniform { values } => { + if !random_allowed { + self.diagnostics + .error(expr.span.clone(), "random expressions are not allowed here".to_string()); + 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, + } + } + Expression::Range { min, max } => { + if !random_allowed { + self.diagnostics + .error(expr.span.clone(), "random expressions are not allowed here".to_string()); + 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), + } + } + Expression::Not(inner) => { + let ty = self.check_expression(inner, random_allowed); + self.expect(&StarkType::Boolean, ty, &inner.span) + } + Expression::UnaryPlus(inner) | Expression::UnaryMinus(inner) => { + let ty = self.check_expression(inner, random_allowed); + self.expect_numerical(ty, &inner.span) + } + Expression::Binary(op, left, right) => self.check_binary(*op, left, right, random_allowed), + Expression::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 + } + } + Expression::Call { function, arguments } => self.check_call(function, arguments, random_allowed), + Expression::MathCall { function, arguments } => self.check_math_call(*function, arguments, random_allowed), + } + } + + fn check_binary(&mut self, op: BinaryOp, left: &SpannedExpression, right: &SpannedExpression, 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: &[SpannedExpression], 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(), + format!( + "`{}` expects {} argument(s), found {}", + function.name.name, + signature.arguments.len(), + 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: &[SpannedExpression], random_allowed: bool) -> StarkType { + match function { + MathFunction::Atan2 | MathFunction::Hypot | MathFunction::Max | MathFunction::Min | MathFunction::Pow => { + self.combine_to_real(&arguments[0], &arguments[1], random_allowed) + } + _ => { + 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::resolve::resolve; + + #[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")), + ] { + 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)); + } + } + + /// Ported from + /// `~/STARK/speclang/src/test/java/stark/speclang/types/ExpressionTypeInferenceTest.java`. + /// + /// The original tests a bare expression directly against + /// `ExpressionTypeInference`, with `randomExpressionAllowed` 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; absorbs into further checks so a single + /// mistake doesn't cascade into a wall of unrelated diagnostics. + 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 `StarkRandomType` constructor. + 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. `Random` never wraps `Error` + /// in practice (every constructor here checks first), so this only ever + /// needs to look at the top level. + 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` (the + /// expected type) is required. Ignores randomness on both sides (a + /// `Random(int)` fits wherever a plain `int` is expected, since it is + /// resolved to a concrete value before use) — the original + /// `StarkRandomType.isCompatibleWith` delegates straight through to its + /// content type for the same reason. + /// + /// 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 — + /// both branches of a ternary, both sides of a relation, elements of a + /// `U[...]` — without a type error. 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 (`int` (+) `real` + /// -> `real`; identical types merge to themselves), propagating a + /// `Random` wrapper if either side carries one. Returns `Error` if the + /// two types have nothing in common — mirrors `StarkType.merge`. + 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"); + } + + /// Ported from `~/STARK/speclang/src/test/java/stark/speclang/types/StarkTypeTest.java`. + /// 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})"); + } + } + } +} From 5f9c489bd0bb66dbaaf55be77112ceade1ab64c4 Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Sat, 18 Jul 2026 18:08:43 +0200 Subject: [PATCH 16/50] Started a stark CLI tool --- Cargo.lock | 12 +++ Cargo.toml | 2 + README.md | 1 + crates/stark/src/lib.rs | 18 +++- crates/xtask/src/package.rs | 2 +- tools/stark/Cargo.toml | 15 ++++ tools/stark/src/main.rs | 160 ++++++++++++++++++++++++++++++++++++ 7 files changed, 205 insertions(+), 5 deletions(-) create mode 100644 tools/stark/Cargo.toml create mode 100644 tools/stark/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 1f5f31d0c..20804d20b 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" diff --git a/Cargo.toml b/Cargo.toml index 349f8a5b6..35f1c6939 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,6 +51,7 @@ members = [ "crates/xtask", "tools/lts", "tools/rewrite", + "tools/stark", "tools/sym", "tools/vpg", ] @@ -154,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 ee60a3765..5e3afc9e8 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/src/lib.rs b/crates/stark/src/lib.rs index ff6adb38a..ead1b825b 100644 --- a/crates/stark/src/lib.rs +++ b/crates/stark/src/lib.rs @@ -1,9 +1,19 @@ mod ast; mod consume; +mod diagnostics; mod parse; mod precedence; +mod resolve; +mod specification; +mod typecheck; +mod types; -pub(crate) use ast::*; -pub(crate) use consume::*; -pub(crate) use parse::*; -pub(crate) use precedence::*; +pub use ast::*; +pub use consume::*; +pub use diagnostics::*; +pub use parse::*; +pub use precedence::*; +pub use resolve::*; +pub use specification::*; +pub use typecheck::*; +pub use types::*; diff --git a/crates/xtask/src/package.rs b/crates/xtask/src/package.rs index f6d08d309..ec18e38fc 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/tools/stark/Cargo.toml b/tools/stark/Cargo.toml new file mode 100644 index 000000000..7cf0e61e7 --- /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 000000000..58084d54a --- /dev/null +++ b/tools/stark/src/main.rs @@ -0,0 +1,160 @@ +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 merc_stark::DefKind; +use merc_stark::StarkSpecification; +use merc_stark::UntypedStarkSpecification; +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), +} + +#[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, +} + +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) => { + let spec = load_specification(&args.specification, timing)?; + + if args.print_symbols { + print_symbols(&spec); + } + + info!("{} is a valid STARK specification", args.specification.display()); + } + } + } + + Ok(()) +} + +/// Reads `path` into an [UntypedStarkSpecification] and checks it 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 load_specification(path: &Path, timing: &Timing) -> Result { + let source = read_to_string(path).map_err(|err| MercError::from(format!("cannot read {}: {err}", path.display())))?; + + let untyped = timing.measure("parsing", || UntypedStarkSpecification::parse(&source))?; + + timing + .measure("resolving and type checking", || untyped.check()) + .map_err(|diagnostics| { + 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)); + } + } +} + +/// 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", + } +} From 11488cfda10fd983dd745a13568d7d1ca24f1de3 Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Sat, 18 Jul 2026 18:08:55 +0200 Subject: [PATCH 17/50] Ignore this package for the semver checks --- .github/workflows/semver.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/semver.yml b/.github/workflows/semver.yml index ca8caa9cc..98cc22357 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 From 4ba5bf27a9ceb265e4946da08839779fdd98e50c Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Sat, 18 Jul 2026 18:09:15 +0200 Subject: [PATCH 18/50] Fixed an issue with the ternary operators --- crates/stark/stark_grammar.pest | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/crates/stark/stark_grammar.pest b/crates/stark/stark_grammar.pest index b2623f163..9e421d9fe 100644 --- a/crates/stark/stark_grammar.pest +++ b/crates/stark/stark_grammar.pest @@ -32,7 +32,7 @@ INTEGER = @{ DIGIT+ } REAL = @{ ((DIGIT* ~ "." ~ DIGIT+) | (DIGIT+ ~ ".")) ~ (("E" | "e") ~ "-"? ~ DIGIT+)? } // Entry point -StarkSpecification = { SOI ~ Element* ~ EOI } +UntypedStarkSpecification = { SOI ~ Element* ~ EOI } Element = _{ DeclarationConstant @@ -137,8 +137,18 @@ EnvironmentIfThenElse = { "if" ~ "(" ~ Expression ~ ")" ~ EnvironmentCommand ~ ( EnvironmentLet = { "let" ~ LocalVariable ~ ("and" ~ LocalVariable)* ~ "in" ~ EnvironmentCommand } LocalVariable = { ID ~ "=" ~ Expression } -// Expressions: a flat prefix* primary postfix* (infix ...)* stream for the Pratt parser. -Expression = { ExpressionPrefix* ~ ExpressionPrimary ~ ExpressionPostfix* ~ (ExpressionInfix ~ ExpressionPrefix* ~ ExpressionPrimary ~ ExpressionPostfix*)* } +// 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 } @@ -183,9 +193,8 @@ ExpressionInfix = _{ ExpressionBitOr = { "|" } // Postfix operators -ExpressionPostfix = _{ ExpressionCall | ExpressionTernary } - ExpressionCall = { "(" ~ (Expression ~ ("," ~ Expression)*)? ~ ")" } - ExpressionTernary = { "?" ~ Expression ~ ":" ~ Expression } +ExpressionPostfix = _{ ExpressionCall } + ExpressionCall = { "(" ~ (Expression ~ ("," ~ Expression)*)? ~ ")" } ExpressionPrimary = _{ "(" ~ Expression ~ ")" From 3b0992f5064288da42d13a753d0a2ae9df4c9bfc Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Sat, 18 Jul 2026 18:09:30 +0200 Subject: [PATCH 19/50] Converted some additional examples --- examples/stark/agriculturalDT.stark | 160 ++++++++++++++++++++++++++++ examples/stark/monitoring.stark | 39 +++++++ examples/stark/tollbooth.stark | 120 +++++++++++++++++++++ 3 files changed, 319 insertions(+) create mode 100644 examples/stark/agriculturalDT.stark create mode 100644 examples/stark/monitoring.stark create mode 100644 examples/stark/tollbooth.stark diff --git a/examples/stark/agriculturalDT.stark b/examples/stark/agriculturalDT.stark new file mode 100644 index 000000000..188986ad8 --- /dev/null +++ b/examples/stark/agriculturalDT.stark @@ -0,0 +1,160 @@ +/* + * Ported from ~/STARK/examples/agriculturalDT/src/main/java/agriculturalDT/Main.java: + * 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 calls `evaluateDeltaZero(...)` twice (once per `DataStateUpdate`) + * 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 { + aiState 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; + } + } + aiState Idle { + if (timer > 0) { + step Idle; + } else { + step Ctrl; + } + } + aiState 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/monitoring.stark b/examples/stark/monitoring.stark new file mode 100644 index 000000000..fc4c813d2 --- /dev/null +++ b/examples/stark/monitoring.stark @@ -0,0 +1,39 @@ +/* + * Ported from ~/STARK/examples/monitoring/basic/src/main/java/monitoring/Main.java. + * + * The original demonstrates STARK's *online monitoring* framework (the + * `stark.udistl`/`stark.distl`/`stark.monitors` Java packages): it builds a + * uDisTL 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 a uDisTL + * 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 { + aiState 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/tollbooth.stark b/examples/stark/tollbooth.stark new file mode 100644 index 000000000..b60666ed9 --- /dev/null +++ b/examples/stark/tollbooth.stark @@ -0,0 +1,120 @@ +/* + * Ported from ~/STARK/examples/tollbooth/src/main/java/tollbooth/Main.java. + * + * 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 (`IterativePenalty`/`SequentialPenalty` from + * `stark.penalty`, and the `AlwaysDisTLFormula`/`EventuallyDisTLFormula`/ + * `TargetDisTLFormula` robustness properties from `stark.distl`) is the same + * online-monitoring 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 { + aiState 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; + } + } + } + aiState Accelerate { + if (timer_V > 0) { + step Accelerate; + } else { + step Ctrl; + } + } + aiState Decelerate { + if (timer_V > 0) { + step Decelerate; + } else { + step Ctrl; + } + } + aiState 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; From ea345ea7b81c607f2e172b0011385dd38fbdc821 Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Sat, 18 Jul 2026 19:59:15 +0200 Subject: [PATCH 20/50] Gave the erorr diagnostics proper messages that are also rendered --- crates/stark/src/ast.rs | 5 +- crates/stark/src/consume.rs | 37 ++++-- crates/stark/src/diagnostics.rs | 207 +++++++++++++++++++++++++++++--- 3 files changed, 225 insertions(+), 24 deletions(-) diff --git a/crates/stark/src/ast.rs b/crates/stark/src/ast.rs index 567fa318a..63d874186 100644 --- a/crates/stark/src/ast.rs +++ b/crates/stark/src/ast.rs @@ -416,7 +416,10 @@ pub enum Expression { /// A name reference: a constant/parameter/variable, a local binding /// (function argument, `let` binding, `it`), or (before resolution) /// unresolved. `binding` is filled in by `resolve.rs`. - Reference { name: String, binding: Option }, + Reference { + name: String, + binding: Option, + }, /// The `it` lambda parameter used inside aggregate/perturbation contexts. Iterator, diff --git a/crates/stark/src/consume.rs b/crates/stark/src/consume.rs index d485f2220..6650e44fd 100644 --- a/crates/stark/src/consume.rs +++ b/crates/stark/src/consume.rs @@ -4,15 +4,36 @@ use merc_pest_consume::Error; use merc_pest_consume::match_nodes; use crate::StarkParser; -use crate::ast::{ - Component, Constant, ControllerCommand, ControllerState, DefRef, Distance, Environment, EnvironmentCommand, - Formula, Function, FunctionArgument, FunctionStatement, Identifier, LocalVariable, Parameter, Penalty, - Perturbation, Range, SpannedExpression, UntypedStarkSpecification, StateRef, Ty, TypeDeclaration, Update, Variable, -}; +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::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::SpannedExpression; +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, parse_expression_node, parse_perturbation_expression, parse_robtl_formula, -}; +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>; diff --git a/crates/stark/src/diagnostics.rs b/crates/stark/src/diagnostics.rs index 0ebadc326..33f9fb79f 100644 --- a/crates/stark/src/diagnostics.rs +++ b/crates/stark/src/diagnostics.rs @@ -4,38 +4,166 @@ //! 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. 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 { + // -- Name resolution (`resolve.rs`) --------------------------------- + /// 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 `aiState`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 }, + + // -- Type checking (`typecheck.rs`) --------------------------------- + /// 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 (ternary branches, the + /// two sides of a comparison, a function's several `return`s) have no + /// common supertype. + #[error("cannot merge {left} with {right}")] + IncompatibleTypes { left: StarkType, right: StarkType }, + + /// `R`/`N[..]`/`U[..]` used somewhere randomness is not permitted — a + /// constant's value, a variable's range bound, an interval bound. + #[error("random expressions are not allowed here")] + RandomNotAllowed, + + #[error("`{name}` expects {expected} argument(s), found {found}")] + ArityMismatch { + name: String, + expected: usize, + found: usize, + }, +} + +impl DiagnosticKind { + /// A second source location worth showing alongside the primary one, + /// with the label to introduce it by. `None` for the majority of kinds, + /// which are fully explained by where they point. + 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]. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, ThisError)] +#[error("{kind}")] pub struct Diagnostic { pub span: Span, pub severity: Severity, - pub message: String, + #[source] + pub kind: DiagnosticKind, } impl Diagnostic { - pub fn error(span: Span, message: impl Into) -> Self { + pub fn error(span: Span, kind: DiagnosticKind) -> Self { Diagnostic { span, severity: Severity::Error, - message: message.into(), + kind, } } /// Renders this diagnostic against its `source` text, in the same - /// `-->`/`|`/`^^^` style parser errors use (see [Span::render]). + /// `-->`/`|`/`^^^` style parser errors use (see [Span::render]), followed + /// by a second annotated snippet when [DiagnosticKind::related] gives + /// one: + /// + /// ```text + /// error: duplicate definition of `a` + /// --> 2:7 + /// | + /// 2 | const a = 2; + /// | ^ + /// note: first defined here + /// --> 1:7 + /// | + /// 1 | const a = 1; + /// | ^ + /// ``` pub fn render(&self, source: &str) -> String { - format!("{}\n{}", self.message, self.span.render(source)) + 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 } } @@ -52,8 +180,9 @@ impl Diagnostics { } /// Records an error diagnostic at `span`. - pub fn error(&mut self, span: Span, message: impl Into) { - self.items.push(Diagnostic::error(span, message)); + 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 { @@ -68,6 +197,12 @@ impl Diagnostics { &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); @@ -82,17 +217,24 @@ impl Diagnostics { /// 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") + 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.message)?; + writeln!(f, "{}: {}", item.severity, item.kind)?; } Ok(()) } @@ -104,9 +246,14 @@ 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(); @@ -117,7 +264,7 @@ mod tests { #[test] fn recorded_error_fails_into_result() { let mut diagnostics = Diagnostics::new(); - diagnostics.error(Span { start: 0, end: 1 }, "boom"); + diagnostics.error(Span { start: 0, end: 1 }, unknown("boom")); assert!(diagnostics.has_errors()); assert!(diagnostics.into_result(()).is_err()); } @@ -125,17 +272,47 @@ mod tests { #[test] fn collects_every_error_not_just_the_first() { let mut diagnostics = Diagnostics::new(); - diagnostics.error(Span { start: 0, end: 1 }, "first"); - diagnostics.error(Span { start: 2, end: 3 }, "second"); + 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 }, "unexpected x"); + diagnostics.error(Span { start: 4, end: 5 }, unknown("x")); let rendered = diagnostics.render("eqn f = x;"); - assert!(rendered.contains("unexpected 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 { .. }))); + } } From 411ab3619b626d63d6e39f536dd8c158d4d74abf Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Sat, 18 Jul 2026 19:59:46 +0200 Subject: [PATCH 21/50] Added debug assertions that resolution performed correctly. --- crates/stark/src/precedence.rs | 54 ++- crates/stark/src/resolve.rs | 700 ++++++++++++++++++++++++++++++--- 2 files changed, 665 insertions(+), 89 deletions(-) diff --git a/crates/stark/src/precedence.rs b/crates/stark/src/precedence.rs index fe12b95fc..4a6180c44 100644 --- a/crates/stark/src/precedence.rs +++ b/crates/stark/src/precedence.rs @@ -71,8 +71,12 @@ pub(crate) fn parse_expression_node(pair: Pair<'_, Rule>) -> ParseResult 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"))?); + 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( Expression::Ternary { guard: Box::new(guard), @@ -186,7 +190,10 @@ fn parse_expression_primary(primary: Pair<'_, Rule>) -> ParseResult { return error( &primary, - format!("integer literal `{}` does not fit in a 64-bit integer", primary.as_str()), + format!( + "integer literal `{}` does not fit in a 64-bit integer", + primary.as_str() + ), ); } }, @@ -420,9 +427,7 @@ fn parse_distance_primary(primary: Pair<'_, Rule>) -> ParseResult) -> ParseResult<(DistanceExpression, DistanceExpression)> { - let mut children = pair - .into_inner() - .filter(|p| p.as_rule() == Rule::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)) @@ -478,9 +483,7 @@ pub static ROBTL_PRATT_PARSER: LazyLock> = LazyLock::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)) + .op(Op::prefix(Rule::RobtlNot) | Op::prefix(Rule::RobtlGlobally) | Op::prefix(Rule::RobtlEventually)) }); #[allow(clippy::result_large_err)] @@ -550,10 +553,14 @@ pub fn parse_robtl_formula(pairs: Pairs) -> ParseResult { #[cfg(test)] mod tests { - use crate::ast::{ - BinaryOp, DistanceExpression, Expression, MathFunction, PerturbationExpression, RobtlFormula, - UntypedStarkSpecification, Ty, - }; + use crate::ast::BinaryOp; + use crate::ast::DistanceExpression; + use crate::ast::Expression; + 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) -> Expression { @@ -651,10 +658,7 @@ mod tests { #[test] fn unresolved_reference_has_no_binding() { - assert!(matches!( - expr("x"), - Expression::Reference { binding: None, .. } - )); + assert!(matches!(expr("x"), Expression::Reference { binding: None, .. })); } #[test] @@ -704,10 +708,8 @@ mod tests { #[test] fn perturbation_sequence_and_iteration() { - let spec = UntypedStarkSpecification::parse( - "perturbation p = ([x <- 1]@0); ([y <- 2]@0)^3;", - ) - .expect("should parse"); + 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) => { @@ -719,16 +721,12 @@ mod tests { #[test] fn distance_and_formula() { - let spec = UntypedStarkSpecification::parse( - "distance d = \\G[0, 10] < rho;\nformula f = \\D[d, p] <= 5;", - ) - .expect("should parse"); + 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, - .. + distance, perturbation, .. } => { assert_eq!(distance.name.name, "d"); assert_eq!(perturbation.name.name, "p"); diff --git a/crates/stark/src/resolve.rs b/crates/stark/src/resolve.rs index 13c7edb0c..b6f7e6019 100644 --- a/crates/stark/src/resolve.rs +++ b/crates/stark/src/resolve.rs @@ -41,9 +41,12 @@ 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. @@ -51,12 +54,18 @@ use crate::diagnostics::Diagnostics; pub enum DefKind { Constant, Parameter, - Variable { global: bool }, - Function { argument_count: usize }, + Variable { + global: bool, + }, + Function { + argument_count: usize, + }, Penalty, Component, /// An element of a custom `type X = A | B | C;` declaration. - TypeElement { type_name: String }, + TypeElement { + type_name: String, + }, Type, Perturbation, Distance, @@ -174,6 +183,37 @@ pub fn resolve(spec: &mut UntypedStarkSpecification) -> (SymbolTable, Diagnostic diagnostics: Diagnostics::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) } @@ -194,39 +234,52 @@ impl Resolver { /// `None`. fn declare(&mut self, name: &Identifier, kind: DefKind) -> Option { if let Some(&existing) = self.table.names.get(&name.name) { - let first_span = self.table.def(existing).span.clone(); + let first = self.table.def(existing).span.clone(); self.diagnostics.error( name.span.clone(), - format!( - "duplicate definition of `{}` (first defined at {}..{})", - name.name, first_span.start, first_span.end - ), + 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 { + fn declare_state( + &mut self, + name: &Identifier, + component: DefId, + states: &mut HashMap, + ) -> Option { if let Some(&existing) = states.get(&name.name) { - let first_span = self.table.state(existing).span.clone(); + let first = self.table.state(existing).span.clone(); self.diagnostics.error( name.span.clone(), - format!( - "duplicate controller state `{}` (first defined at {}..{})", - name.name, first_span.start, first_span.end - ), + 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(), @@ -247,18 +300,19 @@ impl Resolver { let mut ids = Vec::with_capacity(bindings.len()); for name in bindings { if let Some(&existing) = frame.get(&name.name) { - let first_span = self.table.local(existing).span.clone(); + let first = self.table.local(existing).span.clone(); self.diagnostics.error( name.span.clone(), - format!( - "duplicate binding `{}` (first defined at {}..{})", - name.name, first_span.start, first_span.end - ), + 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(), @@ -267,10 +321,16 @@ impl Resolver { 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(); } @@ -281,12 +341,22 @@ impl Resolver { } fn unknown_symbol(&mut self, name: &Identifier) { - self.diagnostics.error(name.span.clone(), format!("unknown symbol `{}`", name.name)); + 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: &str) { + 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; @@ -297,7 +367,11 @@ impl Resolver { let kind = self.table.def(id).kind.clone(); self.diagnostics.error( reference.name.span.clone(), - format!("`{}` is {}, expected {}", reference.name.name, kind.describe(), expected_desc), + DiagnosticKind::IllegalUseOfName { + name: reference.name.name.clone(), + found: kind.describe(), + expected: expected_desc, + }, ); } } @@ -306,8 +380,12 @@ impl Resolver { match states.get(&reference.name.name) { Some(&id) => reference.id = Some(id), None => { - self.diagnostics - .error(reference.name.span.clone(), format!("unknown controller state `{}`", reference.name.name)); + self.diagnostics.error( + reference.name.span.clone(), + DiagnosticKind::UnknownControllerState { + name: reference.name.name.clone(), + }, + ); } } } @@ -319,7 +397,8 @@ impl Resolver { return Some(Binding::Local(id)); } let Some(id) = self.table.names.get(name).copied() else { - self.diagnostics.error(span.clone(), format!("unknown symbol `{name}`")); + self.diagnostics + .error(span.clone(), DiagnosticKind::UnknownSymbol { name: name.to_string() }); return None; }; if self.table.def(id).kind.is_referenceable_value() { @@ -328,10 +407,11 @@ impl Resolver { let kind = self.table.def(id).kind.clone(); self.diagnostics.error( span.clone(), - format!( - "`{name}` is {}, expected a constant, parameter, variable or type element", - kind.describe() - ), + DiagnosticKind::IllegalUseOfName { + name: name.to_string(), + found: kind.describe(), + expected: "a constant, parameter, variable or type element", + }, ); None } @@ -340,6 +420,20 @@ impl Resolver { // -- 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() + ); for constant in &mut spec.constants { self.resolve_expression(&mut constant.value); constant.id = self.declare(&constant.name, DefKind::Constant); @@ -387,7 +481,12 @@ impl Resolver { self.resolve_expression(&mut range.max); } self.resolve_expression(&mut variable.initial_value); - variable.id = self.declare(&variable.name, DefKind::Variable { global: variable.global }); + variable.id = self.declare( + &variable.name, + DefKind::Variable { + global: variable.global, + }, + ); } fn resolve_type_declaration(&mut self, ty: &mut TypeDeclaration) { @@ -397,7 +496,9 @@ impl Resolver { if ty.elements.iter().any(|e| e.name == ty.name.name) { self.diagnostics.error( ty.name.span.clone(), - format!("type `{}` cannot declare an element with the same name", ty.name.name), + DiagnosticKind::TypeElementSharesTypeName { + name: ty.name.name.clone(), + }, ); } else { ty.id = self.declare(&ty.name, DefKind::Type); @@ -413,6 +514,11 @@ impl Resolver { } 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) { @@ -457,6 +563,12 @@ impl Resolver { } 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); } @@ -596,7 +708,8 @@ impl Resolver { 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 } => { + DistanceExpression::Eventually { from, to, argument } + | DistanceExpression::Globally { from, to, argument } => { self.resolve_expression(from); self.resolve_expression(to); self.resolve_distance(argument); @@ -627,7 +740,9 @@ impl Resolver { 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::Reference(reference) => { + self.resolve_def_ref(reference, DefKind::is_formula_kind, "a formula") + } RobtlFormula::Distance { distance, perturbation, @@ -715,12 +830,394 @@ impl Resolver { } } +/// 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: &SpannedExpression) { + match &expr.node { + Expression::False + | Expression::True + | Expression::Integer(_) + | Expression::Real(_) + | Expression::Iterator => {} + Expression::Reference { name, binding } => { + assert!( + binding.is_some(), + "reference `{name}` left unbound by a clean resolution" + ); + } + Expression::Normal { mean, std_dev } => { + check_expression(mean); + check_expression(std_dev); + } + Expression::Uniform { values } => values.iter().for_each(check_expression), + Expression::Range { min, max } => { + min.iter().for_each(|e| check_expression(e)); + max.iter().for_each(|e| check_expression(e)); + } + Expression::Not(inner) | Expression::UnaryPlus(inner) | Expression::UnaryMinus(inner) => { + check_expression(inner) + } + Expression::Binary(_, left, right) => { + check_expression(left); + check_expression(right); + } + Expression::Ternary { + guard, + then_branch, + else_branch, + } => { + check_expression(guard); + check_expression(then_branch); + check_expression(else_branch); + } + Expression::Call { function, arguments } => { + assert!( + function.id.is_some(), + "call to `{}` left unresolved", + function.name.name + ); + arguments.iter().for_each(check_expression); + } + Expression::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, Expression, UntypedStarkSpecification}; + use crate::ast::Binding; + use crate::ast::Expression; + 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) { + 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) @@ -732,7 +1229,13 @@ mod tests { assert!(!diagnostics.has_errors(), "{diagnostics}"); match &spec.constants[1].value.node { Expression::Binary(_, lhs, _) => { - assert!(matches!(lhs.node, Expression::Reference { binding: Some(Binding::Def(_)), .. })); + assert!(matches!( + lhs.node, + Expression::Reference { + binding: Some(Binding::Def(_)), + .. + } + )); } other => panic!("unexpected: {other:?}"), } @@ -741,40 +1244,69 @@ mod tests { #[test] fn forward_reference_is_unknown_symbol() { let (_spec, _table, diagnostics) = resolve_source("const a = b + 1;\nconst b = 1;"); - assert!(diagnostics.has_errors()); + assert!( + diagnostics.any(|kind| matches!(kind, DiagnosticKind::UnknownSymbol { name } if name == "b")), + "{diagnostics}" + ); } #[test] fn duplicate_top_level_name_is_an_error() { let (_spec, _table, diagnostics) = resolve_source("const a = 1;\nconst a = 2;"); - assert!(diagnostics.has_errors()); + 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) = - resolve_source("global variables { int x = 0; }\nconst c = x(1);"); - assert!(diagnostics.has_errors()); + // 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) = - resolve_source("function f(int x) { return x; }\nconst c = f;"); - assert!(diagnostics.has_errors()); + // 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.has_errors()); + 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; }", - ); + 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 { @@ -788,8 +1320,20 @@ mod tests { }; match &value.node { Expression::Binary(_, lhs, rhs) => { - assert!(matches!(lhs.node, Expression::Reference { binding: Some(Binding::Local(_)), .. })); - assert!(matches!(rhs.node, Expression::Reference { binding: Some(Binding::Local(_)), .. })); + assert!(matches!( + lhs.node, + Expression::Reference { + binding: Some(Binding::Local(_)), + .. + } + )); + assert!(matches!( + rhs.node, + Expression::Reference { + binding: Some(Binding::Local(_)), + .. + } + )); } other => panic!("unexpected: {other:?}"), } @@ -798,7 +1342,10 @@ mod tests { #[test] fn duplicate_function_argument_is_an_error() { let (_spec, _table, diagnostics) = resolve_source("function f(int x, int x) { return x; }"); - assert!(diagnostics.has_errors()); + assert!( + diagnostics.any(|kind| matches!(kind, DiagnosticKind::DuplicateBinding { name, .. } if name == "x")), + "{diagnostics}" + ); } #[test] @@ -814,7 +1361,10 @@ mod tests { let (_spec, _table, diagnostics) = resolve_source( "component C1 {\n variables { }\n controller {\n aiState A { step B; }\n }\n init A\n}\ncomponent C2 {\n variables { }\n controller {\n aiState B { exec B; }\n }\n init B\n}", ); - assert!(diagnostics.has_errors()); + assert!( + diagnostics.any(|kind| matches!(kind, DiagnosticKind::UnknownControllerState { name } if name == "B")), + "{diagnostics}" + ); } #[test] @@ -823,27 +1373,38 @@ mod tests { // 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"); + 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, - Expression::Reference { binding: Some(Binding::Def(_)), .. } + Expression::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.has_errors()); + 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; }", + 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}" ); - assert!(diagnostics.has_errors()); } #[test] @@ -851,13 +1412,30 @@ mod tests { 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")), + ( + "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")), + ( + "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 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)); + assert!( + !diagnostics.has_errors(), + "{name} failed to resolve:\n{}", + diagnostics.render(source) + ); } } } From 6953c99f0ba5d9bf3fb23fb7025e65647ebaab8f Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Sat, 18 Jul 2026 20:00:25 +0200 Subject: [PATCH 22/50] Added various debug assertions, and updated some test. --- crates/stark/src/specification.rs | 52 ++++- crates/stark/src/typecheck.rs | 331 ++++++++++++++++++++++++++---- crates/stark/src/types.rs | 52 ++++- crates/utilities/src/span.rs | 5 +- 4 files changed, 390 insertions(+), 50 deletions(-) diff --git a/crates/stark/src/specification.rs b/crates/stark/src/specification.rs index 0a81ac25a..a6148e1a1 100644 --- a/crates/stark/src/specification.rs +++ b/crates/stark/src/specification.rs @@ -56,6 +56,15 @@ impl UntypedStarkSpecification { let (types, type_diagnostics) = typecheck(&self, &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: self, symbols, @@ -67,12 +76,16 @@ impl UntypedStarkSpecification { #[cfg(test)] mod tests { use crate::ast::UntypedStarkSpecification; + use test_log::test; #[test] fn checks_every_example_specification() { for (name, source) in [ ("engine.stark", include_str!("../../../examples/stark/engine.stark")), - ("random_walk.stark", include_str!("../../../examples/stark/random_walk.stark")), + ( + "random_walk.stark", + include_str!("../../../examples/stark/random_walk.stark"), + ), ( "single_vehicle.stark", include_str!("../../../examples/stark/single_vehicle.stark"), @@ -82,10 +95,41 @@ mod tests { "two_vehicles.stark", include_str!("../../../examples/stark/two_vehicles.stark"), ), - ("monitoring.stark", include_str!("../../../examples/stark/monitoring.stark")), - ("agriculturalDT.stark", include_str!("../../../examples/stark/agriculturalDT.stark")), + ( + "monitoring.stark", + include_str!("../../../examples/stark/monitoring.stark"), + ), + ( + "agriculturalDT.stark", + include_str!("../../../examples/stark/agriculturalDT.stark"), + ), + ( + "tollbooth.stark", + include_str!("../../../examples/stark/tollbooth.stark"), + ), + ( + "engine_full.stark", + include_str!("../../../examples/stark/engine_full.stark"), + ), + ( + "isocitrate.stark", + include_str!("../../../examples/stark/isocitrate.stark"), + ), + ("envzompr.stark", include_str!("../../../examples/stark/envzompr.stark")), + ( + "vehicle_full.stark", + include_str!("../../../examples/stark/vehicle_full.stark"), + ), + ( + "multiscler.stark", + include_str!("../../../examples/stark/multiscler.stark"), + ), + ("lotka.stark", include_str!("../../../examples/stark/lotka.stark")), + ("polistil.stark", include_str!("../../../examples/stark/polistil.stark")), + ("turtle.stark", include_str!("../../../examples/stark/turtle.stark")), ] { - let spec = UntypedStarkSpecification::parse(source).unwrap_or_else(|e| panic!("{name} failed to parse: {e}")); + let spec = + UntypedStarkSpecification::parse(source).unwrap_or_else(|e| panic!("{name} failed to parse: {e}")); if let Err(diagnostics) = spec.check() { panic!("{name} failed to check:\n{}", diagnostics.render(source)); diff --git a/crates/stark/src/typecheck.rs b/crates/stark/src/typecheck.rs index 1bb15af7a..394597468 100644 --- a/crates/stark/src/typecheck.rs +++ b/crates/stark/src/typecheck.rs @@ -27,7 +27,11 @@ //! `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; @@ -46,6 +50,7 @@ pub struct FunctionSignature { /// (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>, @@ -53,10 +58,20 @@ pub struct TypeTable { 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() } } @@ -73,6 +88,26 @@ pub fn typecheck(spec: &UntypedStarkSpecification, symbols: &SymbolTable) -> (Ty 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, @@ -94,6 +129,16 @@ 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); } @@ -102,6 +147,17 @@ impl Checker<'_> { } 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); } @@ -117,7 +173,8 @@ impl Checker<'_> { 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(), format!("unknown type `{name}`")); + self.diagnostics + .error(span.clone(), DiagnosticKind::UnknownType { name: name.clone() }); StarkType::Error } }, @@ -132,8 +189,13 @@ impl Checker<'_> { if expected.is_compatible_with(&actual) { actual } else { - self.diagnostics - .error(span.clone(), format!("expected {expected}, found {actual}")); + self.diagnostics.error( + span.clone(), + DiagnosticKind::TypeMismatch { + expected: expected.clone(), + found: actual, + }, + ); StarkType::Error } } @@ -142,15 +204,21 @@ impl Checker<'_> { if actual.is_numerical() { actual } else { - self.diagnostics.error(span.clone(), format!("expected a numerical type, found {actual}")); + 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(), format!("expected {left}, found {right}")); + self.diagnostics.error( + span.clone(), + DiagnosticKind::IncompatibleTypes { + left: left.clone(), + right: right.clone(), + }, + ); } } @@ -231,6 +299,7 @@ impl Checker<'_> { } 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); @@ -244,6 +313,12 @@ impl Checker<'_> { // `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 }); } } @@ -285,8 +360,13 @@ impl Checker<'_> { fn check_controller_commands(&mut self, commands: &[ControllerCommand]) { for command in commands { match command { - // Deterministic policy under test: no randomness here (see - // the module doc comment / plan for the justification). + // 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); @@ -295,13 +375,13 @@ impl Checker<'_> { } ControllerCommand::Exec(_) => {} ControllerCommand::Let { id, value, body, .. } => { - let value_ty = self.check_expression(value, false); + 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, false), + ControllerCommand::Assignment(update) => self.check_update(update, true), ControllerCommand::IfThenElse { guard, then_branch, @@ -398,8 +478,11 @@ impl Checker<'_> { 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 } => { + 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); } @@ -463,7 +546,12 @@ impl Checker<'_> { /// `combineToRealType` in the Java source: 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: &SpannedExpression, right: &SpannedExpression, random_allowed: bool) -> StarkType { + fn combine_to_real( + &mut self, + left: &SpannedExpression, + right: &SpannedExpression, + 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); @@ -492,7 +580,7 @@ impl Checker<'_> { Expression::Normal { mean, std_dev } => { if !random_allowed { self.diagnostics - .error(expr.span.clone(), "random expressions are not allowed here".to_string()); + .error(expr.span.clone(), DiagnosticKind::RandomNotAllowed); return StarkType::Error; } let mean_ty = self.check_expression(mean, random_allowed); @@ -508,7 +596,7 @@ impl Checker<'_> { Expression::Uniform { values } => { if !random_allowed { self.diagnostics - .error(expr.span.clone(), "random expressions are not allowed here".to_string()); + .error(expr.span.clone(), DiagnosticKind::RandomNotAllowed); return StarkType::Error; } let mut merged: Option = None; @@ -530,7 +618,7 @@ impl Checker<'_> { Expression::Range { min, max } => { if !random_allowed { self.diagnostics - .error(expr.span.clone(), "random expressions are not allowed here".to_string()); + .error(expr.span.clone(), DiagnosticKind::RandomNotAllowed); return StarkType::Error; } match (min, max) { @@ -579,7 +667,13 @@ impl Checker<'_> { } } - fn check_binary(&mut self, op: BinaryOp, left: &SpannedExpression, right: &SpannedExpression, random_allowed: bool) -> StarkType { + fn check_binary( + &mut self, + op: BinaryOp, + left: &SpannedExpression, + right: &SpannedExpression, + 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 => { @@ -632,12 +726,11 @@ impl Checker<'_> { if signature.arguments.len() != arguments.len() { self.diagnostics.error( function.name.span.clone(), - format!( - "`{}` expects {} argument(s), found {}", - function.name.name, - signature.arguments.len(), - arguments.len() - ), + DiagnosticKind::ArityMismatch { + name: function.name.name.clone(), + expected: signature.arguments.len(), + found: arguments.len(), + }, ); for argument in arguments { self.check_expression(argument, random_allowed); @@ -651,12 +744,34 @@ impl Checker<'_> { signature.return_type } - fn check_math_call(&mut self, function: MathFunction, arguments: &[SpannedExpression], random_allowed: bool) -> StarkType { + fn check_math_call( + &mut self, + function: MathFunction, + arguments: &[SpannedExpression], + 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() { @@ -673,18 +788,135 @@ impl Checker<'_> { 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")), + ( + "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")), + ( + "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 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(), @@ -692,7 +924,11 @@ mod tests { resolve_diagnostics.render(source) ); let (_types, diagnostics) = typecheck(&spec, &symbols); - assert!(!diagnostics.has_errors(), "{name} failed to typecheck:\n{}", diagnostics.render(source)); + assert!( + !diagnostics.has_errors(), + "{name} failed to typecheck:\n{}", + diagnostics.render(source) + ); } } @@ -716,7 +952,8 @@ mod tests { 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 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(), @@ -724,14 +961,23 @@ mod tests { resolve_diagnostics.render(&source) ); let (types, diagnostics) = typecheck(&spec, &symbols); - assert!(!diagnostics.has_errors(), "failed to typecheck `{expr}`:\n{}", diagnostics.render(&source)); + 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() + 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 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(), @@ -739,14 +985,23 @@ mod tests { resolve_diagnostics.render(&source) ); let (types, diagnostics) = typecheck(&spec, &symbols); - assert!(!diagnostics.has_errors(), "failed to typecheck `{expr}`:\n{}", diagnostics.render(&source)); + 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() + 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 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(), @@ -754,7 +1009,11 @@ mod tests { resolve_diagnostics.render(&source) ); let (types, diagnostics) = typecheck(&spec, &symbols); - assert!(!diagnostics.has_errors(), "failed to typecheck `{expr}`:\n{}", diagnostics.render(&source)); + 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() } diff --git a/crates/stark/src/types.rs b/crates/stark/src/types.rs index ee3f07181..caa98a54e 100644 --- a/crates/stark/src/types.rs +++ b/crates/stark/src/types.rs @@ -213,7 +213,10 @@ mod tests { #[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); + assert_eq!( + random(StarkType::Boolean).merge(&random(StarkType::Integer)), + StarkType::Error + ); } #[test] @@ -287,7 +290,11 @@ mod tests { // 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::Integer), + random(StarkType::Integer), + ), (StarkType::Integer, random(StarkType::Real), random(StarkType::Real)), // Real (StarkType::Real, StarkType::Integer, StarkType::Real), @@ -296,23 +303,47 @@ mod tests { (StarkType::Real, random(StarkType::Real), random(StarkType::Real)), // Boolean (StarkType::Boolean, StarkType::Boolean, StarkType::Boolean), - (StarkType::Boolean, random(StarkType::Boolean), random(StarkType::Boolean)), + ( + StarkType::Boolean, + random(StarkType::Boolean), + random(StarkType::Boolean), + ), // Random[Integer] - (random(StarkType::Integer), StarkType::Integer, random(StarkType::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(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(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), + StarkType::Boolean, + random(StarkType::Boolean), + ), ( random(StarkType::Boolean), random(StarkType::Boolean), @@ -390,7 +421,10 @@ mod tests { #[test] fn subtyping() { for (expected, actual) in compatible_types() { - assert!(expected.is_compatible_with(&actual), "{expected}.is_compatible_with({actual})"); + assert!( + expected.is_compatible_with(&actual), + "{expected}.is_compatible_with({actual})" + ); } } } diff --git a/crates/utilities/src/span.rs b/crates/utilities/src/span.rs index b07c392ff..b5df47ddd 100644 --- a/crates/utilities/src/span.rs +++ b/crates/utilities/src/span.rs @@ -218,7 +218,10 @@ mod tests { 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() }; + let span = Span { + start, + end: source.len(), + }; assert_eq!(span.render(source), " --> 1:9\n |\n1 | eqn f = x\n | ^"); } From a9e82391ec743a725b1626f35ae1c8b8e0daa7f2 Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Sun, 19 Jul 2026 01:04:18 +0200 Subject: [PATCH 23/50] Introduce the spanned for all the subtrees as well --- crates/stark/src/ast.rs | 109 +++++++++++++++++--------------- crates/stark/src/consume.rs | 6 +- crates/stark/src/diagnostics.rs | 21 +++++- crates/stark/src/precedence.rs | 97 ++++++++++++++-------------- crates/stark/src/typecheck.rs | 89 ++++++++++++++------------ 5 files changed, 179 insertions(+), 143 deletions(-) diff --git a/crates/stark/src/ast.rs b/crates/stark/src/ast.rs index 63d874186..09d545296 100644 --- a/crates/stark/src/ast.rs +++ b/crates/stark/src/ast.rs @@ -34,8 +34,13 @@ pub struct LocalTag; /// The index type assigned to a local binding during name resolution. pub type LocalId = TagIndex; -/// An `Expression` together with the source span it was parsed from. -pub type SpannedExpression = Spanned; +/// An expression node together with the source span it was parsed from. +/// +/// Mirrors rustc's `Expr`/`ExprKind` split: [Expression] is the spanned node +/// that appears everywhere in the tree, and [ExpressionKind] is the bare +/// variant data. Sub-expressions recurse through `Box` (not +/// `Box`), so every level of nesting carries its own span. +pub type Expression = Spanned; /// A reference to a top-level declaration (variable, constant, parameter, /// function, penalty, distance, perturbation, formula or component), @@ -108,7 +113,7 @@ impl UntypedStarkSpecification { pub struct Constant { pub id: Option, pub name: Identifier, - pub value: SpannedExpression, + pub value: Expression, } /// `param name = value;` @@ -116,7 +121,7 @@ pub struct Constant { pub struct Parameter { pub id: Option, pub name: Identifier, - pub value: SpannedExpression, + pub value: Expression, } /// A single variable in a (`global`) `variables { ... }` block, or in a @@ -128,7 +133,7 @@ pub struct Variable { pub ty: Ty, pub name: Identifier, pub range: Option, - pub initial_value: SpannedExpression, + pub initial_value: Expression, } /// `type name = A | B | C;` @@ -144,7 +149,7 @@ pub struct TypeDeclaration { pub struct Penalty { pub id: Option, pub name: Identifier, - pub value: SpannedExpression, + pub value: Expression, } /// `function name(args) { body }` @@ -165,16 +170,16 @@ pub struct FunctionArgument { #[derive(Clone, Debug)] pub enum FunctionStatement { - Return(SpannedExpression), + Return(Expression), IfThenElse { - guard: SpannedExpression, + guard: Expression, then_branch: Box, else_branch: Option>, }, Let { id: Option, name: Identifier, - value: SpannedExpression, + value: Expression, body: Box, }, Block(Box), @@ -195,7 +200,7 @@ pub struct Component { pub init: Vec, } -/// `aiState name { .. }` +/// `state name { .. }` #[derive(Clone, Debug)] pub struct ControllerState { pub id: Option, @@ -207,7 +212,7 @@ pub struct ControllerState { pub enum ControllerCommand { /// `[steps #] step target;` Step { - steps: Option, + steps: Option, target: StateRef, }, /// `exec target;` @@ -216,14 +221,14 @@ pub enum ControllerCommand { Let { id: Option, name: Identifier, - value: SpannedExpression, + value: Expression, body: Vec, }, /// `[when guard] target' = value;` Assignment(Update), /// `if (guard) { .. } else { .. }` IfThenElse { - guard: SpannedExpression, + guard: Expression, then_branch: Vec, else_branch: Option>, }, @@ -247,7 +252,7 @@ pub enum EnvironmentCommand { Assignment(Update), /// `if (guard) cmd [else cmd]` IfThenElse { - guard: SpannedExpression, + guard: Expression, then_branch: Box, else_branch: Option>, }, @@ -264,7 +269,7 @@ pub enum EnvironmentCommand { pub struct LocalVariable { pub id: Option, pub name: Identifier, - pub value: SpannedExpression, + pub value: Expression, } /// A `[when guard] target' = value;` assignment shared by controllers and the @@ -272,9 +277,9 @@ pub struct LocalVariable { /// `'`), resolved to the [DefId] of the variable it updates. #[derive(Clone, Debug)] pub struct Update { - pub guard: Option, + pub guard: Option, pub target: DefRef, - pub value: SpannedExpression, + pub value: Expression, } // --------------------------------------------------------------------------- @@ -296,21 +301,21 @@ pub enum PerturbationExpression { /// `[ v1 <- e1, v2 <- e2 ] @ time` Atomic { assignments: Vec, - time: SpannedExpression, + time: Expression, }, /// `left ; right` Sequence(Box, Box), /// `argument ^ iterations` Iteration { argument: Box, - iterations: SpannedExpression, + iterations: Expression, }, } #[derive(Clone, Debug)] pub struct PerturbationAssignment { pub target: DefRef, - pub value: SpannedExpression, + pub value: Expression, } /// `distance name = expr;` @@ -331,20 +336,20 @@ pub enum DistanceExpression { AtomicRight(DefRef), /// `\F[from,to] argument` Eventually { - from: SpannedExpression, - to: SpannedExpression, + from: Expression, + to: Expression, argument: Box, }, /// `\G[from,to] argument` Globally { - from: SpannedExpression, - to: SpannedExpression, + from: Expression, + to: Expression, argument: Box, }, /// `left \U[from,to] right` Until { - from: SpannedExpression, - to: SpannedExpression, + from: Expression, + to: Expression, left: Box, right: Box, }, @@ -352,12 +357,12 @@ pub enum DistanceExpression { Threshold { op: ComparisonOp, left: Box, - threshold: SpannedExpression, + threshold: Expression, }, Min(Box, Box), Max(Box, Box), /// `w1 * d1 + w2 * d2 + ...` - LinearCombination(Vec<(SpannedExpression, DistanceExpression)>), + LinearCombination(Vec<(Expression, DistanceExpression)>), } /// `formula name = formula;` @@ -379,24 +384,24 @@ pub enum RobtlFormula { distance: DefRef, perturbation: DefRef, op: ComparisonOp, - value: SpannedExpression, + value: Expression, }, Not(Box), Globally { - from: SpannedExpression, - to: SpannedExpression, + from: Expression, + to: Expression, argument: Box, }, Eventually { - from: SpannedExpression, - to: SpannedExpression, + from: Expression, + to: Expression, argument: Box, }, And(Box, Box), Or(Box, Box), Until { - from: SpannedExpression, - to: SpannedExpression, + from: Expression, + to: Expression, left: Box, right: Box, }, @@ -407,7 +412,7 @@ pub enum RobtlFormula { // --------------------------------------------------------------------------- #[derive(Clone, Debug)] -pub enum Expression { +pub enum ExpressionKind { // Literals False, True, @@ -425,43 +430,43 @@ pub enum Expression { // Distributions / random values Normal { - mean: Box, - std_dev: Box, + mean: Box, + std_dev: Box, }, Uniform { - values: Vec, + values: Vec, }, /// `R` or `R[min,max]`. Range { - min: Option>, - max: Option>, + min: Option>, + max: Option>, }, // Prefix operators - Not(Box), - UnaryPlus(Box), - UnaryMinus(Box), + Not(Box), + UnaryPlus(Box), + UnaryMinus(Box), // Binary operators - Binary(BinaryOp, Box, Box), + Binary(BinaryOp, Box, Box), // `guard ? then : else` Ternary { - guard: Box, - then_branch: Box, - else_branch: Box, + guard: Box, + then_branch: Box, + else_branch: Box, }, /// A user-defined function application `name(args)`. Call { function: DefRef, - arguments: Vec, + arguments: Vec, }, /// A built-in math function application, e.g. `abs(x)`, `max(a, b)`. MathCall { function: MathFunction, - arguments: Vec, + arguments: Vec, }, } @@ -534,8 +539,8 @@ pub enum MathFunction { /// A `range [min, max]` bound on a variable declaration. #[derive(Clone, Debug)] pub struct Range { - pub min: SpannedExpression, - pub max: SpannedExpression, + pub min: Expression, + pub max: Expression, } #[derive(Clone, Debug)] diff --git a/crates/stark/src/consume.rs b/crates/stark/src/consume.rs index 6650e44fd..6bf178745 100644 --- a/crates/stark/src/consume.rs +++ b/crates/stark/src/consume.rs @@ -12,6 +12,7 @@ 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; @@ -22,7 +23,6 @@ use crate::ast::Parameter; use crate::ast::Penalty; use crate::ast::Perturbation; use crate::ast::Range; -use crate::ast::SpannedExpression; use crate::ast::StateRef; use crate::ast::Ty; use crate::ast::TypeDeclaration; @@ -153,11 +153,11 @@ impl StarkParser { }) } - pub(crate) fn Expression(input: ParseNode) -> ParseResult { + pub(crate) fn Expression(input: ParseNode) -> ParseResult { parse_expression_node(input.into_pair()) } - fn WhenGuard(input: ParseNode) -> ParseResult { + fn WhenGuard(input: ParseNode) -> ParseResult { match_nodes!(input.into_children(); [Expression(guard)] => Ok(guard) ) diff --git a/crates/stark/src/diagnostics.rs b/crates/stark/src/diagnostics.rs index 33f9fb79f..36ea06463 100644 --- a/crates/stark/src/diagnostics.rs +++ b/crates/stark/src/diagnostics.rs @@ -47,7 +47,7 @@ pub enum DiagnosticKind { #[error("duplicate definition of `{name}`")] DuplicateDefinition { name: String, first: Span }, - /// Two `aiState`s in the same component share a name. + /// Two `state`s in the same component share a name. #[error("duplicate controller state `{name}`")] DuplicateControllerState { name: String, first: Span }, @@ -76,6 +76,17 @@ pub enum DiagnosticKind { #[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: a `const`/`param` value, or a + /// variable's own range or initializer. `via` names the function through + /// which the variable is reached, when the read is not direct. + #[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, + }, + // -- Type checking (`typecheck.rs`) --------------------------------- /// A `Ty::Named` annotation that names no declared `type`. #[error("unknown type `{name}`")] @@ -106,6 +117,14 @@ pub enum DiagnosticKind { expected: usize, found: usize, }, + + // -- Lowering (`lower.rs`) ------------------------------------------- + /// A construct that resolves and type-checks but has no IR + /// representation yet (see `MISSING_GRAMMAR_FEATURES.md`). Reported + /// rather than panicked on, so a partially-supported spec fails + /// gracefully instead of crashing lowering. + #[error("{construct} is not yet supported by lowering")] + NotYetSupported { construct: &'static str }, } impl DiagnosticKind { diff --git a/crates/stark/src/precedence.rs b/crates/stark/src/precedence.rs index 4a6180c44..82e168e9e 100644 --- a/crates/stark/src/precedence.rs +++ b/crates/stark/src/precedence.rs @@ -5,7 +5,7 @@ //! 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 [SpannedExpression]); the perturbation / distance / +//! 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. @@ -27,12 +27,12 @@ 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::ast::SpannedExpression; use crate::consume::ParseResult; use crate::parse::Rule; @@ -58,7 +58,7 @@ fn error(pair: &Pair<'_, Rule>, message: impl Into) -> ParseResult /// 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 { +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( @@ -78,7 +78,7 @@ pub(crate) fn parse_expression_node(pair: Pair<'_, Rule>) -> ParseResult) -> ParseResult) -> ParseResult> { +fn expression_arguments(pair: Pair<'_, Rule>) -> ParseResult> { pair.into_inner() .filter(|p| p.as_rule() == Rule::Expression) .map(parse_expression_node) @@ -176,7 +176,7 @@ pub static EXPRESSION_PRATT_PARSER: LazyLock> = LazyLock::new( }); #[allow(clippy::result_large_err)] -fn parse_expression_primary(primary: Pair<'_, Rule>) -> ParseResult { +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 { @@ -186,7 +186,7 @@ fn parse_expression_primary(primary: Pair<'_, Rule>) -> ParseResult match primary.as_str().parse::() { - Ok(value) => Expression::Integer(value), + Ok(value) => ExpressionKind::Integer(value), Err(_) => { return error( &primary, @@ -198,29 +198,29 @@ fn parse_expression_primary(primary: Pair<'_, Rule>) -> ParseResult match primary.as_str().parse::() { - Ok(value) => Expression::Real(value), + Ok(value) => ExpressionKind::Real(value), Err(_) => return error(&primary, format!("invalid real literal `{}`", primary.as_str())), }, - Rule::ID => Expression::Reference { + Rule::ID => ExpressionKind::Reference { name: primary.as_str().to_string(), binding: None, }, - Rule::ExpressionTrue => Expression::True, - Rule::ExpressionFalse => Expression::False, - Rule::ExpressionIterator => Expression::Iterator, + Rule::ExpressionTrue => ExpressionKind::True, + Rule::ExpressionFalse => ExpressionKind::False, + Rule::ExpressionIterator => ExpressionKind::Iterator, Rule::ExpressionNormal => { let mut args = expression_arguments(primary)?.into_iter(); - Expression::Normal { + 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 => Expression::Uniform { + Rule::ExpressionUniform => ExpressionKind::Uniform { values: expression_arguments(primary)?, }, Rule::ExpressionRandom => { let mut args = expression_arguments(primary)?.into_iter(); - Expression::Range { + ExpressionKind::Range { min: args.next().map(Box::new), max: args.next().map(Box::new), } @@ -237,7 +237,7 @@ fn parse_expression_primary(primary: Pair<'_, Rule>) -> ParseResult>>()?; - Expression::MathCall { function, arguments } + ExpressionKind::MathCall { function, arguments } } rule => unreachable!("unexpected expression primary: {rule:?}"), }; @@ -245,16 +245,16 @@ fn parse_expression_primary(primary: Pair<'_, Rule>) -> ParseResult) -> ParseResult { +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 => Expression::Not(Box::new(rhs)), - Rule::ExpressionUnaryPlus => Expression::UnaryPlus(Box::new(rhs)), - Rule::ExpressionUnaryMinus => Expression::UnaryMinus(Box::new(rhs)), + 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)) @@ -282,7 +282,10 @@ pub fn parse_expression(pairs: Pairs) -> ParseResult { Rule::ExpressionOr => BinaryOp::Or, rule => unreachable!("unexpected expression binary operator: {rule:?}"), }; - Ok(Spanned::new(Expression::Binary(op, Box::new(lhs), Box::new(rhs)), span)) + Ok(Spanned::new( + ExpressionKind::Binary(op, Box::new(lhs), Box::new(rhs)), + span, + )) }) .map_postfix(|target, postfix| { let target = target?; @@ -290,12 +293,12 @@ pub fn parse_expression(pairs: Pairs) -> ParseResult { match postfix.as_rule() { Rule::ExpressionCall => { let name = match &target.node { - Expression::Reference { name, .. } => name.clone(), + 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(Expression::Call { function, arguments }, span)) + Ok(Spanned::new(ExpressionKind::Call { function, arguments }, span)) } rule => unreachable!("unexpected expression postfix operator: {rule:?}"), } @@ -382,7 +385,7 @@ pub static DISTANCE_PRATT_PARSER: LazyLock> = LazyLock::new(|| /// Parse the two `Expression` children (`from`, `to`) of an interval operator. #[allow(clippy::result_large_err)] -fn parse_interval(pair: Pair<'_, Rule>) -> ParseResult<(SpannedExpression, SpannedExpression)> { +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"), @@ -555,7 +558,7 @@ pub fn parse_robtl_formula(pairs: Pairs) -> ParseResult { mod tests { use crate::ast::BinaryOp; use crate::ast::DistanceExpression; - use crate::ast::Expression; + use crate::ast::ExpressionKind; use crate::ast::MathFunction; use crate::ast::PerturbationExpression; use crate::ast::RobtlFormula; @@ -563,7 +566,7 @@ mod tests { use crate::ast::UntypedStarkSpecification; /// Parse `const c = ;` and return the parsed expression (span discarded). - fn expr(src: &str) -> Expression { + 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 } @@ -572,9 +575,9 @@ mod tests { fn arithmetic_precedence() { // `1 + 2 * 3` must group as `1 + (2 * 3)`. match expr("1 + 2 * 3") { - Expression::Binary(BinaryOp::Add, lhs, rhs) => { - assert!(matches!(lhs.node, Expression::Integer(1))); - assert!(matches!(rhs.node, Expression::Binary(BinaryOp::Mult, _, _))); + 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:?}"), } @@ -584,9 +587,9 @@ mod tests { fn power_is_right_associative() { // `2 ^ 3 ^ 2` must group as `2 ^ (3 ^ 2)`. match expr("2 ^ 3 ^ 2") { - Expression::Binary(BinaryOp::Pow, lhs, rhs) => { - assert!(matches!(lhs.node, Expression::Integer(2))); - assert!(matches!(rhs.node, Expression::Binary(BinaryOp::Pow, _, _))); + 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:?}"), } @@ -596,8 +599,8 @@ mod tests { fn comparison_binds_looser_than_bitand() { // `a > b & c` must group as `(a > b) & c` (relations tighter than `&`). match expr("a > b & c") { - Expression::Binary(BinaryOp::BitAnd, lhs, _) => { - assert!(matches!(lhs.node, Expression::Binary(BinaryOp::Greater, _, _))); + ExpressionKind::Binary(BinaryOp::BitAnd, lhs, _) => { + assert!(matches!(lhs.node, ExpressionKind::Binary(BinaryOp::Greater, _, _))); } other => panic!("unexpected: {other:?}"), } @@ -605,21 +608,21 @@ mod tests { #[test] fn unary_minus_and_not() { - assert!(matches!(expr("-x"), Expression::UnaryMinus(_))); - assert!(matches!(expr("!x"), Expression::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)") { - Expression::MathCall { + ExpressionKind::MathCall { function: MathFunction::Max, arguments, } => assert_eq!(arguments.len(), 2), other => panic!("unexpected: {other:?}"), } match expr("abs(x)") { - Expression::MathCall { + ExpressionKind::MathCall { function: MathFunction::Abs, arguments, } => assert_eq!(arguments.len(), 1), @@ -628,7 +631,7 @@ mod tests { // 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)") { - Expression::Call { function, arguments } => { + ExpressionKind::Call { function, arguments } => { assert_eq!(function.name.name, "eval_bd"); assert!(function.id.is_none()); assert_eq!(arguments.len(), 1); @@ -648,36 +651,36 @@ mod tests { // `italic`/`Rate` must be identifiers, not `it` / `R` followed by junk. assert!(matches!( expr("italic"), - Expression::Reference { name, .. } if name == "italic" + ExpressionKind::Reference { name, .. } if name == "italic" )); assert!(matches!( expr("Rate"), - Expression::Reference { name, .. } if name == "Rate" + ExpressionKind::Reference { name, .. } if name == "Rate" )); } #[test] fn unresolved_reference_has_no_binding() { - assert!(matches!(expr("x"), Expression::Reference { binding: None, .. })); + assert!(matches!(expr("x"), ExpressionKind::Reference { binding: None, .. })); } #[test] fn ternary() { - assert!(matches!(expr("a ? b : c"), Expression::Ternary { .. })); + assert!(matches!(expr("a ? b : c"), ExpressionKind::Ternary { .. })); } #[test] fn distributions() { - assert!(matches!(expr("N[0, 1]"), Expression::Normal { .. })); - assert!(matches!(expr("U[1, 2, 3]"), Expression::Uniform { values } if values.len() == 3)); + 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]"), - Expression::Range { + ExpressionKind::Range { min: Some(_), max: Some(_) } )); - assert!(matches!(expr("R"), Expression::Range { min: None, max: None })); + assert!(matches!(expr("R"), ExpressionKind::Range { min: None, max: None })); } #[test] diff --git a/crates/stark/src/typecheck.rs b/crates/stark/src/typecheck.rs index 394597468..7ce4ac96b 100644 --- a/crates/stark/src/typecheck.rs +++ b/crates/stark/src/typecheck.rs @@ -249,6 +249,18 @@ impl Checker<'_> { } } } + // `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); } @@ -283,8 +295,22 @@ impl Checker<'_> { } } - fn check_variable(&mut self, variable: &Variable) { + /// 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); @@ -293,9 +319,6 @@ impl Checker<'_> { } let initial = self.check_expression(&variable.initial_value, false); self.expect(&declared, initial, &variable.initial_value.span); - if let Some(id) = variable.id { - self.set_def_type(id, declared); - } } fn check_function(&mut self, function: &Function) { @@ -534,7 +557,7 @@ impl Checker<'_> { } } - fn check_interval(&mut self, from: &SpannedExpression, to: &SpannedExpression) { + 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); @@ -546,12 +569,7 @@ impl Checker<'_> { /// `combineToRealType` in the Java source: 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: &SpannedExpression, - right: &SpannedExpression, - random_allowed: bool, - ) -> StarkType { + 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); @@ -563,21 +581,21 @@ impl Checker<'_> { } } - fn check_expression(&mut self, expr: &SpannedExpression, random_allowed: bool) -> StarkType { + fn check_expression(&mut self, expr: &Expression, random_allowed: bool) -> StarkType { match &expr.node { - Expression::False | Expression::True => StarkType::Boolean, - Expression::Integer(_) => StarkType::Integer, - Expression::Real(_) => StarkType::Real, + 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. - Expression::Iterator => StarkType::Error, - Expression::Reference { binding, .. } => match binding { + 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, }, - Expression::Normal { mean, std_dev } => { + ExpressionKind::Normal { mean, std_dev } => { if !random_allowed { self.diagnostics .error(expr.span.clone(), DiagnosticKind::RandomNotAllowed); @@ -593,7 +611,7 @@ impl Checker<'_> { StarkType::random(StarkType::Real) } } - Expression::Uniform { values } => { + ExpressionKind::Uniform { values } => { if !random_allowed { self.diagnostics .error(expr.span.clone(), DiagnosticKind::RandomNotAllowed); @@ -615,7 +633,7 @@ impl Checker<'_> { _ => StarkType::Error, } } - Expression::Range { min, max } => { + ExpressionKind::Range { min, max } => { if !random_allowed { self.diagnostics .error(expr.span.clone(), DiagnosticKind::RandomNotAllowed); @@ -636,16 +654,16 @@ impl Checker<'_> { _ => StarkType::random(StarkType::Real), } } - Expression::Not(inner) => { + ExpressionKind::Not(inner) => { let ty = self.check_expression(inner, random_allowed); self.expect(&StarkType::Boolean, ty, &inner.span) } - Expression::UnaryPlus(inner) | Expression::UnaryMinus(inner) => { + ExpressionKind::UnaryPlus(inner) | ExpressionKind::UnaryMinus(inner) => { let ty = self.check_expression(inner, random_allowed); self.expect_numerical(ty, &inner.span) } - Expression::Binary(op, left, right) => self.check_binary(*op, left, right, random_allowed), - Expression::Ternary { + ExpressionKind::Binary(op, left, right) => self.check_binary(*op, left, right, random_allowed), + ExpressionKind::Ternary { guard, then_branch, else_branch, @@ -662,18 +680,14 @@ impl Checker<'_> { merged } } - Expression::Call { function, arguments } => self.check_call(function, arguments, random_allowed), - Expression::MathCall { function, arguments } => self.check_math_call(*function, arguments, random_allowed), + 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: &SpannedExpression, - right: &SpannedExpression, - random_allowed: bool, - ) -> StarkType { + 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 => { @@ -707,7 +721,7 @@ impl Checker<'_> { } } - fn check_call(&mut self, function: &DefRef, arguments: &[SpannedExpression], random_allowed: bool) -> StarkType { + 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. @@ -744,12 +758,7 @@ impl Checker<'_> { signature.return_type } - fn check_math_call( - &mut self, - function: MathFunction, - arguments: &[SpannedExpression], - random_allowed: bool, - ) -> StarkType { + 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 From 4454464f2b277728f2236e0d467d31462751d44c Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Sun, 19 Jul 2026 01:04:28 +0200 Subject: [PATCH 24/50] Added the various example tests --- Cargo.lock | 4 ++++ crates/stark/Cargo.toml | 8 ++++++- crates/stark/tests/examples.rs | 40 ++++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 crates/stark/tests/examples.rs diff --git a/Cargo.lock b/Cargo.lock index 20804d20b..881185842 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1397,10 +1397,14 @@ dependencies = [ name = "merc_stark" version = "1.0.0" dependencies = [ + "log", "merc_pest_consume", "merc_utilities", "pest", "pest_derive", + "test-case", + "test-log", + "thiserror", ] [[package]] diff --git a/crates/stark/Cargo.toml b/crates/stark/Cargo.toml index c925a1d01..eeafd7668 100644 --- a/crates/stark/Cargo.toml +++ b/crates/stark/Cargo.toml @@ -8,6 +8,12 @@ rust-version.workspace = true [dependencies] merc_utilities.workspace = true +log.workspace = true pest.workspace = true pest_derive.workspace = true -merc_pest_consume.workspace = true \ No newline at end of file +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/tests/examples.rs b/crates/stark/tests/examples.rs new file mode 100644 index 000000000..31161a1c3 --- /dev/null +++ b/crates/stark/tests/examples.rs @@ -0,0 +1,40 @@ +//! 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::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/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/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}")); + + if let Err(diagnostics) = spec.check() { + panic!("failed to check:\n{}", diagnostics.render(source)); + } +} From 70a3b93aa8afb9fcea397242c5e442c4026f811d Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Sun, 19 Jul 2026 01:04:48 +0200 Subject: [PATCH 25/50] Started the lowering pass --- crates/stark/src/ir.rs | 988 ++++++++++++++++++++++ crates/stark/src/lib.rs | 8 + crates/stark/src/lower.rs | 1367 +++++++++++++++++++++++++++++++ crates/stark/stark_grammar.pest | 4 +- 4 files changed, 2365 insertions(+), 2 deletions(-) create mode 100644 crates/stark/src/ir.rs create mode 100644 crates/stark/src/lower.rs diff --git a/crates/stark/src/ir.rs b/crates/stark/src/ir.rs new file mode 100644 index 000000000..1aec616f6 --- /dev/null +++ b/crates/stark/src/ir.rs @@ -0,0 +1,988 @@ +//! The evaluation IR that `lower.rs` produces: a flat arena of small, `Copy` +//! nodes rather than a closure tree, so evaluation walks an array instead of +//! chasing pointers. See `IR_LOWERING_PLAN.md` for the design rationale. +//! +//! Currently 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. Perturbations, distances and formulas +//! are not lowered yet (`lower.rs` reports them as +//! [crate::diagnostics::DiagnosticKind::NotYetSupported]) and so have no IR +//! representation here yet either — see the plan's Step 5. + +use std::fmt; + +use merc_utilities::Span; +use merc_utilities::TagIndex; + +use crate::types::StarkType; +use crate::value::Value; + +// --------------------------------------------------------------------------- +// Index types +// --------------------------------------------------------------------------- +// +// All backed by `u32`, not `usize`: nodes that hold these stay small. Each +// has its own tag so, say, an `ExprRef` can never be mixed up with a +// `SlotId` at a call site even though both are "just a `u32`" underneath. + +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 (future) evaluator maintains — +/// see "Slot layout" in `IR_LOWERING_PLAN.md`. +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; + +// --------------------------------------------------------------------------- +// 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 (see `IR_LOWERING_PLAN.md` +/// Step 2 for the full rationale): +/// - `Expression::UnaryPlus` disappears (it is the identity). +/// - `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), + /// 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), + Negate(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;`. +#[derive(Clone, Copy, Debug)] +pub struct PenaltyIr { + 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` (see `IR_LOWERING_PLAN.md`'s Step 4). +/// +/// 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 Step 4's `IR_LOWERING_PLAN.md` note and the `buffered_swap_*` tests. +/// +/// **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, matching Java's `Controller.doTick(k-1, ..)`. + 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, +} + +// --------------------------------------------------------------------------- +// The program +// --------------------------------------------------------------------------- + +/// The result of lowering: one flat arena, plus the tables that index into +/// it. See the module doc comment for what is (and isn't) populated yet. +#[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. `None` if it is + /// absent, or present but empty — both mean "nothing runs". + pub(crate) environment: Option, +} + +impl IrProgram { + 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] + } + + 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] + } + + /// Independently re-checks the arena's internal consistency: every + /// `ExprRef`/`StmtRef`/`CommandRef`/`SlotId`/`FunctionId`/`IrStateId` + /// reachable from a top-level entry (globals, variables, functions, + /// penalties, components, the environment) is in bounds, and every list + /// slice lies within `expr_lists`. This is a partial version of the full + /// check `IR_LOWERING_PLAN.md`'s Step 7 describes — it does not yet + /// cover perturbations/distances/formulas, since those aren't lowered + /// yet. + 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())) + } + }; + + 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::SampleUnit => {} + ExprNode::Load(slot) => check_slot(slot)?, + ExprNode::Not(inner) | ExprNode::Negate(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)?; + } + } + } + + for variable in &self.variables { + check_slot(variable.slot)?; + 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)?; + check_expr(global.value)?; + } + for function in &self.functions { + for &argument in &function.arguments { + check_slot(argument)?; + } + 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)?; + } + + 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, "}}")?; + } + + 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::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::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}]") + } + } + } +} + +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", + } +} diff --git a/crates/stark/src/lib.rs b/crates/stark/src/lib.rs index ead1b825b..bd3ba29a4 100644 --- a/crates/stark/src/lib.rs +++ b/crates/stark/src/lib.rs @@ -1,16 +1,24 @@ mod ast; mod consume; mod diagnostics; +// `ir`/`value` are kept as their own public modules, rather than flattened +// like the rest of this crate's API, because `ir::BinaryOp` deliberately +// collides in name (not in meaning) with `ast::BinaryOp` — see `ir.rs`'s doc +// comment. Flattening both would be an ambiguous glob re-export. +pub mod ir; +mod lower; mod parse; mod precedence; mod resolve; mod specification; mod typecheck; mod types; +pub mod value; pub use ast::*; pub use consume::*; pub use diagnostics::*; +pub use lower::lower; pub use parse::*; pub use precedence::*; pub use resolve::*; diff --git a/crates/stark/src/lower.rs b/crates/stark/src/lower.rs new file mode 100644 index 000000000..31e87899f --- /dev/null +++ b/crates/stark/src/lower.rs @@ -0,0 +1,1367 @@ +//! Lowers a checked [StarkSpecification] to an [IrProgram]. See +//! `IR_LOWERING_PLAN.md` for the full design; this implements its Steps 0-3 +//! (crate infra, `Value`, the IR arena, and expression/function/global/ +//! variable/penalty lowering). +//! +//! Components' controller states, the environment block, perturbations, +//! distances and formulas (`IR_LOWERING_PLAN.md`'s Steps 4-5) have no IR +//! representation yet — [lower] reports each as a +//! [DiagnosticKind::NotYetSupported] diagnostic (with a span) rather than +//! panicking, so a spec using them fails gracefully instead of crashing. +//! +//! One deliberate deviation from the plan's stated Step 3 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. + +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::MathFunction; +use crate::ast::Ty; +use crate::ast::Variable; +use crate::diagnostics::DiagnosticKind; +use crate::diagnostics::Diagnostics; +use crate::ir::BinaryOp; +use crate::ir::CommandNode; +use crate::ir::CommandRef; +use crate::ir::ComponentId; +use crate::ir::ComponentIr; +use crate::ir::ExprList; +use crate::ir::ExprNode; +use crate::ir::ExprRef; +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::PenaltyIr; +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 exactly one error class: constructs that resolve +/// and type-check but have no IR representation yet (see +/// `MISSING_GRAMMAR_FEATURES.md`; Java has the same hole). Everything else is +/// infallible. +pub fn lower(spec: &StarkSpecification) -> Result { + let mut lowerer = Lowerer::new(spec); + + lowerer.check_not_yet_supported(); + 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(); + + debug!( + "lowered {} expression(s), {} statement(s), {} command(s), {} slot(s), {} global(s), \ + {} variable(s), {} function(s), {} component(s)/{} state(s), {} penalty/-ies; \ + {} 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.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, + }; + + debug_assert!( + program.validate().is_ok(), + "lower produced an internally inconsistent arena: {:?}", + program.validate().err() + ); + + lowerer.diagnostics.into_result(program) +} + +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 — built once so `lower_reference` doesn't have to re-walk + /// `spec.ast().types` for every reference. + 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>, + + variables: Vec, + globals: Vec, + functions: Vec, + penalties: Vec, + states: Vec, + components: Vec, + environment: Option, + + 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()], + variables: Vec::new(), + globals: Vec::new(), + functions: Vec::new(), + penalties: Vec::new(), + states: Vec::new(), + components: Vec::new(), + environment: None, + 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 + } + + fn expr_type(&self, id: ExprRef) -> StarkType { + self.expr_types[id.value() as usize].clone() + } + + // -- Constructs with no IR representation yet ------------------------- + + /// Reports every perturbation, distance and formula in `spec` as not yet + /// supported (components/controllers and the environment are lowered — + /// see [Self::lower_components]/[Self::lower_environment]). Collected as + /// diagnostics (rather than the first one short-circuiting) so a spec + /// using several of these still reports all of them at once, the way + /// `resolve.rs`/`typecheck.rs` do for their own diagnostics. + fn check_not_yet_supported(&mut self) { + for perturbation in &self.spec.ast().perturbations { + self.diagnostics.error( + perturbation.name.span.clone(), + DiagnosticKind::NotYetSupported { + construct: "perturbations", + }, + ); + } + for distance in &self.spec.ast().distances { + self.diagnostics.error( + distance.name.span.clone(), + DiagnosticKind::NotYetSupported { construct: "distances" }, + ); + } + for formula in &self.spec.ast().formulas { + self.diagnostics.error( + formula.name.span.clone(), + DiagnosticKind::NotYetSupported { + construct: "ROBTL formulas", + }, + ); + } + } + + // -- Slot allocation ---------------------------------------------------- + + /// Allocates `[0, n_variables)`: the global `variables { .. }` block, + /// then every component's local one, matching `StarkGlobalVariableCollector`. + 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) = variable.id else { return }; + let ty = self.types.type_of(id).cloned().unwrap_or(StarkType::Error); + let slot = self.alloc_slot( + variable.name.name.clone(), + ty, + SlotKind::Variable, + variable.name.span.clone(), + ); + self.def_slots[id.value()] = Some(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) = constant.id else { continue }; + let ty = self.types.type_of(id).cloned().unwrap_or(StarkType::Error); + let slot = self.alloc_slot( + constant.name.name.clone(), + ty, + SlotKind::Global, + constant.name.span.clone(), + ); + self.def_slots[id.value()] = Some(slot); + } + for parameter in &self.spec.ast().parameters { + let Some(id) = parameter.id else { continue }; + let ty = self.types.type_of(id).cloned().unwrap_or(StarkType::Error); + let slot = self.alloc_slot( + parameter.name.name.clone(), + ty, + SlotKind::Global, + parameter.name.span.clone(), + ); + self.def_slots[id.value()] = Some(slot); + } + } + + // -- Globals, variables, penalties -------------------------------------- + + fn lower_globals(&mut self) { + for constant in &self.spec.ast().constants { + self.lower_global(constant.id, &constant.value); + } + for parameter in &self.spec.ast().parameters { + self.lower_global(parameter.id, ¶meter.value); + } + } + + fn lower_global(&mut self, id: Option, value: &Expression) { + let Some(id) = id else { return }; + let slot = self.def_slots[id.value()].expect("global slot allocated during slot allocation"); + let value = self.lower_expression(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) = variable.id else { return }; + let slot = self.def_slots[id.value()].expect("variable slot allocated during slot allocation"); + 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); + self.penalties.push(PenaltyIr { value }); + } + } + + // -- 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) = state.id 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.local_slots[local_id.value()] = Some(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.local_slots[local_id.value()] = Some(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) = function.id 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) = argument.id 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.local_slots[local_id.value()] = Some(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.local_slots[local_id.value()] = Some(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` / + // `MISSING_GRAMMAR_FEATURES.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::Literal(Value::Error), 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) + } + // The identity: lowers straight through to its operand, pushing + // no node of its own. + ExpressionKind::UnaryPlus(inner) => self.lower_expression(inner), + ExpressionKind::UnaryMinus(inner) => { + let inner = self.lower_expression(inner); + let ty = self.expr_type(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) + } + } + } + } + + /// `combineToRealType` in the Java source: always widens to `real`, + /// propagating randomness from either operand. 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 + } + } + + 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) + } + } + } +} + +/// 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"), + } +} + +#[cfg(test)] +mod tests { + use test_log::test; + + use super::*; + use crate::ast::UntypedStarkSpecification; + use crate::ir::ExprNode; + use crate::ir::StmtNode; + + fn lower_source(src: &str) -> IrProgram { + let spec = UntypedStarkSpecification::parse(src) + .unwrap_or_else(|e| panic!("failed to parse: {e}")) + .check() + .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 (see `IR_LOWERING_PLAN.md`'s "Slot layout"). + 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_disappears() { + let program = lower_source("const c = +1;"); + let global = &program.globals()[0]; + assert!(matches!( + program.expr(global.value), + ExprNode::Literal(Value::Integer(1)) + )); + } + + #[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 (`IR_LOWERING_PLAN.md` + // Step 4) 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 Java's + // "collect updates, apply them all at the end of the step" + // semantics (`IR_LOWERING_PLAN.md`'s Step 4). + 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_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()); + } +} diff --git a/crates/stark/stark_grammar.pest b/crates/stark/stark_grammar.pest index 9e421d9fe..c77bd1d00 100644 --- a/crates/stark/stark_grammar.pest +++ b/crates/stark/stark_grammar.pest @@ -20,7 +20,7 @@ LETTER = _{ 'a'..'z' | 'A'..'Z' | "_" } KEYWORD = @{ ( "const" | "param" | "global" | "variables" | "type" | "environment" | "penalty" | "function" | "component" | "perturbation" | "distance" - | "formula" | "controller" | "aiState" | "init" | "when" | "step" | "exec" + | "formula" | "controller" | "state" | "init" | "when" | "step" | "exec" | "let" | "in" | "and" | "if" | "else" | "return" | "range" | "int" | "real" | "bool" | "true" | "false" | "nil" ) ~ !(LETTER | DIGIT) @@ -98,7 +98,7 @@ DeclarationComponent = { "}" } -ControllerState = { "aiState" ~ ID ~ ControllerBlock } +ControllerState = { "state" ~ ID ~ ControllerBlock } ControllerBlock = { "{" ~ ControllerCommand* ~ "}" } ControllerCommand = _{ From 5dcef0a56e1e040376109aea3fc457f31de3ac7d Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Sun, 19 Jul 2026 01:04:57 +0200 Subject: [PATCH 26/50] Implement runtime values with CustomValue and Value enums --- crates/stark/src/value.rs | 92 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 crates/stark/src/value.rs diff --git a/crates/stark/src/value.rs b/crates/stark/src/value.rs new file mode 100644 index 000000000..0bc68583c --- /dev/null +++ b/crates/stark/src/value.rs @@ -0,0 +1,92 @@ +//! Runtime values, ported from `values/StarkValue.java`'s sealed hierarchy. +//! +//! The Java reference models `StarkValue` as an interface with one class per +//! case (`StarkIntegerValue`, `StarkRealValue`, ...). Here the same case +//! analysis is one flat `Copy` enum, matching the arena IR's "small, `Copy`, +//! contiguous" philosophy (see `IR_LOWERING_PLAN.md`). +//! +//! Only construction, `Debug`/`Display` and [Value::type_of] land here. The +//! arithmetic (`sum`/`product`/`isLessThan`/…, with Java's int-preserving- +//! then-widening promotion rules) belongs to the (deferred) evaluator. + +use std::fmt; + +use crate::ast::DefId; +use crate::resolve::SymbolTable; +use crate::types::StarkType; + +/// An instance of a user-defined `type X = A | B | C;` value. +/// +/// `element` is the declared element's position within `type_id`'s own +/// `elements` list (`0` for the first alternative, and so on), not a name — +/// mirroring `StarkCustomValue`, but index-keyed rather than string-keyed, so +/// comparing two custom values of the same type is an integer compare. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CustomValue { + /// The `DefId` of the owning `type X = ...;` declaration (not the + /// element itself). + pub type_id: DefId, + pub element: u32, +} + +/// A runtime value flowing through the (future) evaluator. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Value { + Integer(i64), + Real(f64), + Boolean(bool), + Custom(CustomValue), + /// The result of a runtime error (e.g. division by zero). The evaluator + /// is meant to propagate this rather than panic, mirroring + /// `StarkValue.ERROR_VALUE` — worth preserving even though the evaluator + /// itself is out of scope here. + Error, +} + +impl Value { + /// This value's [StarkType]. `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()), + Value::Error => StarkType::Error, + } + } +} + +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), + Value::Error => write!(f, ""), + } + } +} + +#[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); + assert_eq!(Value::Error.type_of(&symbols), StarkType::Error); + } + + #[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"); + assert_eq!(Value::Error.to_string(), ""); + } +} From 8c7a9c16ac7095ee9619e619fc0081b35965c26d Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Sun, 19 Jul 2026 01:05:02 +0200 Subject: [PATCH 27/50] Moved these tests --- crates/stark/src/specification.rs | 61 ++----------------------------- 1 file changed, 3 insertions(+), 58 deletions(-) diff --git a/crates/stark/src/specification.rs b/crates/stark/src/specification.rs index a6148e1a1..d12c732c3 100644 --- a/crates/stark/src/specification.rs +++ b/crates/stark/src/specification.rs @@ -78,64 +78,9 @@ mod tests { use crate::ast::UntypedStarkSpecification; use test_log::test; - #[test] - fn checks_every_example_specification() { - for (name, source) in [ - ("engine.stark", include_str!("../../../examples/stark/engine.stark")), - ( - "random_walk.stark", - include_str!("../../../examples/stark/random_walk.stark"), - ), - ( - "single_vehicle.stark", - include_str!("../../../examples/stark/single_vehicle.stark"), - ), - ("toll.stark", include_str!("../../../examples/stark/toll.stark")), - ( - "two_vehicles.stark", - include_str!("../../../examples/stark/two_vehicles.stark"), - ), - ( - "monitoring.stark", - include_str!("../../../examples/stark/monitoring.stark"), - ), - ( - "agriculturalDT.stark", - include_str!("../../../examples/stark/agriculturalDT.stark"), - ), - ( - "tollbooth.stark", - include_str!("../../../examples/stark/tollbooth.stark"), - ), - ( - "engine_full.stark", - include_str!("../../../examples/stark/engine_full.stark"), - ), - ( - "isocitrate.stark", - include_str!("../../../examples/stark/isocitrate.stark"), - ), - ("envzompr.stark", include_str!("../../../examples/stark/envzompr.stark")), - ( - "vehicle_full.stark", - include_str!("../../../examples/stark/vehicle_full.stark"), - ), - ( - "multiscler.stark", - include_str!("../../../examples/stark/multiscler.stark"), - ), - ("lotka.stark", include_str!("../../../examples/stark/lotka.stark")), - ("polistil.stark", include_str!("../../../examples/stark/polistil.stark")), - ("turtle.stark", include_str!("../../../examples/stark/turtle.stark")), - ] { - let spec = - UntypedStarkSpecification::parse(source).unwrap_or_else(|e| panic!("{name} failed to parse: {e}")); - - if let Err(diagnostics) = spec.check() { - panic!("{name} failed to check:\n{}", diagnostics.render(source)); - } - } - } + // The per-file example checks used to live here, hand-listed one by one; + // they've moved to `tests/examples.rs`, which discovers every + // `examples/stark/*.stark` file at runtime instead. #[test] fn reports_resolve_and_type_errors_together() { From dbed2698d28c3f3bcfda97a53251a9155d23d4d6 Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Sun, 19 Jul 2026 01:05:16 +0200 Subject: [PATCH 28/50] Enhance name resolution by pre-declaring state variables and adding static expression checks --- crates/stark/src/resolve.rs | 382 +++++++++++++++++++++++++++++++----- 1 file changed, 332 insertions(+), 50 deletions(-) diff --git a/crates/stark/src/resolve.rs b/crates/stark/src/resolve.rs index b6f7e6019..e0464a82d 100644 --- a/crates/stark/src/resolve.rs +++ b/crates/stark/src/resolve.rs @@ -12,10 +12,18 @@ //! is sufficient: by the time a name is used, everything it could legally //! refer to has already been registered. //! -//! The one exception is controller states: `step`/`exec` inside a state may -//! target a *later* state in the same component (state machines are -//! naturally mutually recursive), so each component's states are registered -//! in a first pass before any state body is resolved. +//! 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 (state machines are naturally mutually +//! recursive), 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 Java implementation, whose `StarkGlobalVariableCollector` +//! pass collects exactly these names ahead of `StarkModelGenerator`. //! //! Caveat: [UntypedStarkSpecification] buckets declarations by kind (all //! constants, then all parameters, then all variables, …) rather than @@ -34,6 +42,16 @@ //! 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 Java +//! implementation accepts all of these and evaluates them to `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` @@ -73,7 +91,7 @@ pub enum DefKind { } impl DefKind { - /// Whether a plain `Expression::Reference` may resolve to this kind: + /// Whether a plain `ExpressionKind::Reference` may resolve to this kind: /// whether it names a *value*, as opposed to a function, penalty, /// component, perturbation, distance, formula or type, each of which is /// only referenceable from its own dedicated syntax (a call, a `\D[...]`, @@ -181,6 +199,7 @@ pub fn resolve(spec: &mut UntypedStarkSpecification) -> (SymbolTable, Diagnostic table: SymbolTable::default(), scopes: Vec::new(), diagnostics: Diagnostics::new(), + functions_reading_variables: HashMap::new(), }; resolver.resolve_specification(spec); @@ -222,6 +241,10 @@ struct Resolver { /// 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 { @@ -434,6 +457,16 @@ impl Resolver { 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); @@ -473,14 +506,109 @@ impl Resolver { 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 Java implementation has this hole, evaluating such reads to + /// `ERROR_VALUE` at runtime with no diagnostic. + /// + /// Runs as a post-pass so every function is resolved and its + /// [Self::function_reads_variable] 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); + } + } - 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); + for constant in &spec.constants { + self.reject_state_variables(&constant.value, "const"); } - self.resolve_expression(&mut variable.initial_value); + 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 { @@ -489,6 +617,16 @@ impl Resolver { ); } + /// 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 @@ -772,26 +910,26 @@ impl Resolver { } } - fn resolve_expression(&mut self, expr: &mut SpannedExpression) { + fn resolve_expression(&mut self, expr: &mut Expression) { match &mut expr.node { - Expression::False - | Expression::True - | Expression::Integer(_) - | Expression::Real(_) - | Expression::Iterator => {} - Expression::Reference { name, binding } => { + ExpressionKind::False + | ExpressionKind::True + | ExpressionKind::Integer(_) + | ExpressionKind::Real(_) + | ExpressionKind::Iterator => {} + ExpressionKind::Reference { name, binding } => { *binding = self.resolve_reference(name, &expr.span); } - Expression::Normal { mean, std_dev } => { + ExpressionKind::Normal { mean, std_dev } => { self.resolve_expression(mean); self.resolve_expression(std_dev); } - Expression::Uniform { values } => { + ExpressionKind::Uniform { values } => { for value in values { self.resolve_expression(value); } } - Expression::Range { min, max } => { + ExpressionKind::Range { min, max } => { if let Some(min) = min { self.resolve_expression(min); } @@ -799,14 +937,14 @@ impl Resolver { self.resolve_expression(max); } } - Expression::Not(inner) | Expression::UnaryPlus(inner) | Expression::UnaryMinus(inner) => { + ExpressionKind::Not(inner) | ExpressionKind::UnaryPlus(inner) | ExpressionKind::UnaryMinus(inner) => { self.resolve_expression(inner); } - Expression::Binary(_, left, right) => { + ExpressionKind::Binary(_, left, right) => { self.resolve_expression(left); self.resolve_expression(right); } - Expression::Ternary { + ExpressionKind::Ternary { guard, then_branch, else_branch, @@ -815,13 +953,13 @@ impl Resolver { self.resolve_expression(then_branch); self.resolve_expression(else_branch); } - Expression::Call { function, arguments } => { + 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"); } - Expression::MathCall { arguments, .. } => { + ExpressionKind::MathCall { arguments, .. } => { for argument in arguments { self.resolve_expression(argument); } @@ -830,6 +968,77 @@ impl Resolver { } } +/// 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`. /// @@ -840,36 +1049,36 @@ impl Resolver { /// 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: &SpannedExpression) { + fn check_expression(expr: &Expression) { match &expr.node { - Expression::False - | Expression::True - | Expression::Integer(_) - | Expression::Real(_) - | Expression::Iterator => {} - Expression::Reference { name, binding } => { + 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" ); } - Expression::Normal { mean, std_dev } => { + ExpressionKind::Normal { mean, std_dev } => { check_expression(mean); check_expression(std_dev); } - Expression::Uniform { values } => values.iter().for_each(check_expression), - Expression::Range { min, max } => { + 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)); } - Expression::Not(inner) | Expression::UnaryPlus(inner) | Expression::UnaryMinus(inner) => { + ExpressionKind::Not(inner) | ExpressionKind::UnaryPlus(inner) | ExpressionKind::UnaryMinus(inner) => { check_expression(inner) } - Expression::Binary(_, left, right) => { + ExpressionKind::Binary(_, left, right) => { check_expression(left); check_expression(right); } - Expression::Ternary { + ExpressionKind::Ternary { guard, then_branch, else_branch, @@ -878,7 +1087,7 @@ fn assert_fully_resolved(spec: &UntypedStarkSpecification) { check_expression(then_branch); check_expression(else_branch); } - Expression::Call { function, arguments } => { + ExpressionKind::Call { function, arguments } => { assert!( function.id.is_some(), "call to `{}` left unresolved", @@ -886,7 +1095,7 @@ fn assert_fully_resolved(spec: &UntypedStarkSpecification) { ); arguments.iter().for_each(check_expression); } - Expression::MathCall { arguments, .. } => arguments.iter().for_each(check_expression), + ExpressionKind::MathCall { arguments, .. } => arguments.iter().for_each(check_expression), } } @@ -1204,7 +1413,7 @@ fn assert_fully_resolved(spec: &UntypedStarkSpecification) { mod tests { use super::resolve; use crate::ast::Binding; - use crate::ast::Expression; + 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` @@ -1228,10 +1437,10 @@ mod tests { let (spec, _table, diagnostics) = resolve_source("const a = 1;\nconst b = a + 1;"); assert!(!diagnostics.has_errors(), "{diagnostics}"); match &spec.constants[1].value.node { - Expression::Binary(_, lhs, _) => { + ExpressionKind::Binary(_, lhs, _) => { assert!(matches!( lhs.node, - Expression::Reference { + ExpressionKind::Reference { binding: Some(Binding::Def(_)), .. } @@ -1250,6 +1459,79 @@ mod tests { ); } + #[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;"); @@ -1319,17 +1601,17 @@ mod tests { panic!("expected a return statement"); }; match &value.node { - Expression::Binary(_, lhs, rhs) => { + ExpressionKind::Binary(_, lhs, rhs) => { assert!(matches!( lhs.node, - Expression::Reference { + ExpressionKind::Reference { binding: Some(Binding::Local(_)), .. } )); assert!(matches!( rhs.node, - Expression::Reference { + ExpressionKind::Reference { binding: Some(Binding::Local(_)), .. } @@ -1351,7 +1633,7 @@ mod tests { #[test] fn controller_state_can_forward_reference_a_sibling_state() { let (_spec, _table, diagnostics) = resolve_source( - "component C {\n variables { }\n controller {\n aiState A { step B; }\n aiState B { step A; }\n }\n init A\n}", + "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}"); } @@ -1359,7 +1641,7 @@ mod tests { #[test] fn controller_state_cannot_target_another_components_state() { let (_spec, _table, diagnostics) = resolve_source( - "component C1 {\n variables { }\n controller {\n aiState A { step B; }\n }\n init A\n}\ncomponent C2 {\n variables { }\n controller {\n aiState B { exec B; }\n }\n init B\n}", + "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")), @@ -1377,7 +1659,7 @@ mod tests { assert!(!diagnostics.has_errors(), "{diagnostics}"); assert!(matches!( spec.penalties[0].value.node, - Expression::Reference { + ExpressionKind::Reference { binding: Some(Binding::Def(_)), .. } From 93329ab03199f48a08b36c563b5378db475e14f9 Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Sun, 19 Jul 2026 01:05:27 +0200 Subject: [PATCH 29/50] Print the parsed AST as well --- tools/stark/src/main.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tools/stark/src/main.rs b/tools/stark/src/main.rs index 58084d54a..df029d87e 100644 --- a/tools/stark/src/main.rs +++ b/tools/stark/src/main.rs @@ -7,6 +7,7 @@ use clap::Parser; use clap::Subcommand; use log::info; +use log::trace; use merc_stark::DefKind; use merc_stark::StarkSpecification; use merc_stark::UntypedStarkSpecification; @@ -100,9 +101,11 @@ fn handle_command(commands: Option, timing: &Timing) -> Result<(), Mer /// 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 load_specification(path: &Path, timing: &Timing) -> Result { - let source = read_to_string(path).map_err(|err| MercError::from(format!("cannot read {}: {err}", path.display())))?; + let source = + read_to_string(path).map_err(|err| MercError::from(format!("cannot read {}: {err}", path.display())))?; let untyped = timing.measure("parsing", || UntypedStarkSpecification::parse(&source))?; + trace!("AST: {:#?}", untyped); timing .measure("resolving and type checking", || untyped.check()) From 68ee8932af5f20fa2edb13241d8887f530bd04bd Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Sun, 19 Jul 2026 12:22:11 +0200 Subject: [PATCH 30/50] Ported various examples into the stark language --- crates/stark/tests/examples.rs | 2 + examples/stark/README.md | 2 +- .../stark/abz2025_one_lane_three_cars.stark | 197 +++ .../stark/abz2025_single_lane_two_cars.stark | 178 +++ .../stark/abz2025_two_lanes_two_cars.stark | 358 +++++ examples/stark/agriculturalDT.stark | 15 +- examples/stark/engine.stark | 8 +- examples/stark/engine_full.stark | 226 +++ examples/stark/envzompr.stark | 150 ++ examples/stark/isocitrate.stark | 122 ++ examples/stark/lotka.stark | 73 + examples/stark/monitoring.stark | 2 +- examples/stark/multiscler.stark | 144 ++ examples/stark/polistil.stark | 234 +++ examples/stark/polistil_race.stark | 434 ++++++ .../stark/reactionsystems_lacoperon.stark | 156 ++ examples/stark/reactionsystems_running.stark | 124 ++ examples/stark/reactionsystems_synapse.stark | 130 ++ .../reactionsystems_synapse_3neuron.stark | 144 ++ examples/stark/repressilator.stark | 292 ++++ examples/stark/single_vehicle.stark | 10 +- examples/stark/toll.stark | 8 +- examples/stark/tollbooth.stark | 29 +- examples/stark/turtle.stark | 199 +++ examples/stark/turtle_hospital.stark | 211 +++ examples/stark/two_vehicles.stark | 8 +- examples/stark/vehicle_full.stark | 332 +++++ examples/stark/ventilator.stark | 1257 +++++++++++++++++ 28 files changed, 5012 insertions(+), 33 deletions(-) create mode 100644 examples/stark/abz2025_one_lane_three_cars.stark create mode 100644 examples/stark/abz2025_single_lane_two_cars.stark create mode 100644 examples/stark/abz2025_two_lanes_two_cars.stark create mode 100644 examples/stark/engine_full.stark create mode 100644 examples/stark/envzompr.stark create mode 100644 examples/stark/isocitrate.stark create mode 100644 examples/stark/lotka.stark create mode 100644 examples/stark/multiscler.stark create mode 100644 examples/stark/polistil.stark create mode 100644 examples/stark/polistil_race.stark create mode 100644 examples/stark/reactionsystems_lacoperon.stark create mode 100644 examples/stark/reactionsystems_running.stark create mode 100644 examples/stark/reactionsystems_synapse.stark create mode 100644 examples/stark/reactionsystems_synapse_3neuron.stark create mode 100644 examples/stark/repressilator.stark create mode 100644 examples/stark/turtle.stark create mode 100644 examples/stark/turtle_hospital.stark create mode 100644 examples/stark/vehicle_full.stark create mode 100644 examples/stark/ventilator.stark diff --git a/crates/stark/tests/examples.rs b/crates/stark/tests/examples.rs index 31161a1c3..c5fb77f78 100644 --- a/crates/stark/tests/examples.rs +++ b/crates/stark/tests/examples.rs @@ -22,6 +22,7 @@ use test_case::test_case; #[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")] @@ -29,6 +30,7 @@ use test_case::test_case; #[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) { diff --git a/examples/stark/README.md b/examples/stark/README.md index 67c0fafdd..c9d45a46f 100644 --- a/examples/stark/README.md +++ b/examples/stark/README.md @@ -1,3 +1,3 @@ # Overview -These examples are taken from the [Stark](https://github.com/mlaveaux/STARK.git) repository. \ No newline at end of file +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 000000000..120f730d7 --- /dev/null +++ b/examples/stark/abz2025_one_lane_three_cars.stark @@ -0,0 +1,197 @@ +/* + * Ported from ~/STARK/examples/ABZ2025/src/main/java/Scenarios/OneLaneThreeCars.java: + * 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 original's commented-out alternative + * "paper controller proposal" for the uncontrolled cars is dead code, not + * ported, matching the active code path only). 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. + * + * `getCrashFormula`/`getSafetyGapViolationFormula` are defined in the + * original but never actually invoked from its constructor (only the raw + * penalty function is used there, for per-step CSV diagnostics) — ported + * anyway as genuine `formula` declarations, matching the shape of + * `abz2025_single_lane_two_cars.stark`'s formulas, since they're + * well-defined ROBTL queries the original just never wired up to its + * demo `main`. + */ + +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 000000000..4e09b787a --- /dev/null +++ b/examples/stark/abz2025_single_lane_two_cars.stark @@ -0,0 +1,178 @@ +/* + * Ported from ~/STARK/examples/ABZ2025/src/main/java/Scenarios/SingleLaneTwoCars.java: + * 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). + * + * `OneLaneThreeCars` is the same RSS car-following mechanic generalised + * from 2 to 3 chained cars in one lane (arrays instead of named variables, + * otherwise identical rules), so it isn't ported separately, matching the + * `turtle.stark`/`repressilator.stark` precedent. + * + * `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 + * DisTL/feedback exclusions already documented in + * `MISSING_GRAMMAR_FEATURES.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 000000000..c1880d79d --- /dev/null +++ b/examples/stark/abz2025_two_lanes_two_cars.stark @@ -0,0 +1,358 @@ +/* + * Ported from ~/STARK/examples/ABZ2025/src/main/java/Scenarios/TwoLanesTwoCars.java + * (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 + * `AtomicPerturbation`, 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 `AfterPerturbation(5, ...)` + * 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 MISSING_GRAMMAR_FEATURES.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) { + exec 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 { + if (my_timer > 0) { exec Idling; } else { exec Control; } + } + + state Moving_right { + if (my_timer > 0) { + exec 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) { + exec 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 index 188986ad8..a84b3a7b2 100644 --- a/examples/stark/agriculturalDT.stark +++ b/examples/stark/agriculturalDT.stark @@ -98,7 +98,7 @@ global variables { component Tractor { variables { } controller { - aiState Ctrl { + 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); @@ -111,14 +111,21 @@ component Tractor { step Stop; } } - aiState Idle { + state Idle { if (timer > 0) { step Idle; } else { - step Ctrl; + /* BUG FIXED: was `step Ctrl;`. The Java `Idle` is + `ifThenElse(timer>0, doTick(ref Idle), reference("Ctrl"))`; the else is + a *bare* `reference("Ctrl")`, 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; } } - aiState Stop { + state Stop { if (timer > 0) { step Stop; } else { diff --git a/examples/stark/engine.stark b/examples/stark/engine.stark index 3cd4875c3..9b3d63718 100644 --- a/examples/stark/engine.stark +++ b/examples/stark/engine.stark @@ -89,7 +89,7 @@ component Engine{ int ch_in = HALF; } controller { - aiState Ctrl { + state Ctrl { if (ch_temp >= 99.8) { cool' = true; step Cooling; @@ -97,7 +97,7 @@ component Engine{ exec Check; } } - aiState Check { + state Check { if (ch_speed == LOW) { speed' = LOW; cool' = false; @@ -108,10 +108,10 @@ component Engine{ step Ctrl; } } - aiState Cooling { + state Cooling { 4#step Check; } - aiState IDS { + state IDS { if (temp>101.0 & !cool) { ch_wrn' = HOT; ch_speed' = LOW; diff --git a/examples/stark/engine_full.stark b/examples/stark/engine_full.stark new file mode 100644 index 000000000..d25dc6d2e --- /dev/null +++ b/examples/stark/engine_full.stark @@ -0,0 +1,226 @@ +/* + * Ported from ~/STARK/examples/engine/src/main/java/engine/Main.java: 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. + * + * `Controller.doAction`/`doTick` are STARK's time-consuming `step`; a bare + * controller reference (`registry.reference(...)`) with no action is an + * immediate `exec` (matches how `engine.stark` already treats the same + * `Ctrl -> Check` transition). The original's `AfterPerturbation(100, + * IterativePerturbation(N, ...))` (wait N steps, then iterate) 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 000000000..ba01bf9db --- /dev/null +++ b/examples/stark/envzompr.stark @@ -0,0 +1,150 @@ +/* + * Ported from ~/STARK/examples/envzompr/src/main/java/envzompr/Main.java: an + * 11-reaction, 8-species chemical reaction network (same Gillespie-SSA / + * `NilController` / 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 000000000..e0f6c93e3 --- /dev/null +++ b/examples/stark/isocitrate.stark @@ -0,0 +1,122 @@ +/* + * Ported from ~/STARK/examples/Isocitrate/src/main/java/isocitrate/Main.java: + * 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's `TimedSystem` uses a `NilController` (no decision-making + * controller at all) and tracks continuous reaction time + * (`selectReactionTime`, via `ds.getTimeDelta()`/`getTimeReal()`); this + * grammar has no continuous-time/`NilController` concept, 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 an unlisted + * `DataStateUpdate` does in the original). + * + * The original draws each species' initial amount randomly + * (`ceil(100*rand.nextDouble())`); 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 000000000..477b106cd --- /dev/null +++ b/examples/stark/lotka.stark @@ -0,0 +1,73 @@ +/* + * Ported from ~/STARK/examples/lotka/src/main/java/lotka/Main.java: 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 `AtomicRobustnessFormula`/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 index fc4c813d2..67a801060 100644 --- a/examples/stark/monitoring.stark +++ b/examples/stark/monitoring.stark @@ -25,7 +25,7 @@ global variables { component Monitor { variables { } controller { - aiState Ctrl { + state Ctrl { step Ctrl; } } diff --git a/examples/stark/multiscler.stark b/examples/stark/multiscler.stark new file mode 100644 index 000000000..5f4091db1 --- /dev/null +++ b/examples/stark/multiscler.stark @@ -0,0 +1,144 @@ +/* + * Ported from ~/STARK/examples/mutliScler/src/main/java/ms/Main.java: 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 every `DisTLFormula`/monitor-based robustness analysis + * (everything past `writeRunsToCSV` in `main`) is already commented out in + * the source itself — 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 a + * `NilController` for comparison); only the controlled "sick" variant + * (`systemS5`, `var=5`) is ported, since it's the one that actually + * exercises the controller logic. + * + * `(rg.nextDouble()*var*2 - var)` (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 Java `getController` + 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 Java. */ + 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 000000000..6e563a358 --- /dev/null +++ b/examples/stark/polistil.stark @@ -0,0 +1,234 @@ +/* + * Ported from ~/STARK/examples/polistil/src/main/java/polistil/Main.java: 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. + * + * `GenerativeChoiceController(1/3, A, GenerativeChoiceController(1/2, B, C))` + * 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 nested + * probabilistic-controller combinators. + * + * The DisTL-based robustness analysis at the end of the original (`phi_out_1`, + * `phi_out_2`, `phi_speed_1`, `phi_speed_2`, all `stark.distl` formulas) 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;`. Java `Stop` is + `ifThenElse(back>0, doTick(ref Stop), reference("Ctrl"))`; the else is + a bare `reference("Ctrl")` = 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 000000000..72eb8a963 --- /dev/null +++ b/examples/stark/polistil_race.stark @@ -0,0 +1,434 @@ +/* + * Ported from ~/STARK/examples/polistil/src/main/java/polistil/Main.java'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;`. Java `getCar_1`'s `Stop` is + `ifThenElse(back>0, doTick(ref Stop), reference("Ctrl"))`; the else is + a bare 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;`. Java `getCar_2`'s `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/reactionsystems_lacoperon.stark b/examples/stark/reactionsystems_lacoperon.stark new file mode 100644 index 000000000..8e7a1d262 --- /dev/null +++ b/examples/stark/reactionsystems_lacoperon.stark @@ -0,0 +1,156 @@ +/* + * Ported from ~/STARK/examples/reactionsystems/src/main/java/reacsys/MainLO.java: + * 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's `ParallelController(DefaultCondition, Glucose5-chain)` 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 `stark.distl` (`TargetDisTLFormula`/ + * `ImplicationDisTLFormula`/`EventuallyDisTLFormula`/`AlwaysDisTLFormula`) — + * the same untranslatable online-monitoring formalism already documented in + * `monitoring.stark`/`MISSING_GRAMMAR_FEATURES.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 000000000..6e82d62bb --- /dev/null +++ b/examples/stark/reactionsystems_running.stark @@ -0,0 +1,124 @@ +/* + * Ported from ~/STARK/examples/reactionsystems/src/main/java/reacsys/runningEx.java: + * 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 (a `NilController` with 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 (`AtomicPerturbation(0, ...)`) composed + * with `SequentialPerturbation`, plus one step of `NonePerturbation` (a + * no-op) and one `IterativePerturbation(1, 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 Java `getContextSequence`, Ag4 is + `Controller.doTick(reference("Ag5"))`. `doTick` is time-consuming (it + returns `EffectStep([], Ag5)`, i.e. an empty-context round, then Ag5 + next round), which the textual language spells `step` — not `exec` + (which is StarkControllerStateGenerator's 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 + (doAction/doTick -> 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 Java `Ag5rep` is + `ifThenElse(C5_count>0, doAction(..., ref Ag5rep), reference("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 000000000..42a655a5f --- /dev/null +++ b/examples/stark/reactionsystems_synapse.stark @@ -0,0 +1,130 @@ +/* + * Ported from ~/STARK/examples/reactionsystems/src/main/java/reacsys/Main2N.java: + * 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`. + * + * `Main.java` in the same directory models the identical mechanism scaled + * up to 3 neurons (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 (`NilController` in the original, 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's `state.get(e2)` does + * without ever appearing on the left of a `DataStateUpdate`. + * + * `c2`'s and `o20`/`o21`/`o22`'s updates share one random draw + * (`w2 = rg.nextDouble() < 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 000000000..422ada9d8 --- /dev/null +++ b/examples/stark/reactionsystems_synapse_3neuron.stark @@ -0,0 +1,144 @@ +/* + * Ported from ~/STARK/examples/reactionsystems/src/main/java/reacsys/Main.java: + * 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 000000000..e1c587749 --- /dev/null +++ b/examples/stark/repressilator.stark @@ -0,0 +1,292 @@ +/* + * Ported from ~/STARK/examples/repressilator/src/main/java/repressilator/Main.java: + * 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 `NilController`/no-`component` shape and cumulative-weight + * `if`/`else` reaction-selection pattern established in + * `isocitrate.stark`/`envzompr.stark`/`lotka.stark`. + * + * `Main_Skorokhod.java` 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 + * `Main.java` and `Main_Skorokhod.java`, 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 index 491c62020..ff5bf7b4c 100644 --- a/examples/stark/single_vehicle.stark +++ b/examples/stark/single_vehicle.stark @@ -74,7 +74,7 @@ component Vehicle { int warning = OK; } controller { - aiState Ctrl { + state Ctrl { if (s_speed > 0) { if (gap > 0) { accel' = A; @@ -91,21 +91,21 @@ component Vehicle { step Stop; } } - aiState Accelerate { + state Accelerate { if (counter > 0) { step Accelerate; } else { exec Ctrl; } } - aiState Decelerate { + state Decelerate { if (counter > 0) { step Decelerate; } else { exec Ctrl; } } - aiState Stop { + state Stop { if (counter > 0) { step Stop; } else { @@ -119,7 +119,7 @@ component Vehicle { } } } - aiState IDS { + state IDS { if (IDS_guard(p_distance <= 2*TIMER*SAFETY_DISTANCE, accel == A, accel == V, p_speed > 0.0)) { warning' = DANGER; step IDS; diff --git a/examples/stark/toll.stark b/examples/stark/toll.stark index 75a081913..ae1488c72 100644 --- a/examples/stark/toll.stark +++ b/examples/stark/toll.stark @@ -41,7 +41,7 @@ component vehicle { int timer_V range [0,TIMER] = 0; } controller { - aiState Ctrl { + state Ctrl { if (s_speed > 0) { if (gap > 0) { accel' = A; @@ -64,21 +64,21 @@ component vehicle { } } } - aiState Accelerate { + state Accelerate { if (timer_V > 0) { step Accelerate; } else { exec Ctrl; } } - aiState Decelerate { + state Decelerate { if (timer_V > 0) { step Decelerate; } else { exec Ctrl; } } - aiState Stop { + state Stop { if (timer_V > 0) { step Stop; } else { diff --git a/examples/stark/tollbooth.stark b/examples/stark/tollbooth.stark index b60666ed9..88e677b9f 100644 --- a/examples/stark/tollbooth.stark +++ b/examples/stark/tollbooth.stark @@ -41,7 +41,7 @@ global variables { component Vehicle { variables { } controller { - aiState Ctrl { + state Ctrl { if (s_speed > 0) { if (gap > 0) { accel' = ACCELERATION; @@ -64,21 +64,30 @@ component Vehicle { } } } - aiState Accelerate { + state Accelerate { if (timer_V > 0) { step Accelerate; } else { - step Ctrl; + /* BUG FIXED: was `step Ctrl;`. The Java `Accelerate` is + `ifThenElse(timer_V>0, doTick(ref Accelerate), reference("Ctrl"))`; the + else 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; } } - aiState Decelerate { + state Decelerate { if (timer_V > 0) { step Decelerate; } else { - step Ctrl; + /* BUG FIXED: was `step Ctrl;` — same fix as Accelerate above (Java else + branch is a bare `reference("Ctrl")` = same-tick `exec`). */ + exec Ctrl; } } - aiState Stop { + state Stop { if (timer_V > 0) { step Stop; } else { @@ -111,10 +120,10 @@ environment { } } -penalty rho_100 = p_distance / INIT_DISTANCE; +penalty rho_100 = p_distance / INIT_DISTANCE -penalty rho_200 = p_distance / 7000; +penalty rho_200 = p_distance / 7000 -penalty rho_275 = p_distance / 2500; +penalty rho_275 = p_distance / 2500 -penalty rho_350 = p_distance / 10; +penalty rho_350 = p_distance / 10 diff --git a/examples/stark/turtle.stark b/examples/stark/turtle.stark new file mode 100644 index 000000000..2f8de2bfa --- /dev/null +++ b/examples/stark/turtle.stark @@ -0,0 +1,199 @@ +/* + * Ported from ~/STARK/examples/turtle/src/main/java/turtle/Industrial_plant.java: + * 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, `Smart_hospital.java`, 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): `stark.feedback` is + * a Java-only extension with no textual-STARK construct at all, so it isn't + * ported, same as the DisTL monitoring gap in `monitoring.stark`. + * + * The original's perturbation is a `PersistentPerturbation` (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 `MISSING_GRAMMAR_FEATURES.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 Java 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;`. Java `Accelerate` is + `ifThenElse(timer_V>0, doTick(ref Accelerate), reference("Ctrl"))`; the + else is a bare 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 (Java 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 000000000..9c4bc73a3 --- /dev/null +++ b/examples/stark/turtle_hospital.stark @@ -0,0 +1,211 @@ +/* + * Ported from ~/STARK/examples/turtle/src/main/java/turtle/Smart_hospital.java: + * the same waypoint-following robot controller/environment shape as + * `turtle.stark` (`Industrial_plant.java`) — `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 `stark.feedback` Java-only extension + * 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 + * `MISSING_GRAMMAR_FEATURES.md`. As in `turtle.stark`, `PersistentPerturbation` + * (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) { + return (wp_x(wp) == x ? 0.0 : (wp_x(wp) < x ? PI : 0.0)) + atan((wp_y(wp) - y) / (wp_x(wp) - x)); +} + +function braking_distance(real speed) { + return (speed^2 + (ACCELERATION+BRAKE)*(ACCELERATION*TIMER^2 + 2*speed*TIMER)) / (2*BRAKE); +} + +global variables { + real x = INIT_X; + real y = INIT_Y; + real theta = INIT_THETA; + real p_speed = INIT_SPEED; + real s_speed = INIT_SPEED; + real p_distance = INIT_DISTANCE; + real accel = NEUTRAL; + int timer_V = 0; + real gap = INIT_DISTANCE - braking_distance(INIT_SPEED); + int currentWP = 0; + real previous_theta = INIT_THETA; + real get_medicine = 0.0; + real fail = 0.0; + real flag = 0.0; +} + +component Robot { + variables { } + controller { + state SetDir { + if (gap > 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 { + step Ctrl; + } + } + state Decelerate { + if (timer_V > 0) { + step Decelerate; + } else { + step 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 index 75a081913..ae1488c72 100644 --- a/examples/stark/two_vehicles.stark +++ b/examples/stark/two_vehicles.stark @@ -41,7 +41,7 @@ component vehicle { int timer_V range [0,TIMER] = 0; } controller { - aiState Ctrl { + state Ctrl { if (s_speed > 0) { if (gap > 0) { accel' = A; @@ -64,21 +64,21 @@ component vehicle { } } } - aiState Accelerate { + state Accelerate { if (timer_V > 0) { step Accelerate; } else { exec Ctrl; } } - aiState Decelerate { + state Decelerate { if (timer_V > 0) { step Decelerate; } else { exec Ctrl; } } - aiState Stop { + state Stop { if (timer_V > 0) { step Stop; } else { diff --git a/examples/stark/vehicle_full.stark b/examples/stark/vehicle_full.stark new file mode 100644 index 000000000..60e8f4981 --- /dev/null +++ b/examples/stark/vehicle_full.stark @@ -0,0 +1,332 @@ +/* + * Ported from ~/STARK/examples/vehicle/src/main/java/vehicle/Main.java: 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 `MISSING_GRAMMAR_FEATURES.md`. + * + * The original's `AfterPerturbation(1, ...)` (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. + * `getIteratedCombinedPerturbation` (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;`. Java `Accelerate_V1` is + `ifThenElse(timer_V1>0, doTick(ref Accelerate_V1), reference("Ctrl_V1"))`; + the else is a bare reference = 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 Java 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;` — Java 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 000000000..a14261300 --- /dev/null +++ b/examples/stark/ventilator.stark @@ -0,0 +1,1257 @@ +/* + * Ported from ~/STARK/examples/mechanicallungventilator/src/main/java/mechanicallungventilator/Main.java + * (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 Java + * (`ds.get(x)` -> `x`, `DataStateUpdate(x, v)` -> `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's `DataState` storage is `double` for every variable anyway. + * + * ~1600 lines of the original `main()` build `stark.distl` (`DisTLFormula`/ + * `TargetDisTLFormula`/`AlwaysDisTLFormula`/...) online-monitoring queries — + * the same untranslatable formalism already documented in + * `monitoring.stark`/`MISSING_GRAMMAR_FEATURES.md` — so none of those are + * ported; only the genuinely-ROBTL `RobustnessFormula`/`DistanceExpression`/ + * `Perturbation` queries near the top of `main()` are (see the bottom of + * this file). + * + * `ControllerRegistry`'s `Controller.doTick` (advance one round with no + * variable change) maps to this grammar's `exec`; `Controller.doAction` + * (assign, then transition) maps to a `step`-terminated block whose body is + * the assignments; nested `Controller.ifThenElse` maps directly to nested + * `if`/`else`. `P_Alarms_final`'s self-transition references + * `registry.reference("P_alarms_final")` (lower-case `a`) instead of the + * actually-registered `"P_Alarms_final"` — a latent typo in the original + * (an unregistered name would fail to resolve at runtime) — ported as the + * evidently-intended self-loop (`step P_Alarms_final;`), matching this + * session's precedent of fixing clear original 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 { + exec P; + } + } + + state P_checkcond { + if (conn_breathing == 1) { exec P_checkcond1; } else { exec P_RepNotConnBreath; } + } + state P_checkcond1 { + if (conn_air_supply == 1) { exec P_checkcond2; } else { exec P_RepNotConnAir; } + } + state P_checkcond2 { + if (conn_power_source == 1) { exec P_checkcond3; } else { exec P_RepNotConnPower; } + } + state P_checkcond3 { + if (conn_patient == 0) { exec P_start_up; } else { exec 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) { + exec P_final; + } else { + rr_pcv' = RR_PCV; + ie_pcv' = IE_PCV; + p_insp_pcv' = P_INSP_PCV; + 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 { + exec P_start_up; + } + } + + state P_start_up1 { + if (b_powerOff == 1) { + exec 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 { + exec P_start_up1; + } + } + + state P_start_up2 { + if (b_powerOff == 1) { + exec P_final; + } else { + if (comm_memory == 1 && comm_cont_gui_ok == 1) { + init_succ' = 1; + step P_self_test; + } else { + exec 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 { + init_succ' = 0; + Status' = 4; + if (b_powerOff == 1) { + exec 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)) { + exec P_VentOff; + } else { + selfTest_fail' = 1; + sys_out_of_service' = 1; + step P_failSafe; + } + } + } + + /* Ventilation off mode */ + state P_VentOff { + a_IN_valve' = 0; + a_OUT_valve' = 1; + Status' = 5; + phase' = 0; + timer_insp' = 0; + timer_exp' = 0; + if (b_powerOff == 1) { + exec 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) { + exec P_PCV; + } else { + if (gui_req_change_mode_PSV == 1) { exec P_PSV; } else { exec 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) { + exec 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) { + exec 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) { + exec 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) { + exec 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) { + exec 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) { + exec 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) { + exec 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) { + exec 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) { + exec 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) { + exec 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) { exec P_final; } else { exec 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 { + exec 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 { + exec 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 { + exec 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 { + exec 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 { + exec P_alarms; + } + } + } + } + } + } + + state Idle_Alarms { + if (b_powerOff == 1) { exec P_Alarms_final; } else { exec 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 stark.distl formalism, not ported — see the + file header); eta_sav_6/eta_sav_16 are the original's local `main()` + doubles, 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; From 865222bfcee40013f0d918d9b345cb124073c473 Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Sun, 19 Jul 2026 12:22:26 +0200 Subject: [PATCH 31/50] Applied formatting --- crates/stark/src/ir.rs | 5 +++- crates/stark/src/lower.rs | 52 +++++++++++++++++++++++++-------------- 2 files changed, 38 insertions(+), 19 deletions(-) diff --git a/crates/stark/src/ir.rs b/crates/stark/src/ir.rs index 1aec616f6..be3ee1cad 100644 --- a/crates/stark/src/ir.rs +++ b/crates/stark/src/ir.rs @@ -336,7 +336,10 @@ pub enum CommandNode { Sequence(CommandRef, CommandRef), /// `[steps #] step target;` — controller-only. `steps` (if present) is /// evaluated once per step, matching Java's `Controller.doTick(k-1, ..)`. - Step { steps: Option, target: IrStateId }, + Step { + steps: Option, + target: IrStateId, + }, /// `exec target;` — controller-only. Exec(IrStateId), } diff --git a/crates/stark/src/lower.rs b/crates/stark/src/lower.rs index 31e87899f..cf535b409 100644 --- a/crates/stark/src/lower.rs +++ b/crates/stark/src/lower.rs @@ -1,11 +1,10 @@ //! Lowers a checked [StarkSpecification] to an [IrProgram]. See -//! `IR_LOWERING_PLAN.md` for the full design; this implements its Steps 0-3 -//! (crate infra, `Value`, the IR arena, and expression/function/global/ -//! variable/penalty lowering). +//! `IR_LOWERING_PLAN.md` for the full design; this implements its Steps 0-4 +//! (crate infra, `Value`, the IR arena, expression/function/global/variable/ +//! penalty lowering, and controller/environment lowering). //! -//! Components' controller states, the environment block, perturbations, -//! distances and formulas (`IR_LOWERING_PLAN.md`'s Steps 4-5) have no IR -//! representation yet — [lower] reports each as a +//! Perturbations, distances and formulas (`IR_LOWERING_PLAN.md`'s Step 5) +//! have no IR representation yet — [lower] reports each as a //! [DiagnosticKind::NotYetSupported] diagnostic (with a span) rather than //! panicking, so a spec using them fails gracefully instead of crashing. //! @@ -539,7 +538,10 @@ impl<'a> Lowerer<'a> { let Some(environment) = &self.spec.ast().environment else { return; }; - trace!("lowering the environment block with {} command(s)", environment.commands.len()); + trace!( + "lowering the environment block with {} command(s)", + environment.commands.len() + ); self.environment = self.lower_environment_commands(&environment.commands); } @@ -573,7 +575,9 @@ impl<'a> Lowerer<'a> { } => { 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)); + let else_branch = else_branch + .as_ref() + .and_then(|branch| self.lower_environment_command(branch)); Some(self.push_command(CommandNode::IfThenElse { guard, then_branch, @@ -589,7 +593,11 @@ impl<'a> Lowerer<'a> { } } - fn lower_environment_let(&mut self, bindings: &[ast::LocalVariable], body: &ast::EnvironmentCommand) -> Option { + 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); }; @@ -1229,7 +1237,8 @@ mod tests { #[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}"); + 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"); @@ -1249,8 +1258,9 @@ mod tests { 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 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 { @@ -1304,14 +1314,16 @@ mod tests { #[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 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 { + 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. @@ -1321,13 +1333,17 @@ mod tests { 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(_))); + 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 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"); From 18cc78b75965ba0be599c489d958ead7cf8c8f2a Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Mon, 20 Jul 2026 01:37:23 +0200 Subject: [PATCH 32/50] Added evaluation of expressions/programs --- crates/stark/src/eval/expr.rs | 376 ++++++++++++++++++++++++++++ crates/stark/src/eval/mod.rs | 21 ++ crates/stark/src/eval/sim.rs | 236 ++++++++++++++++++ crates/stark/src/eval/step.rs | 444 +++++++++++++++++++++++++++++++++ crates/stark/src/eval/store.rs | 98 ++++++++ 5 files changed, 1175 insertions(+) create mode 100644 crates/stark/src/eval/expr.rs create mode 100644 crates/stark/src/eval/mod.rs create mode 100644 crates/stark/src/eval/sim.rs create mode 100644 crates/stark/src/eval/step.rs create mode 100644 crates/stark/src/eval/store.rs diff --git a/crates/stark/src/eval/expr.rs b/crates/stark/src/eval/expr.rs new file mode 100644 index 000000000..b937f2009 --- /dev/null +++ b/crates/stark/src/eval/expr.rs @@ -0,0 +1,376 @@ +//! Expression and function-body evaluation over [IrProgram]'s arena, ported +//! from `StarkExpressionEvaluator.java`'s case analysis — but as a straight +//! post-order walk of `ExprRef`/`StmtRef` indices instead of a tree of +//! `Supplier`/lambda closures, since lowering already collapsed the AST into +//! that arena (see `IR_LOWERING_PLAN.md`). +//! +//! Every function here returns a [Value] and never panics: a malformed +//! runtime state (which shouldn't arise against a checked + lowered +//! [IrProgram]) yields [Value::Error], mirroring `StarkValue.ERROR_VALUE` — +//! see `EVALUATOR_PLAN.md`'s "the one contract to preserve". + +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::Value; + +use super::store::Store; + +/// Evaluates one expression against `store`, sampling from `rng` wherever +/// the expression does. +pub(crate) fn eval(program: &IrProgram, store: &mut Store, rng: &mut R, id: ExprRef) -> Value { + match *program.expr(id) { + ExprNode::Literal(value) => value, + ExprNode::Load(slot) => store.load(slot), + ExprNode::Not(inner) => !eval(program, store, rng, inner), + // Both always widen to `Real`, matching Java — see `ExprNode::Negate` + // and `ExprNode::Widen`'s doc comments in `ir.rs`. + ExprNode::Negate(inner) => eval(program, store, rng, inner).apply_unary(|x| -x), + ExprNode::Widen(inner) => eval(program, store, rng, inner).apply_unary(|x| x), + 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) + } + ExprNode::MathUnary(function, inner) => { + let value = eval(program, store, rng, inner); + value.apply_unary(math_unary_fn(function)) + } + ExprNode::MathBinary(function, left, right) => { + let left = eval(program, store, rng, left); + let right = eval(program, store, rng, right); + left.apply_binary(right, math_binary_fn(function)) + } + ExprNode::Select { + guard, + then_branch, + else_branch, + } => { + // Lazy, matching `StarkValue.ifThenElse`'s `Supplier`-based + // laziness in the Java reference: only the taken branch is + // evaluated, since the untaken one may sample (advancing `rng`) + // or divide by zero. + match eval(program, store, rng, guard) { + Value::Boolean(true) => eval(program, store, rng, then_branch), + Value::Boolean(false) => eval(program, store, rng, else_branch), + _ => Value::Error, + } + } + 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 + // (see `IR_LOWERING_PLAN.md`, "Why one flat slot space works"). + for (&slot, value) in function_ir.arguments.iter().zip(values) { + store.set(slot, value); + } + eval_stmt(program, store, rng, function_ir.body) + } + ExprNode::SampleUnit => 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`: `visitUniformExpression` indexes + // `elements[selected]` 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) -> Value { + match *program.stmt(id) { + StmtNode::Return(value) => eval(program, store, rng, value), + StmtNode::IfThenElse { + guard, + then_branch, + else_branch, + } => match eval(program, store, rng, guard) { + Value::Boolean(true) => eval_stmt(program, store, rng, then_branch), + Value::Boolean(false) => 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 => { + debug_assert!(false, "function body has no return on this path"); + Value::Error + } + }, + _ => Value::Error, + }, + 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) -> Value { + 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), + BinaryOp::Leq => left.is_less_or_equal_than(right), + BinaryOp::Eq => left.is_equal_to(right), + BinaryOp::Geq => left.is_greater_or_equal_than(right), + BinaryOp::Greater => left.is_greater_than(right), + // `&&`/`&` and `||`/`|` are one operation each, two spellings — see + // `Value::and`/`Value::or`'s doc comments. + BinaryOp::And | BinaryOp::BitAnd => left.and(right), + BinaryOp::Or | BinaryOp::BitOr => left.or(right), + } +} + +fn math_unary_fn(function: MathUnaryFunction) -> fn(f64) -> f64 { + match function { + MathUnaryFunction::Abs => f64::abs, + MathUnaryFunction::Acos => f64::acos, + MathUnaryFunction::Asin => f64::asin, + MathUnaryFunction::Atan => f64::atan, + MathUnaryFunction::Cbrt => f64::cbrt, + MathUnaryFunction::Ceil => f64::ceil, + MathUnaryFunction::Cos => f64::cos, + MathUnaryFunction::Cosh => f64::cosh, + MathUnaryFunction::Exp => f64::exp, + MathUnaryFunction::Expm1 => f64::exp_m1, + MathUnaryFunction::Floor => f64::floor, + MathUnaryFunction::Log => f64::ln, + MathUnaryFunction::Log10 => f64::log10, + MathUnaryFunction::Log1p => f64::ln_1p, + MathUnaryFunction::Signum => java_signum, + MathUnaryFunction::Sin => f64::sin, + MathUnaryFunction::Sinh => f64::sinh, + MathUnaryFunction::Sqrt => f64::sqrt, + MathUnaryFunction::Tan => f64::tan, + } +} + +fn math_binary_fn(function: MathBinaryFunction) -> fn(f64, f64) -> f64 { + match function { + MathBinaryFunction::Atan2 => f64::atan2, + MathBinaryFunction::Hypot => f64::hypot, + MathBinaryFunction::Max => java_max, + MathBinaryFunction::Min => java_min, + MathBinaryFunction::Pow => f64::powf, + } +} + +/// `Math.signum`: unlike [f64::signum] (which returns `±1.0` for `±0.0` and +/// never `0.0`), Java's version returns the zero itself (`0.0` or `-0.0`) +/// unchanged, and propagates `NaN`. +fn java_signum(x: f64) -> f64 { + if x == 0.0 || x.is_nan() { x } else { x.signum() } +} + +/// `Math.max`: propagates `NaN` if *either* argument is `NaN`. [f64::max] +/// instead returns the non-`NaN` argument, so it can't be used directly. +fn java_max(a: f64, b: f64) -> f64 { + if a.is_nan() || b.is_nan() { f64::NAN } else { a.max(b) } +} + +/// `Math.min`, see [java_max]. +fn java_min(a: f64, b: f64) -> f64 { + if a.is_nan() || b.is_nan() { f64::NAN } else { a.min(b) } +} + +/// `StarkValue.sample`: `from + rng.nextDouble() * (to - from)`. +fn sample_range(rng: &mut R, min: Value, max: Value) -> Value { + match (double_of(min), double_of(max)) { + (Some(from), Some(to)) => Value::Real(from + rng.random::() * (to - from)), + _ => Value::Error, + } +} + +/// `StarkValue.sampleNormal`. **Not actually Gaussian** — despite the name +/// and the `N[mean, variance]` syntax, the Java reference computes +/// `rng.nextDouble() * mean + variance` (a scaled-and-shifted uniform +/// sample), not a normal distribution. This is ported *exactly*, not +/// "fixed", so behaviour matches the reference tool; it reads as a bug in +/// `StarkValue.sampleNormal`, but is not this port's place to silently +/// correct. +fn sample_normal(rng: &mut R, mean: Value, variance: Value) -> Value { + match (double_of(mean), double_of(variance)) { + (Some(mean), Some(variance)) => Value::Real(rng.random::() * mean + variance), + _ => Value::Error, + } +} + +/// `StarkValue.doubleOf`, except a non-numeric value maps to `None` (evaluated +/// as [Value::Error] at the call site) rather than `Double.NaN` — every call +/// site here is already guaranteed numeric by `typecheck.rs` (`R[a,b]`'s +/// bounds and `N[m,v]`'s mean/variance are both checked against `real`), so +/// this only matters for an otherwise-unreachable malformed IR. +fn double_of(value: Value) -> Option { + match value { + Value::Integer(v) => Some(v as f64), + Value::Real(v) => Some(v), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use rand::SeedableRng; + use rand::rngs::StdRng; + use test_case::test_case; + use test_log::test; + + use super::*; + use crate::UntypedStarkSpecification; + use crate::lower; + + fn eval_expression(source: &str) -> Value { + let full_source = format!("const result = {source};"); + let spec = UntypedStarkSpecification::parse(&full_source) + .expect("should parse") + .check() + .expect("should check"); + let program = lower(&spec).expect("should lower"); + let mut rng = StdRng::seed_from_u64(0); + let store = Store::new(&program, &mut rng); + 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 select_only_evaluates_the_taken_branch() { + // The untaken branch divides by zero; if `Select` weren't lazy this + // would produce `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 spec = UntypedStarkSpecification::parse(source) + .expect("should parse") + .check() + .expect("should check"); + let program = lower(&spec).expect("should lower"); + let mut rng = StdRng::seed_from_u64(0); + let store = Store::new(&program, &mut rng); + 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 spec = UntypedStarkSpecification::parse(source) + .expect("should parse") + .check() + .expect("should check"); + let program = lower(&spec).expect("should lower"); + let mut rng = StdRng::seed_from_u64(0); + let store = Store::new(&program, &mut rng); + 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)) { + 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_java_quirk() { + // Pin the `rng.nextDouble() * 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, 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/mod.rs b/crates/stark/src/eval/mod.rs new file mode 100644 index 000000000..067bb33ad --- /dev/null +++ b/crates/stark/src/eval/mod.rs @@ -0,0 +1,21 @@ +//! The evaluator: executes a checked, lowered [crate::ir::IrProgram] — +//! expression evaluation, function calls, sampling, and simulation stepping. +//! See `EVALUATOR_PLAN.md` for the full design. +//! +//! ```text +//! parse -> resolve -> typecheck -> lower -> IrProgram -> [ evaluate ] +//! ``` +//! +//! The store (`Store`, one flat `Vec` indexed by [crate::ir::SlotId]) +//! and the per-component controller cursor (`Cursor`) are internal +//! implementation details, not part of this module's public surface — the +//! only things a caller needs are [Simulation] and [Observer]. + +mod expr; +mod sim; +mod step; +mod store; + +pub use sim::Observer; +pub use sim::RecordingObserver; +pub use sim::Simulation; diff --git a/crates/stark/src/eval/sim.rs b/crates/stark/src/eval/sim.rs new file mode 100644 index 000000000..a117b49a0 --- /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, matching `ControlledSystem`'s role in the Java +//! reference (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 (later) drive an ensemble of independently-seeded [Simulation]s +//! to build the `SampleSet`-style evolution sequence `EvolutionSequence.java` +//! models — `SampleSet`, sampled and regenerated lazily via +//! `generateUpTo` — 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, not into it. + +use rand::Rng; +use rand::SeedableRng; +use rand::rngs::StdRng; + +use crate::ir::IrProgram; +use crate::value::Value; + +use super::step::Cursor; +use super::step::macro_step; +use super::store::Store; + +/// 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 + /// `EvolutionSequence`/`SampleSet` would checkpoint in the Java + /// reference. + 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. +/// Mirrors `ControlledSystem`, minus the `Controller`/`DataStateFunction` +/// indirection lowering already collapsed into `program`. +pub struct Simulation<'a, R: Rng> { + program: &'a IrProgram, + store: Store, + cursors: Vec, + rng: R, + step: u64, +} + +impl<'a> Simulation<'a, StdRng> { + /// Builds a simulation seeded from a `u64`, for reproducibility. + /// **Not** bit-compatible with the Java reference's Mersenne-Twister + /// 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. See + /// `EVALUATOR_PLAN.md`'s "Deliberate deviations". + pub fn new(program: &'a IrProgram, seed: u64) -> Simulation<'a, StdRng> { + 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. + pub fn with_rng(program: &'a IrProgram, mut rng: R) -> Simulation<'a, R> { + let store = Store::new(program, &mut 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 + // — `ParallelController` doesn't care 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(); + Simulation { + program, + store, + cursors, + rng, + step: 0, + } + } + + /// The current `[0, n_variables)` state prefix. + pub fn state(&self) -> &[Value] { + self.store.state_prefix(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. + pub fn step(&mut self) { + macro_step(self.program, &mut self.store, &mut self.rng, &mut self.cursors); + self.step += 1; + } + + /// 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. + pub fn run(&mut self, steps: u64, observer: &mut impl Observer) { + for _ in 0..steps { + self.step(); + observer.on_step(self.step, self.state()); + } + } +} + +#[cfg(test)] +mod tests { + use test_log::test; + + use super::*; + use crate::UntypedStarkSpecification; + use crate::lower; + + fn build(source: &str) -> IrProgram { + let spec = UntypedStarkSpecification::parse(source) + .expect("should parse") + .check() + .expect("should check"); + lower(&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); + let mut observer = RecordingObserver::default(); + simulation.run(5, &mut observer); + + 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); + let mut b = Simulation::new(&program, 123); + for _ in 0..10 { + a.step(); + b.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); + let mut b = Simulation::new(&program, 2); + a.step(); + b.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); + let mut observer = RecordingObserver::default(); + simulation.run(2, &mut observer); + 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 000000000..afa9117e6 --- /dev/null +++ b/crates/stark/src/eval/step.rs @@ -0,0 +1,444 @@ +//! 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. +//! Mirrors `ControlledSystem.sampleNext`: +//! +//! ```java +//! public SystemState sampleNext(RandomGenerator rg) { +//! EffectStep step = controller.next(rg, state); +//! int c_step = state.getStep(); +//! DataState newState = environment.apply(rg, state.apply(step.effect())); +//! newState.setStep(c_step+1); +//! return new ControlledSystem(step.next(), environment, newState); +//! } +//! ``` +//! +//! [Cursor] replaces Java's recursive `Controller` object tree +//! (`StepController`/`ExecController`/`AssignmentController`/`NilController`/ +//! `ParallelController`) with a plain value: a controller's entire "next +//! state" is exactly "which named state, and how many ticks left before it's +//! live" (`StepController`'s idle count is the only state Java's controller +//! tree actually threads through `next()`). Walking a state's body is one +//! flat recursion over [CommandNode] instead of a tree of controller +//! objects, since lowering already collapsed the controller AST into that +//! arena (see `IR_LOWERING_PLAN.md`'s Step 4). + +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::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` — + /// `NilController`: 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. +/// Mirrors Java's `DataStateUpdate`: pushed into a list during the walk, +/// 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 `ParallelController`'s "both effects +/// concatenated before the single `apply`"), then the environment runs +/// against the post-controller state. +pub(crate) fn macro_step(program: &IrProgram, store: &mut Store, rng: &mut R, cursors: &mut [Cursor]) { + 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` (`IR_LOWERING_PLAN.md` + // Step 4), 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); + } +} + +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, +) -> Cursor { + match cursor { + Cursor::Nil => Cursor::Nil, + Cursor::Idle { remaining, target } => { + if remaining <= 1 { + Cursor::Run(target) + } else { + 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 => Cursor::Nil, + Walk::Transitioned(next) => next, + }, + None => Cursor::Nil, + }, + } +} + +fn run_command( + program: &IrProgram, + store: &mut Store, + rng: &mut R, + updates: &mut Vec, + exec_budget: &mut u32, + id: CommandRef, +) -> Walk { + match *program.command(id) { + CommandNode::Assign(update) => { + // `StarkValue.isTrue` semantics: a missing guard is + // unconditionally true; a non-boolean guard is false, not an + // error (see `Value::truthy`'s doc comment). + let guarded = match update.guard { + Some(guard) => eval(program, store, rng, guard).truthy(), + None => true, + }; + if guarded { + let value = eval(program, store, rng, update.value); + updates.push(PendingUpdate { + target: update.target, + value, + }); + } + Walk::FellThrough + } + CommandNode::IfThenElse { + guard, + then_branch, + else_branch, + } => { + let branch = if eval(program, store, rng, guard).truthy() { + then_branch + } else { + else_branch + }; + match branch { + Some(branch) => run_command(program, store, rng, updates, exec_budget, branch), + None => 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 => 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 => transitioned, + }, + CommandNode::Step { steps, target } => { + // `StepController`: `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 { + Some(steps) => match eval(program, store, rng, steps) { + Value::Integer(v) => v, + // 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); + // treat it as "no delay" rather than panicking. + _ => 0, + }, + None => 0, + }; + let cursor = if k <= 0 { + Cursor::Run(target) + } else { + Cursor::Idle { + remaining: k as u32, + target, + } + }; + Walk::Transitioned(cursor) + } + CommandNode::Exec(target) => { + // Same-tick tail jump: `ExecController.next` immediately + // delegates to `target`'s controller within the same call, 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 Walk::Transitioned(Cursor::Nil); + } + *exec_budget -= 1; + match program.state(target).body { + Some(body) => run_command(program, store, rng, updates, exec_budget, body), + None => 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::lower; + + fn build(source: &str) -> IrProgram { + let spec = UntypedStarkSpecification::parse(source) + .expect("should parse") + .check() + .expect("should check"); + lower(&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); + let mut cursors = Vec::new(); + macro_step(&program, &mut store, &mut rng, &mut cursors); + assert_eq!(store.state_prefix(&program), &[Value::Integer(2), Value::Integer(1)]); + } + + #[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); + 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); + assert_eq!(store.state_prefix(&program), &[Value::Integer(1)]); + + macro_step(&program, &mut store, &mut rng, &mut cursors); + 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); + 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); + 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); + 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); + // 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); + 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); + // 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 000000000..0d7320865 --- /dev/null +++ b/crates/stark/src/eval/store.rs @@ -0,0 +1,98 @@ +//! The flat evaluator store: one `Vec` indexed directly by [SlotId], +//! matching `IR_LOWERING_PLAN.md`'s slot layout (`[0, n_variables)` state, +//! `[n_variables, n_globals)` `const`/`param`, `[n_globals, n_slots)` scratch) +//! instead of `StarkStore.java`'s `variable -> value` closure/map. + +use rand::Rng; + +use crate::ir::IrProgram; +use crate::ir::SlotId; +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 + /// (already a valid order — no forward references), then every + /// variable's `initial_value`. + /// + /// `rng` is threaded through even though `typecheck.rs` disallows + /// sampling directly in a global/variable initializer (`random_allowed: + /// false` there) — a *function call* reached from one is still allowed + /// to sample internally (`random_allowed: true` for function bodies), so + /// [eval] needs an `Rng` regardless of whether this particular call + /// tree happens to use it. + pub(crate) fn new(program: &IrProgram, rng: &mut R) -> Store { + let mut store = Store { + slots: vec![Value::Error; 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); + } + 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 — exactly + /// what `EvolutionSequence`/`SampleSet` would sample in the Java + /// reference (see `EVALUATOR_PLAN.md`'s Step 5). + 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 super::*; + use crate::UntypedStarkSpecification; + use crate::lower; + + fn lower_source(source: &str) -> IrProgram { + let spec = UntypedStarkSpecification::parse(source) + .expect("should parse") + .check() + .expect("should check"); + lower(&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); + + let state = store.state_prefix(&program); + assert_eq!(state, &[Value::Integer(7)]); + } +} From 87b2bbb4b66cf809a1c8fe1ec359e35ced7bce26 Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Mon, 20 Jul 2026 01:37:42 +0200 Subject: [PATCH 33/50] Fixed some issues with the examples --- .../stark/abz2025_two_lanes_two_cars.stark | 23 ++- examples/stark/turtle_hospital.stark | 18 +- examples/stark/ventilator.stark | 159 ++++++++++++------ 3 files changed, 145 insertions(+), 55 deletions(-) diff --git a/examples/stark/abz2025_two_lanes_two_cars.stark b/examples/stark/abz2025_two_lanes_two_cars.stark index c1880d79d..4d5f6b710 100644 --- a/examples/stark/abz2025_two_lanes_two_cars.stark +++ b/examples/stark/abz2025_two_lanes_two_cars.stark @@ -130,7 +130,11 @@ component Vehicle1 { controller { state Control { if (my_timer > 0) { - exec Control; + /* BUG FIXED: was `exec Control;`. Java `Control` is + `ifThenElse(my_timer>0, doTick(ref Control), ...)`; `doTick` 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) { @@ -196,12 +200,20 @@ component Vehicle1 { } state Idling { - if (my_timer > 0) { exec Idling; } else { exec Control; } + /* Java `Idling` = `ifThenElse(my_timer>0, doTick(ref Idling), reference("Control"))`. + BUG FIXED: the then-branch `doTick(ref Idling)` 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) { - exec Moving_right; + /* BUG FIXED: was `exec Moving_right;`. Java then-branch is + `doTick(ref 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; @@ -229,7 +241,10 @@ component Vehicle1 { state Moving_left { if (my_timer > 0) { - exec Moving_left; + /* BUG FIXED: was `exec Moving_left;`. Java then-branch is + `doTick(ref 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; diff --git a/examples/stark/turtle_hospital.stark b/examples/stark/turtle_hospital.stark index 9c4bc73a3..1104bc313 100644 --- a/examples/stark/turtle_hospital.stark +++ b/examples/stark/turtle_hospital.stark @@ -74,7 +74,13 @@ function wp_y(int i) { } function heading_to(int wp, real x, real y) { - return (wp_x(wp) == x ? 0.0 : (wp_x(wp) < x ? PI : 0.0)) + atan((wp_y(wp) - y) / (wp_x(wp) - x)); + /* BUG FIXED: Java's ternary `?:` binds looser than `+`, so in + (WPx[wp]==x) ? 0 : ((WPx[wp] 0) { step Accelerate; } else { - step Ctrl; + /* BUG FIXED: was `step Ctrl;`. Java Smart_hospital `Accelerate` is + `ifThenElse(timer_V>0, doTick(ref Accelerate), reference("Ctrl"))`; the + else is a bare same-tick `exec`, not a time-consuming `step` (same as + turtle.stark). */ + exec Ctrl; } } state Decelerate { if (timer_V > 0) { step Decelerate; } else { - step Ctrl; + /* BUG FIXED: was `step Ctrl;` — same as Accelerate (bare `reference("Ctrl")` + in Java else = same-tick `exec`). */ + exec Ctrl; } } state Stop { diff --git a/examples/stark/ventilator.stark b/examples/stark/ventilator.stark index a14261300..f38845d88 100644 --- a/examples/stark/ventilator.stark +++ b/examples/stark/ventilator.stark @@ -33,17 +33,53 @@ * `Perturbation` queries near the top of `main()` are (see the bottom of * this file). * - * `ControllerRegistry`'s `Controller.doTick` (advance one round with no - * variable change) maps to this grammar's `exec`; `Controller.doAction` - * (assign, then transition) maps to a `step`-terminated block whose body is - * the assignments; nested `Controller.ifThenElse` maps directly to nested - * `if`/`else`. `P_Alarms_final`'s self-transition references + * Both `Controller.doAction(assignments, next)` and `Controller.doTick(next)` + * construct the *same* underlying class (`ActionController` — `doTick` is + * literally `doAction` with a trivial no-op update function, see + * `stark.controller.Controller`/`ActionController`), and `ActionController + * .next()` *always* returns `EffectStep(updates, next)` unconditionally — + * i.e. it always consumes exactly one simulation round (one call from + * `ControlledSystem.sampleNext`, with a full environment step in between), + * with `next` becoming "the controller" for the round after. So every + * `doAction`/`doTick` is ported as `assignments; step next;` — `step` is + * used *regardless* of whether `next` is a bare `registry.reference(...)` or + * an inline node, and regardless of whether there's an accompanying + * assignment. `Controller.ifThenElse`, by contrast + * (`IfThenElseController.next()`), recurses into whichever branch + * immediately, in the *same* call — it never consumes a round on its own, + * so a chain of nested `ifThenElse`s (as most states below have) collapses + * into one `if`/`else` in a single STARK state with no round cost. The one + * place this grammar's `exec X;` is the right translation is when an + * `ifThenElse` branch is a *bare* `registry.reference(X)` with no + * `doAction`/`doTick` wrapping it at all — that branch, taken, recurses + * directly into `X`'s own `.next()` in the same call (confirmed against + * `StarkControllerStateGenerator.visitControllerExecAction`, which does + * exactly this): no state in this file's controller happens to have that + * shape, so `exec` doesn't appear here (every leaf below is reached via a + * `doAction`/`doTick`, hence `step`). (An earlier version of this file had + * the `step`/`exec` distinction backwards; every controller state has been + * corrected.) + * + * A `doAction`/`doTick` whose `next` is itself a fresh, *inline* + * `Controller.ifThenElse(...)` (not a bare `registry.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 Java source + * line-by-line and confirmed to only ever wrap `doAction`/`doTick` around a + * *bare* reference (never an inline branch), so no further splits are + * needed. `P_Alarms_final`'s self-transition references * `registry.reference("P_alarms_final")` (lower-case `a`) instead of the * actually-registered `"P_Alarms_final"` — a latent typo in the original * (an unregistered name would fail to resolve at runtime) — ported as the - * evidently-intended self-loop (`step P_Alarms_final;`), matching this - * session's precedent of fixing clear original typos (e.g. `toll.stark`'s - * `pen_stress`/`accel==N` fixes) rather than reproducing them. + * evidently-intended self-loop (`step P_Alarms_final;`, since the original + * wraps it in `Controller.doTick`), matching this session's precedent of + * fixing clear original typos (e.g. `toll.stark`'s `pen_stress`/`accel==N` + * fixes) rather than reproducing them. */ param PRM = 20.0; @@ -358,21 +394,21 @@ component Ventilator { b_powerOn' = 0; step P_checkcond; } else { - exec P; + step P; } } state P_checkcond { - if (conn_breathing == 1) { exec P_checkcond1; } else { exec P_RepNotConnBreath; } + if (conn_breathing == 1) { step P_checkcond1; } else { step P_RepNotConnBreath; } } state P_checkcond1 { - if (conn_air_supply == 1) { exec P_checkcond2; } else { exec P_RepNotConnAir; } + if (conn_air_supply == 1) { step P_checkcond2; } else { step P_RepNotConnAir; } } state P_checkcond2 { - if (conn_power_source == 1) { exec P_checkcond3; } else { exec P_RepNotConnPower; } + if (conn_power_source == 1) { step P_checkcond3; } else { step P_RepNotConnPower; } } state P_checkcond3 { - if (conn_patient == 0) { exec P_start_up; } else { exec P_RepConnPatient; } + if (conn_patient == 0) { step P_start_up; } else { step P_RepConnPatient; } } state P_RepNotConnBreath { conn_failToPowerOn' = 1; step P; } @@ -383,18 +419,28 @@ component Ventilator { /* renamed from "P_start-up" (hyphens are not valid identifiers here) */ state P_start_up { if (b_powerOff == 1) { - exec P_final; + step P_final; } else { + /* BUG FIXED: the original wraps this assignment's `next` in a fresh + inline `Controller.ifThenElse(...)`, not a bare `registry.reference`, + so it's a SECOND round (its own `ActionController.next()` call, + 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; - 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; - } + 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; } } @@ -403,13 +449,13 @@ component Ventilator { nr_of_retries' = 0; step P_failSafe; } else { - exec P_start_up; + step P_start_up; } } state P_start_up1 { if (b_powerOff == 1) { - exec P_final; + step P_final; } else { if (conn_power_source == 1) { nr_of_retries_p' = 0; @@ -426,19 +472,19 @@ component Ventilator { nr_of_retries_p' = 0; step P_failSafe; } else { - exec P_start_up1; + step P_start_up1; } } state P_start_up2 { if (b_powerOff == 1) { - exec P_final; + step P_final; } else { if (comm_memory == 1 && comm_cont_gui_ok == 1) { init_succ' = 1; step P_self_test; } else { - exec P_start_upFail; + step P_start_upFail; } } } @@ -453,15 +499,24 @@ component Ventilator { /* Self-test mode */ state P_self_test { + /* BUG FIXED: the original wraps this assignment's `next` in a fresh + inline `Controller.ifThenElse(...)`, not a bare `registry.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) { - exec P_final; + 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)) { - exec P_VentOff; + step P_VentOff; } else { selfTest_fail' = 1; sys_out_of_service' = 1; @@ -472,14 +527,22 @@ component Ventilator { /* Ventilation off mode */ state P_VentOff { + /* BUG FIXED: same pattern as `P_start_up`/`P_self_test` above — the + original wraps this assignment's `next` in a fresh inline + `Controller.ifThenElse(...)`, 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) { - exec P_final; + step P_final; } else { if (fs == 1) { a_IN_valve' = 0; @@ -491,9 +554,9 @@ component Ventilator { step P_VentOff; } else { if (gui_req_change_mode_PCV == 1) { - exec P_PCV; + step P_PCV; } else { - if (gui_req_change_mode_PSV == 1) { exec P_PSV; } else { exec P_VentOff; } + if (gui_req_change_mode_PSV == 1) { step P_PSV; } else { step P_VentOff; } } } } @@ -583,7 +646,7 @@ component Ventilator { state P_IP_PCV { if (b_powerOff == 1) { - exec P_final; + step P_final; } else { if (fs == 1) { a_IN_valve' = 0; @@ -623,7 +686,7 @@ component Ventilator { state P_RM { if (b_powerOff == 1) { - exec P_final; + step P_final; } else { if (fs == 1) { a_IN_valve' = 0; @@ -663,7 +726,7 @@ component Ventilator { state P_PCV_exp0 { if (b_powerOff == 1) { - exec P_final; + step P_final; } else { if (fs == 1) { a_IN_valve' = 0; @@ -684,7 +747,7 @@ component Ventilator { state P_PCV_exp { if (b_powerOff == 1) { - exec P_final; + step P_final; } else { if (fs == 1) { a_IN_valve' = 0; @@ -748,7 +811,7 @@ component Ventilator { state P_EP_PCV { if (b_powerOff == 1) { - exec P_final; + step P_final; } else { if (fs == 1) { a_IN_valve' = 0; @@ -850,7 +913,7 @@ component Ventilator { state P_IP_PSV { if (b_powerOff == 1) { - exec P_final; + step P_final; } else { if (fs == 1) { a_IN_valve' = 0; @@ -876,7 +939,7 @@ component Ventilator { state P_RM_PSV { if (b_powerOff == 1) { - exec P_final; + step P_final; } else { if (fs == 1) { a_IN_valve' = 0; @@ -902,7 +965,7 @@ component Ventilator { state P_PSV_exp0 { if (b_powerOff == 1) { - exec P_final; + step P_final; } else { if (fs == 1) { a_IN_valve' = 0; @@ -924,7 +987,7 @@ component Ventilator { state P_PSV_exp { if (b_powerOff == 1) { - exec P_final; + step P_final; } else { if (fs == 1) { a_IN_valve' = 0; @@ -994,7 +1057,7 @@ component Ventilator { state P_EP_PSV { if (b_powerOff == 1) { - exec P_final; + step P_final; } else { if (fs == 1) { a_IN_valve' = 0; @@ -1045,7 +1108,7 @@ component Ventilator { } state P_failSafeI { - if (b_powerOff == 1) { exec P_final; } else { exec P_failSafeI; } + if (b_powerOff == 1) { step P_final; } else { step P_failSafeI; } } } init P @@ -1090,7 +1153,7 @@ component Alarm { a_LED' = 1; step P_alarms; } else { - exec P_alarms; + step P_alarms; } } else { if (s_PS_ins_pressure < (MIN_P_INSP/100*P_INSP_PSV) || comm_sens_valves_ok == 0 @@ -1101,7 +1164,7 @@ component Alarm { a_LED' = 1; step P_alarms; } else { - exec P_alarms; + step P_alarms; } } } else { @@ -1112,7 +1175,7 @@ component Alarm { a_LED' = 1; step P_alarms; } else { - exec P_alarms; + step P_alarms; } } else { if (s_PS_ins_pressure < (MIN_P_INSP/100*P_INSP_PSV) || comm_sens_valves_ok == 0 @@ -1121,7 +1184,7 @@ component Alarm { a_LED' = 1; step P_alarms; } else { - exec P_alarms; + step P_alarms; } } } @@ -1152,7 +1215,7 @@ component Alarm { a_LED' = 1; step P_alarms; } else { - exec P_alarms; + step P_alarms; } } } @@ -1161,7 +1224,7 @@ component Alarm { } state Idle_Alarms { - if (b_powerOff == 1) { exec P_Alarms_final; } else { exec Idle_Alarms; } + if (b_powerOff == 1) { step P_Alarms_final; } else { step Idle_Alarms; } } state P_Alarms_final { From 0bdb8d4098d0f321b1d53c50b0e50cc44099788e Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Mon, 20 Jul 2026 01:38:06 +0200 Subject: [PATCH 34/50] Fixed a discrepancy between the Java implementation and this in the unary operators --- crates/stark/src/ir.rs | 41 +++- crates/stark/src/lib.rs | 1 + crates/stark/src/lower.rs | 32 ++- crates/stark/src/typecheck.rs | 25 ++- crates/stark/src/value.rs | 371 +++++++++++++++++++++++++++++++++- 5 files changed, 457 insertions(+), 13 deletions(-) diff --git a/crates/stark/src/ir.rs b/crates/stark/src/ir.rs index be3ee1cad..8f7096fd2 100644 --- a/crates/stark/src/ir.rs +++ b/crates/stark/src/ir.rs @@ -147,7 +147,6 @@ impl ExprList { /// /// Deliberate simplifications made while lowering (see `IR_LOWERING_PLAN.md` /// Step 2 for the full rationale): -/// - `Expression::UnaryPlus` disappears (it is the identity). /// - `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 @@ -165,7 +164,23 @@ pub enum ExprNode { /// 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 Java's `StarkExpressionEvaluator`, which + /// routes unary `-`/`+` through the *same* always-widening + /// `DoubleUnaryOperator` mechanism as the math functions + /// (`unaryOperators` map, `StarkInteger.apply(DoubleUnaryOperator)` -> + /// `StarkReal`), not 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 reference tool, but worth reconsidering if that + /// surprises users badly enough in practice. Negate(ExprRef), + /// `+x`. Unlike most unary-plus operators this is *not* the identity at + /// the type level: Java widens it exactly like `Negate` (same + /// `unaryOperators` map, 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), @@ -440,6 +455,25 @@ impl IrProgram { &self.slots[id.value() as usize] } + /// The number of `[0, n_variables)` slots — the simulation state prefix. + /// Equal to `self.variables.len()`, since every variable gets exactly one + /// slot and slot allocation lays this range out first (see + /// `IR_LOWERING_PLAN.md`'s slot layout table). + pub fn n_variables(&self) -> u32 { + self.variables.len() as u32 + } + + /// The number of `[0, n_globals)` slots — variables plus `const`/`param` + /// globals. Equal to `n_variables() + self.globals.len()`. + 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. + pub fn n_slots(&self) -> u32 { + self.slots.len() as u32 + } + pub fn variables(&self) -> &[VariableInfo] { &self.variables } @@ -525,7 +559,9 @@ impl IrProgram { match *node { ExprNode::Literal(_) | ExprNode::SampleUnit => {} ExprNode::Load(slot) => check_slot(slot)?, - ExprNode::Not(inner) | ExprNode::Negate(inner) | ExprNode::MathUnary(_, inner) => check_expr(inner)?, + 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)?; @@ -879,6 +915,7 @@ impl IrProgram { 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!( "({} {} {})", diff --git a/crates/stark/src/lib.rs b/crates/stark/src/lib.rs index bd3ba29a4..c43399a3d 100644 --- a/crates/stark/src/lib.rs +++ b/crates/stark/src/lib.rs @@ -1,6 +1,7 @@ mod ast; mod consume; mod diagnostics; +pub mod eval; // `ir`/`value` are kept as their own public modules, rather than flattened // like the rest of this crate's API, because `ir::BinaryOp` deliberately // collides in name (not in meaning) with `ast::BinaryOp` — see `ir.rs`'s doc diff --git a/crates/stark/src/lower.rs b/crates/stark/src/lower.rs index cf535b409..11e185e92 100644 --- a/crates/stark/src/lower.rs +++ b/crates/stark/src/lower.rs @@ -797,12 +797,16 @@ impl<'a> Lowerer<'a> { let ty = self.expr_type(inner); self.push_expr(ExprNode::Not(inner), span, ty) } - // The identity: lowers straight through to its operand, pushing - // no node of its own. - ExpressionKind::UnaryPlus(inner) => self.lower_expression(inner), + // Both widen to `real`, matching Java's `unaryOperators["+"/"-"]` + // — 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.expr_type(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), @@ -871,6 +875,17 @@ impl<'a> Lowerer<'a> { } } + /// [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 { @@ -1187,13 +1202,18 @@ mod tests { } #[test] - fn unary_plus_disappears() { + fn unary_plus_widens_to_real_like_unary_minus() { + // Matches Java: `unaryOperators["+"]`/`["-"]` both route through the + // same always-widening `DoubleUnaryOperator` 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::Literal(Value::Integer(1)) + ExprNode::Widen(inner) if matches!(program.expr(*inner), ExprNode::Literal(Value::Integer(1))) )); + assert_eq!(*program.expr_type(global.value), StarkType::Real); } #[test] diff --git a/crates/stark/src/typecheck.rs b/crates/stark/src/typecheck.rs index 7ce4ac96b..5293cd57e 100644 --- a/crates/stark/src/typecheck.rs +++ b/crates/stark/src/typecheck.rs @@ -581,6 +581,17 @@ impl Checker<'_> { } } + /// [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, @@ -659,8 +670,18 @@ impl Checker<'_> { self.expect(&StarkType::Boolean, ty, &inner.span) } ExpressionKind::UnaryPlus(inner) | ExpressionKind::UnaryMinus(inner) => { - let ty = self.check_expression(inner, random_allowed); - self.expect_numerical(ty, &inner.span) + // Matches Java: `StarkExpressionEvaluator`'s `unaryOperators` + // map routes `+`/`-` through the *same* always-widening + // `DoubleUnaryOperator` mechanism as `abs`/`sqrt`/etc. + // (`StarkInteger.apply(DoubleUnaryOperator)` -> `StarkReal`), + // 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 reference tool, but worth + // reconsidering if that 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 { diff --git a/crates/stark/src/value.rs b/crates/stark/src/value.rs index 0bc68583c..3ddf3687a 100644 --- a/crates/stark/src/value.rs +++ b/crates/stark/src/value.rs @@ -5,9 +5,25 @@ //! analysis is one flat `Copy` enum, matching the arena IR's "small, `Copy`, //! contiguous" philosophy (see `IR_LOWERING_PLAN.md`). //! -//! Only construction, `Debug`/`Display` and [Value::type_of] land here. The -//! arithmetic (`sum`/`product`/`isLessThan`/…, with Java's int-preserving- -//! then-widening promotion rules) belongs to the (deferred) evaluator. +//! Construction, `Debug`/`Display`, [Value::type_of] and the arithmetic +//! (`sum`/`product`/`is_less_than`/…) all land here, ported from +//! `StarkValue`'s static dispatch methods with Java's int-preserving-then- +//! widening promotion rules (`int ⊕ int -> Integer`, anything touching a +//! `real -> Real`). The operator *dispatch* (`ir::BinaryOp` / `MathUnaryFunction` +//! / `MathBinaryFunction` -> the right method here) lives in `eval::expr`, +//! not here, so this module never has to depend on `ir`. +//! +//! Every operation here returns [Value::Error] instead of panicking on a +//! type mismatch, mirroring `StarkValue.ERROR_VALUE`. See +//! `EVALUATOR_PLAN.md`'s "the one contract to preserve" for why, and its +//! "Deliberate deviations from the Java reference" for the handful of places +//! this intentionally does *not* match Java: integer division/modulo by zero +//! (and the `i64::MIN / -1` overflow) yield [Value::Error] rather than +//! throwing/panicking, and `==` is defined on [Value::Boolean] and +//! [Value::Custom] as well as the numeric cases — Java's `StarkValue.isEqualTo` +//! only dispatches on `StarkInteger`/`StarkReal` and silently errors on any +//! other pairing (including two equal booleans), which reads as an oversight +//! rather than a deliberate semantics, so it isn't preserved. use std::fmt; @@ -55,6 +71,214 @@ impl Value { Value::Error => StarkType::Error, } } + + /// Mirrors `StarkValue.isTrue`: a non-boolean value is simply *not* true + /// (no error) — this is the "is this guard satisfied" reading used by + /// the controller/environment stepper. Contrast with the `Select`/`if` + /// expression, which errors on a non-boolean guard (see `eval::expr`). + pub fn truthy(&self) -> bool { + matches!(self, Value::Boolean(true)) + } + + /// `StarkValue.sum` / `StarkInteger.sum` / `StarkReal.sum`. Integer + /// overflow wraps rather than panicking (Java's 32-bit `int` also wraps + /// silently on `+`/`-`/`*`; this just does it at 64 bits). + pub fn sum(self, other: Value) -> Value { + numeric_op(self, other, i64::wrapping_add, |a, b| a + b) + } + + /// `StarkValue.product`. + pub fn product(self, other: Value) -> Value { + numeric_op(self, other, i64::wrapping_mul, |a, b| a * b) + } + + /// `StarkValue.subtraction`. + pub fn subtraction(self, other: Value) -> Value { + numeric_op(self, other, i64::wrapping_sub, |a, b| a - b) + } + + /// `StarkValue.division`. **Deliberate deviation:** Java's `int / int` + /// throws `ArithmeticException` on a zero divisor (the source even flags + /// this as unresolved: `//TODO: Check how to handle division by zero!`). + /// This must never panic, so `int / 0` (and the `i64::MIN / -1` overflow) + /// yield [Value::Error] instead. Real division keeps `f64`'s `±inf`/`NaN` + /// behaviour, since that never threw in Java either. + pub fn division(self, other: Value) -> Value { + match (self, other) { + (Value::Integer(a), Value::Integer(b)) => a.checked_div(b).map(Value::Integer).unwrap_or(Value::Error), + (Value::Integer(a), Value::Real(b)) => Value::Real(a as f64 / b), + (Value::Real(a), Value::Integer(b)) => Value::Real(a / b as f64), + (Value::Real(a), Value::Real(b)) => Value::Real(a / b), + _ => Value::Error, + } + } + + /// `StarkValue.modulo`. Same zero/overflow guard as [Value::division]. + pub fn modulo(self, other: Value) -> Value { + match (self, other) { + (Value::Integer(a), Value::Integer(b)) => a.checked_rem(b).map(Value::Integer).unwrap_or(Value::Error), + (Value::Integer(a), Value::Real(b)) => Value::Real(a as f64 % b), + (Value::Real(a), Value::Integer(b)) => Value::Real(a % b as f64), + (Value::Real(a), Value::Real(b)) => Value::Real(a % b), + _ => Value::Error, + } + } + + /// Truncating integer division (`//`, `ir::BinaryOp::IntDiv`). + /// + /// **Not a port of Java behaviour — there isn't any to port.** The `.g4` + /// grammar parses `//` (`mulDivExpression` accepts `'*'|'/'|'//'`), but + /// `StarkExpressionEvaluator`'s `binaryOperators` map only registers + /// `"+" "*" "-" "/" "%"` plus the math functions; `getBinaryOperator` + /// falls back to `(x,y) -> ERROR_VALUE` for anything else, so every use + /// of `//` in the Java reference evaluates to `ERROR_VALUE` unconditionally + /// — the operator parses but was never implemented. Rather than replicate + /// that gap, this implements the operator its syntax promises: truncating + /// division that always yields an integral quotient — `Integer` for + /// `int // int` (same zero/overflow guard as [Value::division]), and the + /// real quotient truncated toward zero, as a `Real`, whenever either side + /// is real. + pub fn int_div(self, other: Value) -> Value { + match (self, other) { + (Value::Integer(a), Value::Integer(b)) => a.checked_div(b).map(Value::Integer).unwrap_or(Value::Error), + (Value::Integer(a), Value::Real(b)) => Value::Real((a as f64 / b).trunc()), + (Value::Real(a), Value::Integer(b)) => Value::Real((a / b as f64).trunc()), + (Value::Real(a), Value::Real(b)) => Value::Real((a / b).trunc()), + _ => Value::Error, + } + } + + /// `StarkValue.isLessThan`. + pub fn is_less_than(self, other: Value) -> Value { + comparison_op(self, other, |a, b| a < b, |a, b| a < b) + } + + /// `StarkValue.isLessOrEqualThan`. + pub fn is_less_or_equal_than(self, other: Value) -> Value { + comparison_op(self, other, |a, b| a <= b, |a, b| a <= b) + } + + /// `StarkValue.isGreaterOrEqualThan`. + pub fn is_greater_or_equal_than(self, other: Value) -> Value { + comparison_op(self, other, |a, b| a >= b, |a, b| a >= b) + } + + /// `StarkValue.isGreaterThan`. + pub fn is_greater_than(self, other: Value) -> Value { + comparison_op(self, other, |a, b| a > b, |a, b| a > b) + } + + /// `StarkValue.isEqualTo`, **extended**: Java's version dispatches only + /// on `StarkInteger`/`StarkReal` and returns `ERROR_VALUE` for every other + /// pairing — including two equal `StarkBoolean`s or two equal + /// `StarkCustomValue`s, since neither class overrides it. That reads as + /// an oversight (`.equals()` is defined and correct on both; `isEqualTo` + /// just never calls it) rather than an intended "booleans/custom values + /// aren't comparable" semantics, especially since `typecheck.rs` already + /// accepts `==` between two booleans or two same-typed custom values. So + /// this covers those cases too, numeric comparison still widening. + pub fn is_equal_to(self, other: Value) -> Value { + match (self, other) { + (Value::Boolean(a), Value::Boolean(b)) => Value::Boolean(a == b), + (Value::Custom(a), Value::Custom(b)) => Value::Boolean(a == b), + // Exact integer comparison when both sides are `Integer` (not + // widened through `f64`, which loses precision above 2^53) — + // matches `StarkInteger.isEqualTo`'s own `instanceof StarkInteger` + // fast path. + _ => comparison_op(self, other, |a, b| a == b, |a, b| a == b), + } + } + + /// `StarkValue.and` (`StarkBoolean.and`). Boolean-only, like Java: both + /// `&&` and `&` (`ir::BinaryOp::And`/`BitAnd`) lower to this — the two + /// spellings are one grammar rule split across two precedence levels in + /// both the Java `.g4` and `stark_grammar.pest`, not two operations (see + /// `visitAndExpression`, which ignores `ctx.op.getText()` entirely). + pub fn and(self, other: Value) -> Value { + match (self, other) { + (Value::Boolean(a), Value::Boolean(b)) => Value::Boolean(a && b), + _ => Value::Error, + } + } + + /// `StarkValue.or` (`StarkBoolean.or`). See [Value::and]'s doc comment — + /// `||` and `|` (`Or`/`BitOr`) are likewise one operation, two spellings. + pub fn or(self, other: Value) -> Value { + match (self, other) { + (Value::Boolean(a), Value::Boolean(b)) => Value::Boolean(a || b), + _ => Value::Error, + } + } + + /// `StarkValue.apply(DoubleUnaryOperator, ..)`: always widens to `Real`, + /// even for an integer argument — `max(1, 2)` is `Real(2.0)`, not + /// `Integer(2)`. Used for every `MathUnaryFunction`. + pub fn apply_unary(self, f: impl Fn(f64) -> f64) -> Value { + match self { + Value::Integer(v) => Value::Real(f(v as f64)), + Value::Real(v) => Value::Real(f(v)), + _ => Value::Error, + } + } + + /// `StarkValue.apply(DoubleBinaryOperator, ..)`: the binary counterpart + /// of [Value::apply_unary], used for every `MathBinaryFunction`. + pub fn apply_binary(self, other: Value, f: impl Fn(f64, f64) -> f64) -> Value { + match (self, other) { + (Value::Integer(a), Value::Integer(b)) => Value::Real(f(a as f64, b as f64)), + (Value::Integer(a), Value::Real(b)) => Value::Real(f(a as f64, b)), + (Value::Real(a), Value::Integer(b)) => Value::Real(f(a, b as f64)), + (Value::Real(a), Value::Real(b)) => Value::Real(f(a, b)), + _ => Value::Error, + } + } +} + +/// The shared "int-preserving-then-widening" promotion used by `+`, `*`, `-`: +/// `int op int -> Integer`; anything touching a `Real` (or a non-numeric +/// operand) -> `Real`, or [Value::Error] if either side isn't numeric at all. +fn numeric_op(lhs: Value, rhs: Value, int_op: impl Fn(i64, i64) -> i64, real_op: impl Fn(f64, f64) -> f64) -> Value { + match (lhs, rhs) { + (Value::Integer(a), Value::Integer(b)) => Value::Integer(int_op(a, b)), + (Value::Integer(a), Value::Real(b)) => Value::Real(real_op(a as f64, b)), + (Value::Real(a), Value::Integer(b)) => Value::Real(real_op(a, b as f64)), + (Value::Real(a), Value::Real(b)) => Value::Real(real_op(a, b)), + _ => Value::Error, + } +} + +/// The comparison shared by `<`, `<=`, `>=`, `>`, `==`: `int_op` compares two +/// `Integer`s exactly (matching `StarkInteger`'s own `instanceof StarkInteger` +/// fast path — not widened through `f64`, which loses precision above 2^53); +/// any pairing touching a `Real` widens through `real_op` instead. Anything +/// else (including a mismatched non-numeric pairing) is [Value::Error]. +fn comparison_op( + lhs: Value, + rhs: Value, + int_op: impl Fn(i64, i64) -> bool, + real_op: impl Fn(f64, f64) -> bool, +) -> Value { + match (lhs, rhs) { + (Value::Integer(a), Value::Integer(b)) => Value::Boolean(int_op(a, b)), + (Value::Integer(a), Value::Real(b)) => Value::Boolean(real_op(a as f64, b)), + (Value::Real(a), Value::Integer(b)) => Value::Boolean(real_op(a, b as f64)), + (Value::Real(a), Value::Real(b)) => Value::Boolean(real_op(a, b)), + _ => Value::Error, + } +} + +/// Boolean negation (`!x`), `StarkValue.negate`. An `impl` of the standard +/// trait rather than an inherent `not` method, so `!value` reads naturally +/// at call sites and doesn't collide with `std::ops::Not::not`. +impl std::ops::Not for Value { + type Output = Value; + + fn not(self) -> Value { + match self { + Value::Boolean(v) => Value::Boolean(!v), + _ => Value::Error, + } + } } impl fmt::Display for Value { @@ -89,4 +313,145 @@ mod tests { assert_eq!(Value::Boolean(false).to_string(), "false"); assert_eq!(Value::Error.to_string(), ""); } + + #[test] + fn sum_preserves_integer_then_widens() { + assert_eq!(Value::Integer(1).sum(Value::Integer(2)), Value::Integer(3)); + assert_eq!(Value::Integer(1).sum(Value::Real(2.0)), Value::Real(3.0)); + assert_eq!(Value::Real(1.0).sum(Value::Integer(2)), Value::Real(3.0)); + assert_eq!(Value::Real(1.0).sum(Value::Real(2.0)), Value::Real(3.0)); + assert_eq!(Value::Boolean(true).sum(Value::Integer(1)), Value::Error); + assert_eq!(Value::Integer(1).sum(Value::Boolean(true)), Value::Error); + } + + #[test] + fn product_and_subtraction_promote_the_same_way() { + assert_eq!(Value::Integer(3).product(Value::Integer(4)), Value::Integer(12)); + assert_eq!(Value::Integer(3).product(Value::Real(4.0)), Value::Real(12.0)); + assert_eq!(Value::Integer(5).subtraction(Value::Integer(2)), Value::Integer(3)); + assert_eq!(Value::Real(5.0).subtraction(Value::Integer(2)), 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)), Value::Error); + assert_eq!(Value::Integer(1).modulo(Value::Integer(0)), Value::Error); + assert_eq!(Value::Integer(1).int_div(Value::Integer(0)), Value::Error); + // The i64::MIN / -1 overflow is likewise caught, not a panic. + assert_eq!(Value::Integer(i64::MIN).division(Value::Integer(-1)), Value::Error); + } + + #[test] + fn integer_division_truncates_like_java_int_division() { + assert_eq!(Value::Integer(7).division(Value::Integer(2)), Value::Integer(3)); + assert_eq!(Value::Integer(-7).division(Value::Integer(2)), Value::Integer(-3)); + } + + #[test] + fn real_division_by_zero_keeps_f64_infinities() { + assert_eq!(Value::Real(1.0).division(Value::Real(0.0)), Value::Real(f64::INFINITY)); + assert!(matches!( + Value::Real(0.0).division(Value::Real(0.0)), + 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)), Value::Integer(3)); + assert_eq!(Value::Real(7.5).int_div(Value::Integer(2)), Value::Real(3.0)); + assert_eq!(Value::Real(-7.5).int_div(Value::Integer(2)), Value::Real(-3.0)); + } + + #[test] + fn comparisons_are_numeric_only_and_widen() { + assert_eq!(Value::Integer(1).is_less_than(Value::Integer(2)), Value::Boolean(true)); + assert_eq!(Value::Integer(2).is_less_than(Value::Real(2.5)), Value::Boolean(true)); + assert_eq!(Value::Boolean(true).is_less_than(Value::Integer(1)), Value::Error); + } + + #[test] + fn equality_covers_numeric_boolean_and_custom() { + assert_eq!(Value::Integer(2).is_equal_to(Value::Real(2.0)), Value::Boolean(true)); + assert_eq!( + Value::Boolean(true).is_equal_to(Value::Boolean(true)), + Value::Boolean(true) + ); + assert_eq!( + Value::Boolean(true).is_equal_to(Value::Boolean(false)), + Value::Boolean(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), Value::Boolean(true)); + assert_eq!(a.is_equal_to(c), Value::Boolean(false)); + assert_eq!(Value::Integer(1).is_equal_to(Value::Boolean(true)), Value::Error); + } + + #[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)), Value::Boolean(false)); + } + + #[test] + fn and_or_are_boolean_only() { + assert_eq!(Value::Boolean(true).and(Value::Boolean(false)), Value::Boolean(false)); + assert_eq!(Value::Boolean(true).or(Value::Boolean(false)), Value::Boolean(true)); + assert_eq!(Value::Integer(1).and(Value::Boolean(true)), Value::Error); + } + + #[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), Value::Boolean(false)); + assert_eq!(!Value::Integer(1), Value::Error); + } + + #[test] + fn arithmetic_negate_and_widen_always_widen_to_real() { + // `-x`/`+x` are *not* integer-preserving, matching Java's + // `unaryOperators` map, which routes both through the same + // always-widening `DoubleUnaryOperator` mechanism as the math + // functions — see `ExprNode::Negate`'s doc comment. + assert_eq!(Value::Integer(3).apply_unary(|x| -x), Value::Real(-3.0)); + assert_eq!(Value::Real(3.0).apply_unary(|x| -x), Value::Real(-3.0)); + assert_eq!(Value::Boolean(true).apply_unary(|x| -x), Value::Error); + assert_eq!(Value::Integer(3).apply_unary(|x| x), Value::Real(3.0)); + } + + #[test] + fn math_functions_always_widen_to_real() { + // The pinning test from `EVALUATOR_PLAN.md`: `max(1, 2)` is `Real(2.0)`, + // not `Integer(2)`, since `StarkInteger.apply(DoubleBinaryOperator)` + // always returns a `StarkReal`. + assert_eq!( + Value::Integer(1).apply_binary(Value::Integer(2), f64::max), + Value::Real(2.0) + ); + assert_eq!(Value::Integer(4).apply_unary(f64::sqrt), Value::Real(2.0)); + } + + #[test] + fn truthy_is_false_not_error_for_non_boolean() { + assert!(Value::Boolean(true).truthy()); + assert!(!Value::Boolean(false).truthy()); + assert!(!Value::Integer(1).truthy()); + assert!(!Value::Error.truthy()); + } } From cd124bc29b3083b7a420f691ddb63114acd1f26e Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Mon, 20 Jul 2026 22:04:53 +0200 Subject: [PATCH 35/50] Extended the lowering --- crates/stark/src/ir.rs | 694 ++++++++++++++++++++++++++++++-- crates/stark/src/lower.rs | 772 ++++++++++++++++++++++++++++++++---- crates/stark/src/resolve.rs | 18 +- 3 files changed, 1373 insertions(+), 111 deletions(-) diff --git a/crates/stark/src/ir.rs b/crates/stark/src/ir.rs index 8f7096fd2..991003e50 100644 --- a/crates/stark/src/ir.rs +++ b/crates/stark/src/ir.rs @@ -1,15 +1,18 @@ -//! The evaluation IR that `lower.rs` produces: a flat arena of small, `Copy` -//! nodes rather than a closure tree, so evaluation walks an array instead of -//! chasing pointers. See `IR_LOWERING_PLAN.md` for the design rationale. +//! 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. See `IR_LOWERING_PLAN.md` for the design +//! rationale. //! -//! Currently populated by lowering: constants/parameters (as [GlobalInit]), -//! variables (as [VariableInfo]), functions (as [FunctionIr]), penalties (as +//! 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. Perturbations, distances and formulas -//! are not lowered yet (`lower.rs` reports them as -//! [crate::diagnostics::DiagnosticKind::NotYetSupported]) and so have no IR -//! representation here yet either — see the plan's Step 5. +//! 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). use std::fmt; @@ -68,6 +71,24 @@ 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 // --------------------------------------------------------------------------- @@ -146,7 +167,7 @@ impl ExprList { /// One node of the expression arena. /// /// Deliberate simplifications made while lowering (see `IR_LOWERING_PLAN.md` -/// Step 2 for the full rationale): +/// for the full rationale): /// - `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 @@ -160,6 +181,15 @@ impl ExprList { #[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 `MISSING_GRAMMAR_FEATURES.md`), + /// so reaching one at run time means lowering has a bug; `eval` reports it + /// as [crate::value::EvalError::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), @@ -286,9 +316,14 @@ pub struct FunctionIr { pub body: StmtRef, } -/// A lowered `penalty name = expr;`. -#[derive(Clone, Copy, Debug)] +/// 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, } @@ -312,7 +347,7 @@ pub struct Update { /// 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` (see `IR_LOWERING_PLAN.md`'s Step 4). +/// `Exec` (see `IR_LOWERING_PLAN.md`). /// /// A `Vec`/`Vec` — a /// `{ .. }` block — lowers to a left-associated chain of `Sequence(prior, @@ -324,7 +359,8 @@ pub struct Update { /// `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 Step 4's `IR_LOWERING_PLAN.md` note and the `buffered_swap_*` tests. +/// see `IR_LOWERING_PLAN.md`'s "Semantics that are easy to get silently +/// wrong" and the `buffered_swap_*` tests. /// /// **Where control-flow termination lives**: this arena does not itself /// enforce that every path through a controller state reaches a `Step`/ @@ -380,6 +416,171 @@ pub struct ComponentIr { pub initial: Vec, } +// --------------------------------------------------------------------------- +// Robustness sub-languages: perturbation / distance / ROBTL formula +// --------------------------------------------------------------------------- +// +// These three mirror the shape of `ast.rs`'s `PerturbationExpression`/ +// `DistanceExpression`/`RobtlFormula`, each collapsed the same way the +// expression arena is: `Reference(DefRef)` (a reference to another named +// declaration of the same kind) resolves at lowering time to the referent's +// `*Id`, 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's 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]. + +/// 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 `MISSING_GRAMMAR_FEATURES.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 // --------------------------------------------------------------------------- @@ -407,6 +608,13 @@ pub struct IrProgram { /// The environment block, if the specification has one. `None` if it is /// absent, or present but empty — both mean "nothing runs". 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 { @@ -498,14 +706,55 @@ impl IrProgram { &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: every - /// `ExprRef`/`StmtRef`/`CommandRef`/`SlotId`/`FunctionId`/`IrStateId` - /// reachable from a top-level entry (globals, variables, functions, - /// penalties, components, the environment) is in bounds, and every list - /// slice lies within `expr_lists`. This is a partial version of the full - /// check `IR_LOWERING_PLAN.md`'s Step 7 describes — it does not yet - /// cover perturbations/distances/formulas, since those aren't lowered - /// yet. + /// `ExprRef`/`StmtRef`/`CommandRef`/`SlotId`/`FunctionId`/`IrStateId`/ + /// `PenaltyId`/`PerturbationId`/`DistanceId`/`FormulaId` reachable from a + /// top-level entry (globals, variables, functions, penalties, components, + /// the environment, perturbation/distance/formula declarations) is in + /// bounds, and every list slice lies within `expr_lists`. pub fn validate(&self) -> Result<(), String> { let check_expr = |id: ExprRef| -> Result<(), String> { if (id.value() as usize) < self.exprs.len() { @@ -545,6 +794,46 @@ impl IrProgram { 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!( @@ -557,11 +846,12 @@ impl IrProgram { for (index, node) in self.exprs.iter().enumerate() { match *node { - ExprNode::Literal(_) | ExprNode::SampleUnit => {} + 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::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)?; @@ -624,8 +914,47 @@ impl IrProgram { } } + // 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)?; @@ -634,11 +963,26 @@ impl IrProgram { } 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)?; } @@ -713,6 +1057,102 @@ impl IrProgram { 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(()) } @@ -814,6 +1254,37 @@ impl fmt::Display for IrProgram { 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(()) @@ -912,6 +1383,7 @@ impl IrProgram { 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)), @@ -971,6 +1443,168 @@ impl IrProgram { } } } + + /// 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 { @@ -1026,3 +1660,13 @@ fn display_math_binary(function: MathBinaryFunction) -> &'static str { 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/lower.rs b/crates/stark/src/lower.rs index 11e185e92..e789c374d 100644 --- a/crates/stark/src/lower.rs +++ b/crates/stark/src/lower.rs @@ -1,14 +1,29 @@ //! Lowers a checked [StarkSpecification] to an [IrProgram]. See -//! `IR_LOWERING_PLAN.md` for the full design; this implements its Steps 0-4 -//! (crate infra, `Value`, the IR arena, expression/function/global/variable/ -//! penalty lowering, and controller/environment lowering). +//! `IR_LOWERING_PLAN.md` for the full design, which this implements in +//! full: expression/function/global/variable/penalty lowering, +//! controller/environment lowering, and perturbation/distance/formula +//! lowering. //! -//! Perturbations, distances and formulas (`IR_LOWERING_PLAN.md`'s Step 5) -//! have no IR representation yet — [lower] reports each as a -//! [DiagnosticKind::NotYetSupported] diagnostic (with a span) rather than -//! panicking, so a spec using them fails gracefully instead of crashing. +//! [lower]'s `Result` return type is kept even though every construct in the +//! grammar now lowers successfully (nothing in this pass currently produces +//! an `Err`): it's the seam a future not-yet-implemented construct would +//! reuse (`DiagnosticKind::NotYetSupported` exists for exactly that), not a +//! sign that failure is possible today. //! -//! One deliberate deviation from the plan's stated Step 3 order ("Globals, +//! Perturbations, distances and ROBTL formulas lower the same way +//! expressions do: each top-level `perturbation`/ +//! `distance`/`formula name = ..;` declaration's `Reference(DefRef)` to +//! another declaration of the same kind is resolved to that declaration's +//! root `*Id` at lowering time (`def_perturbations`/`def_distances`/ +//! `def_formulas`, mirroring `def_functions`) — no name lookups survive into +//! the IR here either. `resolve.rs` declares each of these *after* its own +//! body resolves (like functions, constants, and everything else that can be +//! referenced by name), so a reference can only ever name something already +//! lowered — the same no-forward-references property that makes +//! `def_functions`' "callee already lowered" invariant sound applies here +//! unchanged. +//! +//! 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 @@ -36,19 +51,26 @@ 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::DiagnosticKind; 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; @@ -56,7 +78,12 @@ 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; @@ -74,14 +101,12 @@ use crate::value::Value; /// Lowers `spec` to an [IrProgram]. /// -/// The `Result` exists for exactly one error class: constructs that resolve -/// and type-check but have no IR representation yet (see -/// `MISSING_GRAMMAR_FEATURES.md`; Java has the same hole). Everything else is -/// infallible. +/// 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 lower(spec: &StarkSpecification) -> Result { let mut lowerer = Lowerer::new(spec); - lowerer.check_not_yet_supported(); lowerer.allocate_variable_slots(); lowerer.allocate_global_slots(); lowerer.lower_globals(); @@ -90,11 +115,14 @@ pub fn lower(spec: &StarkSpecification) -> Result { 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; \ - {} diagnostic(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(), @@ -105,6 +133,9 @@ pub fn lower(spec: &StarkSpecification) -> Result { 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() ); @@ -123,6 +154,12 @@ pub fn lower(spec: &StarkSpecification) -> Result { 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!( @@ -169,6 +206,17 @@ struct Lowerer<'a> { /// 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, @@ -177,6 +225,12 @@ struct Lowerer<'a> { states: Vec, components: Vec, environment: Option, + perturbations: Vec, + perturbation_decls: Vec, + distances: Vec, + distance_decls: Vec, + formulas: Vec, + formula_decls: Vec, diagnostics: Diagnostics, } @@ -202,6 +256,10 @@ impl<'a> Lowerer<'a> { 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(), @@ -209,6 +267,12 @@ impl<'a> Lowerer<'a> { 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(), } } @@ -249,41 +313,51 @@ impl<'a> Lowerer<'a> { id } - fn expr_type(&self, id: ExprRef) -> StarkType { - self.expr_types[id.value() as usize].clone() + /// 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); } - // -- Constructs with no IR representation yet ------------------------- + /// [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); + } - /// Reports every perturbation, distance and formula in `spec` as not yet - /// supported (components/controllers and the environment are lowered — - /// see [Self::lower_components]/[Self::lower_environment]). Collected as - /// diagnostics (rather than the first one short-circuiting) so a spec - /// using several of these still reports all of them at once, the way - /// `resolve.rs`/`typecheck.rs` do for their own diagnostics. - fn check_not_yet_supported(&mut self) { - for perturbation in &self.spec.ast().perturbations { - self.diagnostics.error( - perturbation.name.span.clone(), - DiagnosticKind::NotYetSupported { - construct: "perturbations", - }, - ); - } - for distance in &self.spec.ast().distances { - self.diagnostics.error( - distance.name.span.clone(), - DiagnosticKind::NotYetSupported { construct: "distances" }, - ); - } - for formula in &self.spec.ast().formulas { - self.diagnostics.error( - formula.name.span.clone(), - DiagnosticKind::NotYetSupported { - construct: "ROBTL formulas", - }, - ); - } + /// 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 ---------------------------------------------------- @@ -302,15 +376,17 @@ impl<'a> Lowerer<'a> { } fn allocate_variable_slot(&mut self, variable: &Variable) { - let Some(id) = variable.id else { return }; - let ty = self.types.type_of(id).cloned().unwrap_or(StarkType::Error); + 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.def_slots[id.value()] = Some(slot); + self.bind_def_slot(id, slot); } /// Allocates `[n_variables, n_globals)`: `const`s then `param`s, each in @@ -318,26 +394,30 @@ impl<'a> Lowerer<'a> { /// 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) = constant.id else { continue }; - let ty = self.types.type_of(id).cloned().unwrap_or(StarkType::Error); + 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.def_slots[id.value()] = Some(slot); + self.bind_def_slot(id, slot); } for parameter in &self.spec.ast().parameters { - let Some(id) = parameter.id else { continue }; - let ty = self.types.type_of(id).cloned().unwrap_or(StarkType::Error); + 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.def_slots[id.value()] = Some(slot); + self.bind_def_slot(id, slot); } } @@ -345,17 +425,18 @@ impl<'a> Lowerer<'a> { fn lower_globals(&mut self) { for constant in &self.spec.ast().constants { - self.lower_global(constant.id, &constant.value); + self.lower_global(constant.id, "constant", &constant.name.name, &constant.value); } for parameter in &self.spec.ast().parameters { - self.lower_global(parameter.id, ¶meter.value); + self.lower_global(parameter.id, "parameter", ¶meter.name.name, ¶meter.value); } } - fn lower_global(&mut self, id: Option, value: &Expression) { - let Some(id) = id else { return }; + 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 }); } @@ -371,8 +452,11 @@ impl<'a> Lowerer<'a> { } fn lower_variable(&mut self, variable: &Variable) { - let Some(id) = variable.id else { return }; + 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() @@ -388,10 +472,282 @@ impl<'a> Lowerer<'a> { fn lower_penalties(&mut self) { for penalty in &self.spec.ast().penalties { let value = self.lower_expression(&penalty.value); - self.penalties.push(PenaltyIr { 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) { @@ -416,7 +772,9 @@ impl<'a> Lowerer<'a> { // 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) = state.id else { continue }; + 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(), @@ -488,7 +846,7 @@ impl<'a> Lowerer<'a> { 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.local_slots[local_id.value()] = Some(slot); + self.bind_local_slot(local_id, slot); let body = self.lower_controller_command_list(body); Some(self.push_command(CommandNode::Let { slot, @@ -605,7 +963,7 @@ impl<'a> Lowerer<'a> { 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.local_slots[local_id.value()] = Some(slot); + self.bind_local_slot(local_id, slot); let inner = self.lower_environment_let(rest, body); Some(self.push_command(CommandNode::Let { slot, @@ -623,7 +981,9 @@ impl<'a> Lowerer<'a> { } fn lower_function(&mut self, function: &Function) { - let Some(id) = function.id else { return }; + let Some(id) = resolved(function.id, "function", &function.name.name) else { + return; + }; trace!( "lowering function `{}` with {} argument(s)", function.name.name, @@ -632,7 +992,9 @@ impl<'a> Lowerer<'a> { let mut arguments = Vec::with_capacity(function.arguments.len()); for argument in &function.arguments { - let Some(local_id) = argument.id else { continue }; + 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(), @@ -640,7 +1002,7 @@ impl<'a> Lowerer<'a> { SlotKind::Local, argument.name.span.clone(), ); - self.local_slots[local_id.value()] = Some(slot); + self.bind_local_slot(local_id, slot); arguments.push(slot); } @@ -704,7 +1066,7 @@ impl<'a> Lowerer<'a> { 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.local_slots[local_id.value()] = Some(slot); + self.bind_local_slot(local_id, slot); let body = self.lower_function_statement(body); self.push_stmt(StmtNode::Let { slot, value, body }) } @@ -745,7 +1107,11 @@ impl<'a> Lowerer<'a> { false, "ExpressionKind::Iterator is unreachable: no aggregate context exists in the current grammar" ); - self.push_expr(ExprNode::Literal(Value::Error), span, StarkType::Error) + 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"); @@ -1001,6 +1367,20 @@ impl<'a> Lowerer<'a> { } } +/// 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 @@ -1081,6 +1461,16 @@ fn map_math_unary(function: MathFunction) -> MathUnaryFunction { } } +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; @@ -1222,8 +1612,8 @@ mod tests { // 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 (`IR_LOWERING_PLAN.md` - // Step 4) will build on. + // controller/environment update semantics (`IR_LOWERING_PLAN.md`'s + // "Semantics that are easy to get silently wrong") 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]); @@ -1311,7 +1701,8 @@ mod tests { // `buffered_swap_reads_pre_state_slots` above does): both // assignments must read the *pre*-step value, matching Java's // "collect updates, apply them all at the end of the step" - // semantics (`IR_LOWERING_PLAN.md`'s Step 4). + // semantics (`IR_LOWERING_PLAN.md`'s "Semantics that are easy to + // get silently wrong"). 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 { @@ -1381,6 +1772,50 @@ mod tests { 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 @@ -1400,4 +1835,193 @@ mod tests { *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/resolve.rs b/crates/stark/src/resolve.rs index e0464a82d..ce0dabdab 100644 --- a/crates/stark/src/resolve.rs +++ b/crates/stark/src/resolve.rs @@ -1,23 +1,17 @@ //! Name resolution: assigns every declaration a stable [DefId]/[StateId]/ //! [LocalId] and rewrites every reference in place to point at the -//! declaration it names, mirroring `parsing/SymbolTable.java`. +//! declaration it names. //! -//! STARK has no forward references: a name is only visible to expressions -//! that come *after* its declaration in source order (this is what -//! `SpecificationLanguageValidator`'s single top-down visitor pass implies, -//! and nothing in the example specs relies on forward references either — -//! including function self-recursion, which this resolver also rejects: a -//! function's own name is registered only *after* its body has been -//! resolved). 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. +//! 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 (state machines are naturally mutually -//! recursive), so each component's states are registered up front. +//! 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 From ef4e1a3af7111836897f61eaef7b96a15326e00e Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Mon, 20 Jul 2026 22:05:02 +0200 Subject: [PATCH 36/50] Added missing dependency --- Cargo.lock | 1 + crates/stark/Cargo.toml | 2 ++ 2 files changed, 3 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 881185842..9ab1066a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1402,6 +1402,7 @@ dependencies = [ "merc_utilities", "pest", "pest_derive", + "rand", "test-case", "test-log", "thiserror", diff --git a/crates/stark/Cargo.toml b/crates/stark/Cargo.toml index eeafd7668..cd070ff61 100644 --- a/crates/stark/Cargo.toml +++ b/crates/stark/Cargo.toml @@ -2,6 +2,7 @@ name = "merc_stark" license = "APACHE-2.0" version = "1.0.0" +readme = "README.md" edition.workspace = true rust-version.workspace = true @@ -11,6 +12,7 @@ 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 From 3c93b7055e43c7ec796b8fde03ebd4385a3e6437 Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Mon, 20 Jul 2026 22:05:45 +0200 Subject: [PATCH 37/50] Change the values to not silently result in errors, but instead return a Result from the evaluation --- crates/stark/src/value.rs | 513 ++++++++++++++++++++++++-------------- 1 file changed, 322 insertions(+), 191 deletions(-) diff --git a/crates/stark/src/value.rs b/crates/stark/src/value.rs index 3ddf3687a..6cb90edbb 100644 --- a/crates/stark/src/value.rs +++ b/crates/stark/src/value.rs @@ -1,62 +1,94 @@ -//! Runtime values, ported from `values/StarkValue.java`'s sealed hierarchy. -//! -//! The Java reference models `StarkValue` as an interface with one class per -//! case (`StarkIntegerValue`, `StarkRealValue`, ...). Here the same case -//! analysis is one flat `Copy` enum, matching the arena IR's "small, `Copy`, -//! contiguous" philosophy (see `IR_LOWERING_PLAN.md`). -//! -//! Construction, `Debug`/`Display`, [Value::type_of] and the arithmetic -//! (`sum`/`product`/`is_less_than`/…) all land here, ported from -//! `StarkValue`'s static dispatch methods with Java's int-preserving-then- -//! widening promotion rules (`int ⊕ int -> Integer`, anything touching a -//! `real -> Real`). The operator *dispatch* (`ir::BinaryOp` / `MathUnaryFunction` -//! / `MathBinaryFunction` -> the right method here) lives in `eval::expr`, -//! not here, so this module never has to depend on `ir`. -//! -//! Every operation here returns [Value::Error] instead of panicking on a -//! type mismatch, mirroring `StarkValue.ERROR_VALUE`. See -//! `EVALUATOR_PLAN.md`'s "the one contract to preserve" for why, and its -//! "Deliberate deviations from the Java reference" for the handful of places -//! this intentionally does *not* match Java: integer division/modulo by zero -//! (and the `i64::MIN / -1` overflow) yield [Value::Error] rather than -//! throwing/panicking, and `==` is defined on [Value::Boolean] and -//! [Value::Custom] as well as the numeric cases — Java's `StarkValue.isEqualTo` -//! only dispatches on `StarkInteger`/`StarkReal` and silently errors on any -//! other pairing (including two equal booleans), which reads as an oversight -//! rather than a deliberate semantics, so it isn't preserved. - use std::fmt; +use thiserror::Error; + use crate::ast::DefId; use crate::resolve::SymbolTable; use crate::types::StarkType; +/// Error when evaluating an expression. +#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] +pub enum EvalError { + /// 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 — `SampleSet.distance` throws + /// `IllegalArgumentException("Incompatible size of data sets!")` here. + /// Only reachable by asking for a perturbed sequence with a zero + /// `scale`, since a perturbed sequence is otherwise `scale` replicas of + /// the reference one. + #[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, +} + +/// Which [Value] case a value was, without its payload. +#[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. -/// -/// `element` is the declared element's position within `type_id`'s own -/// `elements` list (`0` for the first alternative, and so on), not a name — -/// mirroring `StarkCustomValue`, but index-keyed rather than string-keyed, so -/// comparing two custom values of the same type is an integer compare. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct CustomValue { - /// The `DefId` of the owning `type X = ...;` declaration (not the - /// element itself). + /// 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 (future) evaluator. +/// A runtime value flowing through the evaluator. #[derive(Clone, Copy, Debug, PartialEq)] pub enum Value { Integer(i64), Real(f64), Boolean(bool), Custom(CustomValue), - /// The result of a runtime error (e.g. division by zero). The evaluator - /// is meant to propagate this rather than panic, mirroring - /// `StarkValue.ERROR_VALUE` — worth preserving even though the evaluator - /// itself is out of scope here. - Error, } impl Value { @@ -68,59 +100,91 @@ impl Value { Value::Real(_) => StarkType::Real, Value::Boolean(_) => StarkType::Boolean, Value::Custom(custom) => StarkType::Custom(symbols.def(custom.type_id).name.clone()), - Value::Error => StarkType::Error, } } - /// Mirrors `StarkValue.isTrue`: a non-boolean value is simply *not* true - /// (no error) — this is the "is this guard satisfied" reading used by - /// the controller/environment stepper. Contrast with the `Select`/`if` - /// expression, which errors on a non-boolean guard (see `eval::expr`). - pub fn truthy(&self) -> bool { - matches!(self, Value::Boolean(true)) + /// 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(EvalError::ExpectedBoolean { + context, + found: other.kind(), + }), + } } - /// `StarkValue.sum` / `StarkInteger.sum` / `StarkReal.sum`. Integer - /// overflow wraps rather than panicking (Java's 32-bit `int` also wraps - /// silently on `+`/`-`/`*`; this just does it at 64 bits). - pub fn sum(self, other: Value) -> Value { - numeric_op(self, other, i64::wrapping_add, |a, b| a + b) + /// Reads this value as an integer. + pub fn as_integer(self, context: &'static str) -> Result { + match self { + Value::Integer(value) => Ok(value), + other => Err(EvalError::ExpectedInteger { + context, + found: other.kind(), + }), + } } - /// `StarkValue.product`. - pub fn product(self, other: Value) -> Value { - numeric_op(self, other, i64::wrapping_mul, |a, b| a * b) + /// Widens either numeric case to `f64`. Errors on a non-numeric values.. + pub fn as_number(self, context: &'static str) -> Result { + match self { + Value::Integer(value) => Ok(value as f64), + Value::Real(value) => Ok(value), + other => Err(EvalError::ExpectedNumber { + context, + found: other.kind(), + }), + } } - /// `StarkValue.subtraction`. - pub fn subtraction(self, other: Value) -> Value { - numeric_op(self, other, i64::wrapping_sub, |a, b| a - b) + /// Integer overflow wraps rather than panicking. + pub fn sum(self, other: Value) -> Result { + numeric_op("+", self, other, i64::wrapping_add, |a, b| a + b) } - /// `StarkValue.division`. **Deliberate deviation:** Java's `int / int` - /// throws `ArithmeticException` on a zero divisor (the source even flags - /// this as unresolved: `//TODO: Check how to handle division by zero!`). - /// This must never panic, so `int / 0` (and the `i64::MIN / -1` overflow) - /// yield [Value::Error] instead. Real division keeps `f64`'s `±inf`/`NaN` - /// behaviour, since that never threw in Java either. - pub fn division(self, other: Value) -> Value { + 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) + } + + /// `StarkValue.division`. Integer division by zero (and the + /// `i64::MIN / -1` overflow) is [EvalError::DivisionByZero]; real division + /// keeps `f64`'s `±inf`/`NaN` behaviour — see the module doc comment. + pub fn division(self, other: Value) -> Result { match (self, other) { - (Value::Integer(a), Value::Integer(b)) => a.checked_div(b).map(Value::Integer).unwrap_or(Value::Error), - (Value::Integer(a), Value::Real(b)) => Value::Real(a as f64 / b), - (Value::Real(a), Value::Integer(b)) => Value::Real(a / b as f64), - (Value::Real(a), Value::Real(b)) => Value::Real(a / b), - _ => Value::Error, + (Value::Integer(a), Value::Integer(b)) => { + a.checked_div(b).map(Value::Integer).ok_or(EvalError::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)), } } /// `StarkValue.modulo`. Same zero/overflow guard as [Value::division]. - pub fn modulo(self, other: Value) -> Value { + pub fn modulo(self, other: Value) -> Result { match (self, other) { - (Value::Integer(a), Value::Integer(b)) => a.checked_rem(b).map(Value::Integer).unwrap_or(Value::Error), - (Value::Integer(a), Value::Real(b)) => Value::Real(a as f64 % b), - (Value::Real(a), Value::Integer(b)) => Value::Real(a % b as f64), - (Value::Real(a), Value::Real(b)) => Value::Real(a % b), - _ => Value::Error, + (Value::Integer(a), Value::Integer(b)) => { + a.checked_rem(b).map(Value::Integer).ok_or(EvalError::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)), } } @@ -138,34 +202,39 @@ impl Value { /// `int // int` (same zero/overflow guard as [Value::division]), and the /// real quotient truncated toward zero, as a `Real`, whenever either side /// is real. - pub fn int_div(self, other: Value) -> Value { + pub fn int_div(self, other: Value) -> Result { match (self, other) { - (Value::Integer(a), Value::Integer(b)) => a.checked_div(b).map(Value::Integer).unwrap_or(Value::Error), - (Value::Integer(a), Value::Real(b)) => Value::Real((a as f64 / b).trunc()), - (Value::Real(a), Value::Integer(b)) => Value::Real((a / b as f64).trunc()), - (Value::Real(a), Value::Real(b)) => Value::Real((a / b).trunc()), - _ => Value::Error, + (Value::Integer(a), Value::Integer(b)) => { + a.checked_div(b).map(Value::Integer).ok_or(EvalError::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)), } } - /// `StarkValue.isLessThan`. - pub fn is_less_than(self, other: Value) -> Value { - comparison_op(self, other, |a, b| a < b, |a, b| a < b) + /// `StarkValue.isLessThan`. Returns a bare `bool` rather than a + /// [Value::Boolean]: now that the failure case is an `Err`, the success + /// case is known to be a boolean, and saying so in the type keeps a caller + /// from having to re-inspect it. `eval::expr` wraps it back into a [Value]. + pub fn is_less_than(self, other: Value) -> Result { + comparison_op("<", self, other, |a, b| a < b, |a, b| a < b) } /// `StarkValue.isLessOrEqualThan`. - pub fn is_less_or_equal_than(self, other: Value) -> Value { - comparison_op(self, other, |a, b| a <= b, |a, b| a <= b) + pub fn is_less_or_equal_than(self, other: Value) -> Result { + comparison_op("<=", self, other, |a, b| a <= b, |a, b| a <= b) } /// `StarkValue.isGreaterOrEqualThan`. - pub fn is_greater_or_equal_than(self, other: Value) -> Value { - comparison_op(self, other, |a, b| a >= b, |a, b| a >= b) + pub fn is_greater_or_equal_than(self, other: Value) -> Result { + comparison_op(">=", self, other, |a, b| a >= b, |a, b| a >= b) } /// `StarkValue.isGreaterThan`. - pub fn is_greater_than(self, other: Value) -> Value { - comparison_op(self, other, |a, b| a > b, |a, b| a > b) + pub fn is_greater_than(self, other: Value) -> Result { + comparison_op(">", self, other, |a, b| a > b, |a, b| a > b) } /// `StarkValue.isEqualTo`, **extended**: Java's version dispatches only @@ -177,15 +246,15 @@ impl Value { /// aren't comparable" semantics, especially since `typecheck.rs` already /// accepts `==` between two booleans or two same-typed custom values. So /// this covers those cases too, numeric comparison still widening. - pub fn is_equal_to(self, other: Value) -> Value { + pub fn is_equal_to(self, other: Value) -> Result { match (self, other) { - (Value::Boolean(a), Value::Boolean(b)) => Value::Boolean(a == b), - (Value::Custom(a), Value::Custom(b)) => Value::Boolean(a == b), + (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) — // matches `StarkInteger.isEqualTo`'s own `instanceof StarkInteger` // fast path. - _ => comparison_op(self, other, |a, b| a == b, |a, b| a == b), + _ => comparison_op("==", self, other, |a, b| a == b, |a, b| a == b), } } @@ -194,56 +263,76 @@ impl Value { /// spellings are one grammar rule split across two precedence levels in /// both the Java `.g4` and `stark_grammar.pest`, not two operations (see /// `visitAndExpression`, which ignores `ctx.op.getText()` entirely). - pub fn and(self, other: Value) -> Value { + pub fn and(self, other: Value) -> Result { match (self, other) { - (Value::Boolean(a), Value::Boolean(b)) => Value::Boolean(a && b), - _ => Value::Error, + (Value::Boolean(a), Value::Boolean(b)) => Ok(a && b), + (left, right) => Err(unsupported("&&", left, right)), } } /// `StarkValue.or` (`StarkBoolean.or`). See [Value::and]'s doc comment — /// `||` and `|` (`Or`/`BitOr`) are likewise one operation, two spellings. - pub fn or(self, other: Value) -> Value { + pub fn or(self, other: Value) -> Result { match (self, other) { - (Value::Boolean(a), Value::Boolean(b)) => Value::Boolean(a || b), - _ => Value::Error, + (Value::Boolean(a), Value::Boolean(b)) => Ok(a || b), + (left, right) => Err(unsupported("||", left, right)), } } /// `StarkValue.apply(DoubleUnaryOperator, ..)`: always widens to `Real`, /// even for an integer argument — `max(1, 2)` is `Real(2.0)`, not - /// `Integer(2)`. Used for every `MathUnaryFunction`. - pub fn apply_unary(self, f: impl Fn(f64) -> f64) -> Value { + /// `Integer(2)`. Used for every `MathUnaryFunction`. `op` names the + /// operation for the error message. + pub fn apply_unary(self, op: &'static str, f: impl Fn(f64) -> f64) -> Result { match self { - Value::Integer(v) => Value::Real(f(v as f64)), - Value::Real(v) => Value::Real(f(v)), - _ => Value::Error, + Value::Integer(v) => Ok(Value::Real(f(v as f64))), + Value::Real(v) => Ok(Value::Real(f(v))), + operand => Err(EvalError::UnsupportedUnaryOperand { + op, + operand: operand.kind(), + }), } } /// `StarkValue.apply(DoubleBinaryOperator, ..)`: the binary counterpart /// of [Value::apply_unary], used for every `MathBinaryFunction`. - pub fn apply_binary(self, other: Value, f: impl Fn(f64, f64) -> f64) -> Value { + 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)) => Value::Real(f(a as f64, b as f64)), - (Value::Integer(a), Value::Real(b)) => Value::Real(f(a as f64, b)), - (Value::Real(a), Value::Integer(b)) => Value::Real(f(a, b as f64)), - (Value::Real(a), Value::Real(b)) => Value::Real(f(a, b)), - _ => Value::Error, + (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 [EvalError::UnsupportedBinaryOperands] for a binary `op` — the +/// fallthrough every operation below shares. +fn unsupported(op: &'static str, left: Value, right: Value) -> EvalError { + EvalError::UnsupportedBinaryOperands { + op, + left: left.kind(), + right: right.kind(), + } +} + /// The shared "int-preserving-then-widening" promotion used by `+`, `*`, `-`: -/// `int op int -> Integer`; anything touching a `Real` (or a non-numeric -/// operand) -> `Real`, or [Value::Error] if either side isn't numeric at all. -fn numeric_op(lhs: Value, rhs: Value, int_op: impl Fn(i64, i64) -> i64, real_op: impl Fn(f64, f64) -> f64) -> Value { +/// `int op int -> Integer`; anything touching a `Real` -> `Real`; anything with +/// a non-numeric operand is an error. +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)) => Value::Integer(int_op(a, b)), - (Value::Integer(a), Value::Real(b)) => Value::Real(real_op(a as f64, b)), - (Value::Real(a), Value::Integer(b)) => Value::Real(real_op(a, b as f64)), - (Value::Real(a), Value::Real(b)) => Value::Real(real_op(a, b)), - _ => Value::Error, + (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)), } } @@ -251,32 +340,37 @@ fn numeric_op(lhs: Value, rhs: Value, int_op: impl Fn(i64, i64) -> i64, real_op: /// `Integer`s exactly (matching `StarkInteger`'s own `instanceof StarkInteger` /// fast path — not widened through `f64`, which loses precision above 2^53); /// any pairing touching a `Real` widens through `real_op` instead. Anything -/// else (including a mismatched non-numeric pairing) is [Value::Error]. +/// else (including a mismatched non-numeric pairing) is an error. fn comparison_op( + op: &'static str, lhs: Value, rhs: Value, int_op: impl Fn(i64, i64) -> bool, real_op: impl Fn(f64, f64) -> bool, -) -> Value { +) -> Result { match (lhs, rhs) { - (Value::Integer(a), Value::Integer(b)) => Value::Boolean(int_op(a, b)), - (Value::Integer(a), Value::Real(b)) => Value::Boolean(real_op(a as f64, b)), - (Value::Real(a), Value::Integer(b)) => Value::Boolean(real_op(a, b as f64)), - (Value::Real(a), Value::Real(b)) => Value::Boolean(real_op(a, b)), - _ => Value::Error, + (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`), `StarkValue.negate`. An `impl` of the standard -/// trait rather than an inherent `not` method, so `!value` reads naturally -/// at call sites and doesn't collide with `std::ops::Not::not`. +/// Boolean negation (`!x`), `StarkValue.negate`. Still an `impl` of the +/// standard trait rather than an inherent method so `!value` reads naturally +/// at call sites; the `Output` is a `Result` like every other +/// operation here, so a call site spells it `(!value)?`. impl std::ops::Not for Value { - type Output = Value; + type Output = Result; - fn not(self) -> Value { + fn not(self) -> Result { match self { - Value::Boolean(v) => Value::Boolean(!v), - _ => Value::Error, + Value::Boolean(v) => Ok(!v), + operand => Err(EvalError::UnsupportedUnaryOperand { + op: "!", + operand: operand.kind(), + }), } } } @@ -288,7 +382,6 @@ impl fmt::Display for Value { Value::Real(value) => write!(f, "{value}"), Value::Boolean(value) => write!(f, "{value}"), Value::Custom(custom) => write!(f, "{:?}#{}", custom.type_id, custom.element), - Value::Error => write!(f, ""), } } } @@ -303,7 +396,6 @@ mod tests { 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); - assert_eq!(Value::Error.type_of(&symbols), StarkType::Error); } #[test] @@ -311,76 +403,101 @@ mod tests { 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"); - assert_eq!(Value::Error.to_string(), ""); } #[test] fn sum_preserves_integer_then_widens() { - assert_eq!(Value::Integer(1).sum(Value::Integer(2)), Value::Integer(3)); - assert_eq!(Value::Integer(1).sum(Value::Real(2.0)), Value::Real(3.0)); - assert_eq!(Value::Real(1.0).sum(Value::Integer(2)), Value::Real(3.0)); - assert_eq!(Value::Real(1.0).sum(Value::Real(2.0)), Value::Real(3.0)); - assert_eq!(Value::Boolean(true).sum(Value::Integer(1)), Value::Error); - assert_eq!(Value::Integer(1).sum(Value::Boolean(true)), Value::Error); + 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(EvalError::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)), Value::Integer(12)); - assert_eq!(Value::Integer(3).product(Value::Real(4.0)), Value::Real(12.0)); - assert_eq!(Value::Integer(5).subtraction(Value::Integer(2)), Value::Integer(3)); - assert_eq!(Value::Real(5.0).subtraction(Value::Integer(2)), Value::Real(3.0)); + 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)), Value::Error); - assert_eq!(Value::Integer(1).modulo(Value::Integer(0)), Value::Error); - assert_eq!(Value::Integer(1).int_div(Value::Integer(0)), Value::Error); + assert_eq!( + Value::Integer(1).division(Value::Integer(0)), + Err(EvalError::DivisionByZero) + ); + assert_eq!( + Value::Integer(1).modulo(Value::Integer(0)), + Err(EvalError::DivisionByZero) + ); + assert_eq!( + Value::Integer(1).int_div(Value::Integer(0)), + Err(EvalError::DivisionByZero) + ); // The i64::MIN / -1 overflow is likewise caught, not a panic. - assert_eq!(Value::Integer(i64::MIN).division(Value::Integer(-1)), Value::Error); + assert_eq!( + Value::Integer(i64::MIN).division(Value::Integer(-1)), + Err(EvalError::DivisionByZero) + ); } #[test] fn integer_division_truncates_like_java_int_division() { - assert_eq!(Value::Integer(7).division(Value::Integer(2)), Value::Integer(3)); - assert_eq!(Value::Integer(-7).division(Value::Integer(2)), Value::Integer(-3)); + 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)), Value::Real(f64::INFINITY)); + 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)), - Value::Real(v) if v.is_nan() + 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)), Value::Integer(3)); - assert_eq!(Value::Real(7.5).int_div(Value::Integer(2)), Value::Real(3.0)); - assert_eq!(Value::Real(-7.5).int_div(Value::Integer(2)), Value::Real(-3.0)); + 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)), Value::Boolean(true)); - assert_eq!(Value::Integer(2).is_less_than(Value::Real(2.5)), Value::Boolean(true)); - assert_eq!(Value::Boolean(true).is_less_than(Value::Integer(1)), Value::Error); + 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)), Value::Boolean(true)); - assert_eq!( - Value::Boolean(true).is_equal_to(Value::Boolean(true)), - Value::Boolean(true) - ); - assert_eq!( - Value::Boolean(true).is_equal_to(Value::Boolean(false)), - Value::Boolean(false) - ); + 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, @@ -393,9 +510,9 @@ mod tests { type_id: DefId::new(0), element: 2, }); - assert_eq!(a.is_equal_to(b), Value::Boolean(true)); - assert_eq!(a.is_equal_to(c), Value::Boolean(false)); - assert_eq!(Value::Integer(1).is_equal_to(Value::Boolean(true)), Value::Error); + 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] @@ -404,14 +521,14 @@ mod tests { // 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)), Value::Boolean(false)); + 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)), Value::Boolean(false)); - assert_eq!(Value::Boolean(true).or(Value::Boolean(false)), Value::Boolean(true)); - assert_eq!(Value::Integer(1).and(Value::Boolean(true)), Value::Error); + 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] @@ -419,8 +536,8 @@ mod tests { // `!` (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), Value::Boolean(false)); - assert_eq!(!Value::Integer(1), Value::Error); + assert_eq!(!Value::Boolean(true), Ok(false)); + assert!((!Value::Integer(1)).is_err()); } #[test] @@ -429,10 +546,10 @@ mod tests { // `unaryOperators` map, which routes both through the same // always-widening `DoubleUnaryOperator` mechanism as the math // functions — see `ExprNode::Negate`'s doc comment. - assert_eq!(Value::Integer(3).apply_unary(|x| -x), Value::Real(-3.0)); - assert_eq!(Value::Real(3.0).apply_unary(|x| -x), Value::Real(-3.0)); - assert_eq!(Value::Boolean(true).apply_unary(|x| -x), Value::Error); - assert_eq!(Value::Integer(3).apply_unary(|x| x), Value::Real(3.0)); + 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] @@ -441,17 +558,31 @@ mod tests { // not `Integer(2)`, since `StarkInteger.apply(DoubleBinaryOperator)` // always returns a `StarkReal`. assert_eq!( - Value::Integer(1).apply_binary(Value::Integer(2), f64::max), - Value::Real(2.0) + 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() { + // The behaviour change from `StarkValue.isTrue`, which silently + // answered `false` here — see the module doc comment. + 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(EvalError::ExpectedBoolean { + context: "a guard", + found: ValueKind::Integer, + }) ); - assert_eq!(Value::Integer(4).apply_unary(f64::sqrt), Value::Real(2.0)); } #[test] - fn truthy_is_false_not_error_for_non_boolean() { - assert!(Value::Boolean(true).truthy()); - assert!(!Value::Boolean(false).truthy()); - assert!(!Value::Integer(1).truthy()); - assert!(!Value::Error.truthy()); + fn as_number_widens_either_numeric_case() { + assert_eq!(Value::Integer(3).as_number("a bound"), Ok(3.0)); + assert_eq!(Value::Real(3.5).as_number("a bound"), Ok(3.5)); + assert!(Value::Boolean(true).as_number("a bound").is_err()); } } From cd588a3440675e5934330794bf5222675cc7835d Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Mon, 20 Jul 2026 22:06:05 +0200 Subject: [PATCH 38/50] Extended the expressions for distance and formulas --- crates/stark/src/eval/distance.rs | 414 ++++++++++++++++++++++++++++++ crates/stark/src/eval/expr.rs | 223 ++++++++-------- crates/stark/src/eval/formula.rs | 253 ++++++++++++++++++ 3 files changed, 785 insertions(+), 105 deletions(-) create mode 100644 crates/stark/src/eval/distance.rs create mode 100644 crates/stark/src/eval/formula.rs diff --git a/crates/stark/src/eval/distance.rs b/crates/stark/src/eval/distance.rs new file mode 100644 index 000000000..a73cc8375 --- /dev/null +++ b/crates/stark/src/eval/distance.rs @@ -0,0 +1,414 @@ +//! Distance expressions: how far apart two evolution sequences are, as a +//! single `f64` per time step. Ported from `lib/.../distance/`. +//! +//! 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 reference implementation 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; `StarkDistanceGenerator` builds a +//! `MinIntervalDistanceExpression` for `\F` and a +//! `MaxIntervalDistanceExpression` for `\G`. +//! - **A distance interval `[from, to]` excludes `to`.** The reference +//! iterates `IntStream.range(from+step, to+step)`. The *formula* layer +//! (see [super::formula]) iterates `to+step+1` and so includes it. That +//! inconsistency is the reference's, not this port's, and is preserved so +//! results match. +//! +//! An empty interval yields `NaN`, matching `.orElse(Double.NaN)` on the +//! reference's empty streams, rather than being an error. +//! +//! # 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 reference's `double[3]` (`{value, lower, +/// upper}`), 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 `MinDistanceExpression`/ + /// `MaxDistanceExpression`/`MaxIntervalDistanceExpression` all do — the + /// reference applies the same operator 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 { + /// `RelationOperator.eval`. + 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, + } + } +} + +/// `Math.min`/`Math.max` propagate `NaN`, 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. +fn java_min(a: f64, b: f64) -> f64 { + if a.is_nan() || b.is_nan() { f64::NAN } else { a.min(b) } +} + +fn java_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` — `DistanceExpression.compute`. + 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, java_min) + } + DistanceIr::Globally { from, to, argument } => { + self.fold_interval(reference, perturbed, step, from, to, argument, java_max) + } + DistanceIr::Until { from, to, left, right } => { + let (from, to) = self.interval(from, to, step)?; + // `UntilDistanceExpression.compute`: 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 — + // it is declared outside the loop in the reference, 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 = java_max(running_left, self.distance(reference, perturbed, j, left)?); + } + result = java_min(result, java_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(java_min(left, right)) + } + DistanceIr::Max(left, right) => { + let left = self.distance(reference, perturbed, step, left)?; + let right = self.distance(reference, perturbed, step, right)?; + Ok(java_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 — + /// `DistanceExpression.evalCI`. + 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, java_min) + } + DistanceIr::Globally { from, to, argument } => { + self.fold_interval_ci(reference, perturbed, step, from, to, argument, java_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 `compute`, the reference re-seeds the running + // left maximum from the left expression *at `i`* on every + // iteration before folding in `[from, i)`. Preserved as + // written. + 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)?, java_max); + } + result = result.zip(right_value.zip(running_left, java_max), java_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, java_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, java_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 — + /// `SampleSet.bootstrapDistance{Leq,Geq}`. + 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 — `SampleSet.bootstrapDistance`. + /// + /// The interval is clamped to `[0, 1]` exactly as the reference 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 + } + + /// `MinIntervalDistanceExpression`/`MaxIntervalDistanceExpression`: 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 reference + /// folds the value and both bounds 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 + /// the reference's `IllegalArgumentException` — bounds are only checked + /// at construction time there, which this port has no equivalent of + /// (they are evaluated on demand), and the never-panic contract rules + /// out throwing. + 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_number("a distance or formula constant") + } + + fn constant_integer(&mut self, id: ExprRef, context: &'static str) -> Result { + eval(self.program, &mut self.globals, &mut self.rng, id)?.as_integer(context) + } +} diff --git a/crates/stark/src/eval/expr.rs b/crates/stark/src/eval/expr.rs index b937f2009..172e8bf5f 100644 --- a/crates/stark/src/eval/expr.rs +++ b/crates/stark/src/eval/expr.rs @@ -4,10 +4,13 @@ //! `Supplier`/lambda closures, since lowering already collapsed the AST into //! that arena (see `IR_LOWERING_PLAN.md`). //! -//! Every function here returns a [Value] and never panics: a malformed -//! runtime state (which shouldn't arise against a checked + lowered -//! [IrProgram]) yields [Value::Error], mirroring `StarkValue.ERROR_VALUE` — -//! see `EVALUATOR_PLAN.md`'s "the one contract to preserve". +//! 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 `StarkValue.ERROR_VALUE` the Java reference propagates — see +//! `value.rs`'s "Errors are a `Result`, not a value" and `EVALUATOR_PLAN.md`'s +//! "the one contract to preserve", which the `Result` honours more strictly +//! (the error cannot be silently dropped). use rand::Rng; use rand::RngExt; @@ -20,34 +23,43 @@ use crate::ir::MathBinaryFunction; use crate::ir::MathUnaryFunction; use crate::ir::StmtNode; use crate::ir::StmtRef; +use crate::value::EvalError; use crate::value::Value; use super::store::Store; /// Evaluates one expression against `store`, sampling from `rng` wherever /// the expression does. -pub(crate) fn eval(program: &IrProgram, store: &mut Store, rng: &mut R, id: ExprRef) -> Value { +pub(crate) fn eval( + program: &IrProgram, + store: &mut Store, + rng: &mut R, + id: ExprRef, +) -> Result { match *program.expr(id) { - ExprNode::Literal(value) => value, - ExprNode::Load(slot) => store.load(slot), - ExprNode::Not(inner) => !eval(program, store, rng, inner), + ExprNode::Literal(value) => Ok(value), + ExprNode::Unreachable(what) => Err(EvalError::Unreachable(what)), + ExprNode::Load(slot) => Ok(store.load(slot)), + ExprNode::Not(inner) => Ok(Value::Boolean((!eval(program, store, rng, inner)?)?)), // Both always widen to `Real`, matching Java — see `ExprNode::Negate` // and `ExprNode::Widen`'s doc comments in `ir.rs`. - ExprNode::Negate(inner) => eval(program, store, rng, inner).apply_unary(|x| -x), - ExprNode::Widen(inner) => eval(program, store, rng, inner).apply_unary(|x| x), + ExprNode::Negate(inner) => eval(program, store, rng, inner)?.apply_unary("-", |x| -x), + ExprNode::Widen(inner) => eval(program, store, rng, inner)?.apply_unary("+", |x| x), ExprNode::Binary(op, left, right) => { - let left = eval(program, store, rng, left); - let right = eval(program, store, rng, right); + let left = eval(program, store, rng, left)?; + let right = eval(program, store, rng, right)?; apply_binary_op(op, left, right) } ExprNode::MathUnary(function, inner) => { - let value = eval(program, store, rng, inner); - value.apply_unary(math_unary_fn(function)) + let value = eval(program, store, rng, inner)?; + let (name, f) = math_unary_fn(function); + value.apply_unary(name, f) } ExprNode::MathBinary(function, left, right) => { - let left = eval(program, store, rng, left); - let right = eval(program, store, rng, right); - left.apply_binary(right, math_binary_fn(function)) + 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) } ExprNode::Select { guard, @@ -58,10 +70,10 @@ pub(crate) fn eval(program: &IrProgram, store: &mut Store, rng: // laziness in the Java reference: only the taken branch is // evaluated, since the untaken one may sample (advancing `rng`) // or divide by zero. - match eval(program, store, rng, guard) { - Value::Boolean(true) => eval(program, store, rng, then_branch), - Value::Boolean(false) => eval(program, store, rng, else_branch), - _ => Value::Error, + 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 } => { @@ -69,7 +81,7 @@ pub(crate) fn eval(program: &IrProgram, store: &mut Store, rng: // 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)); + 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 @@ -81,15 +93,15 @@ pub(crate) fn eval(program: &IrProgram, store: &mut Store, rng: } eval_stmt(program, store, rng, function_ir.body) } - ExprNode::SampleUnit => Value::Real(rng.random::()), + 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); + 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); + let mean = eval(program, store, rng, mean)?; + let variance = eval(program, store, rng, variance)?; sample_normal(rng, mean, variance) } ExprNode::SampleChoice(list) => { @@ -104,36 +116,43 @@ pub(crate) fn eval(program: &IrProgram, store: &mut Store, rng: /// 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) -> Value { +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, - } => match eval(program, store, rng, guard) { - Value::Boolean(true) => eval_stmt(program, store, rng, then_branch), - Value::Boolean(false) => 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 => { - debug_assert!(false, "function body has no return on this path"); - Value::Error + } => { + 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(EvalError::MissingReturn), } - }, - _ => Value::Error, - }, + } + } StmtNode::Let { slot, value, body } => { - let value = eval(program, store, rng, value); + 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) -> Value { +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), @@ -141,49 +160,53 @@ fn apply_binary_op(op: BinaryOp, left: Value, right: Value) -> Value { BinaryOp::Div => left.division(right), BinaryOp::IntDiv => left.int_div(right), BinaryOp::Mod => left.modulo(right), - BinaryOp::Less => left.is_less_than(right), - BinaryOp::Leq => left.is_less_or_equal_than(right), - BinaryOp::Eq => left.is_equal_to(right), - BinaryOp::Geq => left.is_greater_or_equal_than(right), - BinaryOp::Greater => left.is_greater_than(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), - BinaryOp::Or | BinaryOp::BitOr => left.or(right), + BinaryOp::And | BinaryOp::BitAnd => left.and(right).map(Value::Boolean), + BinaryOp::Or | BinaryOp::BitOr => left.or(right).map(Value::Boolean), } } -fn math_unary_fn(function: MathUnaryFunction) -> fn(f64) -> f64 { +/// 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 => f64::abs, - MathUnaryFunction::Acos => f64::acos, - MathUnaryFunction::Asin => f64::asin, - MathUnaryFunction::Atan => f64::atan, - MathUnaryFunction::Cbrt => f64::cbrt, - MathUnaryFunction::Ceil => f64::ceil, - MathUnaryFunction::Cos => f64::cos, - MathUnaryFunction::Cosh => f64::cosh, - MathUnaryFunction::Exp => f64::exp, - MathUnaryFunction::Expm1 => f64::exp_m1, - MathUnaryFunction::Floor => f64::floor, - MathUnaryFunction::Log => f64::ln, - MathUnaryFunction::Log10 => f64::log10, - MathUnaryFunction::Log1p => f64::ln_1p, - MathUnaryFunction::Signum => java_signum, - MathUnaryFunction::Sin => f64::sin, - MathUnaryFunction::Sinh => f64::sinh, - MathUnaryFunction::Sqrt => f64::sqrt, - MathUnaryFunction::Tan => f64::tan, + 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", java_signum), + MathUnaryFunction::Sin => ("sin", f64::sin), + MathUnaryFunction::Sinh => ("sinh", f64::sinh), + MathUnaryFunction::Sqrt => ("sqrt", f64::sqrt), + MathUnaryFunction::Tan => ("tan", f64::tan), } } -fn math_binary_fn(function: MathBinaryFunction) -> fn(f64, f64) -> f64 { +/// The binary counterpart of [math_unary_fn]. +fn math_binary_fn(function: MathBinaryFunction) -> (&'static str, fn(f64, f64) -> f64) { match function { - MathBinaryFunction::Atan2 => f64::atan2, - MathBinaryFunction::Hypot => f64::hypot, - MathBinaryFunction::Max => java_max, - MathBinaryFunction::Min => java_min, - MathBinaryFunction::Pow => f64::powf, + MathBinaryFunction::Atan2 => ("atan2", f64::atan2), + MathBinaryFunction::Hypot => ("hypot", f64::hypot), + MathBinaryFunction::Max => ("max", java_max), + MathBinaryFunction::Min => ("min", java_min), + MathBinaryFunction::Pow => ("pow", f64::powf), } } @@ -206,11 +229,10 @@ fn java_min(a: f64, b: f64) -> f64 { } /// `StarkValue.sample`: `from + rng.nextDouble() * (to - from)`. -fn sample_range(rng: &mut R, min: Value, max: Value) -> Value { - match (double_of(min), double_of(max)) { - (Some(from), Some(to)) => Value::Real(from + rng.random::() * (to - from)), - _ => Value::Error, - } +fn sample_range(rng: &mut R, min: Value, max: Value) -> Result { + let from = min.as_number("the lower bound of an `R[a,b]` sample")?; + let to = max.as_number("the upper bound of an `R[a,b]` sample")?; + Ok(Value::Real(from + rng.random::() * (to - from))) } /// `StarkValue.sampleNormal`. **Not actually Gaussian** — despite the name @@ -220,24 +242,13 @@ fn sample_range(rng: &mut R, min: Value, max: Value) -> Value { /// "fixed", so behaviour matches the reference tool; it reads as a bug in /// `StarkValue.sampleNormal`, but is not this port's place to silently /// correct. -fn sample_normal(rng: &mut R, mean: Value, variance: Value) -> Value { - match (double_of(mean), double_of(variance)) { - (Some(mean), Some(variance)) => Value::Real(rng.random::() * mean + variance), - _ => Value::Error, - } -} - -/// `StarkValue.doubleOf`, except a non-numeric value maps to `None` (evaluated -/// as [Value::Error] at the call site) rather than `Double.NaN` — every call -/// site here is already guaranteed numeric by `typecheck.rs` (`R[a,b]`'s -/// bounds and `N[m,v]`'s mean/variance are both checked against `real`), so -/// this only matters for an otherwise-unreachable malformed IR. -fn double_of(value: Value) -> Option { - match value { - Value::Integer(v) => Some(v as f64), - Value::Real(v) => Some(v), - _ => None, - } +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_number("the mean of an `N[m,v]` sample")?; + let variance = variance.as_number("the variance of an `N[m,v]` sample")?; + Ok(Value::Real(rng.random::() * mean + variance)) } #[cfg(test)] @@ -259,7 +270,7 @@ mod tests { .expect("should check"); let program = lower(&spec).expect("should lower"); let mut rng = StdRng::seed_from_u64(0); - let store = Store::new(&program, &mut rng); + let store = Store::new(&program, &mut rng).expect("should initialise"); store.load(program.globals()[0].slot) } @@ -283,7 +294,9 @@ mod tests { #[test] fn select_only_evaluates_the_taken_branch() { // The untaken branch divides by zero; if `Select` weren't lazy this - // would produce `Value::Error` instead of `Value::Integer(1)`. + // 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)); } @@ -307,7 +320,7 @@ mod tests { .expect("should check"); let program = lower(&spec).expect("should lower"); let mut rng = StdRng::seed_from_u64(0); - let store = Store::new(&program, &mut rng); + 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)); } @@ -329,7 +342,7 @@ mod tests { .expect("should check"); let program = lower(&spec).expect("should lower"); let mut rng = StdRng::seed_from_u64(0); - let store = Store::new(&program, &mut rng); + let store = Store::new(&program, &mut rng).expect("should initialise"); assert_eq!(store.load(program.variables()[0].slot), Value::Integer(3)); } @@ -348,7 +361,7 @@ mod tests { 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)) { - Value::Real(v) => assert!((2.0..5.0).contains(&v)), + Ok(Value::Real(v)) => assert!((2.0..5.0).contains(&v)), other => panic!("expected a Real, got {other:?}"), } } @@ -361,7 +374,7 @@ mod tests { 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, Value::Real(uniform * 10.0 + 1.0)); + assert_eq!(sampled, Ok(Value::Real(uniform * 10.0 + 1.0))); } #[test] diff --git a/crates/stark/src/eval/formula.rs b/crates/stark/src/eval/formula.rs new file mode 100644 index 000000000..adf6461b9 --- /dev/null +++ b/crates/stark/src/eval/formula.rs @@ -0,0 +1,253 @@ +//! 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. Ported from `lib/.../robtl/`. +//! +//! Two semantics, both from the reference: +//! +//! - [Analysis::check] — the **three-valued** semantics +//! (`ThreeValuedSemanticsVisitor`), 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 +//! (`BooleanSemanticsVisitor`), 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` (the reference iterates `to + step + 1`), +//! 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 — `TruthValues`. [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. + pub fn not(self) -> TruthValue { + match self { + TruthValue::True => TruthValue::False, + TruthValue::False => TruthValue::True, + TruthValue::Unknown => TruthValue::Unknown, + } + } + + /// `TruthValues.valueOf`: `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 — `ThreeValuedSemanticsVisitor`. + 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 reference 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)?; + // `UntilRobustnessFormula`: 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 — + /// `BooleanSemanticsVisitor`. 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, matching Java's `&&`. + 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) + } + } + } +} From 087a6257b3dc354a09f72a14dc19d739ef63eb99 Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Mon, 20 Jul 2026 22:06:22 +0200 Subject: [PATCH 39/50] Also for the pertubation and robutness formulas --- crates/stark/src/eval/mod.rs | 34 ++- crates/stark/src/eval/perturbation.rs | 310 +++++++++++++++++++++++++ crates/stark/src/eval/robust.rs | 318 ++++++++++++++++++++++++++ 3 files changed, 658 insertions(+), 4 deletions(-) create mode 100644 crates/stark/src/eval/perturbation.rs create mode 100644 crates/stark/src/eval/robust.rs diff --git a/crates/stark/src/eval/mod.rs b/crates/stark/src/eval/mod.rs index 067bb33ad..eafe4d3bc 100644 --- a/crates/stark/src/eval/mod.rs +++ b/crates/stark/src/eval/mod.rs @@ -6,16 +6,42 @@ //! parse -> resolve -> typecheck -> lower -> IrProgram -> [ evaluate ] //! ``` //! -//! The store (`Store`, one flat `Vec` indexed by [crate::ir::SlotId]) -//! and the per-component controller cursor (`Cursor`) are internal -//! implementation details, not part of this module's public surface — the -//! only things a caller needs are [Simulation] and [Observer]. +//! 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]. This is Milestone B of `EVALUATOR_PLAN.md`. +//! - [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). +//! This is Milestone C. +//! +//! Every entry point is fallible: evaluation returns `Result<_, EvalError>` +//! rather than propagating an absorbing error *value* the way the Java +//! reference's `StarkValue.ERROR_VALUE` does — see `value.rs`'s "Errors are a +//! `Result`, not a value" 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 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 000000000..3d7eb3297 --- /dev/null +++ b/crates/stark/src/eval/perturbation.rs @@ -0,0 +1,310 @@ +//! The perturbation coroutine: a value that, tick by tick, decides whether +//! the state it is attached to gets rewritten and how. Ported from +//! `lib/.../perturbation/` — `Perturbation`'s three-method interface +//! (`effect()`, `step()`, `isDone()`) carries over verbatim, since the whole +//! semantics of a perturbation is "what does it do *now*, and what is it +//! *next*". +//! +//! Like [super::step]'s [Cursor](super::step::Cursor) replacing Java's +//! recursive `Controller` tree, [PerturbationState] replaces the reference's +//! `AtomicPerturbation`/`SequentialPerturbation`/`IterativePerturbation`/ +//! `NonePerturbation` object graph 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. +//! +//! Two of Java's cases have no counterpart here because the grammar cannot +//! produce them: `AfterPerturbation` and `PersistentPerturbation` are +//! unreachable from `StarkPerturbationGenerator`, which only ever builds +//! `NONE`, `Atomic`, `Sequential` and `Iterative`. `PerturbationIr` matches +//! that reachable subset exactly. + +use rand::Rng; + +use crate::ir::IrProgram; +use crate::ir::PerturbationId; +use crate::ir::PerturbationIr; +use crate::value::EvalError; + +use super::expr::eval; +use super::store::Store; + +/// A perturbation's remaining schedule. Immutable: [PerturbationState::step] +/// returns the successor rather than mutating, matching `Perturbation.step()`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum PerturbationState { + /// `NonePerturbation`: no effect, self-loop, already done. + None, + /// `AtomicPerturbation`: fires `node`'s assignments once `after_steps` + /// ticks have elapsed. `node` is always a [PerturbationIr::Atomic]. + Atomic { after_steps: i64, node: PerturbationId }, + /// `SequentialPerturbation`: `first` runs until it is done, then `second`. + Sequence(Box, Box), + /// `IterativePerturbation`: `body`, repeated `replica` times. `body` is + /// kept *pristine* (never stepped), because `IterativePerturbation.step` + /// re-uses the original body to seed each repetition. + 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 `StarkPerturbationGenerator` evaluates + /// them (`StarkValue.intValue(evalToValue(context, registry, ctx.time))`) + /// while building the `Perturbation` object. `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. Java shares one + // `Perturbation` object between every reference to a declaration; + // building a fresh (equal) value per reference is equivalent, + // because a `Perturbation` is immutable — `step()` 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 — + /// `Perturbation.isDone()`. Note an `Atomic` is *never* done in the Java + /// reference, even once it has fired; only `step()` 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 `StarkPerturbationGenerator.getAssignment`'s +/// `ds.apply(updates.stream().map(..).toList())` — the list is fully +/// materialised against the original `StarkStore` first. +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(EvalError::Unreachable("a non-atomic perturbation produced an effect")); + }; + 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::UntypedStarkSpecification; + use crate::lower; + + /// Builds the program's single perturbation declaration's initial state. + fn build(source: &str) -> (IrProgram, PerturbationState) { + let spec = UntypedStarkSpecification::parse(source) + .expect("should parse") + .check() + .expect("should check"); + let program = lower(&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 — `IterativePerturbation.step` queues each + // repetition behind the previous one's `step()`. + 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 spec = UntypedStarkSpecification::parse(source) + .expect("should parse") + .check() + .expect("should check"); + let program = lower(&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 000000000..bbb1cd63f --- /dev/null +++ b/crates/stark/src/eval/robust.rs @@ -0,0 +1,318 @@ +//! 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 SampleSet 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 +//! reference (`RobustnessFunction.eval(sampleSize, step, sequence)`), 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 Java reference's Mersenne Twister; 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 reference's: +/// `ThreeValuedSemanticsVisitor()`'s no-argument constructor uses `m = 50` +/// bootstrap replicas at `z = 1.96` (a 95% normal interval). +#[derive(Clone, Copy, Debug)] +pub struct AnalysisOptions { + /// Samples per step in the reference evolution sequence — how finely the + /// state distribution is approximated. Larger is more accurate and + /// linearly more expensive. + pub sample_size: usize, + /// How many perturbed samples are drawn per reference sample + /// (`sampleSize` in the reference's `RobustnessFunction`). The perturbed + /// sequence therefore holds `sample_size * scale` samples, and each + /// reference sample is compared against the `scale` perturbed samples + /// descended from it. + 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. Its `[0, n_variables)` + /// prefix is never stepped, so reading a *variable* through it would be + /// meaningless — but no interval bound, threshold or weight can refer to + /// one, since those are all evaluated outside any state in the reference + /// too. + 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` — + /// `EvolutionSequence.apply(perturbation, step, scale)`. + 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::UntypedStarkSpecification; + use crate::eval::TruthValue; + use crate::lower; + + fn build(source: &str) -> IrProgram { + let spec = UntypedStarkSpecification::parse(source) + .expect("should parse") + .check() + .expect("should check"); + lower(&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]); + } +} From 8b8217988df91d0b5b391141ee32a3c3e2cdbafb Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Mon, 20 Jul 2026 22:07:10 +0200 Subject: [PATCH 40/50] Added the evolution sequences --- crates/stark/src/eval/sequence.rs | 392 ++++++++++++++++++++++++++++++ crates/stark/src/eval/sim.rs | 87 +++---- crates/stark/src/eval/step.rs | 153 ++++++++---- crates/stark/src/eval/store.rs | 21 +- 4 files changed, 556 insertions(+), 97 deletions(-) create mode 100644 crates/stark/src/eval/sequence.rs diff --git a/crates/stark/src/eval/sequence.rs b/crates/stark/src/eval/sequence.rs new file mode 100644 index 000000000..f4fbc4758 --- /dev/null +++ b/crates/stark/src/eval/sequence.rs @@ -0,0 +1,392 @@ +//! 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. +//! +//! Ported from `EvolutionSequence.java` + `SampleSet.java`. 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 `SampleSet`. An [EvolutionSequence] is the sequence of +//! those sample sets, generated lazily: +//! [EvolutionSequence::generate_up_to] extends it on demand, matching +//! `generateUpTo`. +//! +//! 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] exactly as `SampleSet.computeDistance` does. + +use rand::Rng; + +use crate::ir::IrProgram; +use crate::ir::PenaltyId; +use crate::ir::PerturbationId; +use crate::value::EvalError; +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 — `PerturbedEvolutionSequence` in the reference, which +/// is the same class plus a perturbation that advances alongside generation. +/// Modelling it as a field rather than a subclass keeps one generation path +/// (see [EvolutionSequence::generate_next]). +#[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 — `SampleSet.generate`. + pub(crate) fn generate( + program: &IrProgram, + rng: &mut R, + size: usize, + ) -> Result { + if size == 0 { + return Err(EvalError::EmptySampleSet); + } + 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 — `size` in the + /// reference. 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 — `getLastGeneratedStep`. + fn last_generated_step(&self) -> usize { + self.steps.len() - 1 + } + + /// Extends the sequence so that step `n` exists — `generateUpTo`. + 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 — `generateNextStep`, including + /// `PerturbedEvolutionSequence`'s override, which advances the + /// perturbation *before* generating and applies the resulting effect + /// *after*. + 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) + } + + /// `PerturbedEvolutionSequence.doApply`: 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 + /// — `EvolutionSequence.apply(perturbation, perturbedStep, scale)`. + /// + /// The result **shares this sequence's history** up to `step - 1` (a copy + /// here, where Java shares immutable `SampleSet` objects) 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)?; + + // `select(perturbedStep - 1)` — 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** — `SampleSet.evalPenaltyFunction`. + /// 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_number("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` — `SampleSet.computeDistance`. +/// +/// 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() % reference.len() != 0 { + return Err(EvalError::IncompatibleSampleSizes { + reference: reference.len(), + perturbed: perturbed.len(), + }); + } + 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 `distanceLeq` — 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 `distanceGeq` / `> 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::UntypedStarkSpecification; + use crate::lower; + + fn build(source: &str) -> IrProgram { + let spec = UntypedStarkSpecification::parse(source) + .expect("should parse") + .check() + .expect("should check"); + lower(&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(EvalError::IncompatibleSampleSizes { + reference: 2, + perturbed: 3 + }) + ); + } + + #[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 index a117b49a0..b8508dc2d 100644 --- a/crates/stark/src/eval/sim.rs +++ b/crates/stark/src/eval/sim.rs @@ -19,11 +19,10 @@ use rand::SeedableRng; use rand::rngs::StdRng; 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; +use super::system::SystemState; /// Notified after every macro-step (see [Simulation::run]). pub trait Observer { @@ -55,8 +54,7 @@ impl Observer for RecordingObserver { /// indirection lowering already collapsed into `program`. pub struct Simulation<'a, R: Rng> { program: &'a IrProgram, - store: Store, - cursors: Vec, + state: SystemState, rng: R, step: u64, } @@ -69,7 +67,7 @@ impl<'a> Simulation<'a, StdRng> { /// this seed, which is what matters for regression tests and for /// building an ensemble from independent substreams later. See /// `EVALUATOR_PLAN.md`'s "Deliberate deviations". - pub fn new(program: &'a IrProgram, seed: u64) -> Simulation<'a, StdRng> { + pub fn new(program: &'a IrProgram, seed: u64) -> Result, EvalError> { Simulation::with_rng(program, StdRng::seed_from_u64(seed)) } } @@ -77,32 +75,28 @@ impl<'a> Simulation<'a, StdRng> { 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. - pub fn with_rng(program: &'a IrProgram, mut rng: R) -> Simulation<'a, R> { - let store = Store::new(program, &mut 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 - // — `ParallelController` doesn't care 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(); - Simulation { + /// + /// 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, - store, - cursors, + state, rng, step: 0, - } + }) } /// The current `[0, n_variables)` state prefix. pub fn state(&self) -> &[Value] { - self.store.state_prefix(self.program) + self.state.variables(self.program) } /// The number of macro-steps taken so far. @@ -112,20 +106,31 @@ impl<'a, R: Rng> Simulation<'a, R> { /// Runs one macro-step — see `eval::step`'s doc comment for the exact /// controller-then-environment ordering. - pub fn step(&mut self) { - macro_step(self.program, &mut self.store, &mut self.rng, &mut self.cursors); + /// + /// 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. - pub fn run(&mut self, steps: u64, observer: &mut impl Observer) { + /// + /// 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(); + self.step()?; observer.on_step(self.step, self.state()); } + Ok(()) } } @@ -157,9 +162,9 @@ mod tests { } ", ); - let mut simulation = Simulation::new(&program, 0); + let mut simulation = Simulation::new(&program, 0).expect("should initialise"); let mut observer = RecordingObserver::default(); - simulation.run(5, &mut observer); + simulation.run(5, &mut observer).expect("should run"); assert_eq!( observer.trajectory, @@ -187,11 +192,11 @@ mod tests { } ", ); - let mut a = Simulation::new(&program, 123); - let mut b = Simulation::new(&program, 123); + 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(); - b.step(); + a.step().expect("should step"); + b.step().expect("should step"); } assert_eq!(a.state(), b.state()); } @@ -208,10 +213,10 @@ mod tests { } ", ); - let mut a = Simulation::new(&program, 1); - let mut b = Simulation::new(&program, 2); - a.step(); - b.step(); + 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()); } @@ -227,9 +232,9 @@ mod tests { } ", ); - let mut simulation = Simulation::new(&program, 0); + let mut simulation = Simulation::new(&program, 0).expect("should initialise"); let mut observer = RecordingObserver::default(); - simulation.run(2, &mut observer); + 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 index afa9117e6..5878805f4 100644 --- a/crates/stark/src/eval/step.rs +++ b/crates/stark/src/eval/step.rs @@ -21,7 +21,7 @@ //! tree actually threads through `next()`). Walking a state's body is one //! flat recursion over [CommandNode] instead of a tree of controller //! objects, since lowering already collapsed the controller AST into that -//! arena (see `IR_LOWERING_PLAN.md`'s Step 4). +//! arena (see `IR_LOWERING_PLAN.md`). use rand::Rng; @@ -30,6 +30,7 @@ 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; @@ -76,25 +77,36 @@ enum Walk { /// every cursor has run, matching `ParallelController`'s "both effects /// concatenated before the single `apply`"), then the environment runs /// against the post-controller state. -pub(crate) fn macro_step(program: &IrProgram, store: &mut Store, rng: &mut R, cursors: &mut [Cursor]) { +/// +/// 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); + *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` (`IR_LOWERING_PLAN.md` - // Step 4), so no budget should ever be spent; 0 is a defensive + // 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); + 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]) { @@ -124,25 +136,25 @@ fn run_component( updates: &mut Vec, exec_budget: &mut u32, cursor: Cursor, -) -> Cursor { +) -> Result { match cursor { - Cursor::Nil => Cursor::Nil, + Cursor::Nil => Ok(Cursor::Nil), Cursor::Idle { remaining, target } => { if remaining <= 1 { - Cursor::Run(target) + Ok(Cursor::Run(target)) } else { - Cursor::Idle { + 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 => Cursor::Nil, - Walk::Transitioned(next) => next, + Some(body) => match run_command(program, store, rng, updates, exec_budget, body)? { + Walk::FellThrough => Ok(Cursor::Nil), + Walk::Transitioned(next) => Ok(next), }, - None => Cursor::Nil, + None => Ok(Cursor::Nil), }, } } @@ -154,51 +166,52 @@ fn run_command( updates: &mut Vec, exec_budget: &mut u32, id: CommandRef, -) -> Walk { +) -> Result { match *program.command(id) { CommandNode::Assign(update) => { - // `StarkValue.isTrue` semantics: a missing guard is - // unconditionally true; a non-boolean guard is false, not an - // error (see `Value::truthy`'s doc comment). + // A missing guard is unconditionally true. A *non-boolean* guard + // is now an error: `StarkValue.isTrue` 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).truthy(), + 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); + let value = eval(program, store, rng, update.value)?; updates.push(PendingUpdate { target: update.target, value, }); } - Walk::FellThrough + Ok(Walk::FellThrough) } CommandNode::IfThenElse { guard, then_branch, else_branch, } => { - let branch = if eval(program, store, rng, guard).truthy() { + 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 => Walk::FellThrough, + None => Ok(Walk::FellThrough), } } CommandNode::Let { slot, value, body } => { - let value = eval(program, store, rng, value); + 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 => Walk::FellThrough, + None => Ok(Walk::FellThrough), } } - CommandNode::Sequence(left, right) => match run_command(program, store, rng, updates, exec_budget, left) { + 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 => transitioned, + transitioned => Ok(transitioned), }, CommandNode::Step { steps, target } => { // `StepController`: `k <= 0` behaves like an immediate @@ -206,14 +219,11 @@ fn run_command( // this tick simply ends here); `k > 0` idles `k` further ticks // first. let k = match steps { - Some(steps) => match eval(program, store, rng, steps) { - Value::Integer(v) => v, - // 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); - // treat it as "no delay" rather than panicking. - _ => 0, - }, + // 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 { @@ -224,7 +234,7 @@ fn run_command( target, } }; - Walk::Transitioned(cursor) + Ok(Walk::Transitioned(cursor)) } CommandNode::Exec(target) => { // Same-tick tail jump: `ExecController.next` immediately @@ -235,12 +245,12 @@ fn run_command( "`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 Walk::Transitioned(Cursor::Nil); + 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 => Walk::Transitioned(Cursor::Nil), + None => Ok(Walk::Transitioned(Cursor::Nil)), } } } @@ -279,12 +289,55 @@ mod tests { ", ); let mut rng = StdRng::seed_from_u64(0); - let mut store = Store::new(&program, &mut rng); + 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); + 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() { + // The regression this whole `Result` change exists for. Under + // `StarkValue.isTrue` the guard `1 / zero > 0` evaluated to + // `ERROR_VALUE`, which mapped to `false`, so the assignment silently + // didn't happen and the run continued with `x` unchanged — an + // arithmetic failure indistinguishable from a guard that was + // legitimately not satisfied. + 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(); + + assert_eq!( + macro_step(&program, &mut store, &mut rng, &mut cursors), + Err(EvalError::DivisionByZero) + ); + } + + #[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 `StarkValue.isTrue` did. + assert_eq!( + Value::Integer(1).as_boolean("the guard of an assignment"), + Err(EvalError::ExpectedBoolean { + context: "the guard of an assignment", + found: crate::value::ValueKind::Integer, + }) + ); + } + #[test] fn step_idles_the_requested_number_of_ticks() { let program = build( @@ -309,7 +362,7 @@ mod tests { ", ); let mut rng = StdRng::seed_from_u64(0); - let mut store = Store::new(&program, &mut rng); + let mut store = Store::new(&program, &mut rng).expect("should initialise"); let mut cursors: Vec = program .components() .iter() @@ -317,10 +370,10 @@ mod tests { .map(|&state| Cursor::Run(state)) .collect(); - macro_step(&program, &mut store, &mut rng, &mut cursors); + 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); + macro_step(&program, &mut store, &mut rng, &mut cursors).expect("should step"); assert_eq!(store.state_prefix(&program), &[Value::Integer(101)]); } @@ -347,7 +400,7 @@ mod tests { ", ); let mut rng = StdRng::seed_from_u64(0); - let mut store = Store::new(&program, &mut rng); + let mut store = Store::new(&program, &mut rng).expect("should initialise"); let mut cursors: Vec = program .components() .iter() @@ -355,7 +408,7 @@ mod tests { .map(|&state| Cursor::Run(state)) .collect(); - macro_step(&program, &mut store, &mut rng, &mut cursors); + macro_step(&program, &mut store, &mut rng, &mut cursors).expect("should step"); assert_eq!(store.state_prefix(&program), &[Value::Integer(1)]); } @@ -383,7 +436,7 @@ mod tests { ", ); let mut rng = StdRng::seed_from_u64(0); - let mut store = Store::new(&program, &mut rng); + let mut store = Store::new(&program, &mut rng).expect("should initialise"); let mut cursors: Vec = program .components() .iter() @@ -391,7 +444,7 @@ mod tests { .map(|&state| Cursor::Run(state)) .collect(); - macro_step(&program, &mut store, &mut rng, &mut cursors); + 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)]); @@ -428,7 +481,7 @@ mod tests { ", ); let mut rng = StdRng::seed_from_u64(0); - let mut store = Store::new(&program, &mut rng); + let mut store = Store::new(&program, &mut rng).expect("should initialise"); let mut cursors: Vec = program .components() .iter() @@ -436,7 +489,7 @@ mod tests { .map(|&state| Cursor::Run(state)) .collect(); - macro_step(&program, &mut store, &mut rng, &mut cursors); + 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 index 0d7320865..fc73362e1 100644 --- a/crates/stark/src/eval/store.rs +++ b/crates/stark/src/eval/store.rs @@ -7,6 +7,7 @@ use rand::Rng; use crate::ir::IrProgram; use crate::ir::SlotId; +use crate::value::EvalError; use crate::value::Value; use super::expr::eval; @@ -30,19 +31,27 @@ impl Store { /// to sample internally (`random_allowed: true` for function bodies), so /// [eval] needs an `Rng` regardless of whether this particular call /// tree happens to use it. - pub(crate) fn new(program: &IrProgram, rng: &mut R) -> Store { + /// Every slot starts as `Integer(0)` rather than a dedicated "unset" + /// marker. `Value::Error` used to serve as that marker, which conflated + /// "not written yet" with "an operation failed" (see `value.rs`); with + /// errors moved to `Result`, no marker is needed, because no slot is ever + /// read before it is written: globals and variables are initialised here + /// in dependency order, and lowering guarantees a function's argument and + /// `let` slots are written at the call/binding before its body can load + /// them (`IR_LOWERING_PLAN.md`, "Why one flat slot space works"). + pub(crate) fn new(program: &IrProgram, rng: &mut R) -> Result { let mut store = Store { - slots: vec![Value::Error; program.n_slots() as usize], + slots: vec![Value::Integer(0); program.n_slots() as usize], }; for global in program.globals() { - let value = eval(program, &mut store, rng, global.value); + 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); + let value = eval(program, &mut store, rng, variable.initial_value)?; store.set(variable.slot, value); } - store + Ok(store) } pub(crate) fn load(&self, slot: SlotId) -> Value { @@ -90,7 +99,7 @@ mod tests { ", ); let mut rng = rand::rngs::StdRng::seed_from_u64(0); - let store = Store::new(&program, &mut rng); + let store = Store::new(&program, &mut rng).expect("should initialise"); let state = store.state_prefix(&program); assert_eq!(state, &[Value::Integer(7)]); From 2f7931e5561f5dff26f3ceeb62f53f91f07e1941 Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Mon, 20 Jul 2026 22:07:31 +0200 Subject: [PATCH 41/50] Added various tests. --- crates/stark/tests/lowering.rs | 50 +++++++++++++ crates/stark/tests/simulation.rs | 61 ++++++++++++++++ crates/stark/tests/verification.rs | 109 +++++++++++++++++++++++++++++ 3 files changed, 220 insertions(+) create mode 100644 crates/stark/tests/lowering.rs create mode 100644 crates/stark/tests/simulation.rs create mode 100644 crates/stark/tests/verification.rs diff --git a/crates/stark/tests/lowering.rs b/crates/stark/tests/lowering.rs new file mode 100644 index 000000000..774e1b62d --- /dev/null +++ b/crates/stark/tests/lowering.rs @@ -0,0 +1,50 @@ +//! Lowers every `.stark` file under `examples/stark/` end-to-end (parse -> +//! check -> [lower]) and asserts the resulting [IrProgram] is internally +//! consistent. Mirrors `tests/examples.rs`'s `checks_example_specification` +//! (same file list, one step further down the pipeline) — this is the +//! `lowers_every_example_specification` test `IR_LOWERING_PLAN.md` calls +//! for. Every example lowers, including the ones using perturbations, +//! distances and formulas. + +use merc_stark::UntypedStarkSpecification; +use merc_stark::lower; +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 lowers_every_example_specification(source: &str) { + let spec = UntypedStarkSpecification::parse(source) + .unwrap_or_else(|e| panic!("failed to parse: {e}")) + .check() + .unwrap_or_else(|d| panic!("failed to check:\n{}", d.render(source))); + let program = lower(&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/simulation.rs b/crates/stark/tests/simulation.rs new file mode 100644 index 000000000..dfa559d19 --- /dev/null +++ b/crates/stark/tests/simulation.rs @@ -0,0 +1,61 @@ +//! 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 `EVALUATOR_PLAN.md`'s Milestone B +//! (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::UntypedStarkSpecification; +use merc_stark::eval::RecordingObserver; +use merc_stark::eval::Simulation; +use merc_stark::lower; +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}")) + .check() + .unwrap_or_else(|d| panic!("failed to check:\n{}", d.render(source))); + let program = lower(&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") + .check() + .expect("should check"); + let program = lower(&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/verification.rs b/crates/stark/tests/verification.rs new file mode 100644 index 000000000..346c53e83 --- /dev/null +++ b/crates/stark/tests/verification.rs @@ -0,0 +1,109 @@ +//! Runs a robustness analysis end to end over an example specification — +//! `EVALUATOR_PLAN.md`'s Milestone C, the counterpart of `simulation.rs`'s +//! Milestone B 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::UntypedStarkSpecification; +use merc_stark::eval::Analysis; +use merc_stark::eval::AnalysisOptions; +use merc_stark::ir::IrProgram; +use merc_stark::lower; + +/// 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}")) + .check() + .unwrap_or_else(|d| panic!("failed to check:\n{}", d.render(source))); + lower(&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); +} From 32d3e08ffd64ac17cd8ab62950318e74c1c17243 Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Tue, 21 Jul 2026 18:12:57 +0200 Subject: [PATCH 42/50] Removed references to the source or plans --- .../stark/abz2025_one_lane_three_cars.stark | 15 +- .../stark/abz2025_single_lane_two_cars.stark | 12 +- .../stark/abz2025_two_lanes_two_cars.stark | 25 ++-- examples/stark/agriculturalDT.stark | 15 +- examples/stark/engine_full.stark | 12 +- examples/stark/envzompr.stark | 4 +- examples/stark/isocitrate.stark | 16 +-- examples/stark/lotka.stark | 4 +- examples/stark/monitoring.stark | 8 +- examples/stark/multiscler.stark | 21 +-- examples/stark/polistil.stark | 30 ++-- examples/stark/polistil_race.stark | 11 +- .../stark/reactionsystems_lacoperon.stark | 16 +-- examples/stark/reactionsystems_running.stark | 27 ++-- examples/stark/reactionsystems_synapse.stark | 14 +- .../reactionsystems_synapse_3neuron.stark | 2 +- examples/stark/repressilator.stark | 8 +- examples/stark/tollbooth.stark | 20 +-- examples/stark/turtle.stark | 32 +++-- examples/stark/turtle_hospital.stark | 27 ++-- examples/stark/vehicle_full.stark | 20 +-- examples/stark/ventilator.stark | 132 ++++++++---------- 22 files changed, 228 insertions(+), 243 deletions(-) diff --git a/examples/stark/abz2025_one_lane_three_cars.stark b/examples/stark/abz2025_one_lane_three_cars.stark index 120f730d7..550d665dc 100644 --- a/examples/stark/abz2025_one_lane_three_cars.stark +++ b/examples/stark/abz2025_one_lane_three_cars.stark @@ -1,11 +1,8 @@ /* - * Ported from ~/STARK/examples/ABZ2025/src/main/java/Scenarios/OneLaneThreeCars.java: - * the same RSS car-following idea as `abz2025_single_lane_two_cars.stark`, + * 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 original's commented-out alternative - * "paper controller proposal" for the uncontrolled cars is dead code, not - * ported, matching the active code path only). The controller now compares + * 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. @@ -20,14 +17,6 @@ * 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. - * - * `getCrashFormula`/`getSafetyGapViolationFormula` are defined in the - * original but never actually invoked from its constructor (only the raw - * penalty function is used there, for per-step CSV diagnostics) — ported - * anyway as genuine `formula` declarations, matching the shape of - * `abz2025_single_lane_two_cars.stark`'s formulas, since they're - * well-defined ROBTL queries the original just never wired up to its - * demo `main`. */ param RESPONSE_TIME = 1.0; diff --git a/examples/stark/abz2025_single_lane_two_cars.stark b/examples/stark/abz2025_single_lane_two_cars.stark index 4e09b787a..8a38bf57f 100644 --- a/examples/stark/abz2025_single_lane_two_cars.stark +++ b/examples/stark/abz2025_single_lane_two_cars.stark @@ -1,6 +1,5 @@ /* - * Ported from ~/STARK/examples/ABZ2025/src/main/java/Scenarios/SingleLaneTwoCars.java: - * two cars on a single lane, V1 behind V2, where V1's controller picks + * 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 @@ -10,11 +9,6 @@ * genuine crash/safety-gap-violation robustness queries under two * perturbations (drunk driving, brake-checking). * - * `OneLaneThreeCars` is the same RSS car-following mechanic generalised - * from 2 to 3 chained cars in one lane (arrays instead of named variables, - * otherwise identical rules), so it isn't ported separately, matching the - * `turtle.stark`/`repressilator.stark` precedent. - * * `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 @@ -26,8 +20,8 @@ * 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 - * DisTL/feedback exclusions already documented in - * `MISSING_GRAMMAR_FEATURES.md`, but the same conclusion: not portable. + * 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 diff --git a/examples/stark/abz2025_two_lanes_two_cars.stark b/examples/stark/abz2025_two_lanes_two_cars.stark index 4d5f6b710..859698b85 100644 --- a/examples/stark/abz2025_two_lanes_two_cars.stark +++ b/examples/stark/abz2025_two_lanes_two_cars.stark @@ -1,5 +1,5 @@ /* - * Ported from ~/STARK/examples/ABZ2025/src/main/java/Scenarios/TwoLanesTwoCars.java + * 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) @@ -36,7 +36,7 @@ * 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 `AfterPerturbation(5, ...)` + * 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. @@ -90,7 +90,7 @@ 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 MISSING_GRAMMAR_FEATURES.md), +/* 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 @@ -130,8 +130,9 @@ component Vehicle1 { controller { state Control { if (my_timer > 0) { - /* BUG FIXED: was `exec Control;`. Java `Control` is - `ifThenElse(my_timer>0, doTick(ref Control), ...)`; `doTick` is + /* 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; @@ -200,8 +201,10 @@ component Vehicle1 { } state Idling { - /* Java `Idling` = `ifThenElse(my_timer>0, doTick(ref Idling), reference("Control"))`. - BUG FIXED: the then-branch `doTick(ref Idling)` is tick-consuming, so + /* 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. */ @@ -210,8 +213,8 @@ component Vehicle1 { state Moving_right { if (my_timer > 0) { - /* BUG FIXED: was `exec Moving_right;`. Java then-branch is - `doTick(ref Moving_right)` (tick-consuming == `step`); a same-round + /* 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 { @@ -241,8 +244,8 @@ component Vehicle1 { state Moving_left { if (my_timer > 0) { - /* BUG FIXED: was `exec Moving_left;`. Java then-branch is - `doTick(ref Moving_left)` (tick-consuming == `step`); a same-round + /* 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 { diff --git a/examples/stark/agriculturalDT.stark b/examples/stark/agriculturalDT.stark index a84b3a7b2..ca3fc665e 100644 --- a/examples/stark/agriculturalDT.stark +++ b/examples/stark/agriculturalDT.stark @@ -1,5 +1,5 @@ /* - * Ported from ~/STARK/examples/agriculturalDT/src/main/java/agriculturalDT/Main.java: + * 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. * @@ -7,8 +7,8 @@ * 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 calls `evaluateDeltaZero(...)` twice (once per `DataStateUpdate`) - * rather than reusing one computed array. + * 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 @@ -115,10 +115,11 @@ component Tractor { if (timer > 0) { step Idle; } else { - /* BUG FIXED: was `step Ctrl;`. The Java `Idle` is - `ifThenElse(timer>0, doTick(ref Idle), reference("Ctrl"))`; the else is - a *bare* `reference("Ctrl")`, i.e. a same-tick jump (`exec`), not a - time-consuming `step`. As written, `step Ctrl` doubled the effective + /* 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. */ diff --git a/examples/stark/engine_full.stark b/examples/stark/engine_full.stark index d25dc6d2e..e821a53d2 100644 --- a/examples/stark/engine_full.stark +++ b/examples/stark/engine_full.stark @@ -1,5 +1,5 @@ /* - * Ported from ~/STARK/examples/engine/src/main/java/engine/Main.java: a more + * 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 @@ -16,11 +16,11 @@ * unambiguous evidence of what was intended, it's ported as written rather * than "corrected" on a guess. * - * `Controller.doAction`/`doTick` are STARK's time-consuming `step`; a bare - * controller reference (`registry.reference(...)`) with no action is an - * immediate `exec` (matches how `engine.stark` already treats the same - * `Ctrl -> Check` transition). The original's `AfterPerturbation(100, - * IterativePerturbation(N, ...))` (wait N steps, then iterate) is + * 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 diff --git a/examples/stark/envzompr.stark b/examples/stark/envzompr.stark index ba01bf9db..4ec8d2527 100644 --- a/examples/stark/envzompr.stark +++ b/examples/stark/envzompr.stark @@ -1,7 +1,7 @@ /* - * Ported from ~/STARK/examples/envzompr/src/main/java/envzompr/Main.java: an + * Ported from the original `envzompr` example: an * 11-reaction, 8-species chemical reaction network (same Gillespie-SSA / - * `NilController` / no-`component` pattern as `isocitrate.stark` — see that + * 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). diff --git a/examples/stark/isocitrate.stark b/examples/stark/isocitrate.stark index e0f6c93e3..e4b789a90 100644 --- a/examples/stark/isocitrate.stark +++ b/examples/stark/isocitrate.stark @@ -1,14 +1,14 @@ /* - * Ported from ~/STARK/examples/Isocitrate/src/main/java/isocitrate/Main.java: + * 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's `TimedSystem` uses a `NilController` (no decision-making - * controller at all) and tracks continuous reaction time - * (`selectReactionTime`, via `ds.getTimeDelta()`/`getTimeReal()`); this - * grammar has no continuous-time/`NilController` concept, only a discrete + * 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 @@ -19,11 +19,11 @@ * 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 an unlisted - * `DataStateUpdate` does in the original). + * 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 - * (`ceil(100*rand.nextDouble())`); ported as fixed values since variable + * (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 diff --git a/examples/stark/lotka.stark b/examples/stark/lotka.stark index 477b106cd..46de1b7a8 100644 --- a/examples/stark/lotka.stark +++ b/examples/stark/lotka.stark @@ -1,5 +1,5 @@ /* - * Ported from ~/STARK/examples/lotka/src/main/java/lotka/Main.java: the + * 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): @@ -12,7 +12,7 @@ * the environment block. * * The original computes raw distance *values* for plotting, without ever - * declaring an actual `AtomicRobustnessFormula`/threshold, so there's no + * 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 diff --git a/examples/stark/monitoring.stark b/examples/stark/monitoring.stark index 67a801060..7b8257dac 100644 --- a/examples/stark/monitoring.stark +++ b/examples/stark/monitoring.stark @@ -1,14 +1,14 @@ /* - * Ported from ~/STARK/examples/monitoring/basic/src/main/java/monitoring/Main.java. + * Ported from the original `monitoring` example. * * The original demonstrates STARK's *online monitoring* framework (the - * `stark.udistl`/`stark.distl`/`stark.monitors` Java packages): it builds a - * uDisTL formula ("eventually the observed x gets within 0 of a moving + * 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 a uDisTL + * 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 diff --git a/examples/stark/multiscler.stark b/examples/stark/multiscler.stark index 5f4091db1..73ead92e1 100644 --- a/examples/stark/multiscler.stark +++ b/examples/stark/multiscler.stark @@ -1,22 +1,22 @@ /* - * Ported from ~/STARK/examples/mutliScler/src/main/java/ms/Main.java: an + * 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 every `DisTLFormula`/monitor-based robustness analysis - * (everything past `writeRunsToCSV` in `main`) is already commented out in - * the source itself — dead code, not just untranslatable — so nothing - * working is being left out by omitting it here. + * 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 a - * `NilController` for comparison); only the controlled "sick" variant + * `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. * - * `(rg.nextDouble()*var*2 - var)` (uniform noise in `[-var, var]`) is + * uniform noise in `[-var, var]` is * simplified to the equivalent `R[-VAR, VAR]` rather than spelling out the * scaling from `R[0,1]`. */ @@ -74,7 +74,7 @@ component MS { variables { } controller { state Ctrl { - /* GAP (approximation, not a grammar limitation): in the Java `getController` + /* 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 @@ -84,7 +84,8 @@ component MS { `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 Java. */ + 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; diff --git a/examples/stark/polistil.stark b/examples/stark/polistil.stark index 6e563a358..ef3b4d6a9 100644 --- a/examples/stark/polistil.stark +++ b/examples/stark/polistil.stark @@ -1,5 +1,5 @@ /* - * Ported from ~/STARK/examples/polistil/src/main/java/polistil/Main.java: a + * 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. @@ -12,16 +12,16 @@ * ported, to avoid mechanically duplicating ~150 lines of quadrant branching * a second time for the same mechanic. * - * `GenerativeChoiceController(1/3, A, GenerativeChoiceController(1/2, B, C))` - * 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 nested - * probabilistic-controller combinators. + * 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 DisTL-based robustness analysis at the end of the original (`phi_out_1`, - * `phi_out_2`, `phi_speed_1`, `phi_speed_2`, all `stark.distl` formulas) is - * the same untranslatable online-monitoring formalism discussed in - * `monitoring.stark` and is not ported. + * 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` @@ -81,11 +81,11 @@ component Car { if (back > 0.0) { step Stop; } else { - /* BUG FIXED: was `step Ctrl;`. Java `Stop` is - `ifThenElse(back>0, doTick(ref Stop), reference("Ctrl"))`; the else is - a bare `reference("Ctrl")` = same-tick `exec`, not a `step` (which - added a spurious idle round before the car resumed control once it was - back on track). */ + /* 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; } } diff --git a/examples/stark/polistil_race.stark b/examples/stark/polistil_race.stark index 72eb8a963..ba7c08dec 100644 --- a/examples/stark/polistil_race.stark +++ b/examples/stark/polistil_race.stark @@ -1,5 +1,5 @@ /* - * Ported from ~/STARK/examples/polistil/src/main/java/polistil/Main.java's + * 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 @@ -90,9 +90,10 @@ component Car1 { if (back > 0.0) { step Stop; } else { - /* BUG FIXED: was `step Ctrl;`. Java `getCar_1`'s `Stop` is - `ifThenElse(back>0, doTick(ref Stop), reference("Ctrl"))`; the else is - a bare same-tick `exec`, not a time-consuming `step`. */ + /* 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; } } @@ -122,7 +123,7 @@ component Car2 { if (you_back > 0.0) { step Stop2; } else { - /* BUG FIXED: was `step Ctrl2;`. Java `getCar_2`'s `Stop2` else is a bare + /* 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; } diff --git a/examples/stark/reactionsystems_lacoperon.stark b/examples/stark/reactionsystems_lacoperon.stark index 8e7a1d262..3263cdad8 100644 --- a/examples/stark/reactionsystems_lacoperon.stark +++ b/examples/stark/reactionsystems_lacoperon.stark @@ -1,5 +1,5 @@ /* - * Ported from ~/STARK/examples/reactionsystems/src/main/java/reacsys/MainLO.java: + * 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 @@ -12,9 +12,10 @@ * per-round order (context supplies entities, then the reactions read them * that same round). * - * The original's `ParallelController(DefaultCondition, Glucose5-chain)` maps - * directly to this grammar's `init A || B` parallel-state-composition - * syntax — one `component` whose controller has both the always-on + * 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 @@ -31,10 +32,9 @@ * `state.get(lactose)` reading the pre-call `state` in the original's * `applyReactions`, exactly as ported. * - * The original's robustness queries are all `stark.distl` (`TargetDisTLFormula`/ - * `ImplicationDisTLFormula`/`EventuallyDisTLFormula`/`AlwaysDisTLFormula`) — - * the same untranslatable online-monitoring formalism already documented in - * `monitoring.stark`/`MISSING_GRAMMAR_FEATURES.md` — so only the reaction + * 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`. */ diff --git a/examples/stark/reactionsystems_running.stark b/examples/stark/reactionsystems_running.stark index 6e82d62bb..5f3115594 100644 --- a/examples/stark/reactionsystems_running.stark +++ b/examples/stark/reactionsystems_running.stark @@ -1,5 +1,5 @@ /* - * Ported from ~/STARK/examples/reactionsystems/src/main/java/reacsys/runningEx.java: + * 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 @@ -9,7 +9,7 @@ * round — exactly matching this grammar's controller-then-environment * per-round order. * - * The original's "plain" scenario (a `NilController` with a different fixed + * 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 @@ -22,9 +22,9 @@ * not an approximation on this end. * * The original's context/perturbation sequence (`p_cont_seq`) is a chain of - * zero-delay atomic perturbations (`AtomicPerturbation(0, ...)`) composed + * zero-delay atomic perturbations composed * with `SequentialPerturbation`, plus one step of `NonePerturbation` (a - * no-op) and one `IterativePerturbation(1, p5)` (apply once, same as `p5` + * no-op) and one single-iteration repeat of `p5` (apply once, same as `p5` * alone) — ported directly via this grammar's `;` sequencing and `nil` * primary. * @@ -63,16 +63,16 @@ component Context { step Ag4; } state Ag4 { - /* BUG FIXED: was `exec Ag5;`. In the Java `getContextSequence`, Ag4 is - `Controller.doTick(reference("Ag5"))`. `doTick` is time-consuming (it - returns `EffectStep([], Ag5)`, i.e. an empty-context round, then Ag5 - next round), which the textual language spells `step` — not `exec` - (which is StarkControllerStateGenerator's same-tick, transparent jump - into the target's block). The original `exec Ag5` collapsed the + /* 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 - (doAction/doTick -> step; bare reference -> exec, see engine_full.stark). */ + (assign-or-idle-then-continue -> step; bare reference -> exec, see + engine_full.stark). */ step Ag5; } state Ag5 { @@ -88,8 +88,9 @@ component Context { c5_count' = c5_count - 1; step Ag5rep; } else { - /* BUG FIXED: was `step Ag7;`. In the Java `Ag5rep` is - `ifThenElse(C5_count>0, doAction(..., ref Ag5rep), reference("Ag7"))`. + /* 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 diff --git a/examples/stark/reactionsystems_synapse.stark b/examples/stark/reactionsystems_synapse.stark index 42a655a5f..fa90b1e4e 100644 --- a/examples/stark/reactionsystems_synapse.stark +++ b/examples/stark/reactionsystems_synapse.stark @@ -1,27 +1,27 @@ /* - * Ported from ~/STARK/examples/reactionsystems/src/main/java/reacsys/Main2N.java: + * 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`. * - * `Main.java` in the same directory models the identical mechanism scaled - * up to 3 neurons (28 variables instead of this file's 21, with an extra + * 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 (`NilController` in the original, like + * 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's `state.get(e2)` does - * without ever appearing on the left of a `DataStateUpdate`. + * 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 - * (`w2 = rg.nextDouble() < e2`) within the same reaction step — unlike the + * (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 diff --git a/examples/stark/reactionsystems_synapse_3neuron.stark b/examples/stark/reactionsystems_synapse_3neuron.stark index 422ada9d8..55e049cb2 100644 --- a/examples/stark/reactionsystems_synapse_3neuron.stark +++ b/examples/stark/reactionsystems_synapse_3neuron.stark @@ -1,5 +1,5 @@ /* - * Ported from ~/STARK/examples/reactionsystems/src/main/java/reacsys/Main.java: + * 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 diff --git a/examples/stark/repressilator.stark b/examples/stark/repressilator.stark index e1c587749..c3c43e0dd 100644 --- a/examples/stark/repressilator.stark +++ b/examples/stark/repressilator.stark @@ -1,14 +1,14 @@ /* - * Ported from ~/STARK/examples/repressilator/src/main/java/repressilator/Main.java: + * 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 `NilController`/no-`component` shape and cumulative-weight + * (SSA) — same no-`component` shape and cumulative-weight * `if`/`else` reaction-selection pattern established in * `isocitrate.stark`/`envzompr.stark`/`lotka.stark`. * - * `Main_Skorokhod.java` simulates the identical model with a different + * 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. @@ -35,7 +35,7 @@ * `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 - * `Main.java` and `Main_Skorokhod.java`, which read the identical + * of the original's two variants, which read the identical * `state.get(Zi)`; fixed here by dropping those six redundant, incorrect * recomputes.) * diff --git a/examples/stark/tollbooth.stark b/examples/stark/tollbooth.stark index 88e677b9f..00e305fa9 100644 --- a/examples/stark/tollbooth.stark +++ b/examples/stark/tollbooth.stark @@ -1,5 +1,5 @@ /* - * Ported from ~/STARK/examples/tollbooth/src/main/java/tollbooth/Main.java. + * 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` @@ -10,10 +10,9 @@ * 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 (`IterativePenalty`/`SequentialPenalty` from - * `stark.penalty`, and the `AlwaysDisTLFormula`/`EventuallyDisTLFormula`/ - * `TargetDisTLFormula` robustness properties from `stark.distl`) is the same - * online-monitoring formalism discussed in `monitoring.stark` — a different + * 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. @@ -68,10 +67,11 @@ component Vehicle { if (timer_V > 0) { step Accelerate; } else { - /* BUG FIXED: was `step Ctrl;`. The Java `Accelerate` is - `ifThenElse(timer_V>0, doTick(ref Accelerate), reference("Ctrl"))`; the - else is a *bare* controller reference, which is a same-tick jump - (`exec`), not a time-consuming `step`. `step Ctrl` inserted a spurious + /* 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`. */ @@ -82,7 +82,7 @@ component Vehicle { if (timer_V > 0) { step Decelerate; } else { - /* BUG FIXED: was `step Ctrl;` — same fix as Accelerate above (Java 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; } diff --git a/examples/stark/turtle.stark b/examples/stark/turtle.stark index 2f8de2bfa..195b30d33 100644 --- a/examples/stark/turtle.stark +++ b/examples/stark/turtle.stark @@ -1,12 +1,12 @@ /* - * Ported from ~/STARK/examples/turtle/src/main/java/turtle/Industrial_plant.java: + * 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, `Smart_hospital.java`, is the same + * `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. @@ -18,17 +18,18 @@ * * The original also builds a `FeedbackSystem`/`PersistentFeedback` * (comparing the running system against the mean of its own nominal - * evolution sequence, correcting speed/waypoint drift): `stark.feedback` is - * a Java-only extension with no textual-STARK construct at all, so it isn't - * ported, same as the DisTL monitoring gap in `monitoring.stark`. + * 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 `PersistentPerturbation` (applied at + * 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 `MISSING_GRAMMAR_FEATURES.md`. + * is a known fidelity gap — see `crates/stark/plan.md`. */ param PI = 3.141592653589793; @@ -56,16 +57,16 @@ function wp_y(int i) { } function heading_to(int wp, real x, real y) { - /* BUG FIXED: the Java computes + /* BUG FIXED: the original computes (WPx[wp]==x) ? 0 : ((WPx[wp] 0) { step Accelerate; } else { - /* BUG FIXED: was `step Ctrl;`. Java `Accelerate` is - `ifThenElse(timer_V>0, doTick(ref Accelerate), reference("Ctrl"))`; the - else is a bare same-tick `exec`, not a time-consuming `step`. */ + /* 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; } } @@ -142,7 +144,7 @@ component Robot { if (timer_V > 0) { step Decelerate; } else { - /* BUG FIXED: was `step Ctrl;` — same as Accelerate (Java else is a bare + /* BUG FIXED: was `step Ctrl;` — same as Accelerate (the original's else is a bare `reference("Ctrl")` = same-tick `exec`). */ exec Ctrl; } diff --git a/examples/stark/turtle_hospital.stark b/examples/stark/turtle_hospital.stark index 1104bc313..7a07dfd4f 100644 --- a/examples/stark/turtle_hospital.stark +++ b/examples/stark/turtle_hospital.stark @@ -1,7 +1,7 @@ /* - * Ported from ~/STARK/examples/turtle/src/main/java/turtle/Smart_hospital.java: + * Ported from the original `turtle` example's smart-hospital scenario: * the same waypoint-following robot controller/environment shape as - * `turtle.stark` (`Industrial_plant.java`) — `SetDir`/`Ctrl`/`Accelerate`/ + * `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 @@ -24,7 +24,7 @@ * * The original's `FeedbackSystem`/`PersistentFeedback` (comparing the * running system to the mean of its own nominal evolution, correcting - * heading/waypoint drift) is the same `stark.feedback` Java-only extension + * heading/waypoint drift) is the same feedback extension, available only * already documented as untranslatable in `turtle.stark`'s header — not * ported here either. * @@ -35,9 +35,9 @@ * 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 - * `MISSING_GRAMMAR_FEATURES.md`. As in `turtle.stark`, `PersistentPerturbation` - * (applied at *every* step, forever) is approximated with a large but finite - * iteration count (`^300`). + * `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 @@ -74,12 +74,12 @@ function wp_y(int i) { } function heading_to(int wp, real x, real y) { - /* BUG FIXED: Java's ternary `?:` binds looser than `+`, so in + /* BUG FIXED: the original's ternary `?:` binds looser than `+`, so in (WPx[wp]==x) ? 0 : ((WPx[wp] 0) { step Accelerate; } else { - /* BUG FIXED: was `step Ctrl;`. Java Smart_hospital `Accelerate` is - `ifThenElse(timer_V>0, doTick(ref Accelerate), reference("Ctrl"))`; the - else is a bare same-tick `exec`, not a time-consuming `step` (same as - turtle.stark). */ + /* 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; } } @@ -163,7 +164,7 @@ component Robot { step Decelerate; } else { /* BUG FIXED: was `step Ctrl;` — same as Accelerate (bare `reference("Ctrl")` - in Java else = same-tick `exec`). */ + in the original, else = same-tick `exec`). */ exec Ctrl; } } diff --git a/examples/stark/vehicle_full.stark b/examples/stark/vehicle_full.stark index 60e8f4981..243cac0ac 100644 --- a/examples/stark/vehicle_full.stark +++ b/examples/stark/vehicle_full.stark @@ -1,5 +1,5 @@ /* - * Ported from ~/STARK/examples/vehicle/src/main/java/vehicle/Main.java: a + * 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. @@ -10,9 +10,9 @@ * 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 `MISSING_GRAMMAR_FEATURES.md`. + * gap, not a stylistic choice — see `crates/stark/plan.md`. * - * The original's `AfterPerturbation(1, ...)` (wait one step before the + * 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. @@ -111,10 +111,12 @@ component Vehicle1 { if (timer_V1 > 0) { step Accelerate_V1; } else { - /* BUG FIXED: was `step Ctrl_V1;`. Java `Accelerate_V1` is - `ifThenElse(timer_V1>0, doTick(ref Accelerate_V1), reference("Ctrl_V1"))`; - the else is a bare reference = same-tick `exec`, not a time-consuming - `step` (which added a spurious idle round before Ctrl re-planned). + /* 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; } @@ -124,7 +126,7 @@ component Vehicle1 { step Decelerate_V1; } else { /* BUG FIXED: was `step Ctrl_V1;` — same as Accelerate_V1 (bare reference - in Java else = same-tick `exec`). */ + in the original, else = same-tick `exec`). */ exec Ctrl_V1; } } @@ -182,7 +184,7 @@ component Vehicle2 { if (timer_V2 > 0) { step Accelerate_V2; } else { - /* BUG FIXED: was `step Ctrl_V2;` — Java else is a bare + /* 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; } diff --git a/examples/stark/ventilator.stark b/examples/stark/ventilator.stark index f38845d88..18a1be861 100644 --- a/examples/stark/ventilator.stark +++ b/examples/stark/ventilator.stark @@ -1,5 +1,5 @@ /* - * Ported from ~/STARK/examples/mechanicallungventilator/src/main/java/mechanicallungventilator/Main.java + * 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 @@ -11,8 +11,8 @@ * 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 Java - * (`ds.get(x)` -> `x`, `DataStateUpdate(x, v)` -> `x' = v`), not a + * 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. @@ -23,63 +23,54 @@ * 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's `DataState` storage is `double` for every variable anyway. + * the original stores every variable as a double anyway. * - * ~1600 lines of the original `main()` build `stark.distl` (`DisTLFormula`/ - * `TargetDisTLFormula`/`AlwaysDisTLFormula`/...) online-monitoring queries — - * the same untranslatable formalism already documented in - * `monitoring.stark`/`MISSING_GRAMMAR_FEATURES.md` — so none of those are - * ported; only the genuinely-ROBTL `RobustnessFormula`/`DistanceExpression`/ - * `Perturbation` queries near the top of `main()` are (see the bottom of - * this file). + * 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). * - * Both `Controller.doAction(assignments, next)` and `Controller.doTick(next)` - * construct the *same* underlying class (`ActionController` — `doTick` is - * literally `doAction` with a trivial no-op update function, see - * `stark.controller.Controller`/`ActionController`), and `ActionController - * .next()` *always* returns `EffectStep(updates, next)` unconditionally — - * i.e. it always consumes exactly one simulation round (one call from - * `ControlledSystem.sampleNext`, with a full environment step in between), - * with `next` becoming "the controller" for the round after. So every - * `doAction`/`doTick` is ported as `assignments; step next;` — `step` is - * used *regardless* of whether `next` is a bare `registry.reference(...)` or - * an inline node, and regardless of whether there's an accompanying - * assignment. `Controller.ifThenElse`, by contrast - * (`IfThenElseController.next()`), recurses into whichever branch - * immediately, in the *same* call — it never consumes a round on its own, - * so a chain of nested `ifThenElse`s (as most states below have) collapses - * into one `if`/`else` in a single STARK state with no round cost. The one - * place this grammar's `exec X;` is the right translation is when an - * `ifThenElse` branch is a *bare* `registry.reference(X)` with no - * `doAction`/`doTick` wrapping it at all — that branch, taken, recurses - * directly into `X`'s own `.next()` in the same call (confirmed against - * `StarkControllerStateGenerator.visitControllerExecAction`, which does - * exactly this): no state in this file's controller happens to have that - * shape, so `exec` doesn't appear here (every leaf below is reached via a - * `doAction`/`doTick`, hence `step`). (An earlier version of this file had - * the `step`/`exec` distinction backwards; every controller state has been - * corrected.) + * 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. * - * A `doAction`/`doTick` whose `next` is itself a fresh, *inline* - * `Controller.ifThenElse(...)` (not a bare `registry.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 Java source - * line-by-line and confirmed to only ever wrap `doAction`/`doTick` around a - * *bare* reference (never an inline branch), so no further splits are - * needed. `P_Alarms_final`'s self-transition references - * `registry.reference("P_alarms_final")` (lower-case `a`) instead of the - * actually-registered `"P_Alarms_final"` — a latent typo in the original - * (an unregistered name would fail to resolve at runtime) — ported as the - * evidently-intended self-loop (`step P_Alarms_final;`, since the original - * wraps it in `Controller.doTick`), matching this session's precedent of - * fixing clear original typos (e.g. `toll.stark`'s `pen_stress`/`accel==N` - * fixes) rather than reproducing them. + * 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; @@ -421,10 +412,9 @@ component Ventilator { if (b_powerOff == 1) { step P_final; } else { - /* BUG FIXED: the original wraps this assignment's `next` in a fresh - inline `Controller.ifThenElse(...)`, not a bare `registry.reference`, - so it's a SECOND round (its own `ActionController.next()` call, - with a full environment step in between) before + /* 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; @@ -499,10 +489,10 @@ component Ventilator { /* Self-test mode */ state P_self_test { - /* BUG FIXED: the original wraps this assignment's `next` in a fresh - inline `Controller.ifThenElse(...)`, not a bare `registry.reference`, - so `b_powerOff` etc. are read a full round later (with an - environment step in between), not the same round. Split into + /* 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; @@ -528,9 +518,9 @@ component Ventilator { /* Ventilation off mode */ state P_VentOff { /* BUG FIXED: same pattern as `P_start_up`/`P_self_test` above — the - original wraps this assignment's `next` in a fresh inline - `Controller.ifThenElse(...)`, so `b_powerOff` etc. are read a full - round later. Split into `P_VentOff`/`P_VentOff_check` to match. */ + 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; @@ -1267,9 +1257,9 @@ component Switch { } /* Robustness queries. The original evaluates 7 of these (out of ~70 more - built on the untranslatable stark.distl formalism, not ported — see the - file header); eta_sav_6/eta_sav_16 are the original's local `main()` - doubles, ported as params. */ + 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; From b7657bdd2b4bd4e6577c4cdc0b53c0babfe2de18 Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Tue, 21 Jul 2026 18:13:40 +0200 Subject: [PATCH 43/50] Updated documentation and README, and combined some tests. --- crates/stark/README.md | 129 ++++++ crates/stark/plan.md | 366 ++++++++++++++++++ crates/stark/src/ast.rs | 2 +- crates/stark/src/consume.rs | 30 +- crates/stark/src/diagnostics.rs | 19 +- crates/stark/src/eval/distance.rs | 127 +++--- crates/stark/src/eval/expr.rs | 152 +++++--- crates/stark/src/eval/formula.rs | 53 ++- crates/stark/src/eval/mod.rs | 10 +- crates/stark/src/eval/perturbation.rs | 82 ++-- crates/stark/src/eval/robust.rs | 23 +- crates/stark/src/eval/sequence.rs | 77 ++-- crates/stark/src/eval/sim.rs | 35 +- crates/stark/src/eval/step.rs | 85 ++-- crates/stark/src/eval/store.rs | 10 +- crates/stark/src/eval/system.rs | 69 ++++ crates/stark/src/ir.rs | 93 ++--- crates/stark/src/lib.rs | 6 +- crates/stark/src/lower.rs | 41 +- crates/stark/src/parse.rs | 9 + crates/stark/src/resolve.rs | 20 +- crates/stark/src/typecheck.rs | 63 ++- crates/stark/src/types.rs | 41 +- crates/stark/src/value.rs | 250 ++++++------ crates/stark/tests/lowering.rs | 50 --- .../{simulation.rs => simulation_test.rs} | 3 +- .../tests/{examples.rs => stark_examples.rs} | 7 + .../{verification.rs => verification_test.rs} | 3 +- 28 files changed, 1195 insertions(+), 660 deletions(-) create mode 100644 crates/stark/README.md create mode 100644 crates/stark/plan.md create mode 100644 crates/stark/src/eval/system.rs delete mode 100644 crates/stark/tests/lowering.rs rename crates/stark/tests/{simulation.rs => simulation_test.rs} (97%) rename crates/stark/tests/{examples.rs => stark_examples.rs} (91%) rename crates/stark/tests/{verification.rs => verification_test.rs} (97%) diff --git a/crates/stark/README.md b/crates/stark/README.md new file mode 100644 index 000000000..4fc1df5c4 --- /dev/null +++ b/crates/stark/README.md @@ -0,0 +1,129 @@ +# 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 -> ir::IrProgram -> [ evaluate ] + parse check lower +``` + +[`UntypedStarkSpecification::parse`] yields a faithful syntax tree whose +references are unresolved and whose expressions have no types yet. +[`UntypedStarkSpecification::check`] runs name resolution followed by type +checking, and either reports *every* problem at once through [`Diagnostics`] or +produces a [`StarkSpecification`]. Only `check` can produce that type, so +anything holding one knows resolution and type checking already succeeded and +never has to re-derive or re-validate it. [`lower`] then flattens it into an +[`ir::IrProgram`], the arena the evaluator walks. + +```rust +use merc_stark::UntypedStarkSpecification; +use merc_stark::lower; +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 specification = UntypedStarkSpecification::parse(source) + .expect("should parse") + .check() + .unwrap_or_else(|diagnostics| panic!("{}", diagnostics.render(source))); + +let program = lower(&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/plan.md b/crates/stark/plan.md new file mode 100644 index 000000000..2cbdd2941 --- /dev/null +++ b/crates/stark/plan.md @@ -0,0 +1,366 @@ +# STARK: open work + +Everything the `merc_stark` crate still owes, in one place. This replaces the +old `EVALUATOR_PLAN.md`, `IR_LOWERING_PLAN.md` and `MISSING_GRAMMAR_FEATURES.md` +— the *design rationale* those carried now lives in the developer documentation +(`merc-website`, `docs/developer/stark.md`); only open work lives here. + +Reference implementation: `~/STARK/` — `speclang/` (parser and lowering), +`lib/src/main/java/stark/` (the runtime: `robtl/`, `distance/`, +`perturbation/`, `penalty/`, `feedback/`, `distl/`, `monitors/`, +`SampleSet.java`, `EvolutionSequence.java`), and `cli/` (the interactive +shell). + +**What runs today.** Parsing, resolution, type checking and lowering are +complete for every construct the grammar accepts, and all 27 +`examples/stark/*.stark` files lower and validate. `eval::Simulation` runs a +single trajectory; `eval::Analysis` samples an ensemble, perturbs a copy of it, +and evaluates `distance` and `formula` declarations under both the three-valued +(`check`, with a bootstrap confidence interval) and boolean (`check_boolean`) +semantics. + +--- + +## 1. Correctness gaps against the Java reference + +These are divergences in constructs this crate *does* implement — bugs, not +missing features. Highest priority. + +### 1.1 `range [from, to]` is lowered but never enforced + +`VariableInfo::range` survives into the IR, `IrProgram::validate` checks it and +`Display` prints it, but nothing in `eval/` ever reads it. In the reference, +`DataState.set` clamps *every* write through `DataRange.apply` (`Math.max(min, +Math.min(max, v))`), so the bound is a runtime invariant on the whole state +vector, not a declaration-site annotation. + +Three write paths need the clamp: `Store::new`'s variable initialisation, the +buffered `PendingUpdate` flush in `eval::step`, and the perturbation +assignments in `eval::perturbation`. Note that `from`/`to` are `ExprRef`s, so +they need evaluating once at startup and caching alongside the store rather +than being re-evaluated per write. + +### 1.2 `k # step target` idles one tick too many + +`StarkControllerStateGenerator.visitControllerStepAtion` builds +`Controller.doTick(k-1, controller)` — `k-1` tick-only wrappers — so `target` +runs `k` ticks after the `step` command, and `k < 1` behaves exactly like +`k == 1`. `eval::step` instead produces `Cursor::Idle { remaining: k }`, which +consumes `k` idle ticks *before* a further tick runs `target`, i.e. `k+1`. +The fix is `k <= 1 => Cursor::Run(target)`, `k > 1 => Cursor::Idle { remaining: +k - 1 }`. Add a test pinning `1 # step s` as equivalent to a bare `step s`. + +### 1.3 Value-for-value cross-checks against the Java tool + +Unit tests, `tests/simulation.rs` and `tests/verification.rs` all pass, but +nothing has been compared against the Java tool's actual output. For a +*deterministic* spec (no sampling), compare a trajectory — and a +distance/formula verdict — value for value. Stochastic specs can only be +compared distributionally: the RNG stream is deliberately not bit-compatible +with Java's Mersenne Twister, only the distributions match. + +### 1.4 Confidence-interval quirks carried over verbatim + +Two reference behaviours in `eval/distance.rs` were ported as written and are +easy to have mistranslated. They should be confirmed once 1.3 gives a way to +compare: + +- `\U`'s `evalCI` re-seeds its running-left maximum from the left expression + *at `i`* on every outer iteration, unlike its own `compute`. +- `bootstrapDistance` clamps the interval to `[0, 1]`, assuming penalty values + are normalised to that range. + +--- + +## 2. Language features absent from the STARK textual language + +These match the original ANTLR grammar (`StarkSpecificationLanguage.g4`) +exactly — they are limitations of the STARK *language*, not of this port. Each +entry gives the workaround the ported examples use. Implementing any of them +means extending the grammar past the reference, which is a deliberate decision +to make rather than a gap to close. + +- **No `//` line comments.** Only `/* ... */` blocks (`COMMENT: '/*' .*? '*/'`). + Workaround: block comments everywhere, including short inline notes. + +- **No parenthesised grouping in RobTL formulas.** `RobtlFormula` has no + `'(' robtlFormula ')'` alternative, so `\F[0,H] (!(A && B) || (C && D))` + cannot be written inline. Workaround: name each sub-formula as its own + `formula` declaration and compose by reference, as `engine.stark` does with + `phi_5`/`phi_6`/`phi_7`. + +- **No implication operator.** RobTL has `!`, `&&`, `||` but no `->`, even + though the Java runtime has `ImplicationRobustnessFormula` (see §3.1). + Workaround: `A -> B` ≡ `!A || B`, combined with the no-parens point above. + +- **No `when`-guarded perturbation assignments.** A controller or environment + assignment can be guarded (`when guard target' = value;`); a + `PerturbationAssignment` (`target <- value` inside `[...]@time`) cannot. + Workaround: fold the condition into a ternary that leaves the variable + unchanged — `target <- (guard ? new_value : target)`. + +- **No `let` inside a perturbation's `[...]@time` block.** A controller or + environment step can bind a shared intermediate once and reuse it across + several assignments; a perturbation's atomic block is a flat list of + `target <- expression` pairs with no binding form. This is a real fidelity + loss, not a style difference: `vehicle`'s `fasterPerturbation` draws *one* + random offset and derives a fake speed, a fake required distance and a fake + safety gap from it, whereas each ported assignment must redraw `R[0,1]` + independently, so the three "sensor" readings are no longer correlated. + +- **No primed-variable references inside expressions.** `NEXT_ID` (`x'`) + appears only in assignment *target* position; an expression can never read + "the value `x` is about to become". Workaround: a `let` binding stands in — + `let new_x = ... in { x' = new_x; d' = f(new_x); }`. + +- **No array/list types or aggregate functions.** The `.count()`/`.min()`/ + `.max()`/`.mean()` postfix aggregates, the array literal and the `array` + type are all present in the original `.g4` only as commented-out rules, so + there is no array `StarkType` either. The `it` iterator primitive *does* + parse here (`ExpressionKind::Iterator`), but lowering emits + `ExprNode::Unreachable` for it, because the aggregate context that would + bind it does not exist. Adding aggregates is what would make `it` reachable. + +- **No math constants** (`pi`, `e`, …). Formulas needing `pi` hard-code the + decimal expansion (`1.5707963267948966` for `pi/2`). + +- **No current-step / round-index expression.** `Expression` has no "current + round index" primitive — not `state.getStep()`, not an implicit loop + variable — so an "every `k`-th step" effect cannot be expressed at all. + Unlike the two perturbation gaps above there is no ternary workaround, since + the condition depends on absolute position in the evolution sequence rather + than on any variable in the data state. The Java `turtle` example's + `ChangeDir` gates a speed boost on `state.getStep() % k == 0`; only its + unconditional heading jitter was portable (see `turtle_hospital.stark`'s + header). Note that the reference *does* carry a step counter and time fields + on `DataState` (§3.7) — exposing them would be the enabling change. + +--- + +## 3. Java runtime features with no textual syntax + +The Java library is substantially larger than the language that drives it. +Everything below exists in `~/STARK/lib/` but is unreachable from +`StarkSpecificationLanguage.g4`, so it is only usable by writing Java against +the library directly. Each would need both grammar and IR work here. Ordered +roughly by how close it is to what the crate already does. + +### 3.1 Operators missing from arenas that otherwise match + +Small, self-contained additions to existing IR enums: + +- **`ImplicationRobustnessFormula`** — `FormulaIr` has `Not`/`And`/`Or` but no + `Implies`. Both `BooleanSemanticsVisitor` and `ThreeValuedSemanticsVisitor` + implement it. Needs a `->` in `RobtlFormula`. +- **`PersistentPerturbation`** and **`AfterPerturbation`** — `PerturbationIr` + covers `Nil`/`Atomic`/`Sequence`/`Iteration`, matching exactly what + `StarkPerturbationGenerator` can build. `PersistentPerturbation(body)` + repeats `body` forever (`step()` returns `Sequential(body.step(), this)`); + `AfterPerturbation(steps, body)` delays a whole sub-perturbation rather than + a single atomic block, which `[...]@time` cannot express when the delayed + thing is a composite. +- **`AtomicDistanceExpression` with a custom ground metric.** The grammar + exposes only `p`, which lower to `AtomicLeft`/`AtomicRight` + (`distanceLeq`/`distanceGeq`). Java's plain `AtomicDistanceExpression` takes + an arbitrary `DoubleBinaryOperator` as the ground distance between two + penalty samples, with the Wasserstein lifting built on top of it. Would need + syntax for naming a ground metric. +- **Convex-combination weight validation.** `ConvexCombinationDistanceExpression` + rejects weights that do not sum to exactly 1. `DistanceIr::LinearCombination` + accepts any weights, and since they are `ExprRef`s the check would have to be + a runtime one at construction. Decide whether to enforce it or to document + the divergence. + +### 3.2 Skorokhod distance + +`SkorokhodDistanceExpression` computes a retiming-tolerant distance via a +dynamic-programming table over time offsets, parameterised by a retiming +window, a resolution, a direction flag and an average-vs-maximum mode. There is +no `DistanceIr` node and no grammar production for it. Note that its own +`evalCI` throws `UnsupportedOperationException` upstream, so only `compute` +would be portable — meaning it could not appear under a `\D[...]` in a +three-valued `formula`. The `repressilator` example (`Main_Skorokhod.java`) +is the reference use. + +### 3.3 Compositional penalties + +`PenaltyIr` is a single expression evaluated at every step. Java's +`stark.penalty` package makes a penalty a *coroutine* with the same shape as +`Perturbation`: `AtomicPenalty(afterSteps, expr)`, `SequentialPenalty`, +`IterativePenalty(replica, body)`, `NonePenalty`, with `effect()`/`next()`/ +`isDone()` and `effectUpTo(step)`. This lets a penalty change over time — score +one thing for the first `k` steps and another afterwards. `SampleSet` already +has `distanceLeq(Penalty, other, step)` overloads that take one. The grammar +would need a penalty-expression sub-language mirroring `PerturbationExpression`. + +### 3.4 Feedback + +`stark.feedback` is a whole framework with no syntax at all: `Feedback` has the +same six-case shape as `Perturbation` (`Atomic`/`Delayed`/`Iterative`/`None`/ +`Sequential`/`Persistent`) but an `AtomicFeedback` closes the loop — its +`FeedbackFunction` receives the *evolution sequence so far* alongside the +random generator and data state, so the system can react to statistics of its +own ensemble (`SampleSet.mean` over a previous step). `FeedbackSystem` is the +corresponding `SystemState`. This is architecturally the largest gap: nothing +in `eval/` gives a running system access to the sequence it belongs to. + +### 3.5 Online monitoring: DisTL, UDisTL and monitors + +A second, independent verification formalism. Where RobTL compares a nominal +evolution sequence against a perturbed one via a distance metric (offline, two +trajectories), DisTL evaluates a temporal formula directly against one observed +trajectory (online, incremental). + +- `stark.distl` — `True`/`False`/`Negation`/`Conjunction`/`Disjunction`/ + `Implication`/`Always`/`Eventually`/`Until`, plus the two atomic forms + `TargetDisTLFormula` and `BrinkDisTLFormula`, each carrying a target + distribution, a penalty (or a compositional `Penalty`, §3.3) and a + threshold. `DoubleSemanticsVisitor` gives the quantitative semantics. +- `stark.udistl` — `UnboundedUntiluDisTLFormula`; note its semantic evaluation + throws upstream ("not formally defined") and is only meaningful via a monitor. +- `stark.monitors` — the incremental evaluators (`TargetMonitor`, + `BrinkMonitor`, `UntilMonitor`, `UnboundedUntilMonitor`, the boolean + combinators, `DefaultMonitorBuilder`) plus `MonitorBuildingVisitor`. +- `PerceivedSystemState` — a `SystemState` stripped down to its data state, + which is what monitors consume; `EvolutionSequence.getAsPerceivedSystemStates` + produces them. It deliberately throws on `sampleNext`. + +The `monitoring` example and the monitoring-only parts of `tollbooth` are +ported here only as far as their variable/controller/environment model goes; +the monitored property itself is a comment, not a translation. + +### 3.6 Probabilistic and non-deterministic controllers + +`eval::step`'s `Cursor` covers `Assign`/`IfThenElse`/`Let`/`Sequence`/`Step`/ +`Exec`, matching `AssignmentController`/`IfThenElseController`/`StepController`/ +`ExecController`/`NilController` and the flattening of `ParallelController` +into a `Vec`. Three Java controllers have no counterpart: + +- `GenerativeChoiceController(p, left, right)` — pick one branch with + probability `p` and delegate to it for this step. +- `ProbabilisticInterleavingController(p, left, right)` — advance *one* of two + concurrently-live controllers, chosen with probability `p`; the other keeps + its cursor. This is a genuinely different composition from the parallel one + `init a || b` gives, where both advance every tick. +- `RandomChoiceBehaviour` — uniform choice between two behaviours. + +The commented-out `controllerProbabilisticBehaviour` / +`controllerProbabilisticItem` rules in the original `.g4` +(`('when' guard)? '[' probability '>' block`) are the intended syntax, and +`controllerSwitchStatement` / `controllerCaseStatment` are commented out +alongside them — `visitControllerCaseStatment` is a bare `//TODO: FIXME!` +upstream, so `switch` is unimplemented in Java too. `lower`'s `Result` and +`DiagnosticKind::NotYetSupported` exist precisely for this class of construct. + +### 3.7 Timed systems and the `DataState` clock + +`SystemState` here is a store plus cursors. Java's `DataState` additionally +carries `step` (the round index, §2), `timeStep`, `timeReal`, `timeDelta`, +`granularity`, `ctrl_granularity` and `ctrl_timeStep`, and two alternative +system implementations use them: + +- `TimedSystem` — a macro-step runs *many* micro-steps, each advancing real + time by a sampled `generateNextTime`, until the accumulated time crosses the + next granularity boundary. Sampling is decoupled from the tick. +- `DecoupledTimedSystem` — the same idea with the controller running on its own + granularity, independent of the environment's. + +Both are constructed from Java, never from a specification. + +### 3.8 Sequence and sample-set operations + +`eval::EvolutionSequence` implements `generate`, `generate_up_to`, +`generate_next`, `apply` (perturbation) and the Wasserstein lifting. Not ported: + +- `generateUpToCond(conditions)` / `generateNextStepCond(condition)` — generate + until a `DataStateBooleanExpression` holds rather than to a fixed step count. +- `select(from, to)` — a sub-sequence view. +- `SampleSet::mean`, `replica(k)`, `applyDistribution` — used by feedback (§3.4). +- `SimulationMonitor` / `ConsoleMonitor` / `SilentMonitor` — progress reporting + during long ensemble generation, which the CLI would want for `--samples` in + the thousands. + +### 3.9 Path planning + +`stark.planning` (`RRTstar`, `RRTstar_vis`, `DefaultMap`, `Pos`, `Goal`, +`Obstacle`) is a support library for the `rover` and `turtle` examples rather +than part of the language. Listed for completeness; porting it is only worth it +if those examples are to run end to end. + +--- + +## 4. Tooling: the CLI + +`tools/stark` has `check`, `simulate` and `verify`. The Java `cli/` is an +interactive shell (`StarkScript.g4`) built around a loaded specification and a +mutable analysis configuration. The gaps worth closing, roughly in order of +value: + +- **`eval at `** — evaluate a `penalty` declaration over the + reference sequence and report the per-sample values. No equivalent exists; + `penalty` declarations are only reachable indirectly, through a `distance`. +- **`compute after at `** — report the raw + distance rather than a formula verdict. `Analysis::distance_under` already + does the work; only the subcommand is missing. +- **Step ranges.** `stepExpression` is either `at s1, s2, …` or + `from a to b every k`; `verify --step` takes a single value, so a verdict + cannot be swept over time in one run. +- **`save in "f.csv"` / `print` / `clear`** — the last result set is retained + and exportable as CSV. Nothing here persists results. +- **Listing commands** — `formulas`, `penalties`, `distances`, + `perturbations`, `info`. `check --print-symbols` covers part of this but is + not per-kind. +- **`set size|m|z|scale|seed`** — the analysis parameters, which here are + per-invocation flags on `verify`. A REPL would need them as state. +- **Shell plumbing** — `load`, `cd`, `ls`, `cwd`, `quit`. Only relevant if an + interactive mode is wanted at all; a non-interactive CLI is arguably the + better fit for this workspace and these belong in the "won't do" column. + +--- + +## 5. Improvements specific to this port + +Not gaps against Java — things this implementation should tidy up. + +- **Rename `Expression::Normal`'s `std_dev` field to `variance`.** The original + grammar names the second argument of `N[mean, ...]` `variance`. The parser + does not care, but the current name asserts a meaning the reference does not, + which will silently mislead anyone porting a Java model that specifies one or + the other explicitly. Check what `eval/expr.rs` actually does with it while + renaming. +- **Spans on perturbation, distance and formula arena nodes.** Expressions and + slots carry `Span`s; these three arenas do not, so a runtime error inside a + `distance` can only be anchored to the sub-expression, not to the distance + operator that failed. Add when the first diagnostic wants one. +- **A `const`/`param` initializer cannot call a function.** + `UntypedStarkSpecification` buckets declarations by kind, so `resolve.rs` + works in a fixed kind order — constants and parameters, then types, then + functions, then variables — rather than in source order. A function + therefore is not yet declared when a `param` initializer references it, even + when it appears first in the source. Java's `StarkModelGenerator` walks the + parse tree in source order and has no such restriction. + `abz2025_two_lanes_two_cars.stark` works around it by inlining `rss_gap`'s + formula into `INIT_SAFETY_GAP` for both orderings. Fixing this means either + preserving a linear source-order declaration list alongside the buckets, or + hoisting function declarations ahead of constants and parameters (variables + are already pre-declared for the same reason). +- **Reserved keywords cannot be used as identifiers**, including ones that read + as ordinary variable names — `distance` is the one that came up (a tractor's + distance-to-target had to become `dist_to_target`). The set is + `stark_grammar.pest`'s `KEYWORD` rule; it exists so an `ID` cannot swallow a + following declaration keyword, but could likely be narrowed with lookahead. +- **Functions return exactly one value.** Java models sometimes compute two + related outputs from one control law and return a small array; ported as two + functions that each recompute the shared intermediates (see + `agriculturalDT.stark`'s `eval_speed_zero`/`eval_steer_zero`). Tuple returns + would fix this, at the cost of diverging from the grammar. +- **Optimisation passes over the IR** — constant folding, common-subexpression + elimination, dead-slot elimination. The arena representation was chosen to + make these straightforward; nothing needs them for correctness, and they + should wait until a profile says an analysis run is expression-bound. +- **Parallelism.** `SampleSet` uses parallel streams for `evalPenaltyFunction` + and the bootstrap resampling, and ensemble generation is embarrassingly + parallel across samples. Everything here is single-threaded. This is the most + likely source of a large speedup on `verify`, and it interacts with + reproducibility: per-sample RNG streams have to be derived deterministically + from the seed rather than drawn from one shared generator. diff --git a/crates/stark/src/ast.rs b/crates/stark/src/ast.rs index 09d545296..e214b3f62 100644 --- a/crates/stark/src/ast.rs +++ b/crates/stark/src/ast.rs @@ -1,7 +1,7 @@ //! Abstract syntax tree for the STARK specification language. //! //! This mirrors the structure of the original STARK ANTLR grammar -//! (`StarkSpecificationLanguage.g4`). The tree is produced by `consume.rs` +//! grammar. The tree is produced by `consume.rs` //! (structural declarations) together with the Pratt parsers in `precedence.rs` //! (expressions and the perturbation / distance / ROBTL sub-languages). //! diff --git a/crates/stark/src/consume.rs b/crates/stark/src/consume.rs index 6bf178745..34444c062 100644 --- a/crates/stark/src/consume.rs +++ b/crates/stark/src/consume.rs @@ -1,3 +1,15 @@ +//! Turns the `pest` parse tree produced by `parse.rs` into the [crate::ast] +//! tree, one `merc_pest_consume` consumer per grammar rule. +//! +//! Only the *structural* declarations are consumed here; every expression +//! language (plain expressions and the perturbation / distance / ROBTL +//! sub-languages) reaches this module as a flat token stream that is handed +//! to the Pratt parsers in `precedence.rs` instead. +//! +//! Nothing is resolved or typed at this stage: every `DefRef`/`StateRef` +//! carries a `None` id and every expression a `None` type, both filled in +//! later by `resolve.rs` and `typecheck.rs`. + #![allow(clippy::result_large_err)] use merc_pest_consume::Error; @@ -40,13 +52,17 @@ pub(crate) type ParseResult = std::result::Result>; pub(crate) type ParseNode<'i> = merc_pest_consume::Node<'i, Rule, ()>; // --------------------------------------------------------------------------- -// Dispatch helpers for silent alternation groups. -// -// The grammar's `FunctionStatement`, `ControllerCommand` and `EnvironmentCommand` -// rules are silent, so their concrete variant nodes appear directly as children. -// These helpers route a variant node to its consumer. +// Dispatch helpers for silent alternation groups // --------------------------------------------------------------------------- +/// Routes one variant node of the silent `FunctionStatement` rule to its +/// consumer. +/// +/// The grammar's `FunctionStatement`, `ControllerCommand` and +/// `EnvironmentCommand` rules are silent, so their concrete variant nodes +/// appear directly as children of whatever contains them rather than under a +/// node of their own — hence the three hand-written dispatchers here instead +/// of a generated consumer per rule. fn function_statement(node: ParseNode) -> ParseResult { match node.as_rule() { Rule::FunctionReturn => StarkParser::FunctionReturn(node), @@ -57,6 +73,8 @@ fn function_statement(node: ParseNode) -> ParseResult { } } +/// Routes one variant node of the silent `ControllerCommand` rule to its +/// consumer — see [function_statement] for why these dispatchers exist. fn controller_command(node: ParseNode) -> ParseResult { match node.as_rule() { Rule::ControllerStep => StarkParser::ControllerStep(node), @@ -69,6 +87,8 @@ fn controller_command(node: ParseNode) -> ParseResult { } } +/// Routes one variant node of the silent `EnvironmentCommand` rule to its +/// consumer — see [function_statement] for why these dispatchers exist. fn environment_command(node: ParseNode) -> ParseResult { match node.as_rule() { Rule::EnvironmentAssignment => StarkParser::EnvironmentAssignment(node), diff --git a/crates/stark/src/diagnostics.rs b/crates/stark/src/diagnostics.rs index 36ea06463..bb903c129 100644 --- a/crates/stark/src/diagnostics.rs +++ b/crates/stark/src/diagnostics.rs @@ -1,9 +1,10 @@ //! Diagnostics collected during name resolution and type checking. //! -//! Ported from `parsing/ParseErrorCollector.java`: rather than failing 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. +//! 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]` @@ -120,9 +121,9 @@ pub enum DiagnosticKind { // -- Lowering (`lower.rs`) ------------------------------------------- /// A construct that resolves and type-checks but has no IR - /// representation yet (see `MISSING_GRAMMAR_FEATURES.md`). Reported - /// rather than panicked on, so a partially-supported spec fails - /// gracefully instead of crashing lowering. + /// representation yet (see `plan.md`). Reported rather than panicked on, + /// so a partially-supported spec fails gracefully instead of crashing + /// lowering. #[error("{construct} is not yet supported by lowering")] NotYetSupported { construct: &'static str }, } @@ -259,8 +260,8 @@ impl fmt::Display for Diagnostics { } } -// Letting `Diagnostics` implement `std::error::Error` means it converts into -// `MercError` for free via that type's blanket `From` impl. +/// Implemented so that a [Diagnostics] converts into `MercError` for free, via +/// that type's blanket `From` impl. impl Error for Diagnostics {} #[cfg(test)] diff --git a/crates/stark/src/eval/distance.rs b/crates/stark/src/eval/distance.rs index a73cc8375..1956d97e7 100644 --- a/crates/stark/src/eval/distance.rs +++ b/crates/stark/src/eval/distance.rs @@ -1,5 +1,5 @@ //! Distance expressions: how far apart two evolution sequences are, as a -//! single `f64` per time step. Ported from `lib/.../distance/`. +//! 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 @@ -7,24 +7,20 @@ //! thresholds, convex combinations) combining those pointwise values over an //! interval. //! -//! Two things about the reference implementation are preserved deliberately -//! and are easy to get wrong: +//! 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; `StarkDistanceGenerator` builds a -//! `MinIntervalDistanceExpression` for `\F` and a -//! `MaxIntervalDistanceExpression` for `\G`. -//! - **A distance interval `[from, to]` excludes `to`.** The reference -//! iterates `IntStream.range(from+step, to+step)`. The *formula* layer -//! (see [super::formula]) iterates `to+step+1` and so includes it. That -//! inconsistency is the reference's, not this port's, and is preserved so -//! results match. +//! 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`, matching `.orElse(Double.NaN)` on the -//! reference's empty streams, rather than being an error. +//! An empty interval yields `NaN` rather than being an error, again matching +//! the original. //! //! # Confidence intervals //! @@ -52,8 +48,8 @@ use super::sequence::ground_leq; use super::sequence::wasserstein; /// A distance value together with the empirical-bootstrap confidence -/// interval around it — the reference's `double[3]` (`{value, lower, -/// upper}`), named. +/// interval around it — the original's bare `{value, lower, upper}` triple, +/// named. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Ci { pub value: f64, @@ -62,11 +58,10 @@ pub struct Ci { } impl Ci { - /// Combines two intervals component-wise, as `MinDistanceExpression`/ - /// `MaxDistanceExpression`/`MaxIntervalDistanceExpression` all do — the - /// reference applies the same operator to the value and to both bounds - /// independently rather than propagating the bounds of whichever operand - /// won. + /// 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), @@ -86,7 +81,7 @@ impl Ci { } impl ComparisonOp { - /// `RelationOperator.eval`. + /// Applies this operator to two distance values. pub(crate) fn compare(self, left: f64, right: f64) -> bool { match self { ComparisonOp::Less => left < right, @@ -98,21 +93,21 @@ impl ComparisonOp { } } -/// `Math.min`/`Math.max` propagate `NaN`, 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. -fn java_min(a: f64, b: f64) -> f64 { +/// `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 java_max(a: f64, b: f64) -> f64 { +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` — `DistanceExpression.compute`. + /// time `step`. pub(crate) fn distance( &mut self, reference: &mut EvolutionSequence, @@ -132,27 +127,26 @@ impl Analysis<'_, R> { } // `\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, java_min) + 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, java_max) + 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)?; - // `UntilDistanceExpression.compute`: 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 — - // it is declared outside the loop in the reference, which is - // equivalent to recomputing the running maximum each time. + // 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 = java_max(running_left, self.distance(reference, perturbed, j, left)?); + running_left = nan_max(running_left, self.distance(reference, perturbed, j, left)?); } - result = java_min(result, java_max(right_value, running_left)); + result = nan_min(result, nan_max(right_value, running_left)); } Ok(result) } @@ -166,12 +160,12 @@ impl Analysis<'_, R> { DistanceIr::Min(left, right) => { let left = self.distance(reference, perturbed, step, left)?; let right = self.distance(reference, perturbed, step, right)?; - Ok(java_min(left, 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(java_max(left, right)) + Ok(nan_max(left, right)) } DistanceIr::LinearCombination(terms) => { let mut total = 0.0; @@ -183,8 +177,7 @@ impl Analysis<'_, R> { } } - /// [Analysis::distance], plus a bootstrap confidence interval around it — - /// `DistanceExpression.evalCI`. + /// [Analysis::distance], plus a bootstrap confidence interval around it. pub(crate) fn distance_ci( &mut self, reference: &mut EvolutionSequence, @@ -197,25 +190,25 @@ impl Analysis<'_, R> { 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, java_min) + 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, java_max) + 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 `compute`, the reference re-seeds the running - // left maximum from the left expression *at `i`* on every - // iteration before folding in `[from, i)`. Preserved as - // written. + // 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)?, java_max); + running_left = running_left.zip(self.distance_ci(reference, perturbed, j, left)?, nan_max); } - result = result.zip(right_value.zip(running_left, java_max), java_min); + result = result.zip(right_value.zip(running_left, nan_max), nan_min); } Ok(result) } @@ -241,12 +234,12 @@ impl Analysis<'_, R> { 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, java_min)) + 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, java_max)) + Ok(left.zip(right, nan_max)) } DistanceIr::LinearCombination(terms) => { let mut total = Ci::exact(0.0); @@ -277,8 +270,7 @@ impl Analysis<'_, R> { Ok((left, right)) } - /// An atomic distance with its bootstrap interval — - /// `SampleSet.bootstrapDistance{Leq,Geq}`. + /// An atomic distance with its bootstrap interval. fn atomic_ci( &mut self, reference: &mut EvolutionSequence, @@ -295,9 +287,9 @@ impl Analysis<'_, R> { /// The empirical bootstrap: resample both distributions with replacement /// `m` times, and take a `z`-quantile normal interval around the mean of - /// the resulting distances — `SampleSet.bootstrapDistance`. + /// the resulting distances. /// - /// The interval is clamped to `[0, 1]` exactly as the reference clamps + /// 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; @@ -333,8 +325,8 @@ impl Analysis<'_, R> { sample } - /// `MinIntervalDistanceExpression`/`MaxIntervalDistanceExpression`: fold - /// `argument` over `[from + step, to + step)`, `NaN` if empty. + /// 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, @@ -358,8 +350,8 @@ impl Analysis<'_, R> { Ok(folded.unwrap_or(f64::NAN)) } - /// [Analysis::fold_interval] for confidence intervals: the reference - /// folds the value and both bounds independently. + /// [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, @@ -389,10 +381,9 @@ impl Analysis<'_, R> { /// evaluator, against the program's `const`/`param` slots. /// /// A negative bound, or `to <= from`, gives an empty range rather than - /// the reference's `IllegalArgumentException` — bounds are only checked - /// at construction time there, which this port has no equivalent of - /// (they are evaluated on demand), and the never-panic contract rules - /// out throwing. + /// 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")?; @@ -405,10 +396,14 @@ impl Analysis<'_, R> { /// 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_number("a distance or formula constant") + 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) + 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 index 172e8bf5f..405961de8 100644 --- a/crates/stark/src/eval/expr.rs +++ b/crates/stark/src/eval/expr.rs @@ -1,16 +1,13 @@ -//! Expression and function-body evaluation over [IrProgram]'s arena, ported -//! from `StarkExpressionEvaluator.java`'s case analysis — but as a straight -//! post-order walk of `ExprRef`/`StmtRef` indices instead of a tree of -//! `Supplier`/lambda closures, since lowering already collapsed the AST into -//! that arena (see `IR_LOWERING_PLAN.md`). +//! 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 `StarkValue.ERROR_VALUE` the Java reference propagates — see -//! `value.rs`'s "Errors are a `Result`, not a value" and `EVALUATOR_PLAN.md`'s -//! "the one contract to preserve", which the `Result` honours more strictly -//! (the error cannot be silently dropped). +//! 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; @@ -24,52 +21,72 @@ 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 [crate::ir::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(EvalError::Unreachable(what)), + 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 Java — see `ExprNode::Negate` - // and `ExprNode::Widen`'s doc comments in `ir.rs`. - ExprNode::Negate(inner) => eval(program, store, rng, inner)?.apply_unary("-", |x| -x), - ExprNode::Widen(inner) => eval(program, store, rng, inner)?.apply_unary("+", |x| x), + // 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) + 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) + 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) + left.apply_binary(right, name, f).map_err(EvalError::from) } ExprNode::Select { guard, then_branch, else_branch, } => { - // Lazy, matching `StarkValue.ifThenElse`'s `Supplier`-based - // laziness in the Java reference: only the taken branch is - // evaluated, since the untaken one may sample (advancing `rng`) - // or divide by zero. + // 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 { @@ -86,8 +103,7 @@ pub(crate) fn eval( // ...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 - // (see `IR_LOWERING_PLAN.md`, "Why one flat slot space works"). + // 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); } @@ -106,8 +122,8 @@ pub(crate) fn eval( } ExprNode::SampleChoice(list) => { let elements = program.expr_list(list); - // Lazy like `Select`: `visitUniformExpression` indexes - // `elements[selected]` and evaluates only that one element. + // 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]) } @@ -137,7 +153,7 @@ pub(crate) fn eval_stmt( // `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(EvalError::MissingReturn), + None => Err(EvalErrorKind::MissingReturn.into()), } } } @@ -149,7 +165,7 @@ pub(crate) fn eval_stmt( } } -fn apply_binary_op(op: BinaryOp, left: Value, right: Value) -> Result { +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. @@ -191,7 +207,7 @@ fn math_unary_fn(function: MathUnaryFunction) -> (&'static str, fn(f64) -> f64) MathUnaryFunction::Log => ("log", f64::ln), MathUnaryFunction::Log10 => ("log10", f64::log10), MathUnaryFunction::Log1p => ("log1p", f64::ln_1p), - MathUnaryFunction::Signum => ("signum", java_signum), + MathUnaryFunction::Signum => ("signum", signum), MathUnaryFunction::Sin => ("sin", f64::sin), MathUnaryFunction::Sinh => ("sinh", f64::sinh), MathUnaryFunction::Sqrt => ("sqrt", f64::sqrt), @@ -204,50 +220,50 @@ fn math_binary_fn(function: MathBinaryFunction) -> (&'static str, fn(f64, f64) - match function { MathBinaryFunction::Atan2 => ("atan2", f64::atan2), MathBinaryFunction::Hypot => ("hypot", f64::hypot), - MathBinaryFunction::Max => ("max", java_max), - MathBinaryFunction::Min => ("min", java_min), + MathBinaryFunction::Max => ("max", nan_max), + MathBinaryFunction::Min => ("min", nan_min), MathBinaryFunction::Pow => ("pow", f64::powf), } } -/// `Math.signum`: unlike [f64::signum] (which returns `±1.0` for `±0.0` and -/// never `0.0`), Java's version returns the zero itself (`0.0` or `-0.0`) -/// unchanged, and propagates `NaN`. -fn java_signum(x: f64) -> f64 { +/// `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() } } -/// `Math.max`: propagates `NaN` if *either* argument is `NaN`. [f64::max] -/// instead returns the non-`NaN` argument, so it can't be used directly. -fn java_max(a: f64, b: f64) -> f64 { +/// `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) } } -/// `Math.min`, see [java_max]. -fn java_min(a: f64, b: f64) -> f64 { +/// `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) } } -/// `StarkValue.sample`: `from + rng.nextDouble() * (to - from)`. +/// `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_number("the lower bound of an `R[a,b]` sample")?; - let to = max.as_number("the upper bound of an `R[a,b]` sample")?; + 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))) } -/// `StarkValue.sampleNormal`. **Not actually Gaussian** — despite the name -/// and the `N[mean, variance]` syntax, the Java reference computes -/// `rng.nextDouble() * mean + variance` (a scaled-and-shifted uniform -/// sample), not a normal distribution. This is ported *exactly*, not -/// "fixed", so behaviour matches the reference tool; it reads as a bug in -/// `StarkValue.sampleNormal`, but is not this port's place to silently -/// correct. +/// `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_number("the mean of an `N[m,v]` sample")?; - let variance = variance.as_number("the variance of an `N[m,v]` sample")?; + 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)) } @@ -291,6 +307,36 @@ mod tests { 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 spec = UntypedStarkSpecification::parse(source) + .expect("should parse") + .check() + .expect("should check"); + let program = lower(&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 @@ -368,8 +414,8 @@ mod tests { } #[test] - fn sample_normal_matches_the_non_gaussian_java_quirk() { - // Pin the `rng.nextDouble() * mean + variance` quirk exactly. + 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); diff --git a/crates/stark/src/eval/formula.rs b/crates/stark/src/eval/formula.rs index adf6461b9..d594fccff 100644 --- a/crates/stark/src/eval/formula.rs +++ b/crates/stark/src/eval/formula.rs @@ -1,25 +1,22 @@ //! 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. Ported from `lib/.../robtl/`. +//! itself. //! -//! Two semantics, both from the reference: +//! Two semantics, both from the original: //! -//! - [Analysis::check] — the **three-valued** semantics -//! (`ThreeValuedSemanticsVisitor`), 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 -//! (`BooleanSemanticsVisitor`), 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. +//! - [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` (the reference iterates `to + step + 1`), -//! whereas a distance's excludes it. Preserved as-is; see the distance module -//! doc. +//! `[from, to]` **includes** `to`, whereas a distance's excludes it. +//! Preserved as-is; see the distance module doc. use rand::Rng; @@ -30,7 +27,7 @@ use crate::value::EvalError; use super::robust::Analysis; use super::sequence::EvolutionSequence; -/// A three-valued verdict — `TruthValues`. [TruthValue::Unknown] means the +/// 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 { @@ -59,7 +56,10 @@ impl TruthValue { } } - /// Kleene negation: `Unknown` is its own negation. + /// 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, @@ -68,8 +68,7 @@ impl TruthValue { } } - /// `TruthValues.valueOf`: `1.0`/`0.0`/`-1.0`, for callers that want a - /// numeric verdict. + /// `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, @@ -87,7 +86,7 @@ impl From for TruthValue { impl Analysis<'_, R> { /// Checks a formula against `sequence` at time `step`, under the - /// three-valued semantics — `ThreeValuedSemanticsVisitor`. + /// three-valued semantics. pub fn check( &mut self, sequence: &mut EvolutionSequence, @@ -130,7 +129,7 @@ impl Analysis<'_, R> { for i in from..=to { value = value.and(self.check(sequence, i, argument)?); // `false` is absorbing, so nothing later can change the - // verdict — the reference short-circuits here too. + // verdict — the original short-circuits here too. if value == TruthValue::False { break; } @@ -160,8 +159,8 @@ impl Analysis<'_, R> { } FormulaIr::Until { from, to, left, right } => { let (from, to) = self.interval(from, to, step)?; - // `UntilRobustnessFormula`: walk forward while the left side - // still holds, looking for a point where the right side does. + // 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; @@ -180,9 +179,9 @@ impl Analysis<'_, R> { } } - /// Checks a formula under the two-valued semantics — - /// `BooleanSemanticsVisitor`. Compares point estimates, so it never needs - /// the bootstrap and never answers "unknown". + /// 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, @@ -224,7 +223,7 @@ impl Analysis<'_, R> { Ok(false) } FormulaIr::And(left, right) => { - // Short-circuiting, matching Java's `&&`. + // Short-circuiting, as in the original. Ok(self.check_boolean(sequence, step, left)? && self.check_boolean(sequence, step, right)?) } FormulaIr::Or(left, right) => { diff --git a/crates/stark/src/eval/mod.rs b/crates/stark/src/eval/mod.rs index eafe4d3bc..36a1da135 100644 --- a/crates/stark/src/eval/mod.rs +++ b/crates/stark/src/eval/mod.rs @@ -1,6 +1,5 @@ //! The evaluator: executes a checked, lowered [crate::ir::IrProgram] — //! expression evaluation, function calls, sampling, and simulation stepping. -//! See `EVALUATOR_PLAN.md` for the full design. //! //! ```text //! parse -> resolve -> typecheck -> lower -> IrProgram -> [ evaluate ] @@ -14,16 +13,14 @@ //! 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]. This is Milestone B of `EVALUATOR_PLAN.md`. +//! 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). -//! This is Milestone C. //! //! Every entry point is fallible: evaluation returns `Result<_, EvalError>` -//! rather than propagating an absorbing error *value* the way the Java -//! reference's `StarkValue.ERROR_VALUE` does — see `value.rs`'s "Errors are a -//! `Result`, not a value" for why. +//! rather than propagating an absorbing error *value* the way the original +//! does — see `value.rs` for why. mod distance; mod expr; @@ -37,6 +34,7 @@ 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; diff --git a/crates/stark/src/eval/perturbation.rs b/crates/stark/src/eval/perturbation.rs index 3d7eb3297..e4ea17140 100644 --- a/crates/stark/src/eval/perturbation.rs +++ b/crates/stark/src/eval/perturbation.rs @@ -1,23 +1,21 @@ //! The perturbation coroutine: a value that, tick by tick, decides whether -//! the state it is attached to gets rewritten and how. Ported from -//! `lib/.../perturbation/` — `Perturbation`'s three-method interface -//! (`effect()`, `step()`, `isDone()`) carries over verbatim, since the whole -//! semantics of a perturbation is "what does it do *now*, and what is it -//! *next*". +//! 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 Java's -//! recursive `Controller` tree, [PerturbationState] replaces the reference's -//! `AtomicPerturbation`/`SequentialPerturbation`/`IterativePerturbation`/ -//! `NonePerturbation` object graph 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. +//! 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. //! -//! Two of Java's cases have no counterpart here because the grammar cannot -//! produce them: `AfterPerturbation` and `PersistentPerturbation` are -//! unreachable from `StarkPerturbationGenerator`, which only ever builds -//! `NONE`, `Atomic`, `Sequential` and `Iterative`. `PerturbationIr` matches -//! that reachable subset exactly. +//! 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; @@ -25,24 +23,25 @@ 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, matching `Perturbation.step()`. +/// returns the successor rather than mutating, as in the original. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum PerturbationState { - /// `NonePerturbation`: no effect, self-loop, already done. + /// `nil`: no effect, self-loop, already done. None, - /// `AtomicPerturbation`: fires `node`'s assignments once `after_steps` - /// ticks have elapsed. `node` is always a [PerturbationIr::Atomic]. + /// `[..]@time`: fires `node`'s assignments once `after_steps` ticks have + /// elapsed. `node` is always a [PerturbationIr::Atomic]. Atomic { after_steps: i64, node: PerturbationId }, - /// `SequentialPerturbation`: `first` runs until it is done, then `second`. + /// `a ; b`: `first` runs until it is done, then `second`. Sequence(Box, Box), - /// `IterativePerturbation`: `body`, repeated `replica` times. `body` is - /// kept *pristine* (never stepped), because `IterativePerturbation.step` - /// re-uses the original body to seed each repetition. + /// `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, @@ -54,11 +53,10 @@ impl PerturbationState { /// /// 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 `StarkPerturbationGenerator` evaluates - /// them (`StarkValue.intValue(evalToValue(context, registry, ctx.time))`) - /// while building the `Perturbation` object. `globals` is the store - /// holding the program's `const`/`param` slots, which is all such a bound - /// can legally refer to. + /// 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, @@ -68,10 +66,10 @@ impl PerturbationState { 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. Java shares one - // `Perturbation` object between every reference to a declaration; + // 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 — `step()` returns a new + // 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 { @@ -155,10 +153,9 @@ impl PerturbationState { } } - /// Whether this schedule can still produce an effect — - /// `Perturbation.isDone()`. Note an `Atomic` is *never* done in the Java - /// reference, even once it has fired; only `step()` retires it (to - /// `None`), and only a `Sequence` ever asks. + /// 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, @@ -173,9 +170,8 @@ impl PerturbationState { /// /// **Buffered**, like a controller assignment: every right-hand side is /// evaluated against the pre-perturbation store before any of them is -/// written, matching `StarkPerturbationGenerator.getAssignment`'s -/// `ds.apply(updates.stream().map(..).toList())` — the list is fully -/// materialised against the original `StarkStore` first. +/// 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, @@ -184,7 +180,7 @@ pub(crate) fn apply_effect( ) -> Result<(), EvalError> { let PerturbationIr::Atomic { assignments, .. } = program.perturbation(node) else { // `effect()` only ever returns the id of an `Atomic` node. - return Err(EvalError::Unreachable("a non-atomic perturbation produced an effect")); + return Err(EvalErrorKind::Unreachable("a non-atomic perturbation produced an effect").into()); }; let mut values = Vec::with_capacity(assignments.len()); for assignment in assignments { @@ -254,8 +250,8 @@ mod tests { #[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 — `IterativePerturbation.step` queues each - // repetition behind the previous one's `step()`. + // 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]); } diff --git a/crates/stark/src/eval/robust.rs b/crates/stark/src/eval/robust.rs index bbb1cd63f..92e04a7a1 100644 --- a/crates/stark/src/eval/robust.rs +++ b/crates/stark/src/eval/robust.rs @@ -13,15 +13,15 @@ //! | 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 SampleSet per step, perturbed copies (super::sequence) +//! 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 -//! reference (`RobustnessFunction.eval(sampleSize, step, sequence)`), and it -//! is what lets one analysis — one RNG stream, one set of options — be reused +//! 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 @@ -33,7 +33,7 @@ //! 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 Java reference's Mersenne Twister; only the distributions match. +//! the original's; only the distributions match. use rand::Rng; use rand::SeedableRng; @@ -46,18 +46,16 @@ use crate::value::EvalError; use super::sequence::EvolutionSequence; use super::store::Store; -/// The statistical knobs of an analysis. The defaults are the reference's: -/// `ThreeValuedSemanticsVisitor()`'s no-argument constructor uses `m = 50` -/// bootstrap replicas at `z = 1.96` (a 95% normal interval). +/// 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 — how finely the /// state distribution is approximated. Larger is more accurate and /// linearly more expensive. pub sample_size: usize, - /// How many perturbed samples are drawn per reference sample - /// (`sampleSize` in the reference's `RobustnessFunction`). The perturbed - /// sequence therefore holds `sample_size * scale` samples, and each + /// How many perturbed samples are drawn per reference sample. The + /// perturbed sequence therefore holds `sample_size * scale` samples, and each /// reference sample is compared against the `scale` perturbed samples /// descended from it. pub scale: usize, @@ -93,7 +91,7 @@ pub struct Analysis<'a, R: Rng> { /// A store used only for program-level constants. Its `[0, n_variables)` /// prefix is never stepped, so reading a *variable* through it would be /// meaningless — but no interval bound, threshold or weight can refer to - /// one, since those are all evaluated outside any state in the reference + /// one, since those are all evaluated outside any state in the original /// too. pub(crate) globals: Store, pub(crate) rng: R, @@ -142,8 +140,7 @@ impl<'a, R: Rng> Analysis<'a, R> { self.distance(sequence, &mut perturbed, step, distance) } - /// Builds the perturbed counterpart of `sequence` — - /// `EvolutionSequence.apply(perturbation, step, scale)`. + /// Builds the perturbed counterpart of `sequence`. pub(crate) fn perturb( &mut self, sequence: &mut EvolutionSequence, diff --git a/crates/stark/src/eval/sequence.rs b/crates/stark/src/eval/sequence.rs index f4fbc4758..fd38fa266 100644 --- a/crates/stark/src/eval/sequence.rs +++ b/crates/stark/src/eval/sequence.rs @@ -2,19 +2,17 @@ //! [super::sim]'s single trajectory, and what every distance and ROBTL //! formula is actually evaluated over. //! -//! Ported from `EvolutionSequence.java` + `SampleSet.java`. 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 `SampleSet`. An [EvolutionSequence] is the sequence of -//! those sample sets, generated lazily: -//! [EvolutionSequence::generate_up_to] extends it on demand, matching -//! `generateUpTo`. +//! 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] exactly as `SampleSet.computeDistance` does. +//! by [wasserstein]. use rand::Rng; @@ -22,6 +20,7 @@ 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; @@ -33,10 +32,9 @@ 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 — `PerturbedEvolutionSequence` in the reference, which -/// is the same class plus a perturbation that advances alongside generation. -/// Modelling it as a field rather than a subclass keeps one generation path -/// (see [EvolutionSequence::generate_next]). +/// 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]). #[derive(Clone, Debug)] pub struct EvolutionSequence { /// `steps[t]` is the sample set at time `t`; always non-empty (`steps[0]` @@ -47,14 +45,14 @@ pub struct EvolutionSequence { } impl EvolutionSequence { - /// Samples `size` independent initial states — `SampleSet.generate`. + /// Samples `size` independent initial states. pub(crate) fn generate( program: &IrProgram, rng: &mut R, size: usize, ) -> Result { if size == 0 { - return Err(EvalError::EmptySampleSet); + return Err(EvalErrorKind::EmptySampleSet.into()); } let mut initial = Vec::with_capacity(size); for _ in 0..size { @@ -67,8 +65,8 @@ impl EvolutionSequence { }) } - /// The number of samples in the initial sample set — `size` in the - /// reference. A perturbed sequence's sample sets are `scale` times larger + /// 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 { @@ -89,12 +87,12 @@ impl EvolutionSequence { Ok(self.steps[t].iter().map(|state| state.variables(program)).collect()) } - /// The last time step generated so far — `getLastGeneratedStep`. + /// 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 — `generateUpTo`. + /// Extends the sequence so that step `n` exists. pub(crate) fn generate_up_to( &mut self, program: &IrProgram, @@ -108,10 +106,9 @@ impl EvolutionSequence { Ok(()) } - /// One step of every sample — `generateNextStep`, including - /// `PerturbedEvolutionSequence`'s override, which advances the - /// perturbation *before* generating and applies the resulting effect - /// *after*. + /// 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, @@ -129,8 +126,8 @@ impl EvolutionSequence { Ok(next) } - /// `PerturbedEvolutionSequence.doApply`: rewrites every sample with the - /// perturbation's current effect, if it has one this tick. + /// Rewrites every sample with the perturbation's current effect, if it + /// has one this tick. fn apply_perturbation_effect( &self, program: &IrProgram, @@ -146,11 +143,11 @@ impl EvolutionSequence { Ok(()) } - /// The sequence obtained by perturbing this one from step `step` onwards - /// — `EvolutionSequence.apply(perturbation, perturbedStep, scale)`. + /// 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 Java shares immutable `SampleSet` objects) and re-samples + /// 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 @@ -168,8 +165,8 @@ impl EvolutionSequence { self.generate_up_to(program, rng, step)?; let perturbation = PerturbationState::build(program, globals, rng, id)?; - // `select(perturbedStep - 1)` — the history strictly before the - // perturbed step, empty when `step == 0`. + // 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 { @@ -191,7 +188,7 @@ impl EvolutionSequence { } /// Evaluates a penalty function on every sample at step `t`, returning - /// the values **sorted ascending** — `SampleSet.evalPenaltyFunction`. + /// 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. @@ -209,7 +206,7 @@ impl EvolutionSequence { // `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_number("a penalty function")?); + values.push(eval(program, &mut state.store, rng, expression)?.as_f64("a penalty function")?); } values.sort_by(f64::total_cmp); Ok(values) @@ -217,7 +214,7 @@ impl EvolutionSequence { } /// The Wasserstein lifting of a ground distance on reals to the two sampled -/// distributions `reference` and `perturbed` — `SampleSet.computeDistance`. +/// 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 @@ -229,11 +226,12 @@ pub(crate) fn wasserstein( reference: &[f64], perturbed: &[f64], ) -> Result { - if reference.is_empty() || perturbed.len() % reference.len() != 0 { - return Err(EvalError::IncompatibleSampleSizes { + 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; @@ -245,7 +243,7 @@ pub(crate) fn wasserstein( Ok(total / perturbed.len() as f64) } -/// The ground distance behind `distanceLeq` — asymmetric, penalising only +/// 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". @@ -253,7 +251,7 @@ pub(crate) fn ground_leq(reference: f64, perturbed: f64) -> f64 { (perturbed - reference).max(0.0) } -/// The mirror of [ground_leq], behind `distanceGeq` / `> penalty`. +/// The mirror of [ground_leq], behind `> penalty`. pub(crate) fn ground_geq(reference: f64, perturbed: f64) -> f64 { (reference - perturbed).max(0.0) } @@ -347,10 +345,11 @@ mod tests { 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(EvalError::IncompatibleSampleSizes { + Err(EvalErrorKind::IncompatibleSampleSizes { reference: 2, perturbed: 3 - }) + } + .into()) ); } diff --git a/crates/stark/src/eval/sim.rs b/crates/stark/src/eval/sim.rs index b8508dc2d..af696b3b7 100644 --- a/crates/stark/src/eval/sim.rs +++ b/crates/stark/src/eval/sim.rs @@ -1,18 +1,15 @@ //! 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, matching `ControlledSystem`'s role in the Java -//! reference (see `eval::step`'s doc comment for the exact per-step -//! ordering). +//! 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 (later) drive an ensemble of independently-seeded [Simulation]s -//! to build the `SampleSet`-style evolution sequence `EvolutionSequence.java` -//! models — `SampleSet`, sampled and regenerated lazily via -//! `generateUpTo` — 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, not into it. +//! 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; @@ -28,8 +25,7 @@ use super::system::SystemState; 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 - /// `EvolutionSequence`/`SampleSet` would checkpoint in the Java - /// reference. + /// an evolution sequence checkpoints per sample. fn on_step(&mut self, step: u64, state: &[Value]); } @@ -49,9 +45,9 @@ impl Observer for RecordingObserver { } /// A running instance of a checked, lowered specification: the store, every -/// component's controller cursor, the step counter, and the RNG stream. -/// Mirrors `ControlledSystem`, minus the `Controller`/`DataStateFunction` -/// indirection lowering already collapsed into `program`. +/// 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, @@ -61,12 +57,11 @@ pub struct Simulation<'a, R: Rng> { impl<'a> Simulation<'a, StdRng> { /// Builds a simulation seeded from a `u64`, for reproducibility. - /// **Not** bit-compatible with the Java reference's Mersenne-Twister - /// 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. See - /// `EVALUATOR_PLAN.md`'s "Deliberate deviations". + /// **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)) } diff --git a/crates/stark/src/eval/step.rs b/crates/stark/src/eval/step.rs index 5878805f4..a8247e093 100644 --- a/crates/stark/src/eval/step.rs +++ b/crates/stark/src/eval/step.rs @@ -1,27 +1,16 @@ //! 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. -//! Mirrors `ControlledSystem.sampleNext`: +//! 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]). //! -//! ```java -//! public SystemState sampleNext(RandomGenerator rg) { -//! EffectStep step = controller.next(rg, state); -//! int c_step = state.getStep(); -//! DataState newState = environment.apply(rg, state.apply(step.effect())); -//! newState.setStep(c_step+1); -//! return new ControlledSystem(step.next(), environment, newState); -//! } -//! ``` -//! -//! [Cursor] replaces Java's recursive `Controller` object tree -//! (`StepController`/`ExecController`/`AssignmentController`/`NilController`/ -//! `ParallelController`) with a plain value: a controller's entire "next -//! state" is exactly "which named state, and how many ticks left before it's -//! live" (`StepController`'s idle count is the only state Java's controller -//! tree actually threads through `next()`). Walking a state's body is one -//! flat recursion over [CommandNode] instead of a tree of controller -//! objects, since lowering already collapsed the controller AST into that -//! arena (see `IR_LOWERING_PLAN.md`). +//! [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; @@ -39,8 +28,8 @@ 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` — - /// `NilController`: no effect, self-loop, forever. + /// 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), @@ -52,10 +41,9 @@ pub(crate) enum Cursor { } /// A buffered `target' = value` reached while walking a command tree. -/// Mirrors Java's `DataStateUpdate`: pushed into a list during the walk, -/// applied only once the whole step (controller or environment) has run — -/// see [CommandNode]'s doc comment on why updates must not write through -/// immediately. +/// 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, @@ -74,9 +62,9 @@ enum Walk { /// 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 `ParallelController`'s "both effects -/// concatenated before the single `apply`"), then the environment runs -/// against the post-controller state. +/// 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 @@ -170,7 +158,7 @@ fn run_command( match *program.command(id) { CommandNode::Assign(update) => { // A missing guard is unconditionally true. A *non-boolean* guard - // is now an error: `StarkValue.isTrue` mapped it (and a failed + // 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 { @@ -214,10 +202,9 @@ fn run_command( transitioned => Ok(transitioned), }, CommandNode::Step { steps, target } => { - // `StepController`: `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. + // `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 @@ -237,9 +224,8 @@ fn run_command( Ok(Walk::Transitioned(cursor)) } CommandNode::Exec(target) => { - // Same-tick tail jump: `ExecController.next` immediately - // delegates to `target`'s controller within the same call, so - // its effects land in this tick too. + // 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` \ @@ -265,6 +251,7 @@ mod tests { use super::*; use crate::UntypedStarkSpecification; use crate::lower; + use crate::value::EvalErrorKind; fn build(source: &str) -> IrProgram { let spec = UntypedStarkSpecification::parse(source) @@ -297,12 +284,11 @@ mod tests { #[test] fn a_failing_guard_aborts_the_step_instead_of_reading_as_false() { - // The regression this whole `Result` change exists for. Under - // `StarkValue.isTrue` the guard `1 / zero > 0` evaluated to - // `ERROR_VALUE`, which mapped to `false`, so the assignment silently - // didn't happen and the run continued with `x` unchanged — an - // arithmetic failure indistinguishable from a guard that was - // legitimately not satisfied. + // The regression this whole `Result` change exists for. Originally + // the guard `1 / zero > 0` evaluated to the absorbing error value, + // which read as `false`, so the assignment silently didn't happen and + // the run continued with `x` unchanged — an arithmetic failure + // indistinguishable from a guard that was legitimately not satisfied. let program = build( r" global variables { @@ -318,20 +304,21 @@ mod tests { let mut store = Store::new(&program, &mut rng).expect("should initialise"); let mut cursors = Vec::new(); - assert_eq!( - macro_step(&program, &mut store, &mut rng, &mut cursors), - Err(EvalError::DivisionByZero) - ); + 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 `StarkValue.isTrue` did. + // than answering `false` the way the original did. assert_eq!( Value::Integer(1).as_boolean("the guard of an assignment"), - Err(EvalError::ExpectedBoolean { + Err(EvalErrorKind::ExpectedBoolean { context: "the guard of an assignment", found: crate::value::ValueKind::Integer, }) diff --git a/crates/stark/src/eval/store.rs b/crates/stark/src/eval/store.rs index fc73362e1..7f7709d1c 100644 --- a/crates/stark/src/eval/store.rs +++ b/crates/stark/src/eval/store.rs @@ -1,7 +1,7 @@ //! The flat evaluator store: one `Vec` indexed directly by [SlotId], -//! matching `IR_LOWERING_PLAN.md`'s slot layout (`[0, n_variables)` state, +//! matching the IR's slot layout (`[0, n_variables)` state, //! `[n_variables, n_globals)` `const`/`param`, `[n_globals, n_slots)` scratch) -//! instead of `StarkStore.java`'s `variable -> value` closure/map. +//! instead of the original's `variable -> value` map. use rand::Rng; @@ -38,7 +38,8 @@ impl Store { /// read before it is written: globals and variables are initialised here /// in dependency order, and lowering guarantees a function's argument and /// `let` slots are written at the call/binding before its body can load - /// them (`IR_LOWERING_PLAN.md`, "Why one flat slot space works"). + /// them (the language forbids recursion, so no function is ever live on + /// the stack twice and every binding can have its own static slot). pub(crate) fn new(program: &IrProgram, rng: &mut R) -> Result { let mut store = Store { slots: vec![Value::Integer(0); program.n_slots() as usize], @@ -63,8 +64,7 @@ impl Store { } /// The `[0, n_variables)` prefix that a simulation checkpoints — exactly - /// what `EvolutionSequence`/`SampleSet` would sample in the Java - /// reference (see `EVALUATOR_PLAN.md`'s Step 5). + /// what an evolution sequence samples per step. pub(crate) fn state_prefix(&self, program: &IrProgram) -> &[Value] { &self.slots[0..program.n_variables() as usize] } diff --git a/crates/stark/src/eval/system.rs b/crates/stark/src/eval/system.rs new file mode 100644 index 000000000..0e32e2756 --- /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 index 991003e50..b9e40617d 100644 --- a/crates/stark/src/ir.rs +++ b/crates/stark/src/ir.rs @@ -1,7 +1,6 @@ //! 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. See `IR_LOWERING_PLAN.md` for the design -//! rationale. +//! instead of chasing pointers. //! //! Populated by lowering: constants/parameters (as [GlobalInit]), variables //! (as [VariableInfo]), functions (as [FunctionIr]), penalties (as @@ -13,6 +12,29 @@ //! 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; @@ -23,12 +45,8 @@ use crate::types::StarkType; use crate::value::Value; // --------------------------------------------------------------------------- -// Index types +// Index types (see the module documentation) // --------------------------------------------------------------------------- -// -// All backed by `u32`, not `usize`: nodes that hold these stay small. Each -// has its own tag so, say, an `ExprRef` can never be mixed up with a -// `SlotId` at a call site even though both are "just a `u32`" underneath. pub struct ExprTag; /// An index into [IrProgram]'s expression arena. @@ -39,8 +57,8 @@ pub struct StmtTag; pub type StmtRef = TagIndex; pub struct SlotTag; -/// An index into the flat value store the (future) evaluator maintains — -/// see "Slot layout" in `IR_LOWERING_PLAN.md`. +/// 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; @@ -166,8 +184,7 @@ impl ExprList { /// One node of the expression arena. /// -/// Deliberate simplifications made while lowering (see `IR_LOWERING_PLAN.md` -/// for the full rationale): +/// 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 @@ -184,7 +201,7 @@ pub enum ExprNode { /// 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 `MISSING_GRAMMAR_FEATURES.md`), + /// 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::EvalError::Unreachable] rather than inventing a /// value. Before errors became a `Result`, this was a `Literal` holding @@ -195,20 +212,18 @@ pub enum ExprNode { Load(SlotId), Not(ExprRef), /// Arithmetic negation (`-x`). **Always widens to `Real`, even for an - /// integer operand** — matching Java's `StarkExpressionEvaluator`, which - /// routes unary `-`/`+` through the *same* always-widening - /// `DoubleUnaryOperator` mechanism as the math functions - /// (`unaryOperators` map, `StarkInteger.apply(DoubleUnaryOperator)` -> - /// `StarkReal`), not 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 reference tool, but worth reconsidering if that - /// surprises users badly enough in practice. + /// 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: Java widens it exactly like `Negate` (same - /// `unaryOperators` map, same mechanism — see [ExprNode::Negate]'s doc - /// comment), so `+a` for an integer `a` is `real`, not `a` unchanged. + /// 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), @@ -347,7 +362,7 @@ pub struct Update { /// 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` (see `IR_LOWERING_PLAN.md`). +/// `Exec`. /// /// A `Vec`/`Vec` — a /// `{ .. }` block — lowers to a left-associated chain of `Sequence(prior, @@ -359,8 +374,7 @@ pub struct Update { /// `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 `IR_LOWERING_PLAN.md`'s "Semantics that are easy to get silently -/// wrong" and the `buffered_swap_*` tests. +/// 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`/ @@ -386,7 +400,7 @@ pub enum CommandNode { /// Runs its left node, then its right node. Sequence(CommandRef, CommandRef), /// `[steps #] step target;` — controller-only. `steps` (if present) is - /// evaluated once per step, matching Java's `Controller.doTick(k-1, ..)`. + /// evaluated once per step. Step { steps: Option, target: IrStateId, @@ -418,21 +432,8 @@ pub struct ComponentIr { // --------------------------------------------------------------------------- // Robustness sub-languages: perturbation / distance / ROBTL formula +// (see the module documentation) // --------------------------------------------------------------------------- -// -// These three mirror the shape of `ast.rs`'s `PerturbationExpression`/ -// `DistanceExpression`/`RobtlFormula`, each collapsed the same way the -// expression arena is: `Reference(DefRef)` (a reference to another named -// declaration of the same kind) resolves at lowering time to the referent's -// `*Id`, 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's 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]. /// A comparison operator, used by [DistanceIr::Threshold] and /// [FormulaIr::Distance]. Kept as its own type (mirroring `ast::ComparisonOp`) @@ -450,7 +451,7 @@ pub enum ComparisonOp { /// 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 `MISSING_GRAMMAR_FEATURES.md`). +/// guarded; see `plan.md`). #[derive(Clone, Copy, Debug)] pub struct PerturbationAssignment { pub target: SlotId, @@ -665,8 +666,10 @@ impl IrProgram { /// The number of `[0, n_variables)` slots — the simulation state prefix. /// Equal to `self.variables.len()`, since every variable gets exactly one - /// slot and slot allocation lays this range out first (see - /// `IR_LOWERING_PLAN.md`'s slot layout table). + /// slot and slot allocation lays this range out first: variables occupy + /// `[0, n_variables)`, `const`/`param` occupy + /// `[n_variables, n_globals)`, and function arguments and `let` bindings + /// occupy `[n_globals, n_slots)`. pub fn n_variables(&self) -> u32 { self.variables.len() as u32 } diff --git a/crates/stark/src/lib.rs b/crates/stark/src/lib.rs index c43399a3d..3a50cce21 100644 --- a/crates/stark/src/lib.rs +++ b/crates/stark/src/lib.rs @@ -1,11 +1,9 @@ +#![doc = include_str!("../README.md")] + mod ast; mod consume; mod diagnostics; pub mod eval; -// `ir`/`value` are kept as their own public modules, rather than flattened -// like the rest of this crate's API, because `ir::BinaryOp` deliberately -// collides in name (not in meaning) with `ast::BinaryOp` — see `ir.rs`'s doc -// comment. Flattening both would be an ambiguous glob re-export. pub mod ir; mod lower; mod parse; diff --git a/crates/stark/src/lower.rs b/crates/stark/src/lower.rs index e789c374d..8427bdcb6 100644 --- a/crates/stark/src/lower.rs +++ b/crates/stark/src/lower.rs @@ -1,8 +1,6 @@ -//! Lowers a checked [StarkSpecification] to an [IrProgram]. See -//! `IR_LOWERING_PLAN.md` for the full design, which this implements in -//! full: expression/function/global/variable/penalty lowering, -//! controller/environment lowering, and perturbation/distance/formula -//! lowering. +//! Lowers a checked [StarkSpecification] to an [IrProgram]: expression, +//! function, global, variable and penalty lowering, controller and +//! environment lowering, and perturbation, distance and formula lowering. //! //! [lower]'s `Result` return type is kept even though every construct in the //! grammar now lowers successfully (nothing in this pass currently produces @@ -363,7 +361,7 @@ impl<'a> Lowerer<'a> { // -- Slot allocation ---------------------------------------------------- /// Allocates `[0, n_variables)`: the global `variables { .. }` block, - /// then every component's local one, matching `StarkGlobalVariableCollector`. + /// then every component's local one. fn allocate_variable_slots(&mut self) { for variable in &self.spec.ast().variables { self.allocate_variable_slot(variable); @@ -1100,9 +1098,9 @@ impl<'a> Lowerer<'a> { } ExpressionKind::Iterator => { // Only reachable from aggregate/lambda contexts, none of - // which exist in the current grammar (see `ast.rs` / - // `MISSING_GRAMMAR_FEATURES.md`) — `typecheck.rs` types this - // `Error` without diagnosing it for the same reason. + // 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" @@ -1163,8 +1161,8 @@ impl<'a> Lowerer<'a> { let ty = self.expr_type(inner); self.push_expr(ExprNode::Not(inner), span, ty) } - // Both widen to `real`, matching Java's `unaryOperators["+"/"-"]` - // — see `ExprNode::Negate`/`ExprNode::Widen`'s doc comments. + // 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); @@ -1229,8 +1227,8 @@ impl<'a> Lowerer<'a> { } } - /// `combineToRealType` in the Java source: always widens to `real`, - /// propagating randomness from either operand. Mirrors + /// 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 { @@ -1516,7 +1514,7 @@ mod tests { // 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 (see `IR_LOWERING_PLAN.md`'s "Slot layout"). + // 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(); @@ -1593,10 +1591,9 @@ mod tests { #[test] fn unary_plus_widens_to_real_like_unary_minus() { - // Matches Java: `unaryOperators["+"]`/`["-"]` both route through the - // same always-widening `DoubleUnaryOperator` mechanism as the math - // functions, so neither is integer-preserving — see - // `ExprNode::Widen`/`ExprNode::Negate`'s doc comments. + // 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!( @@ -1612,8 +1609,7 @@ mod tests { // 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 (`IR_LOWERING_PLAN.md`'s - // "Semantics that are easy to get silently wrong") will build on. + // 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]); @@ -1699,10 +1695,9 @@ mod tests { // 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 Java's + // assignments must read the *pre*-step value, matching the // "collect updates, apply them all at the end of the step" - // semantics (`IR_LOWERING_PLAN.md`'s "Semantics that are easy to - // get silently wrong"). + // 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 { diff --git a/crates/stark/src/parse.rs b/crates/stark/src/parse.rs index fff837b9a..3b53ef6fe 100644 --- a/crates/stark/src/parse.rs +++ b/crates/stark/src/parse.rs @@ -1,3 +1,12 @@ +//! 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; diff --git a/crates/stark/src/resolve.rs b/crates/stark/src/resolve.rs index ce0dabdab..d4f2f732f 100644 --- a/crates/stark/src/resolve.rs +++ b/crates/stark/src/resolve.rs @@ -16,8 +16,8 @@ //! 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 Java implementation, whose `StarkGlobalVariableCollector` -//! pass collects exactly these names ahead of `StarkModelGenerator`. +//! 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 @@ -42,9 +42,9 @@ //! 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 Java -//! implementation accepts all of these and evaluates them to `ERROR_VALUE` -//! at runtime with no diagnostic. +//! 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` @@ -85,11 +85,7 @@ pub enum DefKind { } impl DefKind { - /// Whether a plain `ExpressionKind::Reference` may resolve to this kind: - /// whether it names a *value*, as opposed to a function, penalty, - /// component, perturbation, distance, formula or type, each of which is - /// only referenceable from its own dedicated syntax (a call, a `\D[...]`, - /// …), never from a bare name in an ordinary expression. + /// Whether a plain `ExpressionKind::Reference` may resolve to this kind. fn is_referenceable_value(&self) -> bool { matches!( self, @@ -507,8 +503,8 @@ impl Resolver { /// 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 Java implementation has this hole, evaluating such reads to - /// `ERROR_VALUE` at runtime with no diagnostic. + /// 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::function_reads_variable] entry is known. diff --git a/crates/stark/src/typecheck.rs b/crates/stark/src/typecheck.rs index 5293cd57e..6250b0cf1 100644 --- a/crates/stark/src/typecheck.rs +++ b/crates/stark/src/typecheck.rs @@ -1,28 +1,27 @@ -//! Type checking, ported from `types/ExpressionTypeInference.java` and -//! `types/StarkFunctionStatementTypeInference.java`. +//! 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 what the Java `TypeEvaluationContext`/ -//! `LocalTypeContext` stack of scopes did — no scope stack is needed here, -//! only "has this local's type been computed yet". +//! `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 Java reference: -//! `visitAndExpression`/`visitOrExpression` never propagate a `Random` -//! result there (`visitOrExpression` even computes an `isRandom` local and -//! then never uses it — reading as an unfinished path, not a deliberate -//! choice, since the very next case, `visitRelationExpression`, does -//! propagate), and `visitUnaryMathCallExpression` never propagates -//! randomness either, while the binary math-call path does. This port -//! propagates randomness in both cases, for consistency with every other +//! 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. @@ -347,8 +346,8 @@ impl Checker<'_> { } /// Returns the type of every `return` reachable from `statement`, merged - /// together (mirrors `StarkFunctionStatementTypeInference`: a function - /// has no return-type annotation, so its type is inferred from its body). + /// 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), @@ -566,9 +565,8 @@ impl Checker<'_> { // -- Expressions ------------------------------------------------------ - /// `combineToRealType` in the Java source: always widens to `real` - /// (`2 ^ 3` and `atan2(1,2)` are both `real`, never `int`), propagating - /// randomness from either operand. + /// 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); @@ -670,17 +668,15 @@ impl Checker<'_> { self.expect(&StarkType::Boolean, ty, &inner.span) } ExpressionKind::UnaryPlus(inner) | ExpressionKind::UnaryMinus(inner) => { - // Matches Java: `StarkExpressionEvaluator`'s `unaryOperators` - // map routes `+`/`-` through the *same* always-widening - // `DoubleUnaryOperator` mechanism as `abs`/`sqrt`/etc. - // (`StarkInteger.apply(DoubleUnaryOperator)` -> `StarkReal`), - // 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 reference tool, but worth - // reconsidering if that surprises users badly enough in - // practice. + // 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), @@ -962,12 +958,11 @@ mod tests { } } - /// Ported from - /// `~/STARK/speclang/src/test/java/stark/speclang/types/ExpressionTypeInferenceTest.java`. + /// The original tool's own expression-type-inference test cases, ported. /// - /// The original tests a bare expression directly against - /// `ExpressionTypeInference`, with `randomExpressionAllowed` as an - /// explicit parameter. There's no equivalent "just an expression, no + /// 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 diff --git a/crates/stark/src/types.rs b/crates/stark/src/types.rs index caa98a54e..e12c3f725 100644 --- a/crates/stark/src/types.rs +++ b/crates/stark/src/types.rs @@ -1,20 +1,6 @@ -//! The STARK type lattice, ported from `speclang/…/types/StarkType.java` and -//! its concrete subclasses (`StarkIntegerType`, `StarkRealType`, -//! `StarkBooleanType`, `StarkCustomType`, `StarkRandomType`, `StarkErrorType`). -//! -//! The original models each case as a class implementing a shared interface -//! with double-dispatch `merge`/`isCompatibleWith`/`canBeMergedWith` methods. -//! Here the same case analysis is expressed as free functions matching on a -//! single enum, which turns out to be exactly equivalent (verified against -//! every branch of the original) and avoids re-deriving the case analysis at -//! every call site. - use std::fmt; -/// A STARK type. `Random(_)` never wraps another `Random(_)` or `Error` — -/// that invariant is enforced by [StarkType::random] rather than by -/// construction, mirroring the defensive unwrap in `StarkRandomType`'s -/// original Java constructor. +/// A STARK type. #[derive(Clone, Debug, Eq, PartialEq)] pub enum StarkType { Integer, @@ -22,18 +8,17 @@ pub enum StarkType { Boolean, /// A user-defined type, identified by name. Custom(String), - /// A statically-known-to-be-random value of the wrapped (always - /// non-random, non-error) type, e.g. the result of `R[0,1]` (`Random(Real)`). + /// A statically declared random value of the inner type. Random(Box), - /// The result of a type error; absorbs into further checks so a single - /// mistake doesn't cascade into a wall of unrelated diagnostics. + /// 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 `StarkRandomType` constructor. + /// 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), @@ -55,14 +40,12 @@ impl StarkType { matches!(self.deterministic(), StarkType::Integer | StarkType::Real) } - /// Whether this is `Random(_)` at the top level. + /// 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. `Random` never wraps `Error` - /// in practice (every constructor here checks first), so this only ever - /// needs to look at the top level. + /// Whether this is exactly the error type. pub fn is_error(&self) -> bool { matches!(self, StarkType::Error) } @@ -87,7 +70,7 @@ impl StarkType { /// expected type) is required. Ignores randomness on both sides (a /// `Random(int)` fits wherever a plain `int` is expected, since it is /// resolved to a concrete value before use) — the original - /// `StarkRandomType.isCompatibleWith` delegates straight through to its + /// A `random[..]` type delegates straight through to its /// content type for the same reason. /// /// Integer widens to real but not vice versa: `real x = 1;` is fine, @@ -125,7 +108,7 @@ impl StarkType { /// Combines `self` and `other` into their common type (`int` (+) `real` /// -> `real`; identical types merge to themselves), propagating a /// `Random` wrapper if either side carries one. Returns `Error` if the - /// two types have nothing in common — mirrors `StarkType.merge`. + /// two types have nothing in common. pub fn merge(&self, other: &StarkType) -> StarkType { if self.is_error() || other.is_error() { return StarkType::Error; @@ -266,7 +249,7 @@ mod tests { assert_eq!(StarkType::Custom("Color".into()).to_string(), "Color"); } - /// Ported from `~/STARK/speclang/src/test/java/stark/speclang/types/StarkTypeTest.java`. + /// 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 diff --git a/crates/stark/src/value.rs b/crates/stark/src/value.rs index 6cb90edbb..6bfef0799 100644 --- a/crates/stark/src/value.rs +++ b/crates/stark/src/value.rs @@ -1,14 +1,16 @@ use std::fmt; +use merc_utilities::Span; use thiserror::Error; use crate::ast::DefId; use crate::resolve::SymbolTable; use crate::types::StarkType; -/// Error when evaluating an expression. +/// The *class* of an evaluation failure, without a source location for runtime +/// errors. #[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] -pub enum EvalError { +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")] @@ -40,11 +42,7 @@ pub enum EvalError { #[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 — `SampleSet.distance` throws - /// `IllegalArgumentException("Incompatible size of data sets!")` here. - /// Only reachable by asking for a perturbed sequence with a zero - /// `scale`, since a perturbed sequence is otherwise `scale` replicas of - /// the reference one. + /// 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 @@ -53,7 +51,50 @@ pub enum EvalError { EmptySampleSet, } -/// Which [Value] case a value was, without its payload. +/// 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, @@ -92,8 +133,10 @@ pub enum Value { } impl Value { - /// This value's [StarkType]. `symbols` resolves a [CustomValue]'s - /// `type_id` back to the type's declared name. + /// 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, @@ -114,10 +157,10 @@ impl Value { } /// Reads this value as a boolean. - pub fn as_boolean(self, context: &'static str) -> Result { + pub fn as_boolean(self, context: &'static str) -> Result { match self { Value::Boolean(value) => Ok(value), - other => Err(EvalError::ExpectedBoolean { + other => Err(EvalErrorKind::ExpectedBoolean { context, found: other.kind(), }), @@ -125,10 +168,10 @@ impl Value { } /// Reads this value as an integer. - pub fn as_integer(self, context: &'static str) -> Result { + pub fn as_integer(self, context: &'static str) -> Result { match self { Value::Integer(value) => Ok(value), - other => Err(EvalError::ExpectedInteger { + other => Err(EvalErrorKind::ExpectedInteger { context, found: other.kind(), }), @@ -136,11 +179,11 @@ impl Value { } /// Widens either numeric case to `f64`. Errors on a non-numeric values.. - pub fn as_number(self, context: &'static str) -> Result { + 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(EvalError::ExpectedNumber { + other => Err(EvalErrorKind::ExpectedNumber { context, found: other.kind(), }), @@ -148,25 +191,25 @@ impl Value { } /// Integer overflow wraps rather than panicking. - pub fn sum(self, other: Value) -> Result { + 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 { + 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 { + pub fn subtraction(self, other: Value) -> Result { numeric_op("-", self, other, i64::wrapping_sub, |a, b| a - b) } - /// `StarkValue.division`. Integer division by zero (and the - /// `i64::MIN / -1` overflow) is [EvalError::DivisionByZero]; real division - /// keeps `f64`'s `±inf`/`NaN` behaviour — see the module doc comment. - pub fn division(self, other: Value) -> Result { + /// 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(EvalError::DivisionByZero) + 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)), @@ -175,11 +218,11 @@ impl Value { } } - /// `StarkValue.modulo`. Same zero/overflow guard as [Value::division]. - pub fn modulo(self, other: Value) -> Result { + /// 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(EvalError::DivisionByZero) + 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)), @@ -188,24 +231,11 @@ impl Value { } } - /// Truncating integer division (`//`, `ir::BinaryOp::IntDiv`). - /// - /// **Not a port of Java behaviour — there isn't any to port.** The `.g4` - /// grammar parses `//` (`mulDivExpression` accepts `'*'|'/'|'//'`), but - /// `StarkExpressionEvaluator`'s `binaryOperators` map only registers - /// `"+" "*" "-" "/" "%"` plus the math functions; `getBinaryOperator` - /// falls back to `(x,y) -> ERROR_VALUE` for anything else, so every use - /// of `//` in the Java reference evaluates to `ERROR_VALUE` unconditionally - /// — the operator parses but was never implemented. Rather than replicate - /// that gap, this implements the operator its syntax promises: truncating - /// division that always yields an integral quotient — `Integer` for - /// `int // int` (same zero/overflow guard as [Value::division]), and the - /// real quotient truncated toward zero, as a `Real`, whenever either side - /// is real. - pub fn int_div(self, other: Value) -> Result { + /// 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(EvalError::DivisionByZero) + 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())), @@ -214,89 +244,71 @@ impl Value { } } - /// `StarkValue.isLessThan`. Returns a bare `bool` rather than a - /// [Value::Boolean]: now that the failure case is an `Err`, the success - /// case is known to be a boolean, and saying so in the type keeps a caller - /// from having to re-inspect it. `eval::expr` wraps it back into a [Value]. - pub fn is_less_than(self, other: Value) -> Result { + /// 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) } - /// `StarkValue.isLessOrEqualThan`. - pub fn is_less_or_equal_than(self, other: Value) -> Result { + /// 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) } - /// `StarkValue.isGreaterOrEqualThan`. - pub fn is_greater_or_equal_than(self, other: Value) -> Result { + /// 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) } - /// `StarkValue.isGreaterThan`. - pub fn is_greater_than(self, other: Value) -> Result { + /// 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) } - /// `StarkValue.isEqualTo`, **extended**: Java's version dispatches only - /// on `StarkInteger`/`StarkReal` and returns `ERROR_VALUE` for every other - /// pairing — including two equal `StarkBoolean`s or two equal - /// `StarkCustomValue`s, since neither class overrides it. That reads as - /// an oversight (`.equals()` is defined and correct on both; `isEqualTo` - /// just never calls it) rather than an intended "booleans/custom values - /// aren't comparable" semantics, especially since `typecheck.rs` already - /// accepts `==` between two booleans or two same-typed custom values. So - /// this covers those cases too, numeric comparison still widening. - pub fn is_equal_to(self, other: Value) -> Result { + /// 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) — - // matches `StarkInteger.isEqualTo`'s own `instanceof StarkInteger` - // fast path. + // 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), } } - /// `StarkValue.and` (`StarkBoolean.and`). Boolean-only, like Java: both - /// `&&` and `&` (`ir::BinaryOp::And`/`BitAnd`) lower to this — the two - /// spellings are one grammar rule split across two precedence levels in - /// both the Java `.g4` and `stark_grammar.pest`, not two operations (see - /// `visitAndExpression`, which ignores `ctx.op.getText()` entirely). - pub fn and(self, other: Value) -> Result { + /// 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)), } } - /// `StarkValue.or` (`StarkBoolean.or`). See [Value::and]'s doc comment — - /// `||` and `|` (`Or`/`BitOr`) are likewise one operation, two spellings. - pub fn or(self, other: Value) -> Result { + /// 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)), } } - /// `StarkValue.apply(DoubleUnaryOperator, ..)`: always widens to `Real`, - /// even for an integer argument — `max(1, 2)` is `Real(2.0)`, not - /// `Integer(2)`. Used for every `MathUnaryFunction`. `op` names the - /// operation for the error message. - pub fn apply_unary(self, op: &'static str, f: impl Fn(f64) -> f64) -> Result { + /// 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(EvalError::UnsupportedUnaryOperand { + operand => Err(EvalErrorKind::UnsupportedUnaryOperand { op, operand: operand.kind(), }), } } - /// `StarkValue.apply(DoubleBinaryOperator, ..)`: 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 { + /// 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))), @@ -307,26 +319,25 @@ impl Value { } } -/// The [EvalError::UnsupportedBinaryOperands] for a binary `op` — the +/// The [EvalErrorKind::UnsupportedBinaryOperands] for a binary `op` — the /// fallthrough every operation below shares. -fn unsupported(op: &'static str, left: Value, right: Value) -> EvalError { - EvalError::UnsupportedBinaryOperands { +fn unsupported(op: &'static str, left: Value, right: Value) -> EvalErrorKind { + EvalErrorKind::UnsupportedBinaryOperands { op, left: left.kind(), right: right.kind(), } } -/// The shared "int-preserving-then-widening" promotion used by `+`, `*`, `-`: -/// `int op int -> Integer`; anything touching a `Real` -> `Real`; anything with -/// a non-numeric operand is an error. +/// 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 { +) -> 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))), @@ -336,18 +347,14 @@ fn numeric_op( } } -/// The comparison shared by `<`, `<=`, `>=`, `>`, `==`: `int_op` compares two -/// `Integer`s exactly (matching `StarkInteger`'s own `instanceof StarkInteger` -/// fast path — not widened through `f64`, which loses precision above 2^53); -/// any pairing touching a `Real` widens through `real_op` instead. Anything -/// else (including a mismatched non-numeric pairing) is an error. +/// 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 { +) -> 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)), @@ -357,17 +364,14 @@ fn comparison_op( } } -/// Boolean negation (`!x`), `StarkValue.negate`. Still an `impl` of the -/// standard trait rather than an inherent method so `!value` reads naturally -/// at call sites; the `Output` is a `Result` like every other -/// operation here, so a call site spells it `(!value)?`. +/// Boolean negation (`!x`). impl std::ops::Not for Value { - type Output = Result; + type Output = Result; - fn not(self) -> Result { + fn not(self) -> Result { match self { Value::Boolean(v) => Ok(!v), - operand => Err(EvalError::UnsupportedUnaryOperand { + operand => Err(EvalErrorKind::UnsupportedUnaryOperand { op: "!", operand: operand.kind(), }), @@ -417,7 +421,7 @@ mod tests { fn arithmetic_on_a_non_numeric_operand_names_both_sides() { assert_eq!( Value::Boolean(true).sum(Value::Integer(1)), - Err(EvalError::UnsupportedBinaryOperands { + Err(EvalErrorKind::UnsupportedBinaryOperands { op: "+", left: ValueKind::Boolean, right: ValueKind::Integer, @@ -441,20 +445,20 @@ mod tests { fn integer_division_and_modulo_by_zero_error_instead_of_panicking() { assert_eq!( Value::Integer(1).division(Value::Integer(0)), - Err(EvalError::DivisionByZero) + Err(EvalErrorKind::DivisionByZero) ); assert_eq!( Value::Integer(1).modulo(Value::Integer(0)), - Err(EvalError::DivisionByZero) + Err(EvalErrorKind::DivisionByZero) ); assert_eq!( Value::Integer(1).int_div(Value::Integer(0)), - Err(EvalError::DivisionByZero) + 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(EvalError::DivisionByZero) + Err(EvalErrorKind::DivisionByZero) ); } @@ -542,10 +546,10 @@ mod tests { #[test] fn arithmetic_negate_and_widen_always_widen_to_real() { - // `-x`/`+x` are *not* integer-preserving, matching Java's - // `unaryOperators` map, which routes both through the same - // always-widening `DoubleUnaryOperator` mechanism as the math - // functions — see `ExprNode::Negate`'s doc comment. + // `-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()); @@ -554,9 +558,9 @@ mod tests { #[test] fn math_functions_always_widen_to_real() { - // The pinning test from `EVALUATOR_PLAN.md`: `max(1, 2)` is `Real(2.0)`, - // not `Integer(2)`, since `StarkInteger.apply(DoubleBinaryOperator)` - // always returns a `StarkReal`. + // 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)) @@ -566,13 +570,13 @@ mod tests { #[test] fn as_boolean_errors_on_a_non_boolean_guard() { - // The behaviour change from `StarkValue.isTrue`, which silently - // answered `false` here — see the module doc comment. + // 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(EvalError::ExpectedBoolean { + Err(EvalErrorKind::ExpectedBoolean { context: "a guard", found: ValueKind::Integer, }) @@ -581,8 +585,8 @@ mod tests { #[test] fn as_number_widens_either_numeric_case() { - assert_eq!(Value::Integer(3).as_number("a bound"), Ok(3.0)); - assert_eq!(Value::Real(3.5).as_number("a bound"), Ok(3.5)); - assert!(Value::Boolean(true).as_number("a bound").is_err()); + 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/tests/lowering.rs b/crates/stark/tests/lowering.rs deleted file mode 100644 index 774e1b62d..000000000 --- a/crates/stark/tests/lowering.rs +++ /dev/null @@ -1,50 +0,0 @@ -//! Lowers every `.stark` file under `examples/stark/` end-to-end (parse -> -//! check -> [lower]) and asserts the resulting [IrProgram] is internally -//! consistent. Mirrors `tests/examples.rs`'s `checks_example_specification` -//! (same file list, one step further down the pipeline) — this is the -//! `lowers_every_example_specification` test `IR_LOWERING_PLAN.md` calls -//! for. Every example lowers, including the ones using perturbations, -//! distances and formulas. - -use merc_stark::UntypedStarkSpecification; -use merc_stark::lower; -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 lowers_every_example_specification(source: &str) { - let spec = UntypedStarkSpecification::parse(source) - .unwrap_or_else(|e| panic!("failed to parse: {e}")) - .check() - .unwrap_or_else(|d| panic!("failed to check:\n{}", d.render(source))); - let program = lower(&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/simulation.rs b/crates/stark/tests/simulation_test.rs similarity index 97% rename from crates/stark/tests/simulation.rs rename to crates/stark/tests/simulation_test.rs index dfa559d19..8b0b89dc3 100644 --- a/crates/stark/tests/simulation.rs +++ b/crates/stark/tests/simulation_test.rs @@ -1,7 +1,6 @@ //! 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 `EVALUATOR_PLAN.md`'s Milestone B -//! (simulation). +//! 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 diff --git a/crates/stark/tests/examples.rs b/crates/stark/tests/stark_examples.rs similarity index 91% rename from crates/stark/tests/examples.rs rename to crates/stark/tests/stark_examples.rs index c5fb77f78..b8cc982a3 100644 --- a/crates/stark/tests/examples.rs +++ b/crates/stark/tests/stark_examples.rs @@ -39,4 +39,11 @@ fn checks_example_specification(source: &str) { if let Err(diagnostics) = spec.check() { panic!("failed to check:\n{}", diagnostics.render(source)); } + + let spec = spec.check().unwrap_or_else(|d| panic!("failed to check:\n{}", d.render(source))); + let program = lower(&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.rs b/crates/stark/tests/verification_test.rs similarity index 97% rename from crates/stark/tests/verification.rs rename to crates/stark/tests/verification_test.rs index 346c53e83..90ed374a3 100644 --- a/crates/stark/tests/verification.rs +++ b/crates/stark/tests/verification_test.rs @@ -1,6 +1,5 @@ //! Runs a robustness analysis end to end over an example specification — -//! `EVALUATOR_PLAN.md`'s Milestone C, the counterpart of `simulation.rs`'s -//! Milestone B smoke test. +//! 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 From 40d7e6c555e127ab6fa022d0a920b29a3feec85f Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Tue, 21 Jul 2026 18:14:00 +0200 Subject: [PATCH 44/50] Extended the CLI to simulate and verify the specifications --- tools/stark/src/main.rs | 315 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 293 insertions(+), 22 deletions(-) diff --git a/tools/stark/src/main.rs b/tools/stark/src/main.rs index df029d87e..6538fccf1 100644 --- a/tools/stark/src/main.rs +++ b/tools/stark/src/main.rs @@ -9,8 +9,18 @@ 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::lower; +use merc_stark::value::Value; use merc_tools::VerbosityFlag; use merc_tools::Version; use merc_tools::VersionFlag; @@ -40,6 +50,10 @@ struct Cli { 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)] @@ -51,6 +65,68 @@ struct CheckArgs { /// 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 { @@ -79,46 +155,146 @@ fn main() -> ExitCode { fn handle_command(commands: Option, timing: &Timing) -> Result<(), MercError> { if let Some(command) = commands { match command { - Commands::Check(args) => { - let spec = load_specification(&args.specification, timing)?; + Commands::Check(args) => check(args, timing)?, + Commands::Simulate(args) => simulate(args, timing)?, + Commands::Verify(args) => verify(args, timing)?, + } + } + + Ok(()) +} - if args.print_symbols { - print_symbols(&spec); - } +/// 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)?; - info!("{} is a valid STARK specification", args.specification.display()); + 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 an [UntypedStarkSpecification] and checks it into a -/// [StarkSpecification]. +/// 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 load_specification(path: &Path, timing: &Timing) -> Result { - let source = - read_to_string(path).map_err(|err| MercError::from(format!("cannot read {}: {err}", path.display())))?; - - let untyped = timing.measure("parsing", || UntypedStarkSpecification::parse(&source))?; +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", || untyped.check()) - .map_err(|diagnostics| { - let count = diagnostics.items().len(); - let plural = if count == 1 { "error" } else { "errors" }; + .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", || lower(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) - )) - }) + 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. @@ -145,6 +321,92 @@ fn print_symbols(spec: &StarkSpecification) { } } +/// 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 { @@ -161,3 +423,12 @@ fn describe(kind: &DefKind) -> &'static str { 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", + } +} From 477051f4ec2b130ec54772c76c44076b3a93a6eb Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Mon, 3 Aug 2026 11:23:44 +0200 Subject: [PATCH 45/50] Removed references to the Java code, updated various comments. Made the interface similar to the merc_syntax crate. --- crates/stark/src/ast.rs | 40 +--------- crates/stark/src/consume.rs | 28 +------ crates/stark/src/diagnostics.rs | 79 ++++++------------- crates/stark/src/eval/expr.rs | 44 +++++------ crates/stark/src/eval/perturbation.rs | 27 +++---- crates/stark/src/eval/robust.rs | 31 +++----- crates/stark/src/eval/sequence.rs | 48 ++++++----- crates/stark/src/eval/sim.rs | 8 +- crates/stark/src/eval/step.rs | 18 ++--- crates/stark/src/eval/store.rs | 44 ++++------- crates/stark/src/ir.rs | 51 +++++++----- crates/stark/src/lib.rs | 22 +++--- crates/stark/src/lower.rs | 77 +++++++----------- crates/stark/src/resolve.rs | 11 +-- crates/stark/src/specification.rs | 41 +++------- crates/stark/src/types.rs | 34 ++++---- crates/stark/src/value.rs | 34 +++++--- crates/stark/tests/simulation_test.rs | 22 +++--- crates/stark/tests/stark_examples.rs | 12 ++- crates/stark/tests/verification_test.rs | 12 +-- .../stark/abz2025_two_lanes_two_cars.stark | 2 +- examples/stark/reactionsystems_running.stark | 4 +- examples/stark/vehicle_full.stark | 5 +- tools/stark/src/main.rs | 6 +- 24 files changed, 283 insertions(+), 417 deletions(-) diff --git a/crates/stark/src/ast.rs b/crates/stark/src/ast.rs index e214b3f62..17d1a5196 100644 --- a/crates/stark/src/ast.rs +++ b/crates/stark/src/ast.rs @@ -1,24 +1,8 @@ -//! Abstract syntax tree for the STARK specification language. -//! -//! This mirrors the structure of the original STARK ANTLR grammar -//! grammar. The tree is produced by `consume.rs` -//! (structural declarations) together with the Pratt parsers in `precedence.rs` -//! (expressions and the perturbation / distance / ROBTL sub-languages). -//! -//! Declarations carry an `id: Option` (or `Option` for -//! controller states) that is `None` after parsing and filled in by name -//! resolution (`resolve.rs`). Every place a declared name is *referenced* -//! (rather than declared) uses [DefRef], [StateRef] or, inside expressions, -//! [Binding] — all `None`/absent until resolution runs. - pub use merc_utilities::Span; pub use merc_utilities::Spanned; use merc_utilities::TagIndex; -/// A unique tag for top-level declarations: constants, parameters, variables, -/// functions, penalties, components, custom types, perturbations, distances -/// and formulas all share this single namespace, mirroring the original -/// STARK `SymbolTable`'s single `symbols` map. +/// 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; @@ -35,16 +19,10 @@ pub struct LocalTag; pub type LocalId = TagIndex; /// An expression node together with the source span it was parsed from. -/// -/// Mirrors rustc's `Expr`/`ExprKind` split: [Expression] is the spanned node -/// that appears everywhere in the tree, and [ExpressionKind] is the bare -/// variant data. Sub-expressions recurse through `Box` (not -/// `Box`), so every level of nesting carries its own span. pub type Expression = Spanned; -/// A reference to a top-level declaration (variable, constant, parameter, -/// function, penalty, distance, perturbation, formula or component), -/// resolved to a [DefId] by name resolution. `id` is `None` until then. +/// 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, @@ -407,10 +385,6 @@ pub enum RobtlFormula { }, } -// --------------------------------------------------------------------------- -// Expressions -// --------------------------------------------------------------------------- - #[derive(Clone, Debug)] pub enum ExpressionKind { // Literals @@ -418,9 +392,7 @@ pub enum ExpressionKind { True, Integer(i64), Real(f64), - /// A name reference: a constant/parameter/variable, a local binding - /// (function argument, `let` binding, `it`), or (before resolution) - /// unresolved. `binding` is filled in by `resolve.rs`. + /// A name reference. Reference { name: String, binding: Option, @@ -532,10 +504,6 @@ pub enum MathFunction { Pow, } -// --------------------------------------------------------------------------- -// Shared leaf types -// --------------------------------------------------------------------------- - /// A `range [min, max]` bound on a variable declaration. #[derive(Clone, Debug)] pub struct Range { diff --git a/crates/stark/src/consume.rs b/crates/stark/src/consume.rs index 34444c062..e340b4c56 100644 --- a/crates/stark/src/consume.rs +++ b/crates/stark/src/consume.rs @@ -1,15 +1,3 @@ -//! Turns the `pest` parse tree produced by `parse.rs` into the [crate::ast] -//! tree, one `merc_pest_consume` consumer per grammar rule. -//! -//! Only the *structural* declarations are consumed here; every expression -//! language (plain expressions and the perturbation / distance / ROBTL -//! sub-languages) reaches this module as a flat token stream that is handed -//! to the Pratt parsers in `precedence.rs` instead. -//! -//! Nothing is resolved or typed at this stage: every `DefRef`/`StateRef` -//! carries a `None` id and every expression a `None` type, both filled in -//! later by `resolve.rs` and `typecheck.rs`. - #![allow(clippy::result_large_err)] use merc_pest_consume::Error; @@ -51,18 +39,8 @@ use crate::precedence::parse_robtl_formula; pub(crate) type ParseResult = std::result::Result>; pub(crate) type ParseNode<'i> = merc_pest_consume::Node<'i, Rule, ()>; -// --------------------------------------------------------------------------- -// Dispatch helpers for silent alternation groups -// --------------------------------------------------------------------------- - /// Routes one variant node of the silent `FunctionStatement` rule to its /// consumer. -/// -/// The grammar's `FunctionStatement`, `ControllerCommand` and -/// `EnvironmentCommand` rules are silent, so their concrete variant nodes -/// appear directly as children of whatever contains them rather than under a -/// node of their own — hence the three hand-written dispatchers here instead -/// of a generated consumer per rule. fn function_statement(node: ParseNode) -> ParseResult { match node.as_rule() { Rule::FunctionReturn => StarkParser::FunctionReturn(node), @@ -74,7 +52,7 @@ fn function_statement(node: ParseNode) -> ParseResult { } /// Routes one variant node of the silent `ControllerCommand` rule to its -/// consumer — see [function_statement] for why these dispatchers exist. +/// consumer. fn controller_command(node: ParseNode) -> ParseResult { match node.as_rule() { Rule::ControllerStep => StarkParser::ControllerStep(node), @@ -88,7 +66,7 @@ fn controller_command(node: ParseNode) -> ParseResult { } /// Routes one variant node of the silent `EnvironmentCommand` rule to its -/// consumer — see [function_statement] for why these dispatchers exist. +/// consumer. fn environment_command(node: ParseNode) -> ParseResult { match node.as_rule() { Rule::EnvironmentAssignment => StarkParser::EnvironmentAssignment(node), @@ -124,6 +102,8 @@ fn assignment_update(node: ParseNode) -> ParseResult { #[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(); diff --git a/crates/stark/src/diagnostics.rs b/crates/stark/src/diagnostics.rs index bb903c129..a0a53c71f 100644 --- a/crates/stark/src/diagnostics.rs +++ b/crates/stark/src/diagnostics.rs @@ -1,19 +1,3 @@ -//! Diagnostics collected during name resolution and type checking. -//! -//! 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. - use std::error::Error; use std::fmt; @@ -41,7 +25,6 @@ impl fmt::Display for Severity { /// nothing else in the crate formats a diagnostic message. #[derive(Clone, Debug, ThisError)] pub enum DiagnosticKind { - // -- Name resolution (`resolve.rs`) --------------------------------- /// 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. @@ -78,9 +61,7 @@ pub enum DiagnosticKind { TypeElementSharesTypeName { name: String }, /// A state variable was read from an expression evaluated once at load - /// time, before any variable store exists: a `const`/`param` value, or a - /// variable's own range or initializer. `via` names the function through - /// which the variable is reached, when the read is not direct. + /// 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, @@ -88,7 +69,6 @@ pub enum DiagnosticKind { via: Option, }, - // -- Type checking (`typecheck.rs`) --------------------------------- /// A `Ty::Named` annotation that names no declared `type`. #[error("unknown type `{name}`")] UnknownType { name: String }, @@ -101,14 +81,12 @@ pub enum DiagnosticKind { #[error("expected a numerical type, found {found}")] NotNumerical { found: StarkType }, - /// Two types that have to meet at a join point (ternary branches, the - /// two sides of a comparison, a function's several `return`s) have no - /// common supertype. + /// 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 — a - /// constant's value, a variable's range bound, an interval bound. + /// `R`/`N[..]`/`U[..]` used somewhere randomness is not permitted. #[error("random expressions are not allowed here")] RandomNotAllowed, @@ -119,19 +97,16 @@ pub enum DiagnosticKind { found: usize, }, - // -- Lowering (`lower.rs`) ------------------------------------------- /// A construct that resolves and type-checks but has no IR - /// representation yet (see `plan.md`). Reported rather than panicked on, - /// so a partially-supported spec fails gracefully instead of crashing - /// lowering. + /// 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. `None` for the majority of kinds, - /// which are fully explained by where they point. + /// 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, .. } @@ -143,6 +118,20 @@ impl DiagnosticKind { } /// 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 { @@ -161,23 +150,9 @@ impl Diagnostic { } } - /// Renders this diagnostic against its `source` text, in the same - /// `-->`/`|`/`^^^` style parser errors use (see [Span::render]), followed - /// by a second annotated snippet when [DiagnosticKind::related] gives - /// one: - /// - /// ```text - /// error: duplicate definition of `a` - /// --> 2:7 - /// | - /// 2 | const a = 2; - /// | ^ - /// note: first defined here - /// --> 1:7 - /// | - /// 1 | const a = 1; - /// | ^ - /// ``` + /// 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() { @@ -228,9 +203,7 @@ impl Diagnostics { self.items.extend(other.items); } - /// `Ok(value)` if nothing errored, otherwise `Err(self)` — the "return - /// `null` only after collecting every error" pattern from - /// `SpecificationLoader.load`, but via `Result` instead of a sentinel. + /// `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) } } diff --git a/crates/stark/src/eval/expr.rs b/crates/stark/src/eval/expr.rs index 405961de8..a4247f400 100644 --- a/crates/stark/src/eval/expr.rs +++ b/crates/stark/src/eval/expr.rs @@ -30,7 +30,7 @@ use super::store::Store; /// the expression does. /// /// This is a thin wrapper over [eval_inner] that pins the offending source -/// location: on failure it attaches `id`'s [crate::ir::Span] to the error +/// 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 @@ -61,8 +61,12 @@ fn eval_inner( // 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::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)?; @@ -275,16 +279,14 @@ mod tests { use test_log::test; use super::*; + use crate::StarkSpecification; use crate::UntypedStarkSpecification; - use crate::lower; fn eval_expression(source: &str) -> Value { let full_source = format!("const result = {source};"); - let spec = UntypedStarkSpecification::parse(&full_source) - .expect("should parse") - .check() - .expect("should check"); - let program = lower(&spec).expect("should lower"); + 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) @@ -315,11 +317,9 @@ mod tests { // rule buys, and what makes the message read as "division by zero at // ". let source = "const result = 4 + 1 / 0;"; - let spec = UntypedStarkSpecification::parse(source) - .expect("should parse") - .check() - .expect("should check"); - let program = lower(&spec).expect("should lower"); + 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"); @@ -360,11 +360,9 @@ mod tests { int result = add(3, 4); } "; - let spec = UntypedStarkSpecification::parse(source) - .expect("should parse") - .check() - .expect("should check"); - let program = lower(&spec).expect("should lower"); + 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; @@ -382,11 +380,9 @@ mod tests { int result = with_let(1); } "; - let spec = UntypedStarkSpecification::parse(source) - .expect("should parse") - .check() - .expect("should check"); - let program = lower(&spec).expect("should lower"); + 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)); diff --git a/crates/stark/src/eval/perturbation.rs b/crates/stark/src/eval/perturbation.rs index e4ea17140..7088041ea 100644 --- a/crates/stark/src/eval/perturbation.rs +++ b/crates/stark/src/eval/perturbation.rs @@ -42,10 +42,7 @@ pub(crate) enum PerturbationState { /// `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, - }, + Iterative { replica: i64, body: Box }, } impl PerturbationState { @@ -199,16 +196,14 @@ mod tests { use test_log::test; use super::*; + use crate::StarkSpecification; use crate::UntypedStarkSpecification; - use crate::lower; /// Builds the program's single perturbation declaration's initial state. fn build(source: &str) -> (IrProgram, PerturbationState) { - let spec = UntypedStarkSpecification::parse(source) - .expect("should parse") - .check() - .expect("should check"); - let program = lower(&spec).expect("should lower"); + 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; @@ -273,7 +268,9 @@ mod tests { #[test] fn a_reference_behaves_like_the_declaration_it_names() { - let (_, referenced) = build(&format!("{PREAMBLE} perturbation base = [x <- 1]@2; perturbation p = base;")); + 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)); } @@ -287,11 +284,9 @@ mod tests { } perturbation swap = [x <- y, y <- x]@0; "; - let spec = UntypedStarkSpecification::parse(source) - .expect("should parse") - .check() - .expect("should check"); - let program = lower(&spec).expect("should lower"); + 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; diff --git a/crates/stark/src/eval/robust.rs b/crates/stark/src/eval/robust.rs index 92e04a7a1..84bea6dd1 100644 --- a/crates/stark/src/eval/robust.rs +++ b/crates/stark/src/eval/robust.rs @@ -50,14 +50,10 @@ use super::store::Store; /// 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 — how finely the - /// state distribution is approximated. Larger is more accurate and - /// linearly more expensive. + /// 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, and each - /// reference sample is compared against the `scale` perturbed samples - /// descended from it. + /// 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 @@ -88,11 +84,7 @@ impl Default for AnalysisOptions { /// 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. Its `[0, n_variables)` - /// prefix is never stepped, so reading a *variable* through it would be - /// meaningless — but no interval bound, threshold or weight can refer to - /// one, since those are all evaluated outside any state in the original - /// too. + /// A store used only for program-level constants. pub(crate) globals: Store, pub(crate) rng: R, pub(crate) options: AnalysisOptions, @@ -108,7 +100,11 @@ impl<'a> Analysis<'a, StdRng> { 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> { + pub fn with_rng( + program: &'a IrProgram, + mut rng: R, + options: AnalysisOptions, + ) -> Result, EvalError> { let globals = Store::new(program, &mut rng)?; Ok(Analysis { program, @@ -163,16 +159,15 @@ mod tests { use test_log::test; use super::*; + use crate::StarkSpecification; use crate::UntypedStarkSpecification; use crate::eval::TruthValue; - use crate::lower; fn build(source: &str) -> IrProgram { - let spec = UntypedStarkSpecification::parse(source) - .expect("should parse") - .check() - .expect("should check"); - lower(&spec).expect("should lower") + 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 diff --git a/crates/stark/src/eval/sequence.rs b/crates/stark/src/eval/sequence.rs index fd38fa266..0a59f3d4c 100644 --- a/crates/stark/src/eval/sequence.rs +++ b/crates/stark/src/eval/sequence.rs @@ -1,19 +1,3 @@ -//! 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]. - use rand::Rng; use crate::ir::IrProgram; @@ -35,6 +19,22 @@ use super::system::SystemState; /// 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]` @@ -221,11 +221,7 @@ impl EvolutionSequence { /// [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 { +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(), @@ -263,15 +259,17 @@ mod tests { use test_log::test; use super::*; + use crate::StarkSpecification; use crate::UntypedStarkSpecification; - use crate::lower; fn build(source: &str) -> IrProgram { let spec = UntypedStarkSpecification::parse(source) - .expect("should parse") - .check() + .expect("should parse"); + + let typed_spec = StarkSpecification::from_untyped(spec) .expect("should check"); - lower(&spec).expect("should lower") + + IrProgram::from_spec(&typed_spec).expect("should lower") } const COUNTER: &str = r" diff --git a/crates/stark/src/eval/sim.rs b/crates/stark/src/eval/sim.rs index af696b3b7..6d22ed7d0 100644 --- a/crates/stark/src/eval/sim.rs +++ b/crates/stark/src/eval/sim.rs @@ -135,14 +135,14 @@ mod tests { use super::*; use crate::UntypedStarkSpecification; - use crate::lower; fn build(source: &str) -> IrProgram { let spec = UntypedStarkSpecification::parse(source) - .expect("should parse") - .check() + .expect("should parse"); + + let typed_spec = crate::StarkSpecification::from_untyped(spec) .expect("should check"); - lower(&spec).expect("should lower") + IrProgram::from_spec(&typed_spec).expect("should lower") } #[test] diff --git a/crates/stark/src/eval/step.rs b/crates/stark/src/eval/step.rs index a8247e093..f24b4aa8a 100644 --- a/crates/stark/src/eval/step.rs +++ b/crates/stark/src/eval/step.rs @@ -250,15 +250,15 @@ mod tests { use super::*; use crate::UntypedStarkSpecification; - use crate::lower; use crate::value::EvalErrorKind; fn build(source: &str) -> IrProgram { let spec = UntypedStarkSpecification::parse(source) - .expect("should parse") - .check() + .expect("should parse"); + + let typed_spec = crate::StarkSpecification::from_untyped(spec) .expect("should check"); - lower(&spec).expect("should lower") + IrProgram::from_spec(&typed_spec).expect("should lower") } #[test] @@ -284,11 +284,8 @@ mod tests { #[test] fn a_failing_guard_aborts_the_step_instead_of_reading_as_false() { - // The regression this whole `Result` change exists for. Originally - // the guard `1 / zero > 0` evaluated to the absorbing error value, - // which read as `false`, so the assignment silently didn't happen and - // the run continued with `x` unchanged — an arithmetic failure - // indistinguishable from a guard that was legitimately not satisfied. + // Originally the guard `1 / zero > 0` evaluated to the absorbing error + // value, resulting in false. let program = build( r" global variables { @@ -304,7 +301,8 @@ mod tests { 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"); + 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. diff --git a/crates/stark/src/eval/store.rs b/crates/stark/src/eval/store.rs index 7f7709d1c..659e4a430 100644 --- a/crates/stark/src/eval/store.rs +++ b/crates/stark/src/eval/store.rs @@ -20,34 +20,21 @@ pub(crate) struct Store { } impl Store { - /// Builds a store sized to `program` and runs startup initialisation: - /// every [crate::ir::GlobalInit] (`const`/`param`) in declaration order - /// (already a valid order — no forward references), then every - /// variable's `initial_value`. - /// - /// `rng` is threaded through even though `typecheck.rs` disallows - /// sampling directly in a global/variable initializer (`random_allowed: - /// false` there) — a *function call* reached from one is still allowed - /// to sample internally (`random_allowed: true` for function bodies), so - /// [eval] needs an `Rng` regardless of whether this particular call - /// tree happens to use it. - /// Every slot starts as `Integer(0)` rather than a dedicated "unset" - /// marker. `Value::Error` used to serve as that marker, which conflated - /// "not written yet" with "an operation failed" (see `value.rs`); with - /// errors moved to `Result`, no marker is needed, because no slot is ever - /// read before it is written: globals and variables are initialised here - /// in dependency order, and lowering guarantees a function's argument and - /// `let` slots are written at the call/binding before its body can load - /// them (the language forbids recursion, so no function is ever live on - /// the stack twice and every binding can have its own static slot). + /// 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); @@ -63,8 +50,7 @@ impl Store { self.slots[slot.value() as usize] = value; } - /// The `[0, n_variables)` prefix that a simulation checkpoints — exactly - /// what an evolution sequence samples per step. + /// 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] } @@ -75,16 +61,16 @@ mod tests { use rand::SeedableRng; use test_log::test; - use super::*; + use crate::IrProgram; use crate::UntypedStarkSpecification; - use crate::lower; + use crate::eval::store::Store; + use crate::value::Value; fn lower_source(source: &str) -> IrProgram { - let spec = UntypedStarkSpecification::parse(source) - .expect("should parse") - .check() - .expect("should check"); - lower(&spec).expect("should lower") + 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] diff --git a/crates/stark/src/ir.rs b/crates/stark/src/ir.rs index b9e40617d..a677df959 100644 --- a/crates/stark/src/ir.rs +++ b/crates/stark/src/ir.rs @@ -41,6 +41,9 @@ 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; @@ -203,7 +206,7 @@ pub enum ExprNode { /// 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::EvalError::Unreachable] rather than inventing a + /// 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), @@ -587,7 +590,7 @@ pub struct FormulaDecl { // --------------------------------------------------------------------------- /// The result of lowering: one flat arena, plus the tables that index into -/// it. See the module doc comment for what is (and isn't) populated yet. +/// it. #[derive(Clone, Debug, Default)] pub struct IrProgram { pub(crate) exprs: Vec, @@ -606,8 +609,7 @@ pub struct IrProgram { pub(crate) states: Vec, pub(crate) components: Vec, - /// The environment block, if the specification has one. `None` if it is - /// absent, or present but empty — both mean "nothing runs". + /// The environment block, if the specification has one. pub(crate) environment: Option, pub(crate) perturbations: Vec, @@ -619,6 +621,15 @@ pub struct IrProgram { } 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] } @@ -664,23 +675,19 @@ impl IrProgram { &self.slots[id.value() as usize] } - /// The number of `[0, n_variables)` slots — the simulation state prefix. - /// Equal to `self.variables.len()`, since every variable gets exactly one - /// slot and slot allocation lays this range out first: variables occupy - /// `[0, n_variables)`, `const`/`param` occupy - /// `[n_variables, n_globals)`, and function arguments and `let` bindings - /// occupy `[n_globals, n_slots)`. + /// 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 — variables plus `const`/`param` - /// globals. Equal to `n_variables() + self.globals.len()`. + /// 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. + /// 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 } @@ -752,12 +759,7 @@ impl IrProgram { &self.formula_decls } - /// Independently re-checks the arena's internal consistency: every - /// `ExprRef`/`StmtRef`/`CommandRef`/`SlotId`/`FunctionId`/`IrStateId`/ - /// `PenaltyId`/`PerturbationId`/`DistanceId`/`FormulaId` reachable from a - /// top-level entry (globals, variables, functions, penalties, components, - /// the environment, perturbation/distance/formula declarations) is in - /// bounds, and every list slice lies within `expr_lists`. + /// 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() { @@ -769,6 +771,7 @@ impl IrProgram { )) } }; + let check_slot = |id: SlotId| -> Result<(), String> { if (id.value() as usize) < self.slots.len() { Ok(()) @@ -776,6 +779,7 @@ impl IrProgram { 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(()) @@ -783,6 +787,7 @@ impl IrProgram { 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(()) @@ -790,6 +795,7 @@ impl IrProgram { 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(()) @@ -797,6 +803,7 @@ impl IrProgram { 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(()) @@ -807,6 +814,7 @@ impl IrProgram { )) } }; + let check_perturbation = |id: PerturbationId| -> Result<(), String> { if (id.value() as usize) < self.perturbations.len() { Ok(()) @@ -817,6 +825,7 @@ impl IrProgram { )) } }; + let check_distance = |id: DistanceId| -> Result<(), String> { if (id.value() as usize) < self.distances.len() { Ok(()) @@ -827,6 +836,7 @@ impl IrProgram { )) } }; + let check_formula = |id: FormulaId| -> Result<(), String> { if (id.value() as usize) < self.formulas.len() { Ok(()) @@ -964,6 +974,7 @@ impl IrProgram { check_expr(max)?; } } + for global in &self.globals { check_slot(global.slot)?; let slot = global.slot.value() as usize; @@ -976,6 +987,7 @@ impl IrProgram { } check_expr(global.value)?; } + for function in &self.functions { for &argument in &function.arguments { check_slot(argument)?; @@ -989,6 +1001,7 @@ impl IrProgram { } check_stmt(function.body)?; } + for penalty in &self.penalties { check_expr(penalty.value)?; } diff --git a/crates/stark/src/lib.rs b/crates/stark/src/lib.rs index 3a50cce21..ec7e130b0 100644 --- a/crates/stark/src/lib.rs +++ b/crates/stark/src/lib.rs @@ -1,4 +1,8 @@ #![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; @@ -14,13 +18,11 @@ mod typecheck; mod types; pub mod value; -pub use ast::*; -pub use consume::*; -pub use diagnostics::*; -pub use lower::lower; -pub use parse::*; -pub use precedence::*; -pub use resolve::*; -pub use specification::*; -pub use typecheck::*; -pub use types::*; +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 index 8427bdcb6..80477b186 100644 --- a/crates/stark/src/lower.rs +++ b/crates/stark/src/lower.rs @@ -1,41 +1,3 @@ -//! Lowers a checked [StarkSpecification] to an [IrProgram]: expression, -//! function, global, variable and penalty lowering, controller and -//! environment lowering, and perturbation, distance and formula lowering. -//! -//! [lower]'s `Result` return type is kept even though every construct in the -//! grammar now lowers successfully (nothing in this pass currently produces -//! an `Err`): it's the seam a future not-yet-implemented construct would -//! reuse (`DiagnosticKind::NotYetSupported` exists for exactly that), not a -//! sign that failure is possible today. -//! -//! Perturbations, distances and ROBTL formulas lower the same way -//! expressions do: each top-level `perturbation`/ -//! `distance`/`formula name = ..;` declaration's `Reference(DefRef)` to -//! another declaration of the same kind is resolved to that declaration's -//! root `*Id` at lowering time (`def_perturbations`/`def_distances`/ -//! `def_formulas`, mirroring `def_functions`) — no name lookups survive into -//! the IR here either. `resolve.rs` declares each of these *after* its own -//! body resolves (like functions, constants, and everything else that can be -//! referenced by name), so a reference can only ever name something already -//! lowered — the same no-forward-references property that makes -//! `def_functions`' "callee already lowered" invariant sound applies here -//! unchanged. -//! -//! 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. - use std::collections::HashMap; use log::debug; @@ -102,7 +64,7 @@ use crate::value::Value; /// 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 lower(spec: &StarkSpecification) -> Result { +pub(crate) fn lower(spec: &StarkSpecification) -> Result { let mut lowerer = Lowerer::new(spec); lowerer.allocate_variable_slots(); @@ -119,8 +81,8 @@ pub fn lower(spec: &StarkSpecification) -> Result { 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)", + {} 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(), @@ -169,13 +131,29 @@ pub fn lower(spec: &StarkSpecification) -> Result { lowerer.diagnostics.into_result(program) } -struct Lowerer<'a> { +/// [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 — built once so `lower_reference` doesn't have to re-walk - /// `spec.ast().types` for every reference. + /// folds to. custom_values: HashMap, exprs: Vec, @@ -1474,15 +1452,18 @@ 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}")) - .check() - .unwrap_or_else(|d| panic!("failed to check:\n{}", d.render(src))); + 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))) } diff --git a/crates/stark/src/resolve.rs b/crates/stark/src/resolve.rs index d4f2f732f..1580a8caf 100644 --- a/crates/stark/src/resolve.rs +++ b/crates/stark/src/resolve.rs @@ -155,9 +155,7 @@ pub struct SymbolTable { pub states: Vec, pub locals: Vec, /// Top-level names, for lookups that don't go through an already-resolved - /// [DefRef] — e.g. `typecheck.rs` validating a `Ty::Named` type - /// annotation against a declared `type` (a check that has nothing to do - /// with binding an expression reference, so it isn't performed here). + /// [DefRef]. pub names: HashMap, } @@ -180,10 +178,7 @@ impl SymbolTable { } /// Resolves every name in `spec` in place, returning the resulting -/// [SymbolTable] together with every diagnostic found along the way. Always -/// returns a table — even a spec with unresolved names produces one, with -/// those references left as `None` — so `typecheck.rs` can still make -/// progress on everything that *did* resolve. +/// [SymbolTable] together with every diagnostic found along the way. pub fn resolve(spec: &mut UntypedStarkSpecification) -> (SymbolTable, Diagnostics) { let mut resolver = Resolver { table: SymbolTable::default(), @@ -507,7 +502,7 @@ impl Resolver { /// value at runtime with no diagnostic. /// /// Runs as a post-pass so every function is resolved and its - /// [Self::function_reads_variable] entry is known. + /// [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. diff --git a/crates/stark/src/specification.rs b/crates/stark/src/specification.rs index d12c732c3..40e010a97 100644 --- a/crates/stark/src/specification.rs +++ b/crates/stark/src/specification.rs @@ -1,18 +1,3 @@ -//! The checked form of a STARK specification. -//! -//! Parsing yields an [UntypedStarkSpecification]: a faithful syntax tree whose -//! references are unresolved (`DefRef::id` is `None`) and whose expressions have -//! no types yet. Running [UntypedStarkSpecification::check] — name resolution -//! followed by type checking, both from `RESOLVE_TYPECHECK_PLAN.md` — either -//! reports every problem at once through [Diagnostics] or produces a -//! [StarkSpecification], which pairs the now fully-resolved tree with the -//! [SymbolTable] and [TypeTable] that describe it. -//! -//! The type distinction is the point: only `check` can produce a -//! [StarkSpecification], so anything holding one (a future model lowering or -//! evaluator) knows resolution and type checking already succeeded and never has -//! to re-derive or re-validate that. - use crate::ast::UntypedStarkSpecification; use crate::diagnostics::Diagnostics; use crate::resolve::SymbolTable; @@ -42,18 +27,14 @@ impl StarkSpecification { pub fn types(&self) -> &TypeTable { &self.types } -} -impl UntypedStarkSpecification { - /// Resolves and type-checks this specification. + /// Resolves and type-checks this untyped specification. /// - /// Type checking runs even when resolution reported errors — unresolved - /// references simply stay `None` and the checker skips them — so a single - /// call reports the problems from both passes together rather than making - /// the caller fix all the name errors before seeing any type errors. - pub fn check(mut self) -> Result { - let (symbols, mut diagnostics) = resolve(&mut self); - let (types, type_diagnostics) = typecheck(&self, &symbols); + /// 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() { @@ -66,7 +47,7 @@ impl UntypedStarkSpecification { } diagnostics.into_result(StarkSpecification { - ast: self, + ast: spec, symbols, types, }) @@ -75,19 +56,17 @@ impl UntypedStarkSpecification { #[cfg(test)] mod tests { - use crate::ast::UntypedStarkSpecification; use test_log::test; - // The per-file example checks used to live here, hand-listed one by one; - // they've moved to `tests/examples.rs`, which discovers every - // `examples/stark/*.stark` file at runtime instead. + 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 = spec.check().err().expect("should not check"); + 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/types.rs b/crates/stark/src/types.rs index e12c3f725..2a5596f38 100644 --- a/crates/stark/src/types.rs +++ b/crates/stark/src/types.rs @@ -15,8 +15,8 @@ pub enum StarkType { } impl StarkType { - /// Wraps `inner` as a random value of that type. - /// + /// 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 { @@ -66,15 +66,10 @@ impl StarkType { matches!(self.deterministic(), StarkType::Custom(_)) } - /// Whether a value of type `actual` may be used where `self` (the - /// expected type) is required. Ignores randomness on both sides (a - /// `Random(int)` fits wherever a plain `int` is expected, since it is - /// resolved to a concrete value before use) — the original - /// A `random[..]` type delegates straight through to its - /// content type for the same reason. - /// - /// Integer widens to real but not vice versa: `real x = 1;` is fine, - /// `int x = 1.0;` is not. + /// 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(), @@ -86,11 +81,9 @@ impl StarkType { } } - /// Whether `self` and `other` can be combined in a symmetric position — - /// both branches of a ternary, both sides of a relation, elements of a - /// `U[...]` — without a type error. Either side already being `Error` - /// is always accepted, so one mistake doesn't cascade into a second - /// diagnostic at the same spot. + /// 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; @@ -105,10 +98,10 @@ impl StarkType { ) || matches!((self.deterministic(), other.deterministic()), (StarkType::Custom(a), StarkType::Custom(b)) if a == b) } - /// Combines `self` and `other` into their common type (`int` (+) `real` - /// -> `real`; identical types merge to themselves), propagating a - /// `Random` wrapper if either side carries one. Returns `Error` if the - /// two types have nothing in common. + /// 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; @@ -126,6 +119,7 @@ impl StarkType { if base.is_error() { return StarkType::Error; } + if self.is_random() || other.is_random() { StarkType::random(base) } else { diff --git a/crates/stark/src/value.rs b/crates/stark/src/value.rs index 6bfef0799..ff280f2eb 100644 --- a/crates/stark/src/value.rs +++ b/crates/stark/src/value.rs @@ -43,7 +43,9 @@ pub enum EvalErrorKind { 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")] + #[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. @@ -134,7 +136,7 @@ pub enum Value { 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 { @@ -208,9 +210,10 @@ impl Value { /// 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::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)), @@ -221,9 +224,10 @@ impl Value { /// 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::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)), @@ -234,9 +238,10 @@ impl Value { /// 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::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())), @@ -308,7 +313,12 @@ impl Value { /// 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 { + 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))), diff --git a/crates/stark/tests/simulation_test.rs b/crates/stark/tests/simulation_test.rs index 8b0b89dc3..5492ee008 100644 --- a/crates/stark/tests/simulation_test.rs +++ b/crates/stark/tests/simulation_test.rs @@ -14,21 +14,22 @@ //! `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 merc_stark::lower; 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}")) - .check() - .unwrap_or_else(|d| panic!("failed to check:\n{}", d.render(source))); - let program = lower(&spec).unwrap_or_else(|d| panic!("failed to lower:\n{}", d.render(source))); + 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(); @@ -42,11 +43,10 @@ fn runs_fifty_steps_without_erroring(source: &str) { #[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") - .check() - .expect("should check"); - let program = lower(&spec).expect("should lower"); + 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(); diff --git a/crates/stark/tests/stark_examples.rs b/crates/stark/tests/stark_examples.rs index b8cc982a3..584f249f5 100644 --- a/crates/stark/tests/stark_examples.rs +++ b/crates/stark/tests/stark_examples.rs @@ -3,6 +3,8 @@ //! 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; @@ -35,13 +37,9 @@ use test_case::test_case; #[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}")); - - if let Err(diagnostics) = spec.check() { - panic!("failed to check:\n{}", diagnostics.render(source)); - } - - let spec = spec.check().unwrap_or_else(|d| panic!("failed to check:\n{}", d.render(source))); - let program = lower(&spec).unwrap_or_else(|d| panic!("failed to lower:\n{}", d.render(source))); + 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() diff --git a/crates/stark/tests/verification_test.rs b/crates/stark/tests/verification_test.rs index 90ed374a3..e456d6913 100644 --- a/crates/stark/tests/verification_test.rs +++ b/crates/stark/tests/verification_test.rs @@ -9,11 +9,11 @@ //! 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; -use merc_stark::lower; /// 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. @@ -27,11 +27,11 @@ fn options() -> AnalysisOptions { } fn build(source: &str) -> IrProgram { - let spec = UntypedStarkSpecification::parse(source) - .unwrap_or_else(|e| panic!("failed to parse: {e}")) - .check() - .unwrap_or_else(|d| panic!("failed to check:\n{}", d.render(source))); - lower(&spec).unwrap_or_else(|d| panic!("failed to lower:\n{}", d.render(source))) + 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` diff --git a/examples/stark/abz2025_two_lanes_two_cars.stark b/examples/stark/abz2025_two_lanes_two_cars.stark index 859698b85..589cd3997 100644 --- a/examples/stark/abz2025_two_lanes_two_cars.stark +++ b/examples/stark/abz2025_two_lanes_two_cars.stark @@ -27,7 +27,7 @@ * instead of just braking when the lane is unsafe to stay in. * * `reckless_driver`'s perturbation is simplified relative to the original's - * `AtomicPerturbation`, which recomputes `other_move`/`other_speed`/ + * 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 diff --git a/examples/stark/reactionsystems_running.stark b/examples/stark/reactionsystems_running.stark index 5f3115594..4e26b615d 100644 --- a/examples/stark/reactionsystems_running.stark +++ b/examples/stark/reactionsystems_running.stark @@ -23,8 +23,8 @@ * * The original's context/perturbation sequence (`p_cont_seq`) is a chain of * zero-delay atomic perturbations composed - * with `SequentialPerturbation`, plus one step of `NonePerturbation` (a - * no-op) and one single-iteration repeat of `p5` (apply once, same as `p5` + * 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. * diff --git a/examples/stark/vehicle_full.stark b/examples/stark/vehicle_full.stark index 243cac0ac..de1992d9d 100644 --- a/examples/stark/vehicle_full.stark +++ b/examples/stark/vehicle_full.stark @@ -16,8 +16,9 @@ * 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. - * `getIteratedCombinedPerturbation` (three "faster" applications sequenced - * with three "slower" ones, that pair repeated 20 times) maps directly to + * 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. */ diff --git a/tools/stark/src/main.rs b/tools/stark/src/main.rs index 6538fccf1..4d01cbe3b 100644 --- a/tools/stark/src/main.rs +++ b/tools/stark/src/main.rs @@ -369,7 +369,11 @@ fn print_trajectory(program: &IrProgram, trajectory: &[Vec], full: bool, // 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_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$} ") From 721fb7ae62d7e0af8d670f6de2c05ad17ef2d1e6 Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Mon, 3 Aug 2026 11:23:54 +0200 Subject: [PATCH 46/50] Fixed some documentation links --- tools/mcrl2/crates/mcrl2/src/atermpp/aterm.rs | 2 +- tools/stark/src/main.rs | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/tools/mcrl2/crates/mcrl2/src/atermpp/aterm.rs b/tools/mcrl2/crates/mcrl2/src/atermpp/aterm.rs index 480a56a1c..3c2476b6a 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/src/main.rs b/tools/stark/src/main.rs index 4d01cbe3b..cfe70b349 100644 --- a/tools/stark/src/main.rs +++ b/tools/stark/src/main.rs @@ -19,7 +19,6 @@ use merc_stark::eval::Simulation; use merc_stark::eval::TruthValue; use merc_stark::ir::IrProgram; use merc_stark::ir::SlotId; -use merc_stark::lower; use merc_stark::value::Value; use merc_tools::VerbosityFlag; use merc_tools::Version; @@ -268,7 +267,7 @@ fn check_specification(source: &str, path: &Path, timing: &Timing) -> Result Result { timing - .measure("lowering", || lower(spec)) + .measure("lowering", || IrProgram::from_spec(spec)) .map_err(|diagnostics| render_diagnostics(&diagnostics, source, path)) } From 153ab138f4d7968df1aea0dcb709f13175091876 Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Mon, 3 Aug 2026 11:24:05 +0200 Subject: [PATCH 47/50] Updated the README --- crates/stark/README.md | 27 +++++++++++++-------------- tools/mcrl2/crates/mCRL2-sys | 1 + 2 files changed, 14 insertions(+), 14 deletions(-) create mode 160000 tools/mcrl2/crates/mCRL2-sys diff --git a/crates/stark/README.md b/crates/stark/README.md index 4fc1df5c4..971f1bfa0 100644 --- a/crates/stark/README.md +++ b/crates/stark/README.md @@ -16,22 +16,23 @@ A specification travels through a fixed pipeline, one type per stage, so a stage can never be skipped by accident: ```text -&str -> UntypedStarkSpecification -> StarkSpecification -> ir::IrProgram -> [ evaluate ] - parse check lower +&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. -[`UntypedStarkSpecification::check`] runs name resolution followed by type -checking, and either reports *every* problem at once through [`Diagnostics`] or -produces a [`StarkSpecification`]. Only `check` can produce that type, so -anything holding one knows resolution and type checking already succeeded and -never has to re-derive or re-validate it. [`lower`] then flattens it into an -[`ir::IrProgram`], the arena the evaluator walks. +[`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::lower; +use merc_stark::StarkSpecification; +use merc_stark::IrProgram; use merc_stark::eval::RecordingObserver; use merc_stark::eval::Simulation; @@ -47,12 +48,10 @@ let source = r#" } "#; -let specification = UntypedStarkSpecification::parse(source) - .expect("should parse") - .check() +let untyped = UntypedStarkSpecification::parse(source).expect("should parse"); +let specification = StarkSpecification::from_untyped(untyped) .unwrap_or_else(|diagnostics| panic!("{}", diagnostics.render(source))); - -let program = lower(&specification) +let program = IrProgram::from_spec(&specification) .unwrap_or_else(|diagnostics| panic!("{}", diagnostics.render(source))); // Run one trajectory of twenty macro-steps, recording every state. diff --git a/tools/mcrl2/crates/mCRL2-sys b/tools/mcrl2/crates/mCRL2-sys new file mode 160000 index 000000000..b0cedc4aa --- /dev/null +++ b/tools/mcrl2/crates/mCRL2-sys @@ -0,0 +1 @@ +Subproject commit b0cedc4aa4317f69259d27409dae6af17a33f303 From 4560f957c5cfac9a20ec8aa8332a3499d5912cfc Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Mon, 17 Aug 2026 16:50:46 +0200 Subject: [PATCH 48/50] Removed the plan --- crates/stark/plan.md | 366 ------------------------------------------- 1 file changed, 366 deletions(-) delete mode 100644 crates/stark/plan.md diff --git a/crates/stark/plan.md b/crates/stark/plan.md deleted file mode 100644 index 2cbdd2941..000000000 --- a/crates/stark/plan.md +++ /dev/null @@ -1,366 +0,0 @@ -# STARK: open work - -Everything the `merc_stark` crate still owes, in one place. This replaces the -old `EVALUATOR_PLAN.md`, `IR_LOWERING_PLAN.md` and `MISSING_GRAMMAR_FEATURES.md` -— the *design rationale* those carried now lives in the developer documentation -(`merc-website`, `docs/developer/stark.md`); only open work lives here. - -Reference implementation: `~/STARK/` — `speclang/` (parser and lowering), -`lib/src/main/java/stark/` (the runtime: `robtl/`, `distance/`, -`perturbation/`, `penalty/`, `feedback/`, `distl/`, `monitors/`, -`SampleSet.java`, `EvolutionSequence.java`), and `cli/` (the interactive -shell). - -**What runs today.** Parsing, resolution, type checking and lowering are -complete for every construct the grammar accepts, and all 27 -`examples/stark/*.stark` files lower and validate. `eval::Simulation` runs a -single trajectory; `eval::Analysis` samples an ensemble, perturbs a copy of it, -and evaluates `distance` and `formula` declarations under both the three-valued -(`check`, with a bootstrap confidence interval) and boolean (`check_boolean`) -semantics. - ---- - -## 1. Correctness gaps against the Java reference - -These are divergences in constructs this crate *does* implement — bugs, not -missing features. Highest priority. - -### 1.1 `range [from, to]` is lowered but never enforced - -`VariableInfo::range` survives into the IR, `IrProgram::validate` checks it and -`Display` prints it, but nothing in `eval/` ever reads it. In the reference, -`DataState.set` clamps *every* write through `DataRange.apply` (`Math.max(min, -Math.min(max, v))`), so the bound is a runtime invariant on the whole state -vector, not a declaration-site annotation. - -Three write paths need the clamp: `Store::new`'s variable initialisation, the -buffered `PendingUpdate` flush in `eval::step`, and the perturbation -assignments in `eval::perturbation`. Note that `from`/`to` are `ExprRef`s, so -they need evaluating once at startup and caching alongside the store rather -than being re-evaluated per write. - -### 1.2 `k # step target` idles one tick too many - -`StarkControllerStateGenerator.visitControllerStepAtion` builds -`Controller.doTick(k-1, controller)` — `k-1` tick-only wrappers — so `target` -runs `k` ticks after the `step` command, and `k < 1` behaves exactly like -`k == 1`. `eval::step` instead produces `Cursor::Idle { remaining: k }`, which -consumes `k` idle ticks *before* a further tick runs `target`, i.e. `k+1`. -The fix is `k <= 1 => Cursor::Run(target)`, `k > 1 => Cursor::Idle { remaining: -k - 1 }`. Add a test pinning `1 # step s` as equivalent to a bare `step s`. - -### 1.3 Value-for-value cross-checks against the Java tool - -Unit tests, `tests/simulation.rs` and `tests/verification.rs` all pass, but -nothing has been compared against the Java tool's actual output. For a -*deterministic* spec (no sampling), compare a trajectory — and a -distance/formula verdict — value for value. Stochastic specs can only be -compared distributionally: the RNG stream is deliberately not bit-compatible -with Java's Mersenne Twister, only the distributions match. - -### 1.4 Confidence-interval quirks carried over verbatim - -Two reference behaviours in `eval/distance.rs` were ported as written and are -easy to have mistranslated. They should be confirmed once 1.3 gives a way to -compare: - -- `\U`'s `evalCI` re-seeds its running-left maximum from the left expression - *at `i`* on every outer iteration, unlike its own `compute`. -- `bootstrapDistance` clamps the interval to `[0, 1]`, assuming penalty values - are normalised to that range. - ---- - -## 2. Language features absent from the STARK textual language - -These match the original ANTLR grammar (`StarkSpecificationLanguage.g4`) -exactly — they are limitations of the STARK *language*, not of this port. Each -entry gives the workaround the ported examples use. Implementing any of them -means extending the grammar past the reference, which is a deliberate decision -to make rather than a gap to close. - -- **No `//` line comments.** Only `/* ... */` blocks (`COMMENT: '/*' .*? '*/'`). - Workaround: block comments everywhere, including short inline notes. - -- **No parenthesised grouping in RobTL formulas.** `RobtlFormula` has no - `'(' robtlFormula ')'` alternative, so `\F[0,H] (!(A && B) || (C && D))` - cannot be written inline. Workaround: name each sub-formula as its own - `formula` declaration and compose by reference, as `engine.stark` does with - `phi_5`/`phi_6`/`phi_7`. - -- **No implication operator.** RobTL has `!`, `&&`, `||` but no `->`, even - though the Java runtime has `ImplicationRobustnessFormula` (see §3.1). - Workaround: `A -> B` ≡ `!A || B`, combined with the no-parens point above. - -- **No `when`-guarded perturbation assignments.** A controller or environment - assignment can be guarded (`when guard target' = value;`); a - `PerturbationAssignment` (`target <- value` inside `[...]@time`) cannot. - Workaround: fold the condition into a ternary that leaves the variable - unchanged — `target <- (guard ? new_value : target)`. - -- **No `let` inside a perturbation's `[...]@time` block.** A controller or - environment step can bind a shared intermediate once and reuse it across - several assignments; a perturbation's atomic block is a flat list of - `target <- expression` pairs with no binding form. This is a real fidelity - loss, not a style difference: `vehicle`'s `fasterPerturbation` draws *one* - random offset and derives a fake speed, a fake required distance and a fake - safety gap from it, whereas each ported assignment must redraw `R[0,1]` - independently, so the three "sensor" readings are no longer correlated. - -- **No primed-variable references inside expressions.** `NEXT_ID` (`x'`) - appears only in assignment *target* position; an expression can never read - "the value `x` is about to become". Workaround: a `let` binding stands in — - `let new_x = ... in { x' = new_x; d' = f(new_x); }`. - -- **No array/list types or aggregate functions.** The `.count()`/`.min()`/ - `.max()`/`.mean()` postfix aggregates, the array literal and the `array` - type are all present in the original `.g4` only as commented-out rules, so - there is no array `StarkType` either. The `it` iterator primitive *does* - parse here (`ExpressionKind::Iterator`), but lowering emits - `ExprNode::Unreachable` for it, because the aggregate context that would - bind it does not exist. Adding aggregates is what would make `it` reachable. - -- **No math constants** (`pi`, `e`, …). Formulas needing `pi` hard-code the - decimal expansion (`1.5707963267948966` for `pi/2`). - -- **No current-step / round-index expression.** `Expression` has no "current - round index" primitive — not `state.getStep()`, not an implicit loop - variable — so an "every `k`-th step" effect cannot be expressed at all. - Unlike the two perturbation gaps above there is no ternary workaround, since - the condition depends on absolute position in the evolution sequence rather - than on any variable in the data state. The Java `turtle` example's - `ChangeDir` gates a speed boost on `state.getStep() % k == 0`; only its - unconditional heading jitter was portable (see `turtle_hospital.stark`'s - header). Note that the reference *does* carry a step counter and time fields - on `DataState` (§3.7) — exposing them would be the enabling change. - ---- - -## 3. Java runtime features with no textual syntax - -The Java library is substantially larger than the language that drives it. -Everything below exists in `~/STARK/lib/` but is unreachable from -`StarkSpecificationLanguage.g4`, so it is only usable by writing Java against -the library directly. Each would need both grammar and IR work here. Ordered -roughly by how close it is to what the crate already does. - -### 3.1 Operators missing from arenas that otherwise match - -Small, self-contained additions to existing IR enums: - -- **`ImplicationRobustnessFormula`** — `FormulaIr` has `Not`/`And`/`Or` but no - `Implies`. Both `BooleanSemanticsVisitor` and `ThreeValuedSemanticsVisitor` - implement it. Needs a `->` in `RobtlFormula`. -- **`PersistentPerturbation`** and **`AfterPerturbation`** — `PerturbationIr` - covers `Nil`/`Atomic`/`Sequence`/`Iteration`, matching exactly what - `StarkPerturbationGenerator` can build. `PersistentPerturbation(body)` - repeats `body` forever (`step()` returns `Sequential(body.step(), this)`); - `AfterPerturbation(steps, body)` delays a whole sub-perturbation rather than - a single atomic block, which `[...]@time` cannot express when the delayed - thing is a composite. -- **`AtomicDistanceExpression` with a custom ground metric.** The grammar - exposes only `p`, which lower to `AtomicLeft`/`AtomicRight` - (`distanceLeq`/`distanceGeq`). Java's plain `AtomicDistanceExpression` takes - an arbitrary `DoubleBinaryOperator` as the ground distance between two - penalty samples, with the Wasserstein lifting built on top of it. Would need - syntax for naming a ground metric. -- **Convex-combination weight validation.** `ConvexCombinationDistanceExpression` - rejects weights that do not sum to exactly 1. `DistanceIr::LinearCombination` - accepts any weights, and since they are `ExprRef`s the check would have to be - a runtime one at construction. Decide whether to enforce it or to document - the divergence. - -### 3.2 Skorokhod distance - -`SkorokhodDistanceExpression` computes a retiming-tolerant distance via a -dynamic-programming table over time offsets, parameterised by a retiming -window, a resolution, a direction flag and an average-vs-maximum mode. There is -no `DistanceIr` node and no grammar production for it. Note that its own -`evalCI` throws `UnsupportedOperationException` upstream, so only `compute` -would be portable — meaning it could not appear under a `\D[...]` in a -three-valued `formula`. The `repressilator` example (`Main_Skorokhod.java`) -is the reference use. - -### 3.3 Compositional penalties - -`PenaltyIr` is a single expression evaluated at every step. Java's -`stark.penalty` package makes a penalty a *coroutine* with the same shape as -`Perturbation`: `AtomicPenalty(afterSteps, expr)`, `SequentialPenalty`, -`IterativePenalty(replica, body)`, `NonePenalty`, with `effect()`/`next()`/ -`isDone()` and `effectUpTo(step)`. This lets a penalty change over time — score -one thing for the first `k` steps and another afterwards. `SampleSet` already -has `distanceLeq(Penalty, other, step)` overloads that take one. The grammar -would need a penalty-expression sub-language mirroring `PerturbationExpression`. - -### 3.4 Feedback - -`stark.feedback` is a whole framework with no syntax at all: `Feedback` has the -same six-case shape as `Perturbation` (`Atomic`/`Delayed`/`Iterative`/`None`/ -`Sequential`/`Persistent`) but an `AtomicFeedback` closes the loop — its -`FeedbackFunction` receives the *evolution sequence so far* alongside the -random generator and data state, so the system can react to statistics of its -own ensemble (`SampleSet.mean` over a previous step). `FeedbackSystem` is the -corresponding `SystemState`. This is architecturally the largest gap: nothing -in `eval/` gives a running system access to the sequence it belongs to. - -### 3.5 Online monitoring: DisTL, UDisTL and monitors - -A second, independent verification formalism. Where RobTL compares a nominal -evolution sequence against a perturbed one via a distance metric (offline, two -trajectories), DisTL evaluates a temporal formula directly against one observed -trajectory (online, incremental). - -- `stark.distl` — `True`/`False`/`Negation`/`Conjunction`/`Disjunction`/ - `Implication`/`Always`/`Eventually`/`Until`, plus the two atomic forms - `TargetDisTLFormula` and `BrinkDisTLFormula`, each carrying a target - distribution, a penalty (or a compositional `Penalty`, §3.3) and a - threshold. `DoubleSemanticsVisitor` gives the quantitative semantics. -- `stark.udistl` — `UnboundedUntiluDisTLFormula`; note its semantic evaluation - throws upstream ("not formally defined") and is only meaningful via a monitor. -- `stark.monitors` — the incremental evaluators (`TargetMonitor`, - `BrinkMonitor`, `UntilMonitor`, `UnboundedUntilMonitor`, the boolean - combinators, `DefaultMonitorBuilder`) plus `MonitorBuildingVisitor`. -- `PerceivedSystemState` — a `SystemState` stripped down to its data state, - which is what monitors consume; `EvolutionSequence.getAsPerceivedSystemStates` - produces them. It deliberately throws on `sampleNext`. - -The `monitoring` example and the monitoring-only parts of `tollbooth` are -ported here only as far as their variable/controller/environment model goes; -the monitored property itself is a comment, not a translation. - -### 3.6 Probabilistic and non-deterministic controllers - -`eval::step`'s `Cursor` covers `Assign`/`IfThenElse`/`Let`/`Sequence`/`Step`/ -`Exec`, matching `AssignmentController`/`IfThenElseController`/`StepController`/ -`ExecController`/`NilController` and the flattening of `ParallelController` -into a `Vec`. Three Java controllers have no counterpart: - -- `GenerativeChoiceController(p, left, right)` — pick one branch with - probability `p` and delegate to it for this step. -- `ProbabilisticInterleavingController(p, left, right)` — advance *one* of two - concurrently-live controllers, chosen with probability `p`; the other keeps - its cursor. This is a genuinely different composition from the parallel one - `init a || b` gives, where both advance every tick. -- `RandomChoiceBehaviour` — uniform choice between two behaviours. - -The commented-out `controllerProbabilisticBehaviour` / -`controllerProbabilisticItem` rules in the original `.g4` -(`('when' guard)? '[' probability '>' block`) are the intended syntax, and -`controllerSwitchStatement` / `controllerCaseStatment` are commented out -alongside them — `visitControllerCaseStatment` is a bare `//TODO: FIXME!` -upstream, so `switch` is unimplemented in Java too. `lower`'s `Result` and -`DiagnosticKind::NotYetSupported` exist precisely for this class of construct. - -### 3.7 Timed systems and the `DataState` clock - -`SystemState` here is a store plus cursors. Java's `DataState` additionally -carries `step` (the round index, §2), `timeStep`, `timeReal`, `timeDelta`, -`granularity`, `ctrl_granularity` and `ctrl_timeStep`, and two alternative -system implementations use them: - -- `TimedSystem` — a macro-step runs *many* micro-steps, each advancing real - time by a sampled `generateNextTime`, until the accumulated time crosses the - next granularity boundary. Sampling is decoupled from the tick. -- `DecoupledTimedSystem` — the same idea with the controller running on its own - granularity, independent of the environment's. - -Both are constructed from Java, never from a specification. - -### 3.8 Sequence and sample-set operations - -`eval::EvolutionSequence` implements `generate`, `generate_up_to`, -`generate_next`, `apply` (perturbation) and the Wasserstein lifting. Not ported: - -- `generateUpToCond(conditions)` / `generateNextStepCond(condition)` — generate - until a `DataStateBooleanExpression` holds rather than to a fixed step count. -- `select(from, to)` — a sub-sequence view. -- `SampleSet::mean`, `replica(k)`, `applyDistribution` — used by feedback (§3.4). -- `SimulationMonitor` / `ConsoleMonitor` / `SilentMonitor` — progress reporting - during long ensemble generation, which the CLI would want for `--samples` in - the thousands. - -### 3.9 Path planning - -`stark.planning` (`RRTstar`, `RRTstar_vis`, `DefaultMap`, `Pos`, `Goal`, -`Obstacle`) is a support library for the `rover` and `turtle` examples rather -than part of the language. Listed for completeness; porting it is only worth it -if those examples are to run end to end. - ---- - -## 4. Tooling: the CLI - -`tools/stark` has `check`, `simulate` and `verify`. The Java `cli/` is an -interactive shell (`StarkScript.g4`) built around a loaded specification and a -mutable analysis configuration. The gaps worth closing, roughly in order of -value: - -- **`eval at `** — evaluate a `penalty` declaration over the - reference sequence and report the per-sample values. No equivalent exists; - `penalty` declarations are only reachable indirectly, through a `distance`. -- **`compute after at `** — report the raw - distance rather than a formula verdict. `Analysis::distance_under` already - does the work; only the subcommand is missing. -- **Step ranges.** `stepExpression` is either `at s1, s2, …` or - `from a to b every k`; `verify --step` takes a single value, so a verdict - cannot be swept over time in one run. -- **`save in "f.csv"` / `print` / `clear`** — the last result set is retained - and exportable as CSV. Nothing here persists results. -- **Listing commands** — `formulas`, `penalties`, `distances`, - `perturbations`, `info`. `check --print-symbols` covers part of this but is - not per-kind. -- **`set size|m|z|scale|seed`** — the analysis parameters, which here are - per-invocation flags on `verify`. A REPL would need them as state. -- **Shell plumbing** — `load`, `cd`, `ls`, `cwd`, `quit`. Only relevant if an - interactive mode is wanted at all; a non-interactive CLI is arguably the - better fit for this workspace and these belong in the "won't do" column. - ---- - -## 5. Improvements specific to this port - -Not gaps against Java — things this implementation should tidy up. - -- **Rename `Expression::Normal`'s `std_dev` field to `variance`.** The original - grammar names the second argument of `N[mean, ...]` `variance`. The parser - does not care, but the current name asserts a meaning the reference does not, - which will silently mislead anyone porting a Java model that specifies one or - the other explicitly. Check what `eval/expr.rs` actually does with it while - renaming. -- **Spans on perturbation, distance and formula arena nodes.** Expressions and - slots carry `Span`s; these three arenas do not, so a runtime error inside a - `distance` can only be anchored to the sub-expression, not to the distance - operator that failed. Add when the first diagnostic wants one. -- **A `const`/`param` initializer cannot call a function.** - `UntypedStarkSpecification` buckets declarations by kind, so `resolve.rs` - works in a fixed kind order — constants and parameters, then types, then - functions, then variables — rather than in source order. A function - therefore is not yet declared when a `param` initializer references it, even - when it appears first in the source. Java's `StarkModelGenerator` walks the - parse tree in source order and has no such restriction. - `abz2025_two_lanes_two_cars.stark` works around it by inlining `rss_gap`'s - formula into `INIT_SAFETY_GAP` for both orderings. Fixing this means either - preserving a linear source-order declaration list alongside the buckets, or - hoisting function declarations ahead of constants and parameters (variables - are already pre-declared for the same reason). -- **Reserved keywords cannot be used as identifiers**, including ones that read - as ordinary variable names — `distance` is the one that came up (a tractor's - distance-to-target had to become `dist_to_target`). The set is - `stark_grammar.pest`'s `KEYWORD` rule; it exists so an `ID` cannot swallow a - following declaration keyword, but could likely be narrowed with lookahead. -- **Functions return exactly one value.** Java models sometimes compute two - related outputs from one control law and return a small array; ported as two - functions that each recompute the shared intermediates (see - `agriculturalDT.stark`'s `eval_speed_zero`/`eval_steer_zero`). Tuple returns - would fix this, at the cost of diverging from the grammar. -- **Optimisation passes over the IR** — constant folding, common-subexpression - elimination, dead-slot elimination. The arena representation was chosen to - make these straightforward; nothing needs them for correctness, and they - should wait until a profile says an analysis run is expression-bound. -- **Parallelism.** `SampleSet` uses parallel streams for `evalPenaltyFunction` - and the bootstrap resampling, and ensemble generation is embarrassingly - parallel across samples. Everything here is single-threaded. This is the most - likely source of a large speedup on `verify`, and it interacts with - reproducibility: per-sample RNG streams have to be derived deterministically - from the seed rather than drawn from one shared generator. From 20bb4cfca60baaba56c4369d0d0f909487a841e6 Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Mon, 17 Aug 2026 17:20:06 +0200 Subject: [PATCH 49/50] Updated the examples --- examples/stark/engine.stark | 54 ++-- examples/stark/single_vehicle.stark | 29 +- examples/stark/two_vehicles.stark | 465 +++++++++++++++++++++++----- 3 files changed, 432 insertions(+), 116 deletions(-) diff --git a/examples/stark/engine.stark b/examples/stark/engine.stark index 9b3d63718..72eef08a0 100644 --- a/examples/stark/engine.stark +++ b/examples/stark/engine.stark @@ -18,8 +18,9 @@ param ETA_2 = 0.02; param ETA_3 = 0.05; param ETA_4 = 0.3; -/*type speed_value = LOW|HALF|FULL; -type warning_value = OK|HOT;*/ +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; @@ -32,6 +33,8 @@ variables { real temp range [MIN_TEMP, MAX_TEMP] = INITIAL_TEMP; bool cool = false; int speed = HALF; + real fn = 0.0; + int time_step = 0; } @@ -67,17 +70,15 @@ function pen_temp (real temperature1, real temperature2) { return abs(temperature1 - temperature2)/abs(MAX_TEMP - MIN_TEMP); } -function pen_wrn (int warning) { - if (warning == HOT) { - return 1.0; + +function pert_cool (real temperature1, bool cooler) { + if (temperature1 >= 99.8-COOL_ATTACK) { + return cooler; } else { - return 0.0; + return false; } } -function get_stress (real stress_value) { - return stress_value; -} component Engine{ @@ -129,11 +130,10 @@ component Engine{ } environment { - let - deltaTemp = temperatureUpdateInOneStep(cool, speed) - in { - temp' = temp + deltaTemp; - ch_temp' = ch_temp + deltaTemp; + 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; @@ -143,16 +143,17 @@ environment { if (isStressed(p1,p2,p3,p4,p5,p6) > 3) { stress' = stress + STRESS_INCR; } - } } penalty rho_temperature = pen_temp(temp,ch_temp) -penalty rho_warning = pen_wrn(ch_wrn) +penalty rho_warning = ch_wrn + +penalty rho_stress = stress -penalty rho_stress = get_stress(stress) +penalty rho_fn = fn @@ -170,11 +171,21 @@ 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 = fake_temperature^K; +perturbation it_fake_temperature = [fn <- fn]@100;fake_temperature^K; + +perturbation fake_cooling = ([cool <- pert_cool(temp,cool)]@0)^K; @@ -192,4 +203,9 @@ formula phi_6 = phi_3 && phi_4; formula phi_7 = !phi_5 || phi_6; -formula phi = \F[0,H] phi_7; \ No newline at end of file +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/single_vehicle.stark b/examples/stark/single_vehicle.stark index ff5bf7b4c..224a707d6 100644 --- a/examples/stark/single_vehicle.stark +++ b/examples/stark/single_vehicle.stark @@ -19,9 +19,6 @@ param DANGER = 1; -/*type IDSmsg = OK|DANGER;*/ - - function new_s_speed (real speed, real acc, real token) { if (token < 0.5) { return min(MAX_SPEED, max(0, speed + acc + 0.3)); @@ -137,8 +134,8 @@ environment { 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) { - s_speed' = new_s_speed(p_speed,accel,token); gap' = p_distance - (accel/2 + new_s_speed(p_speed,accel,token)) - eval_rd(new_s_speed(p_speed,accel,token)); } } @@ -147,16 +144,6 @@ environment { penalty rho_crash = crash_probability(p_distance) -penalty physical_dist = p_distance - -penalty sensed_speed = s_speed - -penalty physical_speed = p_speed - -penalty rho_token = token - -penalty rho_offset = offset_speed - distance exp_crash = \G[250,300] < rho_crash; @@ -169,9 +156,10 @@ perturbation p_slow_02 = [s_speed <- slow_speed(s_speed,offset_speed), 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); + 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; @@ -189,7 +177,6 @@ perturbation p_ItSlow_05 = ([offset_speed <- p_speed * MAX_OFFSET_05 * R[0,1]]@0 - formula phi_slow_02 = \D[exp_crash,p_ItSlow_02] <= ETA_slow; formula phi_slow_03 = \D[exp_crash,p_ItSlow_03] <= ETA_slow; @@ -198,10 +185,12 @@ 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] \D[exp_crash,p_ItSlow_02] <= 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_03 = \G[0,H] \D[exp_crash,p_ItSlow_03] <= ETA_slow; +formula always_slow_05 = \G[0,H] phi_slow_05; -formula always_slow_04 = \G[0,H] \D[exp_crash,p_ItSlow_04] <= ETA_slow; -formula always_slow_05 = \G[0,H] \D[exp_crash,p_ItSlow_05] <= ETA_slow; \ No newline at end of file diff --git a/examples/stark/two_vehicles.stark b/examples/stark/two_vehicles.stark index ae1488c72..97153fa91 100644 --- a/examples/stark/two_vehicles.stark +++ b/examples/stark/two_vehicles.stark @@ -1,110 +1,421 @@ -param A = 0.25; -param B = 2.0; -param N = 0.0; -param TIMER = 1; -param INIT_SPEED = 25.0; +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 INIT_DISTANCE = 10000.0; -param H = 350; +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 eval_bd(real speed) { - return (speed^2 + (A + B) * (A * TIMER^2 + 2 * speed * TIMER)) / (2 * B); + +function new_timer(int timer){ + return timer - 1; } -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 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 new_s_speed (real speed, real acc, real token) { - if (token < 0.5) { - return new_speed(speed, acc) + R[0,0.5]; + +function effectOf(real v) { + if (v == ACCELERATION) { + return 1.0; } else { - return new_speed(speed, acc) - R[0,0.5]; + if (v == NEUTRAL) { + return 0.0; + } else { + return -1.0; + } } } -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); +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 vehicle { + +component Vehicle1 { variables{ - real s_speed range [0,MAX_SPEED] = 25.0; - real accel range [-B,A]= N; - int timer_V range [0,TIMER] = 0; + 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 { - if (s_speed > 0) { - if (gap > 0) { - accel' = A; - timer_V' = TIMER; - step Accelerate; + 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' = - B; - timer_V' = TIMER; - step Decelerate; + accel_V1' = - BRAKE; + timer_V1' = TIMER_INIT; + brake_light_V1' = 1; + step Decelerate_V1; } } else { - if (gap > 0) { - accel' = A; - timer_V' = TIMER; - step Accelerate; + 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 { - accel' = N; - timer_V' = TIMER; - step Stop; + timer_V1' = TIMER_INIT; + step Stop_V1; } } } - state Accelerate { - if (timer_V > 0) { - step Accelerate; + 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 { - exec Ctrl; + warning_V1' = OK; + step IDS_V1; } } - state Decelerate { - if (timer_V > 0) { - step Decelerate; + } + 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 { - exec Ctrl; + accel_V2' = NEUTRAL; + timer_V2' = TIMER_INIT; + step Stop_V2; } } - state Stop { - if (timer_V > 0) { - step Stop; + state Accelerate_V2 { + if (timer_V2 > 0) { + step Accelerate_V2; } else { - timer_V' = TIMER; - step Stop; + exec Ctrl_V2; } } - } - 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); + 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; + } } } -} \ No newline at end of file + 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; + From 00f1ffa9c83c9f6a8698d01a7b14be3850e73db1 Mon Sep 17 00:00:00 2001 From: Maurice Laveaux Date: Mon, 17 Aug 2026 18:52:43 +0200 Subject: [PATCH 50/50] Remove an accidently committed submodule --- tools/mcrl2/crates/mCRL2-sys | 1 - 1 file changed, 1 deletion(-) delete mode 160000 tools/mcrl2/crates/mCRL2-sys diff --git a/tools/mcrl2/crates/mCRL2-sys b/tools/mcrl2/crates/mCRL2-sys deleted file mode 160000 index b0cedc4aa..000000000 --- a/tools/mcrl2/crates/mCRL2-sys +++ /dev/null @@ -1 +0,0 @@ -Subproject commit b0cedc4aa4317f69259d27409dae6af17a33f303