Skip to content

scorecard 9/9: arrays, laplace_nd, genvar/bus nodes, events, and indirect branch assignments (#80)#16

Open
Kreijstal wants to merge 19 commits into
mobfrom
scorecard
Open

scorecard 9/9: arrays, laplace_nd, genvar/bus nodes, events, and indirect branch assignments (#80)#16
Kreijstal wants to merge 19 commits into
mobfrom
scorecard

Conversation

@Kreijstal

Copy link
Copy Markdown
Contributor

This branch extends OpenVAF's Verilog-A frontend to compile and correctly simulate a set of language features it previously rejected, taking the "Writing Verilog-A Code" SimetriX scorecard to 9/9, and additionally implements indirect branch assignments (issue #80).

Highlights

Indirect branch assignments — V(out) : f(...) == 0 (issue #80)

The canonical ideal op-amp from the VAMS LRM (V(out):V(pin,nin) == 0;) previously errored with unexpected token ':'. Now parsed and lowered onto the existing implicit-equation/DAE machinery: a fresh implicit unknown drives the target branch (voltage- or current-form) while an auxiliary equation pins the constraint lhs - rhs == 0. Verified end-to-end in VACASK on an inverting amplifier (out = -R2/R1·vin, inverting input held at virtual ground).

Arrays

  • Const-eval of array dimensions (real x[a:b]Type::Array).
  • Element read/write lowering for both constant and runtime indices (mux/select over fixed-size scalar places — no MIR memory model).
  • Array literals and index expressions plumbed through grammar → HIR.

laplace_nd

State-space (controllable-canonical) realization reusing the idt/implicit-equation DAE machinery — Butterworth filter compiles and simulates. Integer coefficient literals widened to real.

genvar + bus/vectored nodes

Compile-time loop unrolling for genvar for loops and bus nodes expanded to scalar nodes — RC ladder compiles.

Digital / event features

  • @(cross) and @(timer) events; @(cross) state retention across timesteps (prev/next latch); real-typed and array variables in retained cross state.
  • transition(): continuous slew-limited (Level-2) realization, accepts real first argument.
  • Ranged/vector port declarations (input [0:3] in).

Tooling / robustness

  • Standalone Verilog-A runner (openvaf-r --run) that executes the analog body so @(initial_step) $strobe prints.
  • Parser no longer panics on unsupported module-port syntax (graceful diagnostic).
  • Crash-report URLs point at the maintained fork.

Verification

Full workspace test suite green; new behavioral regression tests for arrays, @(cross) retention, and the indirect op-amp; real circuit solves checked in VACASK.

Kreijstal added 18 commits June 24, 2026 09:18
Adds an imperative execution lane separate from the OSDI/DAE compiler: a
module's module-scope `initial`/`final` procedural blocks are lowered to MIR
and interpreted with mir_interpret, with host implementations of the output
and control system tasks. No LLVM, linking, or simulator is involved.

  module hello_world;
      initial begin
          $display("Hello, Verilog-A world!");
      end
  endmodule
  $ openvaf-r --run hello_world.va
  Hello, Verilog-A world!

Pipeline:
- Grammar: new ProceduralBlock node ('initial'|'final') in veriloga.ungram +
  a new 'final' keyword; regenerated AST/token tables; parser arm in
  grammar/items/module.rs.
- HIR: ModuleBodyKind enum (Analog/AnalogInitial/Procedural) replaces the
  initial:bool on DefWithBodyId::ModuleId; procedural body collection in
  hir_def/body.rs; Module::procedural_block(); BodyCtx::ProceduralBlock
  validation (allows var refs, forbids contributions/analog operators).
- Lowering: MirBuilder::with_procedural() lowers all initial-then-final
  blocks into one self-contained MIR function (no DAE/outputs).
- Runner: openvaf::run() wires CompilationDB -> MirBuilder -> mir_interpret
  with a callback table (Print -> stdout/stderr with a printf-style renderer,
  SetRetFlag -> process exit code). mir_interpret gains an early-exit signal
  so $finish/$fatal can stop the run. New --run CLI flag.

Verified: hello-world prints; format args (%d/%g), initial/final ordering,
$finish (exit 0, early-stop) and $fatal (stderr, exit 1) all work; the
analog->OSDI path is unchanged; full workspace tests 354 passed.

v1 limitations: $random isn't lowered by the front-end; module `parameter`
defaults aren't evaluated (treated as 0); engineering %r formatting is
best-effort.
The canonical VerilogA 'hello world' puts $strobe inside the analog block
guarded by @(initial_step):

  module hello_world;
    analog begin @(initial_step) $strobe("Hello World!"); end
  endmodule

@(initial_step) is currently lowered unconditionally (stmt.rs EventControl),
so the $strobe is always present in the analog MIR. Have `openvaf-r --run`
lower and interpret the analog body (then the procedural initial/final body),
sharing one callback table via interpret_body(). A $finish/$fatal in the
analog body stops before the procedural body runs.

Now both the analog @(initial_step) form and the standalone `initial` form
print under --run; device models with ports/contributions also run (the
contributions are harmless writes). Known limitation unchanged: module
`parameter` defaults are not evaluated (read as 0).
…enVAF)

The unsupported-function note and the crash-report message linked to the
unmaintained upstream github.com/pascalkuthe/openvaf; update both to the
active fork.
Makes the SimetriX digital AND-gate example compile and run.

- Parser: `@(...)` now accepts a monitored-event call expression
  (`@(cross(expr,dir,tol))`, `@(timer(...))`) in addition to
  initial_step/final_step. The HIR already degrades an unrecognised event to
  running its guarded body (collect_event_stmt), matching the existing
  EventControl behaviour.
- transition(): removed from the UNSUPPORTED builtin list so its existing stub
  lowering is used; the stub now casts its Integer first argument to Real to
  match the operator's Real return type (fixes an LLVM type mismatch when the
  result is contributed to a real branch).

Semantics (level 1): the event condition is not used for scheduling — the
guarded body is always evaluated — and transition() is instantaneous
(delay/rise/fall ignored). True time-domain event detection and transition
filtering would require simulator-side bound-step/event support.

Verified: the AND gate compiles to OSDI and runs via --run with correct
combinational logic; full workspace tests 354 passed.
module_ports() called port_decl() whenever a port name wasn't found, but
port_decl() asserts a direction keyword is present (bump_ts(DIRECTION_TS)).
Input like the vectored-node port `module m(inode[0], inode[n])` (not yet
supported) hit neither branch and panicked the parser; the recovery path
could also leave a childless MODULE_PORT that later panicked ModulePort::kind().

Only take the port_decl path when actually at a direction/attribute; otherwise
abandon the empty port node, emit an 'expected port name or direction' error
and recover. Vectored-node models now fail with diagnostics instead of crashing.
Verified: directioned and directionless ports still parse; full tests 354 passed.
First increment toward supporting the Butterworth (#8) and RC-ladder (#9)
SimetriX examples. Adds the syntactic + HIR scaffolding for array/bus
indexing; semantic lowering (const-eval, array places, laplace_nd) follows.

- Grammar: IndexExpr (base[index]) and a Dimension node for array-variable
  declarations (real x[a:b]); re-enable array literals and accept the plain
  Verilog-A {..} spelling in addition to '{..}. Regenerated AST/tokens.
- Parser: postfix [index] on path expressions; optional [msb:lsb] dimension on
  var declarations; working array_expr.
- AST helpers: IndexExpr::base/index and Dimension::msb/lsb (expr_ext.rs),
  since the generator can't disambiguate same-type children.
- HIR: Expr::Index{base,index} threaded through hir_def (collect, walk, pretty)
  and hir_ty inference (array element type).

Butterworth's den[order:0] / den[k] / {1.0} now parse; remaining errors are
semantic (array-element lvalue + laplace_nd lowering), the next increment.
Full workspace tests still 354 passed (core parsing/inference unaffected).
Adds a best-effort compile-time integer const-evaluator (literals, +-*/
arithmetic, and parameter references resolved against the enclosing module)
and uses it to size array-variable declarations: real den[order:0] now
lowers to Type::Array{Real, order+1}. Regression-safe (no existing model uses
array dimensions); workspace builds and the 7 working examples still compile.

Next increment: array element read/write lowering (Expr::Index through the
hir layer + hir_lower element places, const index then runtime mux), then
laplace_nd.
… fixes

Arrays are now functional end to end. A fixed-size array variable lowers to
one MIR place per element (PlaceKind::VarElement); reads/writes with a
constant index hit the element directly, and runtime indices use an N-way
select/mux (reads) or per-element conditional writes, staying in pure SSA.

Threaded through the layers: hir_ty AssignDst::VarElement (array element is a
valid lvalue), hir Expr::Index + AssignmentLhs::ArrayElement, hir_lower
PlaceKind::VarElement + lower_index/assign_array_element, and the array-var
default desugar.

Fixes two pre-existing latent bugs that only triggered once arrays became
usable:
- Type::base_type() looped forever (matched on  instead of the running
  ).
- inference panicked rendering the array var's scalar default (checked against
  the array type instead of the element type).

Verified via --run: const-index r/w, runtime-index r/w in a loop, and
compile-time param-sized arrays (real den[order:0]) all produce correct
results.
Implements laplace_nd(input, num, den) as a controllable-canonical-form state
space using den.len()-1 integrator states (implicit equations), reusing the
existing idt/DAE machinery (no simulator changes). Coefficients may be runtime
values (read from array variables/literals), so the const-arg validation is
relaxed for laplace_nd and it is moved from UNSUPPORTED to a supported analog
operator.

Also makes array literals type as arrays (infere_array now returns
Type::Array, not the element type) and lets an array *variable* satisfy the
ArrayAnyLength requirement (Ty::Var(Array) coerces to a value), which the
laplace coefficient arguments need.

The SimetriX Butterworth example (#8) — array vars real den[order:0], the
{1.0} array literal, runtime den[k] writes, and laplace_nd — now compiles to a
working OSDI device model. Scorecard: 8/9.
Phase 2 of the SimetriX "Writing Verilog-A Code" scorecard. Implements the
features the RC ladder (#9) needs, completing all 9 examples:

- genvar declarations and compile-time `for`-loop unrolling. A `for` loop whose
  control variable is a genvar with constant bounds is unrolled in HIR body
  lowering into a flat block of body copies, with the genvar folded to its
  per-iteration constant value.

- Vectored/bus nets `electrical [0:n] inode;` expand at item-tree lowering into
  n+1 ordinary scalar nodes named `inode[0]`..`inode[n]` (sized via the existing
  compile-time const-evaluator). The node model stays scalar internally.

- Bus port references `module rc_ladder(inode[0], inode[n])` in the module header
  resolve their constant index and mark the matching expanded scalar node a port.

- Bus element access `inode[i]` (constant index after unrolling) rewrites to a
  path to the expanded scalar node `inode[<k>]`, so V()/I()/ddt() work unchanged.

Grammar: new `genvar` keyword + GENVAR_DECL, PORT_REF node, optional Dimension on
NetDecl; regenerated AST/tokens.

Verified: rc_ladder (n=16) compiles to a 34KB OSDI with 17 nodes (2 ports); a
3-stage variant produces exactly 3 series resistors + 3 shunt capacitors with a
correct symmetric Jacobian. All 9 scorecard examples compile. Full compiler test
suite green (216 passed, 0 failed), no snapshot drift.
The Level-1 `transition()` returned the target value instantaneously, producing
a time discontinuity that aborts transient analysis ("timestep too small") the
moment an input crosses a threshold -- so event-driven mixed-signal models (gates,
comparators) compiled and loaded but could not be simulated.

Realize `transition(expr, delay, rise, fall)` as a first-order lag (slew-limited
follower) via an implicit equation: dx/dt = (target - x)/tau, with tau = rise when
the target is increasing and fall when decreasing (floored to avoid /0). The output
is continuous, so the transient integrator steps through switching events cleanly,
at the requested transition speed. The `no_equations` path (AC/noise/op-var setup)
still passes the target through directly.

Verified in VACASK: the SimetriX AND gate now simulates a full transient -- output
tracks A AND B, toggling with smooth continuous edges and no abort (previously
"timestep too small" at the first threshold crossing). Scorecard still 9/9
compile+boot (the AND gate gains one transition state node); compiler suite green
(190 passed, 0 failed).

Note: sequential event logic (e.g. Schmitt-trigger hysteresis) now also runs without
aborting, but correct hysteresis additionally needs true edge-triggered @(cross)
with cross-timestep state retention, which is not yet implemented (an integer set in
a @(cross) handler does not persist across timesteps, so such models behave as a
plain comparator). That is the remaining Level-2 @(cross) piece.
…ext_state

`@(cross)` event handlers were lowered unconditionally with no memory, so a
variable they assign could not hold its value between events — hysteretic /
sequential mixed-signal models (Schmitt trigger, comparators with memory)
compiled but behaved as plain comparators.

Give each variable assigned inside an `@(cross)` handler a retained state backed
by the OSDI `prev_state`/`next_state` arrays (the same per-instance, cross-timestep
store the limiting machinery uses):

- Preserve `@(cross)`/`@(timer)` as an `Event::Cross` `EventControl` in HIR
  (previously the monitored-event body was inlined and the event discarded).
- In MIR lowering, detect the variables such handlers assign; for each, allocate a
  retained limit-state slot, initialise the variable's place from its previous
  accepted value (`PrevState`), and store the final value back (`StoreLimit`) at the
  end of the analog block. The slot reuses all existing OSDI state codegen
  (`num_states`, `state_idx`, `store_lim`) but is keyed on a synthetic constant and
  marked retained, so the limit-specific derivative/value passes (1 in hir_lower,
  the limit-rhs pass in sim_back) skip it.
- An `@(initial_step)` assignment to a retained variable becomes its initial value
  (loaded from the state) instead of a per-evaluation reset; non-retained
  `@(initial_step)` work (e.g. Butterworth's filter coefficients) is unchanged.
- `StoreLimit` is now marked side-effecting: it writes `next_state`, so it must not
  be eliminated when the stored value is otherwise unused (retained state). The
  limit path already used the return value, so its behaviour is unchanged (only the
  callback's `const`-ness in MIR dumps differs; snapshots updated).

Verified in VACASK: the Schmitt trigger now shows true hysteresis — output switches
HIGH as V(in) rises past vhi (0.6) and LOW only as it falls past vlo (0.4), holding
in the dead-band across timesteps (previously it toggled at 0.6 both ways). The AND
gate still computes A&B, Butterworth AC is still exact (0.0000 dB), the full
scorecard is 9/9 compile+boot, and the compiler suite is green (190 passed).
The Verilog-A LRM allows transition()'s input expression to be real, not
just integer (e.g. SimetriX/issue #71's inverter that smooths a real
'target' between logic levels). The builtin signature typed the first
argument as Integer, rejecting real-valued targets.

Type all TRANSITION overloads' first argument as Real; integer/bool
arguments still coerce via the normal widening path in lower_expr, so the
manual Integer->Real cast in the transition lowering is removed (it would
otherwise double-cast a coerced integer arg and emit an ifcast on a real).

Verified: issue #71's inverter_va compiles and runs in VACASK with a smooth
slew-limited output (no discontinuity); the integer-arg Schmitt/AND models
still compile; full suite green.
The recently added features (fixed-size arrays, laplace_nd, genvar/bus nodes,
transition, @(cross) state retention) had no in-repo coverage -- only manual
VACASK checks -- so a refactor could silently break them.

Add two behavioral tests built on the existing in-process OSDI harness
(compile -> dlopen -> MockSimulation), which asserts loaded DAE residuals and
Jacobian without needing an external simulator:

- arrays.va / test_arrays: array declaration, constant- and runtime-index
  read/write all assemble one conductance (G=21); the loaded residual and
  Jacobian must match exactly, proving the array lowering computes correctly.

- cross_latch.va / test_cross_latch: a @(cross) latch whose state is assigned
  only inside cross handlers. Using the harness's prev/next state swap
  (next_iter) to advance timesteps, it drives high/dead-band/low and checks the
  output is retained across steps (-1,-1,0,0,-1) -- exercising the
  prev_state/next_state retention mechanism end to end. The descriptor snapshot
  records '1 states' for the retained slot.

Both run in-process under the existing integration harness; full suite green.
…r panic)

Following the Verilog-AMS LRM 2.4 analog-operator examples through OpenVAF: the
LRM's own laplace_nd example writes coefficients as bare integer literals
(H(s)=s/(s^2-1) is 'laplace_nd(V(in), '{0,1}, '{-1,0,1})'). Those lower to
integer MIR values, but the controllable-canonical state-space realization does
real arithmetic on them (fmul/fsub against the float integrator states), so the
optimizer hit 'fsub Float, Int' and the compiler panicked with an internal
error. Our Butterworth test only ever used real coefficients ('{1.0,...}'), so
this never surfaced.

Widen each coefficient to real in array_coeffs (covering anonymous array
literals, array variables via their element type, and the scalar fallback).

Regression test laplace_nd_int.va: a stable first-order low-pass with integer
coefficients '{1},'{1,1} -- compiling+loading its descriptor would have panicked
before this fix. Full suite green.
The Verilog-AMS LRM declares vectored ports with a range on the direction/
discipline declaration -- e.g. the transition() QAM and A/D examples use
'input [0:3] in; voltage [0:3] in;'. We supported ranged *net* declarations
(bus nodes, from the RC-ladder work) but not ranged *port* declarations, so the
LRM examples failed to parse at 'unexpected token [['.

- Grammar/parser: add an optional Dimension to PortDecl (mirrors NetDecl);
  regenerate the AST (PortDecl now exposes dimension()).
- HIR: lower_port_decl expands a ranged port into one scalar port/node per index
  (in[0]..in[3]), exactly as bus nets expand.
- Head/body reconciliation: a port is commonly listed bare in the module head
  ('module m(in, out);') and ranged only in the body ('input [0:3] in;'). Added
  collect_bus_ranges to pre-scan the body, so a bare head name expands to the
  same in[0]..in[3] scalar nodes the body decl produces and the two reconcile by
  name.

Tests (in-process compile -> dlopen -> MockSimulation):
- vector_ports.va: bare-head + body-ranged bus input summed with distinct
  weights; descriptor shows 5 terminals in[0..3]/out and the residual asserts
  -(1+2+3+4) = -10, proving each bit is a distinct, correctly-indexed node.
- qam16.va: the LRM transition() Example 1, compile+load guard.

Full suite green. (The LRM A/D-converter example additionally needs retained
@(cross) *array* state, a separate gap, so it is not yet covered.)
Retained @(cross) state previously only worked for integer scalars (the Schmitt
latch). The LRM A/D-converter example exposed two gaps:

1. Real-typed retained scalars panicked: the init/store path always emitted an
   integer<->real cast, so a real variable hit insert_cast(Real -> Real) ->
   unreachable. (The ADC's sample/thresh are real.)
2. Array retained variables were unhandled: a single state slot and a Var place
   were allocated for the whole array instead of one slot/VarElement place per
   element.

Rework the retained-state machinery (hir_lower) to allocate one retained slot
per scalar, or one per element for arrays, and to widen to/from real only when
the element type isn't already real. retained_states now maps a variable to its
vector of per-element states.

Also fix the test harness: MockSimulation never initialized the per-instance
state_idx map (logical limit-state -> physical state slot). With one state the
default 0 works, but several retained states all aliased slot 0. A real
simulator assigns these; the mock now writes the identity mapping too.

Tests:
- cross_array.va / test_cross_array: two array elements assigned in @(cross)
  retain independently across timesteps (-1,-2 / retained / 0,0 / retained /
  flips back).
- adc.va / test_adc: the LRM transition() Example 2 (8-bit SAR ADC) -- vector
  ports + genvar unroll + retained @(cross) array + transition together.

Verified in real VACASK: the ADC quantizes V(in)=0.7 to 10110011 = 0.6992 (a
mix of bits, so the 8 retained states do not collide). Full suite green.
Implements Verilog-AMS indirect branch assignments (issue #80). The
canonical ideal op-amp

    V(out) : V(pin,nin) == 0;

now parses and lowers correctly instead of erroring on the ':' operator.

The construct "solve the source value of branch `out` so the constraint
holds" maps directly onto the existing implicit-equation/DAE machinery
(the same path behind idt/laplace_nd): a fresh implicit unknown drives the
target branch as a voltage (V form) or current (I form) source, and an
auxiliary equation whose residual is `lhs - rhs` of the `==` pins that
unknown so the constraint is zero at the solution.

Threaded through the whole front end:
  - parser: `:` accepted as a third assignment operator (the lval expr is
    fully parsed first, so a trailing `:` is unambiguous vs `?:`).
  - AST: AssignOp::Indirect.
  - hir_ty: the rhs is inferred as its own constraint equation (not coerced
    to the branch type); indirect requires a branch destination like a
    contribution, with diagnostics for misuse and the in-event/function
    restriction.
  - hir: ContributeKind::IndirectFlow / IndirectPotential.
  - hir_lower: ImplicitEquationKind::IndirectBranch; the ordinary
    contribution path is refactored to lower the rhs at the exact original
    point via a closure, so normal contributions keep byte-identical MIR.

Verified end-to-end in VACASK: an inverting amplifier (R1=1k, R2=2k, vin=0.5)
holds the inverting input at virtual ground (0 V) and solves out = -1.0 V
(= -R2/R1 * vin), with the implicit unknown resolving to the output voltage.
Adds opamp_indirect.va + a behavioral integration test asserting the
constraint residual (V(pin)-V(nin)) and its Jacobian. Full suite green.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces several major features to OpenVAF, including support for fixed-size arrays, @(cross) state retention across timesteps, indirect branch assignments, compile-time genvar loop unrolling, and a standalone Verilog-A runner. Feedback on these changes highlights several critical issues: a mathematical bug in lower_laplace_nd that ignores the last coefficient and lacks proper direct feedthrough support; potential compile-time panics during division operations in constant evaluation; silent clamping of out-of-bounds constant array indices during reads and writes; and silent truncation of unrolled loops when exceeding iteration limits.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +953 to +961
// y = Σ_k num[k] x_k.
let mut out = F_ZERO;
for (k, &nk) in num.iter().enumerate() {
if k < n {
let term = self.ctx.ins().fmul(nk, states[k].1);
out = self.ctx.ins().fadd(out, term);
}
}
out

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current implementation of lower_laplace_nd completely ignores the last coefficient num[n] (where n = den.len() - 1) because of the loop condition if k < n. Furthermore, for proper (but not strictly proper) transfer functions where num.len() == den.len(), the output formula y = Σ_k num[k] x_k is mathematically incorrect because it lacks the direct feedthrough term and does not adjust the state coefficients. This leads to completely incorrect simulation results for many standard filters (e.g., high-pass, band-stop, all-pass).

To fix this, we should implement the controllable canonical form with direct feedthrough support by checking if num.len() > n and adjusting the output and state coefficients accordingly.

        // y = b_n * input + Σ_{k<n} (num[k] - b_n * den[k]) * x_k.
        let mut out = F_ZERO;
        let has_bn = num.len() > n;
        let b_n = if has_bn {
            let bn_val = self.ctx.ins().fdiv(num[n], den[n]);
            out = self.ctx.ins().fmul(bn_val, input);
            Some(bn_val)
        } else {
            None
        };
        for k in 0..n {
            let nk = if k < num.len() { num[k] } else { F_ZERO };
            let coef = if let Some(bn_val) = b_n {
                let term = self.ctx.ins().fmul(bn_val, den[k]);
                self.ctx.ins().fsub(nk, term)
            } else {
                nk
            };
            let term = self.ctx.ins().fmul(coef, states[k].1);
            out = self.ctx.ins().fadd(out, term);
        }
        out

Comment on lines +279 to +280
BinaryOp::Division if r != 0 => Some(l / r),
BinaryOp::Remainder if r != 0 => Some(l % r),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Division and remainder operations (/ and %) can panic at compile time with an overflow if l is i64::MIN and r is -1. This causes the compiler to crash on invalid/overflowing inputs. Use checked_div and checked_rem to safely handle division and remainder without panicking.

Suggested change
BinaryOp::Division if r != 0 => Some(l / r),
BinaryOp::Remainder if r != 0 => Some(l % r),
BinaryOp::Division => l.checked_div(r),
BinaryOp::Remainder => l.checked_rem(r),

BinaryOp::Addition => Some(l.wrapping_add(r)),
BinaryOp::Subtraction => Some(l.wrapping_sub(r)),
BinaryOp::Multiplication => Some(l.wrapping_mul(r)),
BinaryOp::Division if r != 0 => Some(l / r),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Division operation (/) can panic at compile time with an overflow if l is i64::MIN and r is -1. Use checked_div to safely handle division without panicking.

Suggested change
BinaryOp::Division if r != 0 => Some(l / r),
BinaryOp::Division => l.checked_div(r),

Comment on lines +285 to +288
if let Some(c) = self.body.as_literalint(&index) {
let c = (c.max(0) as u32).min(len - 1);
return self.ctx.use_place(PlaceKind::VarElement(var, c));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If a constant array index is out of bounds (e.g., < 0 or >= len), the code silently clamps it to 0 or len - 1 and reads that element. This can hide bugs and lead to unexpected behavior. It should instead return F_ZERO (the default for invalid reads).

        if let Some(c) = self.body.as_literalint(&index) {
            if c >= 0 && c < len as i32 {
                return self.ctx.use_place(PlaceKind::VarElement(var, c as u32));
            } else {
                return F_ZERO;
            }
        }

Comment on lines +167 to +171
if let Some(c) = self.body.as_literalint(&index) {
let c = (c.max(0) as u32).min(len - 1);
self.ctx.def_place(PlaceKind::VarElement(var, c), val);
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If a constant array index is out of bounds (e.g., < 0 or >= len), the code silently clamps it to 0 or len - 1 and writes to that element. This is extremely dangerous as it silently overwrites valid array elements (e.g., writing to g[3] on a 3-element array will silently overwrite g[2]). Check if the constant index is within bounds, and skip the assignment if it is out of bounds.

Suggested change
if let Some(c) = self.body.as_literalint(&index) {
let c = (c.max(0) as u32).min(len - 1);
self.ctx.def_place(PlaceKind::VarElement(var, c), val);
return;
}
if let Some(c) = self.body.as_literalint(&index) {
if c >= 0 && c < len as i32 {
self.ctx.def_place(PlaceKind::VarElement(var, c as u32), val);
}
return;
}

Comment on lines +413 to +415
if guard > 1_000_000 {
break;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If the loop unrolling guard exceeds 1,000,000 iterations, the loop is silently broken out of and a partially unrolled block is returned. This can lead to silent correctness bugs where the compiled model behaves incorrectly without any warning or error. Return None (falling back to a runtime loop or failing gracefully) instead of silently truncating the loop.

Suggested change
if guard > 1_000_000 {
break;
}
if guard > 1_000_000 {
return None;
}

The non-contributes branch of compute_outputs unwrapped each output value
with unwrap_unchecked(), which in release mode returns the reserved
sentinel Value(u32::MAX) for a None PackedOption instead of panicking.
Feeding that sentinel into output_values.insert() computes a word index of
u32::MAX / 64 = 67108863, far past the few-word bitset, causing:

    index out of bounds: the len is 3 but the index is 67108863
    at lib/bitset/src/lib.rs:133 (BitSet::insert)
    via sim_back::context::Context::compute_outputs

A BoundStep/CollapseImplicitEquation/op-var output can legitimately have a
None value when it gets eliminated (e.g. the $bound_step value in the
Skywater sky130 ReRAM model under release-mode DCE). The contributes
branch already guards against this with filter_map(PackedOption::expand);
mirror that here by expanding and skipping None.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant