Skip to content

[AUTOMATED] feat(p8): orchain - returndup must not split the operand chain of a short-circuit (DIV-69) - #285

Merged
mahaloz merged 2 commits into
mainfrom
feat/returndup-orchain
Aug 11, 2026
Merged

[AUTOMATED] feat(p8): orchain - returndup must not split the operand chain of a short-circuit (DIV-69)#285
mahaloz merged 2 commits into
mainfrom
feat/returndup-orchain

Conversation

@mahaloz

@mahaloz mahaloz commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

The finding

returndup (P8 ActionReturnDup, DIV-54) was flipped default-ON on an ablation over O2
and O2-noinline only
. Extended to all three levels -- 795 slices, 85,195 scored
functions
-- it is +640 GED-perfect corpus-wide but -192 at -O0, and one structural
sub-shape carries essentially all of the O0 harm and almost none of the optimized
benefit
: the split that permanently blocks CollapseStructure::rule_block_or.

returndup gives every predecessor of a shared bare-epilogue RETURN block its own private
return. For a guard clause that recovers the source shape; but the operand blocks of a
short-circuit expression are predecessors of that same block, and the shared out-target
is ruleBlockOr's entire precondition (bl -> {orblock, clauseblock},
orblock -> {clauseblock, X}). ActionReturnDup runs in fullloop's returnsplit group
and collapse_conditions runs later, so the fold is lost for good:

/* option orchain off  - GED 21 */          /* shipped here (on) - GED 0 */
if (a0) return 1;                            v1 = ((a0) || ((a1 && ((a2 || (a3))))));
if (!a1) return 0;                           return v1;
if (a2) return 1;
if (a3) return 1;
return 0;

(iproute2 ip::sci_complete; the source is one boolean return.)

The predicate, and its measured fidelity

The measurement identified this shape post hoc, by diffing the two arms' bodies for
&&/|| operators the OFF body has and the ON body does not. That is an outcome, not an
implementable precondition, so +630 was a ceiling, not a promise, and the fidelity of a
real pre-split predicate to it is the first thing this PR has to publish.

p8_structure/kuna_orchain.rs (shortcircuit_shared_targets) is a read-only replay of
collapse_conditions on the bblocks CFG, run inside returndup_apply before the first
node_split. It mirrors ruleBlockOr's own admission test rather than approximating
it
: two condition nodes fuse when the sibling has exactly one in-edge, has two out-edges,
is not a switch block, is not BlockBasic::isComplex (the same bb_is_complex verdict the
rule reads, off the same op lists), and shares one out-target with the head whose other
target is not the head. Each fuse records the shared target and rewrites the head's
out-targets, so a chain collapses inside-out as the real fixpoint does. Three upstream
tests are omitted because they cannot hold at this point in the schedule (no goto has been
elected -- select_goto runs later; and a back edge into a single-in-edge block would make
that block unreachable). It additionally protects a block joining two or more single-exit
arms that are themselves fold targets: before the constants reach the epilogue phi the arms
are one bare v = K block each, which is the graph the first returndup invocation of
the fullloop sees, and without that clause the archetype recovers its || chain but loses
the 0/1 select iteboolean re-rolls.

Fidelity to the post-hoc proxy, over 33,804 O0 marker functions:

count
proxy positives (sc_off > sc_on) 2,951
gate fires 3,059
fires AND proxy 2,933 -- recall 99.4%, precision 95.9%
proxy AND not fired 18
firings byte-identical to the returndup off body 2,883 / 3,059 (94.2%)

The 18 misses are one shape: a degenerate two-out block (both edges to the same target)
between two operands, removed later in P8 than this replay runs.

The three-level sweep -- this implementation, not the ceiling

Base arm = shipped defaults at merge-base 82dd39a7; variant arm = the gate. decbench's
population, names and GEDMetric.

scope n changed GED base GED var perfect base perfect var dPerfect dGED
O0 32,339 3,014 230,291 227,475 14,785 15,396 +611 -2,816
O2 22,546 1,740 396,062 397,294 7,422 7,409 -13 +1,232
O2-noinline 30,310 2,165 286,500 287,117 11,129 11,114 -15 +617
ALL 85,195 6,919 912,853 911,886 33,336 33,919 +583 -967

Both directions, by name (docs/features/orchain/sweep-o0/moved.csv lists every one):

scope to perfect off perfect ratio improved worsened
O0 663 52 12.75 : 1 1,615 1,052
O2 41 54 0.76 : 1 639 910
O2-noinline 77 92 0.84 : 1 1,000 920
ALL 781 198 3.94 : 1 3,254 2,882

McNemar z = +18.63. Per architecture none negative: x86-64 +530, arm +47, pe-x86 +6.
Per project 34 of 37 net-positive; the three that are not are bzip2 -3, libselinux -3,
kmod -2. Biggest moves to perfect: iproute2 ip::sci_complete 21 -> 0, gzip make_ofname
20 -> 0, bash skipname/wskipname 17 -> 0, crazyflie sensorsFindBiasValue 15 -> 0,
cleanflight/betaflight nextArg 14 -> 0 at all three levels -- that last is the
for-loop de-structuring DIV-54 recorded as its one net-negative cell, closed as a side
effect. Biggest move off perfect: coreutils factor::factor 0 -> 12, DIV-54's flagship
win, which is the honest cost: that source did write the guard cascade and nothing in the
binary says so.

Against the ceiling, and against the stated falsifier

The post-hoc counterfactual recomputed on this instrument (it reproduces the measurement's
published table exactly):

O0 O2 O2-noinline total total GED
ceiling -- OFF body wherever sc_off > sc_on +665 -15 -20 +630 -2,024
this implementation +611 -13 -15 +583 -967
(turning returndup off entirely) +192 -362 -470 -640 +24,017

The agreed stop condition was "below about +300 total GED-perfect, or negative at either
optimized level"
. +583 clears the first by a wide margin. On the second clause, stated
plainly rather than shaded: both optimized levels are slightly negative, -13 and -15 --
and so is the ceiling the measurement recommended building, by more (-15 and -20). A
literal reading of that clause falsifies the recommendation itself; the decision-relevant
comparison is implementation against ceiling, where this is better at both optimized levels
and delivers 92% of the perfect count. A reviewer who wants the strict reading has the
numbers to apply it.

Method

The two arms the corpus measurement scored are still on disk and this PR's baseline arm
reproduces its ON arm byte for byte on 33,804 of 33,804 O0 functions, so every function
whose gate-arm body equals one of those two takes that arm's score (exact -- GED is a
function of the body text, which is what optsweep's "unchanged bodies score identically"
control asserts); only the 312 hybrid bodies were Joern-scored fresh. Controls: 809
unchanged-body controls reproduced their cached score with 0 mismatches, and the O0 level
is not a projection at all
-- a full independent scripts/decbench/optsweep.py run over
all 265 O0 slices reproduces its row digit for digit (docs/features/orchain/sweep-o0/report.md),
with all three of its built-in controls passing (29,325 unchanged bodies 0 differ, 30
slug-only diffs 0 differ, baseline agrees with the published verdict on 99.8%).

Standing-requirement-8 audit

Over all 7,018 changed bodies: the set of called functions is identical in 7,018 of
7,018
; 2,883 of 3,059 O0 firings and 3,823 of 3,959 optimized-level firings are
byte-identical to the shipped option returndup off rendering. Gotos: O0 -19/+14,
optimized -12/+126 (the real cost, visible in O2's +1,232 aggregate GED).
while( true ): -365 / +53 -- the gate un-destructures seven loops for every one it
creates.

Why default-ON and not the aggressive preset

A new default-off option normally joins AGGRESSIVE_OVERRIDES. That is not enough here:
36 of 795 slices are over the 500 KiB auto threshold and run reliable, and they hold
38,216 of the 85,195 scored functions (45%)
, including every one of the biggest winners.
Measured directly -- with the gate in the preset only, the no-option arm reproduced the
ungated body on iproute2 ip (76 functions), bash (366) and openssh sshd (201).
returndup is a shipped default in every mode, so its narrowing has to be one too. Hence
the DIV row.

Speed

Whole-binary decompile-all, arms interleaved and pinned to the same four cores, min of 9:

binary off on delta
zlib example (O0, x86-64) 5,237.2 ms 4,966.7 ms -5.17%
coreutils ls (O0, x86-64) 8,663.6 ms 8,670.1 ms +0.07%
crazyflie cf2.elf (O0, ARM Cortex-M) 28,088.1 ms 27,852.0 ms -0.84%
iproute2 ip (O0, x86-64; min of 5, unpinned) 48,759.9 ms 49,252.9 ms +1.01%

Worst +1.01% against the 5% budget; two of four are net speedups, because the gate only
ever declines a node_split and the structurer then runs on a smaller graph.

Gates (verbatim, on the rebased tree)

$ make test
datatests: 675/675 assertions passed
exit: 0

=== baseline parity ===
PARITY OK

$ make test-stages
datatests: 415/415 assertions passed
exit: 0

=== baseline parity ===
PARITY OK

$ make rust-test
passed 4540 failed 0 ignored 37

$ make check-spec
check-spec OK (lenient mode)
check-spec OK (strict mode)

$ kuna catalog --check
catalog OK: documents exactly the registered kuna options

675/675 is reached with the raw flip -- zero assertion movement, no per-test opt-out and
no re-pin of docs/baseline.json. The gate only ever declines a returndup split, and
the nine datatest files that pin the merged epilogue already carry option returndup off.

Rebase, counts and sibling PRs

Rebased onto 11f40f46 (#280 funcboundflow, DIV-67). Every shared counter re-derived
from the built artifact on the rebased tree, never by arithmetic: live catalog 97,
catalog_bytecompat.rs 97, kuna_phases/tests.rs 97 / tiers (20, 43, 34) / },\n 96,
phase_catalog.json 97 (recaptured), docs/options.md 97 (regenerated), xml.rs corpus
195, kuna-catalog.xml structure-recovery 24. docs/baseline-stages.json re-recorded on
the merged base rather than hand-merged. DIV number: 68 was renumbered to 69 after #277
claimed 68; re-checked against origin/main immediately before pushing.

Rebase drift was measured, not assumed: re-running both arms with the rebased binary over
5,777 sampled O0 functions moves 20 functions, identically in both arms (crazyflie
cf2, funcboundflow's own effect), so the deltas above are unaffected.

Sequencing with the sibling P8 work. This PR does not touch
p8_structure/blockaction.rs, so there is no textual conflict with #281 (guardarm) or
#283 (loopcondhoist); it shares substrate/funcdata_block.rs with #282 but in a
different function (returndup_apply vs block_if_flip_negated_guard). All four will
conflict on the shared catalog counters and docs/baseline-stages.json, which resolve to
base + all merged. There is an outcome interaction to price rather than assume: this
gate leaves a merged short-circuit condition where main leaves a guard cascade, which
changes the block shapes rule_block_if_no_exit and its deferred scan then see, so
whichever of #281 / #283 / this lands second should re-measure rather than add the three
deltas. Each was measured independently against shipped main.

The motivating measurement (docs/decbench/returndup-o0-measurement.md) and the
scripts/decbench/optsweep.py instrument both land in #279; this PR references them
and deliberately does not carry a copy. docs/decbench/returndup-regression-triage.md is
extended, not replaced -- its "no discriminator exists" and "net-positive in every
subpopulation" conclusions are now scoped to the data they were measured on, and its
Conclusion-4 loop cell is closed.

🤖 Generated with Claude Code

https://claude.ai/code/session_01C8UQbPqALzdUQ3cLLjUeKH

mahaloz added a commit that referenced this pull request Aug 11, 2026
…ections it forced

Round 4 targeted the slice the campaign had not attacked: the O0 cases where IDA's GED
is 0 and kuna's is not. This lands the reusable parts only -- the instrument, the
analysis, and the two documentation errors the round proved. The per-case triage records
are deliberately not committed.

## The instrument

`scripts/decbench/optsweep.py` -- a bidirectional corpus-scale A/B for any single option
flip. `rescore` prices ONE mined case, which is the wrong unit for anything that changes
a default: the mining pipeline only ever produces one direction of evidence, mining
losses and never pricing the wins the same change destroys. At O0 the net deficit against
IDA is 129 functions while every candidate cluster is 300-430 wide, so a change that
flips 400 each way moves the scoreboard by zero and nothing upstream of this tool would
show it.

It reports both directions of movement by name, the summed GED delta per population, a
per-project and per-architecture breakdown, and the standing-requirement-8 changed set,
with three harness controls built in: functions the pass never fires on must score
identically in both arms, banner-only diffs must score identically, and the ON arm must
reproduce the published column. A run that fails any of them is not a measurement.

Cross-validated against the ad-hoc harness that produced the returndup measurement:
157 of 157 functions agree in both arms on O0 zlib.

## Correction 1: the scored column is `aggressive`, not `reliable`

`docs/decbench-loop.md` has been telling every triage agent that the benchmark injected
`option listing on`, the equivalent of `--mode reliable`. It does not.
`decbench/decompilers/raw/kuna_raw.py`'s `_build_command` builds `kuna decompile-all
<bin> --json --max-fn-seconds 120`, appends `--mode` only when `DECBENCH_KUNA_MODE` is
set, and appends `--option` only from `config.extra_options`, which is empty. So the
benchmark ran `--mode auto` = aggressive on 768 of 803 binaries. An aggressive-only
regression is therefore INSIDE the published number, and any triage that assumes
`reliable` mis-attributes it.

## Correction 2: DIV-54's evidence never saw O0

`docs/decbench/returndup-o0-measurement.md` is the A/B that PR #246 did not run. #246
flipped `returndup` default-ON on 52,862 functions at O2 and O2-noinline; its table has
two rows and neither is O0. Re-run over all three levels, 85,195 scored functions:
O0 -192 GED-perfect, O2 +362, O2-noinline +470, total +640.

Keep the default -- flipping back buys 192 at O0 and sells 832. But one structural
sub-shape carries essentially all the O0 harm and almost none of the O2 benefit: the
split that permanently blocks `rule_block_or` from folding a short-circuit chain (-665
at O0, +15 and +20 at the optimized levels). That is the gate PR #285 implements.

## The grounding

`docs/decbench/round4-grounding.md` locates the gap before triaging anything. O0 is the
only optimisation level where IDA holds more perfect functions than kuna; at O2 and
O2-noinline kuna's lower percentage is a denominator effect, since it is scored on 2,343
more functions and holds more perfect at both. Total perfect: kuna 33,146, IDA 32,838.

`docs/decbench/features-round4.md` is the ranked menu with its killed list. Zero symptoms
fell to the adversarial pass, and four of six filed root causes were materially
overturned -- continuing the campaign's most reliable finding exactly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C8UQbPqALzdUQ3cLLjUeKH
mahaloz added a commit that referenced this pull request Aug 11, 2026
…ections it forced (#279)

Round 4 targeted the slice the campaign had not attacked: the O0 cases where IDA's GED
is 0 and kuna's is not. This lands the reusable parts only -- the instrument, the
analysis, and the two documentation errors the round proved. The per-case triage records
are deliberately not committed.

## The instrument

`scripts/decbench/optsweep.py` -- a bidirectional corpus-scale A/B for any single option
flip. `rescore` prices ONE mined case, which is the wrong unit for anything that changes
a default: the mining pipeline only ever produces one direction of evidence, mining
losses and never pricing the wins the same change destroys. At O0 the net deficit against
IDA is 129 functions while every candidate cluster is 300-430 wide, so a change that
flips 400 each way moves the scoreboard by zero and nothing upstream of this tool would
show it.

It reports both directions of movement by name, the summed GED delta per population, a
per-project and per-architecture breakdown, and the standing-requirement-8 changed set,
with three harness controls built in: functions the pass never fires on must score
identically in both arms, banner-only diffs must score identically, and the ON arm must
reproduce the published column. A run that fails any of them is not a measurement.

Cross-validated against the ad-hoc harness that produced the returndup measurement:
157 of 157 functions agree in both arms on O0 zlib.

## Correction 1: the scored column is `aggressive`, not `reliable`

`docs/decbench-loop.md` has been telling every triage agent that the benchmark injected
`option listing on`, the equivalent of `--mode reliable`. It does not.
`decbench/decompilers/raw/kuna_raw.py`'s `_build_command` builds `kuna decompile-all
<bin> --json --max-fn-seconds 120`, appends `--mode` only when `DECBENCH_KUNA_MODE` is
set, and appends `--option` only from `config.extra_options`, which is empty. So the
benchmark ran `--mode auto` = aggressive on 768 of 803 binaries. An aggressive-only
regression is therefore INSIDE the published number, and any triage that assumes
`reliable` mis-attributes it.

## Correction 2: DIV-54's evidence never saw O0

`docs/decbench/returndup-o0-measurement.md` is the A/B that PR #246 did not run. #246
flipped `returndup` default-ON on 52,862 functions at O2 and O2-noinline; its table has
two rows and neither is O0. Re-run over all three levels, 85,195 scored functions:
O0 -192 GED-perfect, O2 +362, O2-noinline +470, total +640.

Keep the default -- flipping back buys 192 at O0 and sells 832. But one structural
sub-shape carries essentially all the O0 harm and almost none of the O2 benefit: the
split that permanently blocks `rule_block_or` from folding a short-circuit chain (-665
at O0, +15 and +20 at the optimized levels). That is the gate PR #285 implements.

## The grounding

`docs/decbench/round4-grounding.md` locates the gap before triaging anything. O0 is the
only optimisation level where IDA holds more perfect functions than kuna; at O2 and
O2-noinline kuna's lower percentage is a denominator effect, since it is scored on 2,343
more functions and holds more perfect at both. Total perfect: kuna 33,146, IDA 32,838.

`docs/decbench/features-round4.md` is the ranked menu with its killed list. Zero symptoms
fell to the adversarial pass, and four of six filed root causes were materially
overturned -- continuing the campaign's most reliable finding exactly.


Claude-Session: https://claude.ai/code/session_01C8UQbPqALzdUQ3cLLjUeKH

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
mahaloz and others added 2 commits August 11, 2026 22:45
…chain of a short-circuit (DIV-69)

DIV-54 flipped `returndup` on evidence from two of the three optimisation levels.
Extended to all three (795 slices / 85,195 scored functions) the pass is +640
GED-perfect corpus-wide but -192 at -O0, and one sub-shape carries essentially all
of that harm and almost none of the optimized benefit: the split that permanently
blocks `CollapseStructure::rule_block_or`.

`returndup` gives every predecessor of a shared bare-epilogue RETURN its own private
return. For a guard clause that recovers the source; but the operand blocks of a
short-circuit expression are predecessors of that same block, and the shared
out-target IS ruleBlockOr's entire precondition. ActionReturnDup runs in fullloop's
`returnsplit` group and collapse_conditions runs later, so the fold is lost for good
and one source `return a || (b && (c || d));` prints as a five-return constant-guard
cascade (iproute2 ip::sci_complete, GED 21 against 0).

New option `orchain on|off` (default ON, DIV-69, ELEM 4122). `kuna_orchain.rs
(shortcircuit_shared_targets)` replays collapse_conditions read-only on the bblocks
CFG, mirroring ruleBlockOr's own admission test rather than approximating it (one
in-edge, two out-edges, not a switch, not BlockBasic::isComplex, a shared out-target,
sibling's other target is not the head), and `returndup_apply` declines the whole
function's splits when one of its own candidates is a recorded target. It also
protects a block joining two or more single-exit arms that are themselves fold
targets, because the first returndup invocation of the fullloop still sees the
constant-materialisation blocks in between.

Sweep over the same corpus (base = shipped defaults at merge-base 82dd39a):
O0 +611 GED-perfect / -2,816 GED, O2 -13 / +1,232, O2-noinline -15 / +617 --
+583 net and -967 aggregate, 781 functions to perfect against 198 off (3.94 : 1).
That is 92% of the +630 the post-hoc "a short-circuit was lost" signal prices as a
ceiling, at a smaller optimized-level cost than the ceiling itself (-28 vs -35).
Predicate fidelity to that signal: 2,933 of 2,951 O0 positives recognised (99.4%
recall) at 126 further firings (95.9% precision); 94.2% of firings reproduce the
`option returndup off` body byte for byte.

Default-ON rather than an `aggressive` preset member because `returndup` is a shipped
default in every mode and 45% of the corpus (38,216 of 85,195 functions, 36 of 795
slices) is over the 500 KiB `auto` threshold and runs `reliable`, where a preset-only
gate never runs -- measured: with the gate in the preset only, the no-option arm
reproduced the ungated body on iproute2 ip (76 functions), bash (366), sshd (201).

675/675 PARITY OK with the raw flip, zero assertion movement and no per-test opt-out;
stages 415/415 with the new two-pass tests/stages/ghdec-orchain.xml; rust-test 4,540
passed / 0 failed; check-spec OK lenient + strict; catalog --check OK. Speed worst
+1.01% (two of four probes are net speedups). Rebased onto #280 (funcboundflow,
DIV-67): every shared counter re-derived from the built artifact on the rebased tree
(97 settables, tiers 20/43/34, xml corpus 195), and DIV-68 was renumbered to 69 after

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C8UQbPqALzdUQ3cLLjUeKH
…e stages baseline

Rebased onto #278/#281/#283/#284. Every shared counter re-derived from the BUILT
artifact rather than by arithmetic: live catalog 101, phases.toml 101,
catalog_bytecompat.rs 101, kuna_phases/tests.rs 101 / tiers (21, 45, 35) /
`},\n` 100, phase_catalog.json 101 (recaptured), docs/options.md 101
(regenerated), xml.rs corpus 199, kuna-catalog.xml structure-recovery 26. Stages
baseline re-recorded on the merged base: 425 -> 433, +8 orchain keys and the #7
text update, nothing removed. DIV-69 re-checked against origin/main immediately
before pushing: main's highest row is DIV-68 (#277), so 69 stands unrenumbered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C8UQbPqALzdUQ3cLLjUeKH
@mahaloz
mahaloz force-pushed the feat/returndup-orchain branch from e76e049 to 8415155 Compare August 11, 2026 22:53
@mahaloz
mahaloz merged commit 04803c2 into main Aug 11, 2026
9 checks passed
@mahaloz
mahaloz deleted the feat/returndup-orchain branch August 11, 2026 22:57
@mahaloz

mahaloz commented Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

[AUTOMATED] Measurement staleness after the five-PR merge train

This PR landed last in a train of five: #278 poolentry#281 guardarm#283 loopcondhoist#284 calloverlap#285 orchain. Three of the five touch P8 block shaping, and each of their headline numbers was measured against a tree that did not contain the others. Recording here which figures survived the train and which did not — no number below was re-measured, this is a disclosure, not a correction.

What each figure was measured on

PR Option Shipped default Headline figure Measurement tree
#281 guardarm off +297 GED-perfect over 32,339 O0 functions (post-rebase re-measure on bb05b83e, 139 slices / 16,019 functions) no loopcondhoist, no orchain
#283 loopcondhoist off +669 GED-perfect over 32,339 O0 functions (post-rebase re-measure on a77e1ce5, 139 slices / 16,019 functions) carried guardarm's code but with the option default-OFF, so guardarm was in neither arm; no orchain
#285 orchain on (DIV-69) +583 net GED-perfect / −967 aggregate GED over 795 slices / 85,195 scored functions, all three optimisation levels (base arm = shipped defaults at merge-base 82dd39a7; drift onto 11f40f46 measured at 20 functions moving identically in both arms) no guardarm, no loopcondhoist

Which numbers are now stale, and which are not

The default-off/default-on split decides this, and it does not fall on "whichever landed last".

Follow-up

One combined re-measure with scripts/decbench/optsweep.py (on main since #279) against post-train main, over four arms — shipped defaults, guardarm on, loopcondhoist on, both on. Do not add the three deltas; the whole point of the interaction is that they do not compose.

None of this is a correctness question. All four gates were re-run on each rebased tree in the train and were green, and every shared counter was re-derived from the built artifact rather than by arithmetic — which caught three that git had auto-merged at the wrong value with no conflict marker. On this PR specifically: 675/675 datatest assertions PARITY OK with the raw default-ON flip and no docs/baseline.json re-pin, stages 433/433 PARITY OK, make rust-test green, make check-spec OK lenient + strict, kuna catalog --check OK.

mahaloz added a commit that referenced this pull request Sep 3, 2026
…l the build

`ElementId`'s `PartialEq` is an id comparison (ported from C++), so two options
sharing an id compare EQUAL and the first code that dispatches on one takes the
wrong branch. Three ids were allocated twice on main:

  4110  iteregion / returndup          (one commit, 2026-07-10)
  4122  funcboundflow / orchain        (a day apart, #280 and #285)
  4132  varargstackargs / linuxsyscall (THIS ROUND -- two concurrent builders)

The later of each pair is renumbered: returndup 4135, orchain 4136,
linuxsyscall 4134. Renumbering is free here because nothing consumes any of
them -- `grep ELEM_ITEREGION` etc. finds only the declaration -- which is also
precisely why three of these landed unnoticed. The hazard is the first consumer,
not today's output; that is a reason to fix it now rather than a reason not to.

Both 4122 sources carried a comment reasoning "the next free id above
itecondlist's 4121 is 4122". Two agents grepped for the high-water mark a day
apart and got the same answer. 4132 is the same race inside a single round of
this pipeline. Reading the high-water mark by hand cannot be made safe, so the
comments now point at the deriving tool instead.

`counters.py --check` already FOUND duplicates and printed them -- while exiting
0. That is what let all three land, and it is fixed here: duplicates are now a
`FAIL` line and a non-zero exit, verified by injecting one. The check is wired
into the parity-gates CI job, which is the cheap job that runs on every event,
so this cannot recur silently.

Gates on the renumbered tree: make test 675/675 PARITY OK · make test-stages
PARITY OK · make test-cli 10/10 · make check-spec OK · kuna catalog --check OK ·
counters --check clean (38 ids, 0 duplicates) · smoke 86/86.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
mahaloz added a commit that referenced this pull request Sep 3, 2026
…iage, and six correctness options (#367)

* [AUTOMATED] fix(p4): varargstackargs + calleearity — two ways a call argument was recovered and then discarded

RE-friction round 1 filed four "call and argument recovery" observations across
three challenges. They are NOT one bug; reproduced and root-caused separately,
they are three causes, two of which are call-site argument loss with different
owners. Both are fixed here, each behind its own option, each default-ON on a
0/675 ablation and a classified whole-corpus sweep.

varargstackargs (DIV-101, P4 active-input-trial-scoring, ElementId 4132)
  68149b8a `_main`: scanf("%d") loses its destination and three printf calls
  lose the buffer their %s consumes, and the local scanf writes ends up read
  but never written. checkInputTrialUse scores the [sp+0] trial ACTIVE;
  ParamListStandard::fillinMap deactivates it again, because on AArch64 x0-x7
  and the outgoing stack area are ONE resource section and its two positional
  rules read the seven registers Apple's arm64 ABI structurally leaves empty
  between the fixed parameter and the first vararg as the end of the argument
  list. The section is now cut in two at its first stack trial, gated on the
  callee being variadic and on the stack area starting at callee-relative
  offset 0. Sweep: 1 changed function of 3857.

calleearity (DIV-102, P4 trial-finalization, ElementId 4133)
  69a54bd7 0x1400024a0: the same allocator wrapper renders sub_140008160(0x28)
  at one site and sub_140008160() thirty bytes later, at the site where the
  size is also the operand of an MSVC operator new[] overflow guard --
  only_op_use rejects the trial on its CPUI_CBRANCH descendant. Relaxing that
  rejection would fabricate an argument at every `test rcx,rcx; jz; call`, so
  buildInputFromTrials instead reconciles a call whose argument list came out
  EMPTY with a sibling call to the same callee whose list is already final.
  The storage is recorded on the call spec, because a CALL op's inputs carry
  argument values (0x28 is a constant) and the trials that knew the location
  are deleted one statement later. Sweep: 25 changed of 3857, no regression;
  the first cut ("same callee, same arity") changed 43 and produced
  Sleep(200,0), which is where the empty-list condition comes from.
  Its acceptance probe flips.

Gates: make test 675/675 PARITY OK (docs/baseline.json untouched) · make
test-stages 578/578 PARITY OK · make rust-test 334 targets / 5018 passed ·
make check-spec OK · kuna catalog --check OK · counters re-derived, no drift.
Speed flat for both, measured on interleaved child CPU time with a null
control because the box carried load average 11-14 throughout.

Bundles: docs/features/varargstackargs/, docs/features/calleearity/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* [AUTOMATED] feat(cli+analysis): strings, disassemble, function triage, and four correctness options

Round 1 of the RE-friction loop: three codex testers on nine crackmes filed 26
observations; 23 passed the two-arm gate. This lands the fixes for them.

Interface (Track T) -- five testers across four challenges independently asked
for a string inventory, three for disassembly, two for whole-binary triage:
  kuna strings       VMA + section + owning function, which strings(1) cannot give
  kuna disassemble   named function, bare address, or range; --json carries bytes
  kuna functions     --summary --filter --reachable-from --min-size --sort --limit
                     (211 KB PE: 174 KB of JSON -> 2.8 KB that answers "start where")

Correctness (Track D), each behind a named option with a two-pass stage test:
  unmappedentry   ON   a direct CALL target outside every executable range became
                       a DiscoveredFunction; the instruction worklist had already
                       refused to decode it. 250-binary sweep: 150 phantom entries
                       removed, 0 added, 1 function's C changed and it improves.
  entrymainproto  ON   main typed void(void); parameters are recovered from the
                       callee's own body and main never reads its argument
                       registers, so the in-image CRT call site is read instead.
  linuxsyscall    OFF  int 0x80 rendered as an indirect call through swi(0x80)
  switchselector  OFF  loweredswitch detects a cascade on the simplified graph and
                       installs the BRANCHIND on re-lifted raw p-code it cannot
                       re-find the selector in -- and commits regardless, so a
                       Win32 dialog dispatcher rendered switch(0), every case dead

tests/cli grows 5 -> 10 promoted acceptance probes, run by make test-cli in CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* [AUTOMATED] chore: re-derive shared counters after the merge, and close two guard gaps

Counters after merging both lanes: 133 settables (30 core / 54 transform /
49 analysis), stage corpus 228, catalog records 132. Neither branch's number:
this branch said 131 and callargrecovery said 129, both having bumped from 127.
Every site was re-derived from the live tree with `counters.py --fix`, and
phase_catalog.json / docs/options.md / docs/baseline-stages.json were
REGENERATED rather than merged.

Two guards were blind and are now not:

- `counters.py` had no site for the catalog-record assert in kuna_phases/tests.rs
  (`json.matches("},\n").count()`), so the merge left it at 130 against a truth of
  132 and only `make rust-test` caught it. Added as its own site with a
  `settables_minus_one` derivation -- a regex pinned to `settables` would have
  written the wrong number. Verified it catches injected drift.

- `smoke.sh` asserted the literal strings `"settables": 127` and
  `"corpus_files": 222`, so every option that lands broke the smoke. That is a
  false alarm that teaches people to ignore the smoke, and it never caught a real
  drift because `counters --check` is what does that. It now asserts the
  mechanism: the derivation is plausible and every hard-coded site agrees with it.

Gates: make test 675/675 PARITY OK (baseline.json untouched) · make test-stages
594/594 PARITY OK · make rust-test 339 targets, 5203 passed, 0 failed ·
make check-spec OK · kuna catalog --check OK · make test-cli 10/10 ·
tools/repipe/smoke.sh 72/72.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* [AUTOMATED] chore(re-needs): file round 1 -- 8 closed, 6 carried into round 2

The backlog was empty because round 1's observations were gated straight from
the reports; the records are what round 2's tester brief is rendered from, so
without them the next testers would re-file "kuna has no strings command" against
a kuna that now has one.

Closure is the VERIFIED state, from re-running both arms on the merged build:
8 closed (12 of the 23 gated observations, several of them duplicates of each
other), 6 carried open. `void-callee-spurious-arg` is carried OPEN despite its
probe no longer matching -- the probe pinned `_secret_function(v2);` and the
build now emits `_secret_function(v3);`, which is the same defect with one
renumbered local.

Also fixes the renderer: `_recently_shipped` printed each closed need's TITLE,
which states the problem ("kuna cannot list strings"), under a heading that says
"newly available capabilities" -- telling the tester the exact opposite of the
truth. It now renders the `Shipped:` line the record carries, so the brief names
the flag or subcommand to actually exercise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* [AUTOMATED] docs(re-pipeline): record that a CONFLICTING PR runs no gates and looks green

Round 1's own PR hit this: six green CodeQL checks, the Tests workflow with no
run object at all, and `gh pr checks` presenting an all-green list. GitHub does
not dispatch `pull_request` workflows while it cannot compute the merge commit.
The `missing` arm of open_pr.sh --merge -- written for a renamed check -- is what
stands between that and an auto-merge of wholly ungated code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* [AUTOMATED] fix: three duplicate ElementIds, and make a duplicate fail the build

`ElementId`'s `PartialEq` is an id comparison (ported from C++), so two options
sharing an id compare EQUAL and the first code that dispatches on one takes the
wrong branch. Three ids were allocated twice on main:

  4110  iteregion / returndup          (one commit, 2026-07-10)
  4122  funcboundflow / orchain        (a day apart, #280 and #285)
  4132  varargstackargs / linuxsyscall (THIS ROUND -- two concurrent builders)

The later of each pair is renumbered: returndup 4135, orchain 4136,
linuxsyscall 4134. Renumbering is free here because nothing consumes any of
them -- `grep ELEM_ITEREGION` etc. finds only the declaration -- which is also
precisely why three of these landed unnoticed. The hazard is the first consumer,
not today's output; that is a reason to fix it now rather than a reason not to.

Both 4122 sources carried a comment reasoning "the next free id above
itecondlist's 4121 is 4122". Two agents grepped for the high-water mark a day
apart and got the same answer. 4132 is the same race inside a single round of
this pipeline. Reading the high-water mark by hand cannot be made safe, so the
comments now point at the deriving tool instead.

`counters.py --check` already FOUND duplicates and printed them -- while exiting
0. That is what let all three land, and it is fixed here: duplicates are now a
`FAIL` line and a non-zero exit, verified by injecting one. The check is wired
into the parity-gates CI job, which is the cheap job that runs on every event,
so this cannot recur silently.

Gates on the renumbered tree: make test 675/675 PARITY OK · make test-stages
PARITY OK · make test-cli 10/10 · make check-spec OK · kuna catalog --check OK ·
counters --check clean (38 ids, 0 duplicates) · smoke 86/86.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* [AUTOMATED] chore(re-needs): seed round 2 with the analysis-override gap

A source survey of what an agent can OVERRIDE (as opposed to query) found that
kuna has a working console vocabulary for exactly the levers an obfuscated
binary needs -- `map function`, `map address`, `parse line`, `override flow`,
`rename`, `retype`, `map param/return`, `comment instruction` -- and the `kuna`
binary can reach none of it. `decompile.rs::build_script` emits a fixed
vocabulary and there is no `kuna console`, no `--script` and no passthrough.

Three further findings, all verified by running the commands:
- `function F spans [start,end)` does not exist anywhere. Extent is derived in
  funcextent.rs with no override, and phases.toml has no subphase for
  function-entry identification at all.
- The structuring overrides phases.toml advertises in its own `exposure` fields
  are mostly stubs: `force goto`, `override jumptable` and `structure blocks`
  all return `engine integration not yet ported`. Only `override flow` works.
- `--kassert` is the interface that was meant to carry this, and only its
  `naming-policy` and `Option` dispatch arms do real work.

Four needs seeded, top of the backlog. They are NOT tester-filed and say so;
round 2 confirms the demand. The hypothesis section records the thing a builder
must not assume: the cheap half is exposure and the expensive half is the stubs,
and a scriptable console is a different product from flags an agent composes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant