Skip to content

Commit 3a9432c

Browse files
committed
feat(printer): add interned-index receipt system
Add a spy-based serialization pass that detects which JSON paths carry non-deterministic interned indices (Ty, Span, AllocId, etc.) and emits a companion *.smir.receipts.json alongside each *.smir.json output. The receipts declare three categories of interned indices: - interned_keys: object field names whose values are interned - interned_newtypes: enum variant wrappers around bare integers - interned_positions: known tuple positions carrying interned indices These receipts drive the normalise-filter.jq used for golden-file comparison, replacing the previous hardcoded normalization rules with a data-driven approach. See ADR-004 for the design rationale.
1 parent cf67988 commit 3a9432c

3 files changed

Lines changed: 872 additions & 4 deletions

File tree

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
# ADR-004: Interned index receipts for golden-file normalization
2+
3+
**Status:** Proposed
4+
**Date:** 2026-03-10
5+
6+
## Context
7+
8+
Integration test golden files compare serialized Stable MIR JSON across platforms
9+
(macOS vs. Linux CI). Several values in the output are "interned indices":
10+
compile-session-specific integers assigned by rustc's internal interning machinery
11+
for types like `Ty`, `Span`, `AllocId`, `DefId`, and `AdtDef`. These indices are
12+
consistent within a single rustc invocation but differ across platforms and across
13+
runs; a `Ty` that interns as index 42 on macOS might be index 37 on Linux.
14+
15+
The normalise filter (`normalise-filter.jq`) strips or zeroes these indices before
16+
comparison. The trouble is that the filter has to independently know the JSON
17+
schema: every time Stable MIR adds a new field or array position that carries an
18+
interned index, the filter needs a corresponding rule. We've been discovering
19+
these gaps exclusively through CI failures on the other platform; the feedback
20+
loop is slow and the pattern is pure whack-a-mole. In the span of three commits,
21+
we hit three distinct categories of missed interned index (bare `span` fields,
22+
`{"Type": N}` newtype wrappers, and positional indices inside `Cast[2]` and
23+
`Closure[0]` arrays).
24+
25+
The root cause: the schema knowledge (which values are interned) lives in the
26+
Rust type definitions, but the normalization rules live in a separate jq script
27+
that has to reverse-engineer that knowledge from examples. There's no contract
28+
between the producer (the printer) and the consumer (the normaliser) that says
29+
"these are the interned paths."
30+
31+
## Decision
32+
33+
The printer emits a companion "receipts" file (`*.smir.receipts.json`) alongside
34+
each `*.smir.json` output. The receipts file declares which JSON key names,
35+
newtype wrappers, and array positions carry interned indices. The normalise filter
36+
reads the receipts and applies them generically, rather than hardcoding per-field
37+
rules.
38+
39+
The receipts are generated dynamically by observing actual serde serialization
40+
calls, not by a static list. This is the key property: if upstream adds a new `Ty`
41+
field somewhere inside `Body` (which we don't control), the receipt generator
42+
automatically detects it because serde's derive-generated code calls
43+
`serialize_newtype_struct("Ty", ...)` for the new field, and our serialization
44+
observer records it.
45+
46+
### Receipt format
47+
48+
```json
49+
{
50+
"interned_keys": ["span", "ty", "def_id", "id", "alloc_id", "adt_def"],
51+
"interned_newtypes": ["Type"],
52+
"interned_positions": {
53+
"Cast": [2],
54+
"Closure": [0],
55+
"VTable": [0],
56+
"Adt": [0],
57+
"Field": [1]
58+
}
59+
}
60+
```
61+
62+
Three categories, mapping directly to the three normalization patterns the jq
63+
filter needs:
64+
65+
| Category | Meaning | jq action |
66+
|----------|---------|-----------|
67+
| `interned_keys` | Object field names whose values are interned indices | `del(.[$key])` or zero the value |
68+
| `interned_newtypes` | Enum variant names that wrap a bare interned integer (e.g. `{"Type": 42}`) | `.[$name] = 0` when value is a number |
69+
| `interned_positions` | Parent array name to list of positions carrying interned indices | `.[$name][$pos] = 0` |
70+
71+
### How it works: the spy serializer
72+
73+
The mechanism is a "spy" `serde::Serializer` implementation that mirrors the
74+
structure of a real serializer but produces no output; it only tracks context
75+
(which struct field, which array position, which enum variant we're currently
76+
inside) and records findings.
77+
78+
When the spy encounters a `serialize_newtype_struct` call whose type name matches
79+
a known interned type (`Ty`, `Span`, `AllocId`, `DefId`, `AdtDef`, `CrateNum`,
80+
`VariantIdx`), it examines the current context to classify the finding:
81+
82+
- Inside a struct field named `"ty"``interned_keys` gets `"ty"`
83+
- Inside an enum newtype variant named `"Type"``interned_newtypes` gets `"Type"`
84+
- Inside a tuple variant named `"Cast"` at position 2 → `interned_positions["Cast"]` gets `2`
85+
86+
The spy serializer runs as a separate pass before the real `serde_json` serialization.
87+
This means we serialize twice, which is acceptable: the spy pass is cheap (no I/O,
88+
no string formatting, just context tracking) and the SmirJson structure is
89+
typically modest in size. The two passes are:
90+
91+
1. `value.serialize(&mut SpySerializer::new(...))` — collect receipts
92+
2. `serde_json::to_string(&value)` — produce the actual JSON
93+
94+
### How the normaliser consumes receipts
95+
96+
The normalise filter receives the receipts file via jq's `--slurpfile` mechanism:
97+
98+
```shell
99+
jq -S -e --slurpfile receipts input.smir.receipts.json \
100+
-f normalise-filter.jq input.smir.json
101+
```
102+
103+
The items walk simplifies from a list of hardcoded rules to a generic application
104+
of the receipt:
105+
106+
```jq
107+
# Before (hardcoded):
108+
walk(if type == "object" then del(.ty) | del(.span) | del(.def_id) | del(.id)
109+
| if .Field then .Field[1] = 0 else . end
110+
| if .Type and (.Type | type) == "number" then .Type = 0 else . end
111+
# ... more rules added with each CI failure ...
112+
else . end)
113+
114+
# After (receipt-driven):
115+
walk(if type == "object" then
116+
reduce ($receipts[0].interned_keys[]) as $k (.; del(.[$k]))
117+
| reduce ($receipts[0].interned_newtypes[]) as $n (.;
118+
if .[$n] and (.[$n] | type) == "number" then .[$n] = 0 else . end)
119+
| reduce ($receipts[0].interned_positions | to_entries[]) as $e (.;
120+
if .[$e.key] then
121+
reduce ($e.value[]) as $p (.; .[$e.key][$p] = 0)
122+
else . end)
123+
else . end)
124+
```
125+
126+
The normalise filter no longer needs to know about individual field names or
127+
array positions. A new interned field upstream is automatically captured by the
128+
receipts; the filter handles it without any change.
129+
130+
## Consequences
131+
132+
**What improves:**
133+
134+
- The schema knowledge moves from the jq filter to the Rust code, right next to
135+
the type definitions. The jq filter becomes a generic consumer.
136+
- New interned fields in Body (which comes from stable_mir and whose structure we
137+
don't control) are automatically detected via the spy serializer observing
138+
serde's derive-generated code.
139+
- The receipts file is itself a useful diagnostic artifact: it tells you exactly
140+
which parts of the output carry non-deterministic values.
141+
142+
**What stays the same:**
143+
144+
- The top-level array handling in the normalise filter (stripping alloc_id from
145+
the allocs array, removing the Ty key from the types array, etc.) is
146+
structurally different from the walk-based normalization and remains as
147+
explicit jq code. The receipts cover the Body tree where the whack-a-mole
148+
problem lives; the top-level arrays are stable and few.
149+
- Golden files still need regeneration when the normalise filter changes. The
150+
receipts reduce how often the filter needs to change, but they don't eliminate
151+
golden file churn entirely.
152+
153+
**What to watch for:**
154+
155+
- The spy serializer depends on stable_mir types using `#[derive(Serialize)]` with
156+
standard newtype struct serialization. If a type switches to a custom Serialize
157+
impl that doesn't call `serialize_newtype_struct`, the spy won't detect it. In
158+
practice this is unlikely for the interned index types (they're all simple
159+
newtypes around `usize`), but worth noting.
160+
- The `INTERNED_TYPES` list (the set of type names the spy recognizes) is
161+
maintained in Rust. If stable_mir adds a new interned newtype with a name not
162+
in the list, it won't be detected. This is a small, infrequently-changing list
163+
(currently 7 entries) and is trivial to update; it's also easy to validate by
164+
comparing receipts across platforms.
165+
- The receipts file adds one more output artifact per compilation. The Makefile
166+
and test harness need to account for it (passing the receipts to jq, cleaning
167+
up receipts files alongside JSON files).

src/printer/mod.rs

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
//! | [`mir_visitor`] | `BodyAnalyzer`: single-pass MIR body traversal collecting calls, allocs, types, spans |
1515
//! | [`ty_visitor`] | `TyCollector`: recursively collects reachable types with layout info (some special kinds are traversed but not stored) |
1616
//! | [`link_map`] | Function resolution map: type + instance kind to symbol name |
17+
//! | [`receipts`] | Spy serializer that discovers interned-index locations; emits `*.smir.receipts.json` (see ADR-004) |
1718
//! | [`types`] | Type helpers and [`TypeMetadata`](schema::TypeMetadata) construction |
1819
//! | [`util`] | Name resolution, attribute queries, and small collection utilities |
1920
@@ -49,6 +50,7 @@ mod collect;
4950
mod items;
5051
mod link_map;
5152
mod mir_visitor;
53+
pub(crate) mod receipts;
5254
mod schema;
5355
mod ty_visitor;
5456
mod types;
@@ -61,19 +63,38 @@ pub use schema::{AllocInfo, FnSymType, Item, LinkMapKey, SmirJson, TypeMetadata}
6163
pub(crate) use util::hash;
6264

6365
pub fn emit_smir(tcx: TyCtxt<'_>) {
64-
let smir_json =
65-
serde_json::to_string(&collect_smir(tcx)).expect("serde_json failed to write result");
66+
let collected = collect_smir(tcx);
67+
68+
// Run the spy serializer to discover which JSON paths carry interned
69+
// indices, then serialize the receipts alongside the main output.
70+
let receipt = receipts::collect_receipts(&collected);
71+
let receipt_json =
72+
serde_json::to_string(&receipt).expect("serde_json failed to write receipts");
73+
74+
let smir_json = serde_json::to_string(&collected).expect("serde_json failed to write result");
6675

6776
match crate::compat::output::mir_output_path(tcx, "smir.json") {
6877
crate::compat::output::OutputDest::Stdout => {
69-
write!(&io::stdout(), "{}", smir_json).expect("Failed to write smir.json");
78+
write!(&io::stdout(), "{smir_json}").expect("Failed to write smir.json");
79+
// Receipts go to stderr when main output goes to stdout,
80+
// so they can be captured separately.
81+
eprintln!("{receipt_json}");
7082
}
7183
crate::compat::output::OutputDest::File(path) => {
7284
let mut b = io::BufWriter::new(
7385
File::create(&path)
7486
.unwrap_or_else(|e| panic!("Failed to create {}: {}", path.display(), e)),
7587
);
76-
write!(b, "{}", smir_json).expect("Failed to write smir.json");
88+
write!(b, "{smir_json}").expect("Failed to write smir.json");
89+
90+
// Write the receipts file alongside the JSON output:
91+
// foo.smir.json → foo.smir.receipts.json
92+
let receipts_path = path.with_extension("receipts.json");
93+
let mut rb =
94+
io::BufWriter::new(File::create(&receipts_path).unwrap_or_else(|e| {
95+
panic!("Failed to create {}: {}", receipts_path.display(), e)
96+
}));
97+
write!(rb, "{receipt_json}").expect("Failed to write receipts");
7798
}
7899
}
79100
}

0 commit comments

Comments
 (0)