Skip to content

A correct div-by-zero fix is invisible: br_if on i32.eqz gives a disequality, which an interval cannot hold #165

Description

@avrabe

Found while closing FEAT-064 AC3 (PR #164). Reporting separately because the
consequence is consumer-facing, not just a precision number.

The behaviour

Take the canonical repair for a div-by-zero — branch away when the divisor is zero:

(func $d (param i32) (result i32) (local $r i32)
  block
    local.get 0
    i32.eqz
    br_if 0        ;; divisor == 0 -> leave, $r stays 0
    i32.const 10
    local.get 0
    i32.div_s
    local.set $r
  end
  local.get $r)

This is a genuine, semantics-preserving fix. It opens no new region and adds no
second i32.div_s, so obligation identity is untouched — measured: same site_key,
byte-identical obligation_ids before and after.

And div-by-zero still fires afterwards.

Mechanism

Not a bug in the guard recogniser — it fires correctly. try_guard_brif maps
(I32Eqz, BrIf)GuardOp::Eq with c = 0:

  • taken edge (divisor == 0) → refines to [0,0]. Correct, and useless: the
    division is on the other edge.
  • fall-through edge licenses divisor != 0refine_interval returns the
    interval unchanged, and states the reason in the code itself:
(GuardOp::Eq, false) | (GuardOp::Ne, true) => return iv, // ≠ c: no interval

A disequality is a hole in the middle of a range. An interval lattice cannot
represent one. The transfer is sound — it simply carries no information for exactly
the fact the fix establishes.

Why it's worth its own issue

REQ-021 gates an AI agent on scry's verdict over its own edit. Under this behaviour, an
agent that fixes the bug correctly is told the obligation still stands. The
fix-verify loop cannot converge, and the gate is actively punishing the correct repair —
a false alarm, not a missing feature.

Measured across five guard shapes

fix shape site identity fix visible?
br_if out of a pre-existing block survives (ids identical) no — disequality
select-based guard survives no — select is unsupported-op
if/else, folded (if (result i32) …) moves no — region havoc'd
if/else, statement form moves no — region havoc'd
divisor → constant survives not a fix — deletes the parameter's role

Note the if/else rows lose both identity and precision (the region is havoc'd, with
an honest UnmodeledControlFlow gap per REQ-017). Only the br_if and select rows
keep identity — those are where a fix could become adjudicable.

Direction (FEAT-089, not yet a design)

Don't try to encode != 0 in the interval domain — the lattice genuinely cannot
hold it, and widening to disjunctions was already rejected for the bounds decision.
Instead carry a narrow predicate fact ("this local is known non-zero on this path") that
only the div/rem trap check consults — the same shape as FEAT-084's known-bits
reduction: a targeted fact feeding one decision, not a new general domain.

The soundness obligation is small and statable: the fact holds only on the fall-through
edge of a guard whose taken edge pins the local to zero, and must be invalidated by
any write to that local (local.set/local.tee, a clobbering call, a loop back-edge).
That invalidation is the first thing a clean-room should attack.

Also: signed-overflow at the same site must be unaffected!= 0 says nothing
about INT_MIN / -1, and both obligations sit at that one site.

Modelling select is a separate bounded slice; it's the shape where identity is already
free.

Not claimed

How common any of these shapes is in real fixes. Five hand-written fixtures, zero
observed repairs — this establishes which shapes behave how, not their prevalence.
The corpus in #126 could answer prevalence and hasn't been asked.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions