|
| 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). |
0 commit comments