Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -491,7 +491,14 @@ jobs:
echo " The plan value and the membership test disagree about these names -- that is what the dump above shows."
fi
if [ -z "$shard_unrun_mentioned" ] && [ -z "$shard_unrun_skipped" ]; then
echo " Every name in the plan ran, yet the counts differ: the plan's word count and its distinct names disagree (a repeated entry, or separators splitting into more words than names)."
# ONE cause reaches here, not two. A separator the split
# above does not recognise cannot: it merges its neighbours
# into a token no name the harness ran can match, so it
# prints in the NEVER INVOKED arm above -- as the two names
# run together -- and never gets this far. What is left is a
# plan that names one fixture twice.
echo " Every name in the plan ran, yet the counts differ: the plan names one fixture more than once, so its word count exceeds the number of distinct names run."
echo " The plan producer cannot emit that -- it de-duplicates by first occurrence and refuses a repeated name outright -- so this value did not come from it."
fi
fail=$((fail + 1))
failed="${failed}${failed:+ | }SHARD-COVERAGE($shard_ran/$shard_plan_len)"
Expand Down Expand Up @@ -779,7 +786,14 @@ jobs:
echo " The plan value and the membership test disagree about these names -- that is what the dump above shows."
fi
if [ -z "$shard_unrun_mentioned" ] && [ -z "$shard_unrun_skipped" ]; then
echo " Every name in the plan ran, yet the counts differ: the plan's word count and its distinct names disagree (a repeated entry, or separators splitting into more words than names)."
# ONE cause reaches here, not two. A separator the split
# above does not recognise cannot: it merges its neighbours
# into a token no name the harness ran can match, so it
# prints in the NEVER INVOKED arm above -- as the two names
# run together -- and never gets this far. What is left is a
# plan that names one fixture twice.
echo " Every name in the plan ran, yet the counts differ: the plan names one fixture more than once, so its word count exceeds the number of distinct names run."
echo " The plan producer cannot emit that -- it de-duplicates by first occurrence and refuses a repeated name outright -- so this value did not come from it."
fi
fail=$((fail + 1))
failed="${failed}${failed:+ | }SHARD-COVERAGE($shard_ran/$shard_plan_len)"
Expand Down Expand Up @@ -1347,7 +1361,14 @@ jobs:
echo " The plan value and the membership test disagree about these names -- that is what the dump above shows."
fi
if [ -z "$shard_unrun_mentioned" ] && [ -z "$shard_unrun_skipped" ]; then
echo " Every name in the plan ran, yet the counts differ: the plan's word count and its distinct names disagree (a repeated entry, or separators splitting into more words than names)."
# ONE cause reaches here, not two. A separator the split
# above does not recognise cannot: it merges its neighbours
# into a token no name the harness ran can match, so it
# prints in the NEVER INVOKED arm above -- as the two names
# run together -- and never gets this far. What is left is a
# plan that names one fixture twice.
echo " Every name in the plan ran, yet the counts differ: the plan names one fixture more than once, so its word count exceeds the number of distinct names run."
echo " The plan producer cannot emit that -- it de-duplicates by first occurrence and refuses a repeated name outright -- so this value did not come from it."
fi
fail=$((fail + 1))
failed="${failed}${failed:+ | }SHARD-COVERAGE($shard_ran/$shard_plan_len)"
Expand Down
131 changes: 131 additions & 0 deletions docs/rfc/0021-premerge-stack-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1016,3 +1016,134 @@ The A1 ordering (gate before repair), the line-independent multiset fingerprint,
controller-only commits, and the wave scope guard are all untouched. The
reviewed surface is still the whole stack, every attempt. C1–C8 remain the open
constraint set for the design that changes that, and A5 is not it.

### A6 — the growth population narrowed after A5 (2026-08-24)

A5 is the design authority for `STOP_SELF_REFERENTIAL`, and it was accurate
against the tree it landed on. Two later changes narrowed the detector without
touching the amendment: #766 put a second kind of row into the array A5's formula
divides by, and the first two `/premerge` repair rounds on the stack that
combined #765 with #766 excluded that row from the ratio and added a term to the
stop. A5 is **retained as written** — the record of what was decided on
2026-08-23 — and narrowed here, the way A5 narrows A3 and A4 narrows §8.

