feat(mir): implement clean_counter via faint-variable analysis - #2878
Conversation
Closes the M3 deferral in issue hew-lang#2178. `clean_counter` flags a loop-carried counter/accumulator whose value never reaches an observable. Liveness alone cannot do this: a dead accumulator and a legitimate `for i in 0..n` index are both live-in-loop / dead-at-exit. `hew-mir/src/faint.rs` adds the faint-variable (strong-liveness) pass that separates them --- the index feeds the header's comparison into a Branch, the accumulator only feeds itself. The three obstacles the issue names: 1. Shape recovery: `recover_counter_shape` matches the real lowering (a writeback `Move { dest: c, src: t }` whose temp is defined earlier in the same block by pure arithmetic reading `c`), not a single self-update instr. 2. Faint-variable analysis: terminator source operands (including branch conditions), reads by impure instructions, and parameters seed the observable set; only pure single-dest instrs contribute propagation edges. The strongly-live set is the transitive closure, so it over-approximates --- the safe direction for a removal lint. 3. Checked arithmetic NARROWS the lint rather than being worked around. An integer counter's overflow flag feeds a trap branch, so its value decides whether the program traps; it is genuinely strongly live and is never removable. `IntArithChecked` is therefore absent from the purity allowlist and the lint is scoped to non-trapping float accumulation, where removal is provably semantics-preserving. `clean_counter` is now registered in `LintId`, so -D/-W/-A and inline allow directives are real rather than no-ops; the prior test pinning its unregistered fail-closed state is replaced. Tests: unit tests for the purity allowlist and scalar scoping; MIR-level tests for the discriminating pair (two identical float accumulators, only the unobserved one fires) and for the integer overflow-flag chain; e2e tests for -D firing, -A suppression, and the integer soundness guard under -D.
|
Reviewed against post-rc1 main ( Blocking: this does not compile against current main. Green CI here is misleading — the branch predates rc1's change to
pub(crate) fn instr_reads_writes(instr: &Instr) -> (Vec<Place>, Vec<Place>, Vec<Place>)
Soundness of the float-only scoping: holds. I tried to construct a false positive in the classes that would matter for a lint telling users to delete code — FFI and runtime exposure, globals, actor state, aliasing, side-effecting drop — and could not. Each routes through a non-pure instruction or a terminator operand, which seeds the source local observable. Floats have no drop behaviour. The unknown-instruction path yields silence rather than a finding, so the error direction is false negatives (e.g. Ordering with #2873. Land #2873 first. Its body carries a time-qualified scope correction asserting |
…ture
v0.6.0-rc1 changed `instr_reads_writes` to return a 3-tuple
`(reads, writes, interior_writes)`. `faint.rs` destructured it as a pair
at both call sites, so this branch did not compile against main:
error[E0308]: mismatched types
--> hew-mir/src/faint.rs:190:17
expected a tuple with 3 elements, found one with 2 elements
GitHub reported the branch MERGEABLE/CLEAN and CI was green because the
branch and rc1 never touch the same lines; the break only appears once
the two are combined.
Handle the third element rather than discarding it. An interior write
mutates through a place whose MIR slot bytes do not change (BytesAppend
rewriting its receiver buffer, Drop on a variant place) and never appears
in `writes`, which is exactly how a counter can be observed without the
analysis seeing it:
- seed interior targets as observable in collect_seeds_and_edges
- refuse to classify an accumulate step whose counter or temp is
interior-written
Both are redundant today, since every interior-writing instruction is
impure and its reads are already seeded through the pure_single_dest ==
None path. They are kept explicit because that is a property of the
current `is_pure_value_instr` allowlist rather than an invariant of the
IR: adding an interior-writing instruction to that allowlist would
otherwise let the lint call a counter dead while it is still mutated
through an alias. A lint that tells users to delete code must fail toward
silence.
Also notes hew-lang#2176/hew-lang#2873 as the pending editor-surfacing work in the
run_mir_lints doc comment instead of asserting CLI-only surfacing.
cargo test -p hew-mir --test diagnostics faint: 14 passed
cargo test -p hew-cli --test lint_pass_e2e: 40 passed
cargo clippy -p hew-mir --all-targets: clean
|
Rebased onto rc1 ( GitHub reported MERGEABLE/CLEAN and CI was green because the branch and rc1 never touch the same lines; the break only exists once they are combined. I handled the third element rather than discarding it, because an interior write is precisely a way a counter gets observed without the analysis seeing it — it mutates through a place whose MIR slot bytes do not change ( Both guards are redundant today and the comment says so. Every interior-writing instruction is impure, so On the soundness argument: it holds, and it is stronger than the description claims. The trap-edge reasoning is not what closes the integer case. In Verification after rebase: Merge after #2873. The two PRs were written against each other's absence. #2873's scope-correction section asserts |
|
Thanks — this is a nice piece of work. What I appreciated most is that the checked-arithmetic problem became the scope of the lint rather than something to work around: integer counters are genuinely observable through the trap edge, and keeping I mutation-tested the soundness argument rather than taking it on trust. Deleting the faintness gate turns four tests red, and widening both the type guard and the shape allowlist to admit integers leaves the lint silent — so faintness really is the guard doing the work, and the other two are belt-and-braces. Ran the compiled Hew suite and scanned all 1863 Two small followups, neither worth holding this up. In |
Closes #2178.
Implements the
clean_counterMIR lint via faint-variable analysis: a counter that isupdated but never observed is reported as removable.
Scope correction (important)
The lint ships scoped to float accumulators, not integers. This is narrower than
#2178 implies, and the narrowing is a soundness result rather than a shortcut:
overflow flag feeds
Branch→Trap, so the counter is observable through the trapedge and removing it would change program behaviour. These correctly never fire.
removable.
IntArithCheckedis therefore deliberately kept off the purity allowlist.Discrimination, not presence
Tests are built as controls rather than assertions that the lint exists:
clean_counteris registered as a real lint, so-D/-W/-Aall work.Verification (run bare, exit codes read directly)
cargo test -p hew-mir→ RC=0cargo test -p hew-cli --test lint_pass_e2e→ 40/40, RC=0cargo clippy --workspace --all-targets→ RC=0cargo fmt --all --check→ RC=0Relationship to #2873
#2873 wires MIR lint findings into the LSP and wasm/playground surfaces keyed off
IrPipeline::lint_warnings. That plumbing is already written such thatclean_countersurfaces on both surfaces for free once this lands — the two are independent but
complementary, and this one is the substrate.