7.2

A proof harness beyond compare_functions

compare_functions line-diffs Ghidra text, so equality of a rebuilt function needs a harness that runs both over an input grid and compares bytes after masking relocations.

You rebuilt percent_change from the pseudocode in Section 4.6, kept the long the decompiler offered, and ten hand-written tests pass. Section 7.1 showed that rea can only tell you whether two Ghidra texts match. The proof that your rebuild equals the original has to come from a harness you run yourself. Lesson 36 builds one in the Python standard library.

The capture kit ran that harness over ledger-x86_64-O1.o and recorded four files under 17-. When you finish this section, you can run both legs of the harness over your rebuild and read the mismatch rows and the byte report. You can then say which hazard class or which relocation each failure names.

What compare_functions can see

compare_functions compares two analyze_function records in eight dimensions: identity, pseudocode, assembly, comments, calls, references, strings_names, and cfg (the capture). The pseudocode and assembly dimensions are a bounded line diff of two strings. The status is unknown when the diff takes more than 100 ms or more than 10,000 edits. The assembly dimension carries two fixed limitations:

the assembly dimension of the percent_change comparisonjsonFull log
              "dimension": "assembly",
              "left_count": 161,
              "right_count": 161,
              "evidence_links": [
                "ev_b492c85e…",
                "ev_142cc943…"
              ],
              "limitations": [
                "Exact provider text is compared without address or generated-name normalization; layout-only differences do not establish a behavior change.",
                "Assembly is opaque provider text; relocation normalization is unavailable."
              ],
              "status": "unchanged",
One of the eight dimension objects. The counts are characters of provider text. The digests and the text delta are cut.

For total the same comparison reported changed in five dimensions (the capture). The pseudocode grew from 300 to 2,904 characters and the assembly from 336 to 1,866. The two records came from one source line compiled at -O1 and -O2. A text comparison therefore says nothing about behaviour in either direction. The same function can print as different text, and a wrong function can print as the same text.

rea 6.7.0 has no compiler replay and no relocation-aware byte comparison. The two legs below are the part rea leaves to you.

Leg one: execute and compare

Input grid: the list of input cells the harness feeds to the original and to the rebuild. Each class of cells stands for one hazard the listing shows. The lesson's input_grid() takes every pair of ten before values and seven after values for percent_change, and 22 scores for grade, 92 cells in all. Each cell has a class, and the class says why it exists.

the 92 cells by hazard classjsonFull log
{
  "cells": 92,
  "classes": {
    "overflow": 32,
    "negative": 17,
    "boundary": 14,
    "plain": 12,
    "zero guard": 7,
    "negative truncation": 5,
    "beyond the switch": 2,
    "rounding": 2,
    "trap": 1
  },
  …
}
The input values and the ten naive cases are cut.

The zero guard cells take the before == 0 branch. The negative cells check that division truncates toward zero, so -9 / 10 is 0 and -10 / 10 is -1. The rounding cells check that the multiply happens before the divide. The overflow cells make (after - before) * 100 exceed 32 bits. The one trap cell makes the wrapped product equal INT_MIN with before equal to -1.

For grade, the boundary cells sit on both sides of 60, 70, 80, 90, and 100. The two cells beyond the switch are 110 and INT_MAX.

The harness compiles ledger.c and one candidate per function into one program with the lesson's driver.c. The candidate is renamed with -Dpercent_change=cand_percent_change -Dgrade=cand_grade, so both versions link under different names. The driver reads one cell per line, prints both results, and turns a SIGFPE into the value trap.

The ELF object cannot run on the Mac that recorded the capture. The oracle there is ledger.c compiled natively at -O1, and the report says so in original_kind: fixture-source. The capture ran two pairs. The plausible pair is percent_change_wide.c, the pseudocode's long, with grade_thresholds.c, four if statements on 90, 80, 70, and 60:

int percent_change(int before, int after) {
    if (before == 0) return 0;
    return (int)(((long)after - before) * 100 / before);
}
the plausible pair over the gridjsonFull log
  "candidates": {
    "percent_change": "phases/13-tools-and-protocols/36-rebuild-and-prove-equal/code/fixtures/candidates/percent_change_wide.c",
    "grade": "phases/13-tools-and-protocols/36-rebuild-and-prove-equal/code/fixtures/candidates/grade_thresholds.c"
  },
  "original": "fixtures/ledger.c compiled with the host clang at -O1",
  "original_kind": "fixture-source",
  "host": "aarch64 Darwin",
  "arch": "aarch64",
  "naive_passed": true,
  "cells": 92,
  "mismatches": [
    {
      "function": "percent_change",
      "args": [
        3,
        2147483647
      ],
      "kind": "overflow",
      "original": -133,
      "candidate": -1431655899,
      "match": false
    },
    …
    {
      "function": "grade",
      "args": [
        110
      ],
      "kind": "beyond the switch",
      "original": 0,
      "candidate": 4,
      "match": false
    },
    …
  ],
26 of the 28 mismatch rows are cut, and the 92 result rows are cut.