Both narrowings run the same way. `GROWTH` speaks about a **smaller** population
than A5 gives it, and the stop asks **one more question** than A5 records.

#### 1. A carried row counts on neither side

A5's *What it computes* reads, verbatim:

> ```
> GROWTH(N) = |{ b in blockers(N) : b.file in modified(N-1)
> and b.fingerprint not in blockers(N-1) }|
> / |blockers(N)|
> ```
>
> `blockers(N)` is attempt N's `classified-<NN>.json`

When that was written, the array held one kind of row: a blocker a reviewer
raised. A5 says nothing about carried rows because there were none to say
anything about.

**#766 put a second kind in the same array.** A fixer whose honest scope spans
two files now returns `blocked_on_file`, and `_read_blocked_on` turns each such
path into a blocker finding on the **next** attempt, stamped
`blocked_on_carry: True` and carrying a controller-authored summary that states a
scheduling fact rather than a defect. Up to `MAX_BLOCKED_ON` (`8`) of them can
enter one attempt. They are blockers because the wave plan and the clean gate
have to see them — otherwise a run whose only outstanding work was carried gated
green, dispatched nothing and filed nothing — but no reviewer raised them.

**They are excluded from both sides, not one.** `_growth_ratio` iterates
`_reviewer_blockers(document["blockers"])`, and the previous attempt's evidence
reaches it through `_blocker_fingerprints`, which iterates the same filter. So
`blockers(N)`, `blockers(N-1)` and the divisor all mean *the reviewer-raised rows
of* that array — and `-` for "no blockers to divide by" now means no
reviewer-raised blocker: an attempt holding nothing but carried rows reports `-`,
not a ratio.

The numerator half is forced. A path is carried precisely BECAUSE the previous
fixer could not edit it, so it is never in `fix-scope-<NN-1>.modified` and could
not have satisfied the membership test anyway. The **denominator** half is the
load-bearing one: left in the divisor, a row that can never be counted dilutes
the ratio every round, and the suppression is largest on the runs with the most
cross-file churn — exactly the runs `STOP_SELF_REFERENTIAL` exists for. Two
suppressed rounds in a row is all it takes for it never to fire.

Executed against the shipped module — one reviewer-raised blocker in a file the
last repair modified, alongside one carried consequence:

| | stored `blockers` array | divisor | `BLOCKERS=` | `GROWTH=` |
| --- | --- | --- | --- | --- |
| shipped | 2 | 1 | 1 | `1.00` |
| A5's formula as written | 2 | 2 | — | `0.50` |

**A5's other denominator claim survives intact.** "the denominator is the same
population `BLOCKERS=` already reports" is still true: `BLOCKERS=` is
`_blocker_fingerprints`' multiset total, and the two narrowed together through
`_reviewer_blockers` — which is also the single derivation
`counts.blocker_reviewer` uses, so the three spellings of that population cannot
drift apart. What changed is what the shared population *is*, not that the two
agree.

#### 2. The streak test has a third term

A5's *The stop* reads, verbatim:

> It fires when `GROWTH` is at or above `CONVERGE_GROWTH_CEILING` (`0.50`) on
> `CONVERGE_GROWTH_RUNS` (`2`) consecutive **measured** attempts

`_growth_round_counts` — one predicate, applied to this round's numbers and then
to the previous round's own `converge.jsonl` columns — asks **three** things of
each round. It is measured; it is at or above the ceiling; **and
`blockers >= previous_blockers`**, the round did not reduce the reviewer-raised
blocker count.

The third term is not defensive plumbing. `GROWTH=` answers *what is the residual
made of*, never *which way is this going*, and those two come apart in one
direction: the denominator is the residual, so it shrinks as the loop succeeds,
while the numerator counts findings the reviewer newly sampled in the file the
repair just touched — the file it was always likeliest to resample. **The ratio
therefore rises toward `1.00` precisely as a run converges.** Reproduced against
the shipped module with `--max-repairs 5`: five blockers, then four cleared
leaving one survivor and one new (`0.50`), then one more cleared leaving one
survivor and one new (`0.50`) — `STOP_SELF_REFERENTIAL` on a run that went
5 → 2 → 2, with three of five repair rounds unspent. `CONVERGE_GROWTH_RUNS` does
not mitigate it: at two rounds, a single new finding in a repaired file *is*
`0.50`, twice running.

That is C3's failure with the ratio's own name on it — the failure A5 argues
against for one high round and then leaves undefended for two. A round that
removed more of the reviewer's blockers than it was left holding is a round in
which repairing demonstrably worked, whatever its residual is made of, so it is
not eligible to be half of a divergence streak. The ratio is still measured,
still printed and still recorded on such a round; a genuine pump, which by
definition is not reducing the count, reaches the ceiling on consecutive eligible
rounds exactly as A5 describes.

Executed against the shipped predicate at `GROWTH=0.50`:

| round | `previous_blockers` → `blockers` | eligible |
| --- | --- | --- |
| count held | 5 → 5 | yes |
| count grew | 2 → 5 | yes |
| count fell | 5 → 2 | **no** |
| not measured (`GROWTH=-`) | any | **no** |

#### What this does not change

The threshold (`0.50`), the streak length (`2`), the cascade position (after
`STOP_NO_PROGRESS`, before `STOP_EXHAUSTED`), the file-granularity proxy and its
recorded limits, `GROWTH=-` meaning *not measured* rather than `0.00`, and the
survivors reaching Phase 5 through `--include-blockers` are all as A5 wrote them.
Carried rows leave the **evidence** questions only: they still enter the wave
plan, still gate `blockers_remaining=`, and are still filed — at the §5
`SUGGESTION` tier, via `_as_deferred_carry_row` — when the loop stops with the
edit outstanding.

`skills/premerge-pipeline/SKILL.md` (`## Phase 3b — CONVERGE`) and the docstrings
on `_growth_ratio`, `_reviewer_blockers` and `_growth_round_counts` state both
narrowings at the code. This amendment is what stops the design authority
outranking them with the wider population. C1–C8 remain the open constraint set,
and A6, like A5, is not the design that closes them.
40 changes: 24 additions & 16 deletions plugins/uberdev/commands/premerge.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,15 @@ see infinite progress and never stop.
A third measurement catches what those two cannot. Both compare fingerprint sets
drawn from independent full-stack reviews, so neither can tell a defect the
fixers just wrote from one that was always there. `STOP_SELF_REFERENTIAL` fires
when, for two attempts running, at least half of the blockers a round returned
did not exist the round before **and** sit in files the previous repair
modified — the loop reviewing its own output. It reports `GROWTH=` on every
attempt so you can see the difference between a run that was still converging
and one that had started generating its own work. The findings that trip it are
on two consecutive **eligible** rounds, and eligibility is three conditions you
can read straight off the `PREMERGE_CONVERGE` line: `GROWTH=` is measured, it is
at or above `0.50` — at least half of the blockers the round returned did not
exist the round before **and** sit in files the previous repair modified — and
`BLOCKERS=` did not fall below `PREV=`. That last condition is what keeps the
stop off a loop that is working: a round that cut the reviewer's blocker count
repaired something, whatever its residual is made of, so it is never half of a
streak however high its ratio. A run log showing `GROWTH=0.90` twice and no stop
is that rule firing, not the detector failing. The findings that trip it are
filed as issues like every other survivor, not dropped.

