Skip to content

Commit cab07e2

Browse files
authored
Single-pass body analysis with AllocMap coherence checks (#121)
## What's this about? So, #120 fixed the immediate alloc-id mismatch by carrying collected items forward instead of re-fetching them. That was the right call. But it left the underlying structure intact: three separate phases (mk_item, collect_unevaluated_constant_items, collect_interned_values), each with full access to `TyCtxt`, each free to call `inst.body()` or any other side-effecting rustc query whenever it felt like it. Nothing in the types prevented that, and the bug was a direct consequence: one phase called `inst.body()` a second time, rustc minted fresh AllocIds, and suddenly the alloc map had ids that didn't correspond to anything in the stored bodies. The question is: how do we make that class of bug structurally impossible, rather than just fixed for the one case we caught? The full decision record is in [`ADR-002`](https://github.com/cds-rs/stable-mir-json/blob/dc/declarative-spike/docs/adr/002-declarative-pipeline-with-allocmap-coherence.md). ### The restructuring The fix is to split the pipeline into phases with type signatures that enforce the boundary: ```rust collect_and_analyze_items(HashMap<String, Item>) -> (CollectedCrate, DerivedInfo) assemble_smir(CollectedCrate, DerivedInfo) -> SmirJson ``` `CollectedCrate` holds items and unevaluated consts (the output of talking to rustc). `DerivedInfo` holds calls, allocs, types, and spans (the output of walking bodies). `assemble_smir` takes both by value and does pure data transformation; it structurally cannot call `inst.body()` because it has no `MonoItem` or `Instance` to call it on. That's the whole point: if you can't reach the query, you can't accidentally call it. The two body-walking visitors (`InternedValueCollector` and `UnevaluatedConstCollector`) are merged into a single `BodyAnalyzer` that walks each body exactly once. The fixpoint loop for transitive unevaluated constant discovery is integrated: when `BodyAnalyzer` finds an unevaluated const, it records it; the outer loop creates the new `Item` (the one place `inst.body()` is called) and enqueues it. ### But what about catching regressions? Turns out the existing integration tests normalize away `alloc_id`s (via the jq filter), so they literally cannot catch this class of bug. The golden files don't contain alloc ids at all; you could scramble every id in the output and the tests would still pass. `AllocMap` replaces the bare `HashMap<AllocId, ...>` with a newtype that, under `#[cfg(debug_assertions)]`, tracks every insertion and flags duplicates. After the collect/analyze phase completes, `verify_coherence` walks every stored `Item` body with an `AllocIdCollector` visitor and asserts that each referenced `AllocId` exists in the map. This catches both "walked a stale body" (missing ids) and "walked the same body twice" (duplicate insertions) at dev time; zero cost in release builds. ### Other things that fell out of this - Static items now store their body in `MonoItemKind::MonoItemStatic` (collected once in `mk_item`), so the analysis phase never goes back to rustc for static bodies - `get_item_details` takes the pre-collected body as a parameter instead of calling `inst.body()` independently - The `items_clone` full HashMap clone is replaced by a `HashSet` of original item names (which is all the static fixup actually needed) - [we uncovered and fixed a very old bug](#121 (comment)) ### What's deleted `InternedValueCollector`, `UnevaluatedConstCollector`, `collect_interned_values`, `collect_unevaluated_constant_items`, the `InternedValues` type alias, and `items_clone`. Good riddance. ### Downstream impact The tighter allocs representation has already shown positive downstream effects in KMIR: the proof engine can now decode allocations inline (resolving to concrete values like `StringVal("123")`) instead of deferring them as opaque thunks. @dkcumming 's `offset-u8` test went from thunking through `#decodeConstant(constantKindAllo...)` to directly producing `toAlloc(allocId(0)), StringVal("123")`. The test's expected output needed updating, but the new failure mode is semantically grounded in actual data rather than deferred interpretation. ### Test plan - [x] `cargo build` compiles - [x] `cargo clippy` clean - [x] `cargo fmt` clean - [x] `make integration-test` passes (all 28 tests, identical output) - [x] KMIR downstream: `test_prove_rs[offset-u8-fail]` expected output updated
1 parent 62a7917 commit cab07e2

8 files changed

Lines changed: 535 additions & 219 deletions

File tree

CHANGELOG.md

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# Changelog
2+
3+
All notable changes to this project will be documented in this file.
4+
5+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6+
7+
Note: this changelog was introduced at 0.2.0. The 0.1.0 section is a
8+
retroactive best-effort summary; earlier changes were not formally tracked.
9+
10+
## [0.2.0] - 2026-02-21
11+
12+
### Added
13+
- D2 and Mermaid graph renderers alongside existing DOT, with index-first architecture for richer allocation/type lookup in rendered output ([#111])
14+
- `--d2` flag for D2 diagram output
15+
- `MachineInfo` in JSON output: pointer width, endianness ([#80])
16+
- `Span` information in JSON output ([#74])
17+
- Type visitor collecting full type metadata: ADT fields, discriminants, layouts ([#68], [#69], [#82], [#93], [#94])
18+
- `TyKind` in `GlobalAlloc` entries ([#84])
19+
- Extract discriminant information for enums ([#62])
20+
- ADT def and field type metadata for structs and enums ([#64], [#82], [#105])
21+
- UI test infrastructure ([#66])
22+
- `cargo_stable_mir_json` helper binary for cargo integration ([#47])
23+
- Nix derivation for reproducible builds ([#96])
24+
- macOS support ([#97])
25+
26+
### Changed
27+
- Restructured `printer.rs` into a declarative 3-phase pipeline: `collect_items` -> `collect_and_analyze_items` -> `assemble_smir`, with `CollectedCrate`/`DerivedInfo` interface types enforcing the boundary between collection and assembly
28+
- Added `AllocMap` with debug-mode coherence checking (`verify_coherence`): asserts that every `AllocId` in stored bodies exists in the alloc map and that no body is walked twice; zero cost in release builds
29+
- Removed dead static-item fixup from `assemble_smir` (violated the phase boundary, misclassified statics as functions; never triggered in integration tests)
30+
- Switched from compiler build to `rustup`-managed toolchain ([#33])
31+
- Removed forked rust dependency ([#19])
32+
33+
### Fixed
34+
- Fixed early `return` in `BodyAnalyzer::visit_terminator` that skipped `super_terminator()`, causing alloc/type/span collection to miss everything inside `Call` terminators with non-`ZeroSized` function operands (const-evaluated function pointers); bug present since [`aff2dd0`](https://github.com/runtimeverification/stable-mir-json/commit/aff2dd0)
35+
- Avoided duplicate `inst.body()` calls that were reallocating `AllocId`s ([#120])
36+
- Prevented svg/png generation when `dot` is unavailable ([#117])
37+
- Removed unreachable early return in D2 legend rendering ([#118])
38+
- Included ZeroSized FnDef consts in functions map ([#112])
39+
- Support `GlobalAlloc::Function` with non-fn-ptr type ([#102])
40+
- Emitted correct `Alloc` ty for each allocation ([#100])
41+
- Normalized field types before emitting them ([#95])
42+
- Fixed monomorphisation bug for FnDef and ClosureDef ([#53])
43+
44+
[#19]: https://github.com/runtimeverification/stable-mir-json/pull/19
45+
[#33]: https://github.com/runtimeverification/stable-mir-json/pull/33
46+
[#47]: https://github.com/runtimeverification/stable-mir-json/pull/47
47+
[#53]: https://github.com/runtimeverification/stable-mir-json/pull/53
48+
[#62]: https://github.com/runtimeverification/stable-mir-json/pull/62
49+
[#64]: https://github.com/runtimeverification/stable-mir-json/pull/64
50+
[#66]: https://github.com/runtimeverification/stable-mir-json/pull/66
51+
[#68]: https://github.com/runtimeverification/stable-mir-json/pull/68
52+
[#69]: https://github.com/runtimeverification/stable-mir-json/pull/69
53+
[#74]: https://github.com/runtimeverification/stable-mir-json/pull/74
54+
[#80]: https://github.com/runtimeverification/stable-mir-json/pull/80
55+
[#82]: https://github.com/runtimeverification/stable-mir-json/pull/82
56+
[#84]: https://github.com/runtimeverification/stable-mir-json/pull/84
57+
[#93]: https://github.com/runtimeverification/stable-mir-json/pull/93
58+
[#94]: https://github.com/runtimeverification/stable-mir-json/pull/94
59+
[#95]: https://github.com/runtimeverification/stable-mir-json/pull/95
60+
[#96]: https://github.com/runtimeverification/stable-mir-json/pull/96
61+
[#97]: https://github.com/runtimeverification/stable-mir-json/pull/97
62+
[#100]: https://github.com/runtimeverification/stable-mir-json/pull/100
63+
[#102]: https://github.com/runtimeverification/stable-mir-json/pull/102
64+
[#105]: https://github.com/runtimeverification/stable-mir-json/pull/105
65+
[#111]: https://github.com/runtimeverification/stable-mir-json/pull/111
66+
[#112]: https://github.com/runtimeverification/stable-mir-json/pull/112
67+
[#117]: https://github.com/runtimeverification/stable-mir-json/pull/117
68+
[#118]: https://github.com/runtimeverification/stable-mir-json/pull/118
69+
[#120]: https://github.com/runtimeverification/stable-mir-json/pull/120
70+
71+
## [0.1.0] - 2024-11-29
72+
73+
Initial release.
74+
75+
### Added
76+
- Compiler driver hooking into rustc's `after_analysis` phase
77+
- JSON serialization of Stable MIR: monomorphized items, allocations, interned values
78+
- `--json` flag (default) for JSON output, `--dot` flag for graphviz DOT output
79+
- Function link map tracking monomorphized function symbols
80+
- Integration test harness with `.smir.json.expected` golden files and jq normalization

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "stable_mir_json"
3-
version = "0.1.0"
3+
version = "0.2.0"
44
edition = "2021"
55
default-run = "stable_mir_json"
66

@@ -20,4 +20,4 @@ debug_log = []
2020

2121
[package.metadata.rust-analyzer]
2222
# This package uses rustc crates.
23-
rustc_private=true
23+
rustc_private=true
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# ADR-002: Declarative collect/analyze/assemble pipeline with AllocMap coherence
2+
3+
**Status:** Accepted
4+
**Date:** 2026-02-21
5+
6+
## Context
7+
8+
The original `printer.rs` pipeline had three separate phases: `mk_item`, `collect_unevaluated_constant_items`, and `collect_interned_values`. Each phase had full access to `TyCtxt` and was free to call `inst.body()` or any other side-effecting rustc query whenever it felt like it. Nothing in the types prevented that.
9+
10+
This caused a real bug (#120): one phase called `inst.body()` a second time, rustc minted fresh AllocIds (because that's what rustc does), and suddenly the alloc map had ids that didn't correspond to anything in the stored bodies. The downstream effect was that KMIR's proof engine couldn't decode allocations properly; they came out as opaque thunks instead of concrete values.
11+
12+
The fix in #120 was correct (carry collected items forward instead of re-fetching), but it left the underlying structure intact. The question was: how do we make that class of bug structurally impossible rather than just fixed for the one case we caught?
13+
14+
## Decision
15+
16+
Split the pipeline into phases whose type signatures enforce the boundary:
17+
18+
```rust
19+
collect_and_analyze_items(HashMap<String, Item>)
20+
-> (CollectedCrate, DerivedInfo)
21+
22+
assemble_smir(CollectedCrate, DerivedInfo) -> SmirJson
23+
```
24+
25+
`CollectedCrate` holds items and unevaluated consts (the output of talking to rustc). `DerivedInfo` holds calls, allocs, types, and spans (the output of walking bodies). `assemble_smir` takes both by value and does pure data transformation; it structurally cannot call `inst.body()` because it has no `MonoItem` or `Instance` to call it on. If you can't reach the query, you can't accidentally call it.
26+
27+
The two body-walking visitors (`InternedValueCollector` and `UnevaluatedConstCollector`) are merged into a single `BodyAnalyzer` that walks each body exactly once. The fixpoint loop for transitive unevaluated constant discovery is integrated: when `BodyAnalyzer` finds an unevaluated const, it records it; the outer loop creates the new `Item` (the one place `inst.body()` is called) and enqueues it.
28+
29+
### AllocMap coherence verification
30+
31+
The existing integration tests normalize away `alloc_id`s (via the jq filter), so they literally cannot catch this class of bug. The golden files don't contain alloc ids at all; you could scramble every id and the tests would still pass.
32+
33+
`AllocMap` replaces the bare `HashMap<AllocId, ...>` with a newtype that, under `#[cfg(debug_assertions)]`, tracks every insertion and flags duplicates. After the collect/analyze phase completes, `verify_coherence` walks every stored `Item` body with an `AllocIdCollector` visitor and asserts that each referenced `AllocId` exists in the map. This catches both "walked a stale body" (missing ids) and "walked the same body twice" (duplicate insertions) at dev time; zero cost in release builds.
34+
35+
## Consequences
36+
37+
**What's enforced by types:**
38+
- `assemble_smir` cannot call `inst.body()` because it receives `CollectedCrate` and `DerivedInfo`, neither of which contains `Instance` or `MonoItem` handles
39+
- Each body is walked exactly once in `collect_and_analyze_items`; the single `BodyAnalyzer` pass replaces two separate visitor passes
40+
41+
**What's enforced at dev-time (debug builds only):**
42+
- Duplicate `AllocId` insertions are flagged (indicates a body was walked more than once)
43+
- Missing `AllocId`s in the map (referenced in stored bodies but not collected) are flagged (indicates the analysis walked a different body than what's stored)
44+
45+
**What got deleted:**
46+
`InternedValueCollector`, `UnevaluatedConstCollector`, `collect_interned_values`, `collect_unevaluated_constant_items`, the `InternedValues` type alias, and `items_clone`. The `items_clone` is particularly worth noting: it was a full `HashMap` clone that existed only so the static fixup pass could check "was this item in the original collection?" That's now a `HashSet<String>` of original item names.
47+
48+
**Other things that fell out of this:**
49+
- Static items now store their body in `MonoItemKind::MonoItemStatic` (collected once in `mk_item`), so the analysis phase never goes back to rustc for static bodies
50+
- `get_item_details` takes the pre-collected body as a parameter instead of calling `inst.body()` independently
51+
- The `Item` type gains `body_and_locals()` and `warn_missing_body()` helpers that centralize the body-access pattern
52+
53+
**Downstream impact:**
54+
The tighter allocs representation has already shown positive effects in KMIR: the proof engine can now decode allocations inline (resolving to concrete values like `StringVal("123")`) instead of deferring them as opaque thunks.

src/mk_graph/output/dot.rs

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -295,11 +295,7 @@ impl SmirJson<'_> {
295295
let mut n = graph.node_named(short_name(&asm));
296296
n.set_label(&asm.lines().collect::<String>()[..]);
297297
}
298-
MonoItemKind::MonoItemStatic {
299-
name,
300-
id: _,
301-
allocation: _,
302-
} => {
298+
MonoItemKind::MonoItemStatic { name, .. } => {
303299
let mut n = graph.node_named(short_name(&name));
304300
n.set_label(&name[..]);
305301
}

0 commit comments

Comments
 (0)