All ten naive cases pass, and 28 of the 92 cells fail. The 26 percent_change failures are all overflow cells with |before| > 1. The original wraps the product to 32 bits and then divides, and the long rebuild divides the unwrapped product. For before equal to 1 or -1 the two agree by accident, because the 64-bit quotient truncates back to the wrapped value. The two grade failures are 110 and INT_MAX. The original switch has no case for tens of 11 and above and returns 0, and the threshold rebuild returns 4.

The equal pair is percent_change_mul_first.c, which is 100 * (after - before) / before, with grade_switch.c, the original source. It agrees on all 92 cells and the model of the listing disagrees with the observed original on none (the capture). One row of that file deserves a look:

the trap cell on an arm64 hostjsonFull log
    {
      "function": "percent_change",
      "args": [
        -1,
        536870911
      ],
      "kind": "trap",
      "original": -2147483648,
      "candidate": -2147483648,
      "match": true
    },
One of the 92 rows.

The difference is 536870912, the 32-bit product wraps to -2147483648, and the divisor is -1. On x86-64 IDIV raises the divide error exception, because the quotient does not fit in 32 bits. On AArch64 SDIV has no trap and returns -2147483648. The report records "arch": "aarch64" with the row, and a run of the same grid on a Linux x86-64 host records the string trap instead. The oracle depends on the architecture, so a proof names the host that ran it.

Leg two: recompile and compare bytes

Relocation masking: zeroing the bytes of every relocation field in both functions before comparing them. The two steps that follow compare the relocation records by offset, type, and target section, then read the bytes each relocation points at. The lesson's ElfObject reads the ELF64 object with struct.unpack_from, and mask_relocations(), prove_bytes(), and read_table() do the three steps. Without the first step a plain byte compare rejects a correct rebuild whose table sits at another offset. Without the third step it accepts a wrong one.

grade in the original objectjsonFull log
      "grade": {
        "section": ".text",
        "start": 16,
        "size": 46,
        "bytes": "4863c74869c0676666664889c148c1f92248c1e83f01c183c1fa31c083f904770c89c8488d0d000000008b0481c3",
        "relocations": [
          {
            "offset": 38,
            "type": "R_X86_64_PC32",
            "symbol": ".rodata",
            "addend": 16
          }
        ]
      },
One of the four function entries. The start is the offset inside .text.

grade is 46 bytes with one R_X86_64_PC32 relocation at offset 38, the displacement of the lea that loads the table address. In a RELA object the addend lives in the record and the four field bytes are zero. The hex above shows them as 00000000 before 8b0481c3. The target is .rodata+0x10, and because the field ends its instruction the CPU adds 4, so the table starts at .rodata+0x14. Each candidate was compiled with clang -target x86_64-unknown-linux-gnu -O1 -fno-asynchronous-unwind-tables -c, the fixture command, and compared with this entry.

a wrong constant passes the masked comparejsonFull log
      "candidate": "grade_case10_wrong",
      …
      "original_size": 46,
      "candidate_size": 46,
      "masked_equal": true,
      …
      "relocations_match": true,
      "relocations": [
        {
          "offset": 38,
          "type": "R_X86_64_PC32",
          "original_target": ".rodata+0x10",
          "candidate_target": ".rodata-0x4",
          "same_shape": true
        }
      ],
      …
      "status": "fail",
      "reasons": [
        "referenced constants differ: [1, 2, 3, 4, 4] vs [1, 2, 3, 4, 3]"
      ]
One of the eight candidate entries. The source path, the differing offsets, the masked ranges, and the two tables are cut, and the reasons line prints both tables.

grade_case10_wrong.c returns 3 for case 10. Its 46 code bytes equal the original after masking, and its one relocation has the same offset, type, and section. Only the five table entries differ.

The addends differ in every grade candidate, +0x10 against -0x4. The original object holds the labels table of grade_label in .rodata before the switch table, and the candidate object does not. A different addend is a different offset, so the harness compares the shape of the record and then reads the table through it. Both tables read 1, 2, 3, 4, 4 for the two correct rebuilds.

CandidateSource textSizeMasked bytesRelocation recordsTableStatus
percent_change_mul_first100 * (after - before) / before16 and 16equalnonenonepass
percent_change_split(after * 100 - before * 100) / before16 and 16equalnonenonepass
percent_change_widethe pseudocode's long16 and 26size differsnonenonefail
percent_change_div_first(after - before) / before * 10016 and 18size differsnonenonefail
grade_switchthe original switch46 and 46equalsame shape1, 2, 3, 4, 4 on both sidespass
grade_tablepoints[slot] with a bound check46 and 46equalsame shape1, 2, 3, 4, 4 on both sidespass
grade_thresholdsfour if statements46 and 39size differs1 against 0nonefail
grade_case10_wrongcase 10: return 3;46 and 46equalsame shape1, 2, 3, 4, 4 against 1, 2, 3, 4, 3fail

Two rows say what a byte match means. percent_change_split.c has different source text and identical bytes, because clang treats signed overflow as undefined and folds the two multiplies into one. grade_table.c reaches the same table from an array index as the original does from a switch. A match proves that this compiler with these flags produced the same code from the candidate. It does not prove that the candidate is the original text.