Why that measurement has to exist at all: a reviewer asked *what is wrong with
Expand All @@ -90,19 +94,23 @@ the correct response to that is to **stop**, not to repair again.
still winning, raise `--converge`"* and *"the loop was reviewing itself"* are
**opposite** operator responses — and an operator shown the budget stop for a run
that did the second will raise the budget on a loop that can never converge. For
the same reason the stop takes **two consecutive measured rounds** at or above
that half-mark, never one. Each review is a fresh sample, and the files most
likely to be resampled next round are exactly the ones the last repair touched,
so a single high round is equally consistent with a healthy convergence whose
repairs happen to be concentrated in a few files. Removing the two-round
requirement makes the detector stop loops that were working — the failure it
exists to avoid.
the same reason the stop takes **two consecutive eligible rounds**, never one.
Each review is a fresh sample, and the files most likely to be resampled next
round are exactly the ones the last repair touched, so a single high round is
equally consistent with a healthy convergence whose repairs happen to be
concentrated in a few files. Dropping the two-round requirement — or reducing
eligibility to the ratio alone — makes the detector stop loops that were
working, the failure it exists to avoid.

`GROWTH=` is a signal, not a proof. Attribution is at file granularity, so a
genuinely new defect elsewhere in a repaired file over-counts as growth; and a
round whose repair committed nothing leaves no scope list to attribute against,
so it prints `GROWTH=-` — *not measured*, which is a different answer from
`0.00`.
genuinely new defect elsewhere in a repaired file over-counts as growth. And it
prints `GROWTH=-` — *not measured*, a different answer from `0.00` — on attempt
1, on a round with no reviewer-raised blocker to divide by, and whenever the
previous repair left no readable scope list: it committed nothing, its list was
unreadable (announced on stderr), or it went through the Phase 3 CI arm, which
pushes real code edits but publishes no `fix-scope` list. That last one is the
case to watch — while a loop keeps repairing through CI it cannot accumulate two
eligible rounds, so `STOP_SELF_REFERENTIAL` is unreachable for it.

`--converge` is the runaway backstop behind those three, not the stop condition,
and that distinction is the answer to the hazard RFC 0021 originally refused a
Expand Down
26 changes: 21 additions & 5 deletions plugins/uberdev/lib/goal-state.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3862,11 +3862,27 @@ _uberdev_goal_rebase_collision_chain() {
# LC_ALL=C ON EVERY MEMBER OF THE sort/comm TRIO, INCLUDING `comm` ITSELF.
# This compares PATHS AS BYTES. `sort` orders by the ambient collation and
# `comm`'s merge walk assumes the order its own collation would produce; when
# the two disagree the walk desynchronises. Here that fails OPEN -- a `comm
# -12` intersection that misses a shared path reports NO collision between two
# PRs that do collide, and the rebase this function exists to trigger never
# fires. The sorts happen to share a shell today, so they agree by accident;
# pinning makes it a property of the code.
# the two disagree the walk desynchronises and a `comm -12` intersection can
# miss a shared path, reporting NO collision between two PRs that do collide.
#
# THAT MISS IS NOT A DEFECT IN ITSELF, and this pin does not claim otherwise.
# The contract above is best-effort by design -- rc 0 always, collision
# detection never halts the goal -- and the `gh pr diff` arm right above
# already routes EVERY API failure to an empty path-set and so to the same
# silent non-collision, by a far more likely route. That fail-open is
# accepted by the contract and is deliberately left as it stands. Nor is the
# miss wholly unguarded downstream: its renumbering half is re-derived by
# `_uberdev_goal_ensure_version_bump`, which re-fetches `origin/<base>` and
# compares head-vs-base itself before every merge dispatch (goal-watch.sh
# step 2c) and fails CLOSED when it cannot guarantee the bump.
#
# What the pin buys is narrower, and sits INSIDE that best-effort guarantee:
# when both path-sets ARE in hand, the comparison answers correctly instead
# of silently desynchronising. A collision missed because the API was
# unreachable is degraded INPUT, which the contract blesses; one missed while
# computing over complete input is a wrong ANSWER, which it does not. The
# sorts happen to share a shell today, so they agree by accident; pinning
# makes the agreement a property of the code.
merged_sorted="$(printf '%s\n' "$merged_diff" | LC_ALL=C sort -u)"
local pr
while IFS= read -r pr; do
Expand Down
Loading
Loading