Size is a diagnostic and never an acceptance. percent_change_wide is 26 bytes because movslq, imulq, cqto, and idivq replaced the 32-bit forms. The listing had shown the widths before any compile.

Fig. 7.2two legs of one proofflow
Two legs of one proof Two columns of boxes read top down. Leg one, execute and compare: ledger.c is the original compiled natively at -O1, percent_change_wide.c is the candidate renamed cand_percent_change, driver.c links both into one program named differential, the input grid has 92 cells in 9 hazard classes, and the results are 28 mismatches for the plausible pair and 0 for the equal pair. Leg two, recompile and compare: grade in ledger-x86_64-O1.o is 46 bytes with one R_X86_64_PC32 relocation, grade_switch.c is compiled with clang for x86_64-unknown-linux-gnu at -O1, mask_relocations zeroes 4 bytes at offset 38 on both sides, the relocation records .rodata+0x10 and .rodata-0x4 have the same shape, read_table gives 1, 2, 3, 4, 4 on both sides, and grade_case10_wrong gives 1, 2, 3, 4, 3. LEG ONE: EXECUTE AND COMPARE LEG TWO: RECOMPILE AND COMPARE ledger.c the original, compiled natively at -O1 percent_change_wide.c the candidate, renamed cand_percent_change differential driver.c links both into one program input grid 92 cells in 9 hazard classes ledger-x86_64-O1.o grade: 46 bytes, one R_X86_64_PC32 grade_switch.c clang -target x86_64-unknown-linux-gnu -O1 mask_relocations zero 4 bytes at offset 38 on both sides relocation records .rodata+0x10 and .rodata-0x4, same shape plausible pair 28 mismatches equal pair 0 mismatches read_table 1, 2, 3, 4, 4 grade_case10_wrong 1, 2, 3, 4, 3
Leg one runs the original and the candidate over 92 cells, and leg two compares 46 masked bytes, one relocation record, and five table entries. Read each column top down. Grey boxes are the harness and the compiler, which run outside rea, and green boxes are the fixture bytes. Teal is a passed check and rose is a failed one.
Fig. 7.3where each of the eight candidates stopsdecision
Where each of the eight candidates stops Four checks run top down on the left: size, masked bytes, relocation records, and referenced constants, then a pass box. To the right of the size check, three candidates stop: percent_change_wide with 16 against 26 bytes, percent_change_div_first with 16 against 18 bytes, and grade_thresholds with 46 against 39 bytes and 1 against 0 relocations. No candidate stops at masked bytes or at relocation records. At referenced constants, grade_case10_wrong stops with the tables 1, 2, 3, 4, 4 against 1, 2, 3, 4, 3. Four candidates pass: percent_change_mul_first and percent_change_split with 16 bytes and no relocation, and grade_switch and grade_table with 46 bytes and the table 1, 2, 3, 4, 4. size equal percent_change_wide 16 vs 26 bytes percent_change_div_first 16 vs 18 bytes grade_thresholds 46 vs 39 bytes, 0 relocations masked bytes equal no candidate stops here relocation records equal no candidate stops here referenced constants equal grade_case10_wrong 1, 2, 3, 4, 4 vs 1, 2, 3, 4, 3 pass percent_change_mul_first 16 bytes, no relocation percent_change_split 16 bytes, no relocation grade_switch 46 bytes, table 1, 2, 3, 4, 4 grade_table 46 bytes, table 1, 2, 3, 4, 4
Three candidates stop at the size check, one passes every byte check and fails on the table it points at, and four reach pass. Read top down. Each grey box is one check of leg two. The dashed rose boxes to its right are the candidates that fail there, with the reason the report prints. The teal boxes at the bottom passed every check.

What the 2026 measurements add

The hazards in the grid are not special to this fixture. Liu, Raff, and Micinski measured eight decompilation systems in nine configurations in 2026 (arXiv 2609.05370). Candidates that passed every test of the benchmark still diverged on fuzzed inputs. That happened for 4.9 percent of them overall and for up to 13 percent in one system. The test set was 300 library functions and 287 functions with disclosed vulnerabilities. There the strongest refinement model raised Ghidra's build rate from 75 to 90 percent, and the behavioural match rate fell from 74 to 62 percent.

They traced the cause to invented fields, types, callees, and guards that replaced the visible unknowns a decompiler leaves behind. percent_change_wide.c is an invented type, and grade_thresholds.c is an invented guard. Both compile, both pass the ten naive cases, and both count as re-executable under a benchmark that recompiles and runs the assertions it includes. The authors propose per-function driver synthesis and fuzzing from the reference, which is leg one with a generated grid.

The lesson adds a third source for the constant check. The rea evidence pages describe a reconstruction project that compared instruction bytes while trusting the relocation target. A source literal of 0.0 passed against a target literal of 128.0 until the project audited 1,548 floating-point references. grade_case10_wrong.c is this manual's version of that defect, and read_table() is the audit.

rea (rea-agents) with the Ghidra provider rea-agents 6.7.0, Ghidra 12.1.4 · 27dc42d · 2026-10-18 · Edition 2026.10