Skip to content

One calling convention: smaller divisor first, coefficients descending, in all twelve families - #40

Merged
salindne merged 6 commits into
masterfrom
pr10-degree-order
Aug 27, 2026
Merged

One calling convention: smaller divisor first, coefficients descending, in all twelve families#40
salindne merged 6 commits into
masterfrom
pr10-degree-order

Conversation

@salindne

Copy link
Copy Markdown
Owner

Deg<i><j>ADD names the degrees of its two input divisors, and the repository disagreed with itself about what that meant. Three families read the digits as positional — smaller-degree divisor first — while genus-3 split read them as merely sorted and took the larger first. Separately, genus-2 split wrote each operand's coefficients ascending where everyone else wrote them descending.

After this PR all twelve families share one convention. Every Deg12ADD in the repository reads (u0, v0, up1, up0, vp1, vp0, …), differing only in the curve-constant tail.

Why the operand half is a correctness problem

A caller who reads the name and passes operands in the genus-2 order gets a silently wrong answer, not an error, because the arities match. That is the defect class that got the inherited *_use_for_odd_even.m files dropped at import for carrying a reversed operand convention.

The coefficient half is genuinely cosmetic, but genus-2 split contradicted itself: Deg2DBL(u1,u0,v1,v0,ccs) and Deg2ADD(u0,u1,v0,v1,…) sat in the same file, so a reader moving between a doubling and an addition met two conventions without leaving the page.

Scope

commit change
Deg22ADDDeg2ADD 55 occurrences: 6 formula files, 3 whitebox generators, blockcheck.py
genus-3 split reorder 27 functions, 54 call sites, 3 files
genus-2 split coefficient order 30 signatures, 54 call sites, 6 files, both bases

Three kinds of edit, deliberately not conflated

This is the part worth carrying forward, because the three have different risk and different verification:

  1. A pure rename — no operand order to confuse, verifiable by copy-and-diff. Landed first and alone, so the risky diff stayed small.
  2. A prefix swap inside a function body, u ↔ up and v ↔ vp with subscripts untouched, the prefix being the divisor identity and the subscript the coefficient index within it.
  3. An argument reorder at the call site, with no renaming, because the caller's variables keep their meaning.

Conflating 2 and 3 corrupts the dispatcher. Doing 2 without 3 is a wrong answer with no compile error.

The swap is one pass with a lookup, not three through a sentinel

TOK  = re.compile(r'(?<![A-Za-z0-9_])(up|vp|u|v)([0-9])(?![A-Za-z0-9_])')
SWAP = {"u": "up", "up": "u", "v": "vp", "vp": "v"}
new  = TOK.sub(lambda m: SWAP[m.group(1)] + m.group(2), body)

A sentinel is correct, but its correctness rests on the sentinel never colliding and on nobody reordering the passes later. re.sub with a callback replaces each match independently and never rescans its own output, so u0 → up0 and up0 → u0 in the same pass cannot interfere — the hazard becomes structurally impossible rather than avoided by discipline.

Two things would each have produced a half-applied swap, which is the wrong-answer case, and both were settled by measurement before the first edit rather than discovered after:

  • up|vp must precede u|v in the alternation. Python matches leftmost-first, not longest, so the other order matches the u of up0, fails the digit test and skips the token — leaving every up untouched while every u swaps.
  • The substitution must be scoped to the target bodies, not the file. up3 is not a divisor coefficient; it is a live scratch temporary in Deg3ADD holding up2². A file-wide pass would have renamed it to u3.

The pattern was run against all three files beforehand and shown to rewrite exactly thirteen token forms while leaving the other fifty-two identifiers beginning u or v alone — upp0upp3, unp0, vpp0vpp2, vh0vh2, vt0vt2 and the bare u, up, v, vp among them.

The acceptance test for a reorder is that nothing moves

opcount identical per branch, per family, before and after. A reorder that changes a count has changed behaviour. Measured identical across all 42 shapes in each genus-3 split family and every shape in all six genus-2 split families. That gate did not exist when this work was first scheduled; PR41 supplied it.

Two mechanical self-checks ran before any gate: within the swapped bodies the token counts exchange exactly (u 543 ↔ 597, v 602 ↔ 495 in arb), and git diff --stat shows equal insertions and deletions — 1,106/1,106 for arb, 2,200/2,200 for the two specialisations. A partial swap fails both.

The trap that cost two earlier PRs was absent, and was checked rather than assumed

PR21 drifted 56 frozen cases and PR36 another 20, both because a rename touched debug label strings the corpus stores verbatim. Here Deg22ADD never appears inside a label string: genus-3 split labels are numeric and the genus-3 ramified labels were numbered in PR6, carrying the prose name only in a trailing comment. harvested_cases.json holds 0 cases and coverage_baseline.json no Deg-bearing labels. So no frozen-corpus substitution and no re-harvest.

Two other scope facts came from measuring rather than assuming: the whitebox generators and random testers needed no change at all, calling only the ADD dispatcher and never the leaf functions; and doubling was untouched throughout, already descending.

Gates

opcount identical everywhere · whitebox 7,043/7,043 with every branch label unchanged · driver --strict 55,236/55,236 · dominance clean on 39 files · selftest 19/0/0 · blockcheck · three full Magma passes, 30 testers, 0 failures, 0 skips · readme-paths 94/94 · check_paths --strict 259/259.

Two limits, recorded rather than glossed

The _OLD differential was not run. The plan named a same-session old-versus-new comparison as the per-file acceptance test, the technique PR36 used. It was attempted in Python and abandoned after ten minutes without completing — interpreting nine functions of a 10,000-line file at that volume is too slow, and the Magma form is its own piece of tooling. What stands in for it: the frozen corpus was extracted from runs of the old code and records both returned divisors and branch labels, and the new code reproduces every case exactly; driver --strict agrees with an independent Cantor implementation over 55,236 operations across every degree combination; and the specific failure this edit risks, a missed call site feeding old-order arguments to a reordered function, is exactly what the corpus catches, because its cases drive the dispatcher rather than the leaf functions.

One Magma pass took 24m55s of wall clock against the usual five. Not a regression: the suite's own reported times sum to 283s against a 297s baseline, so the formulas did the same work at the same speed. The cause was host contention, traced to an orphaned scratch process from an earlier session.

Same-degree cases are spelled with a single digit everywhere in this repository
-- Deg1ADD for 1+1, Deg3ADD for 3+3, Deg2ADD in genus-2 split and genus-2
ramified -- except the 2+2 case of the two genus-3 models, which spelled it
Deg22ADD. That was the lone holdout. Purely cosmetic: a same-degree case has no
operand order to confuse, which is why it lands separately from the reorder that
follows and is verifiable by copy-and-diff.

55 occurrences in live code: 9 in each of the three genus-3 split additions, 8 in
each of the three genus-3 ramified additions, 1 in each of the three genus-3
split whitebox generators, 1 in verification/blockcheck.py. The variants follow,
Deg22ADDUP -> Deg2ADDUP and Deg22ADDUP2 -> Deg2ADDUP2. Verified beforehand that
no Deg2ADD existed anywhere under g3/, so there is no collision, and the
g3/timings/ copy is deliberately untouched as not-of-record.

The frozen corpus needed no substitution, checked rather than assumed: Deg22ADD
never appears inside a debug label string. Genus-3 split labels are numeric
(ADD000..ADD349) and the genus-3 ramified labels were numbered in PR6, carrying
the prose name only in a trailing comment. harvested_cases.json holds 0 cases and
coverage_baseline.json carries no Deg-bearing labels. This is the trap that
drifted 56 cases in PR21 and 20 in PR36; it is absent here.

Docs handled in three ways rather than substituted blindly. NEW_WORK.md x10 and
the two EFFICIENCY files x3 are references to a function that still exists under
a new name, so they follow the code, on the PR21 precedent. One sentence had both
names collide into "arb_splitG3_ADD's Deg2ADD and genus-2 ramified's Deg2ADD" --
correct, since they now genuinely share a name, but reworded to say it once. The
two README mentions are deliberately left: both sit in prose this PR falsifies,
one calling Deg22ADD "the lone holdout" whose collapse "is queued", and a blind
pass would have produced "collapsing Deg2ADD to Deg2ADD". They are rewritten in
the docs commit.

Gates: whitebox 7043/7043 byte-identical, opcount identical per shape (22ADD
n=0,0 still 61M 3S 78A 25C and so on -- opcount names shapes by degree, so the
check is that nothing else moved), driver --strict 55236/55236, dominance clean
on 39 files, blockcheck 412 compared 0 wrong, adjugate ok, selftest 19/0/0,
Magma 30 testers 0 failures 0 skips in 4m49s.
Nine mixed-degree functions and their eighteen dispatcher call sites. The
signatures now read as the rest of the repository does:

  Deg12ADD(u0,v0,up1,up0,vp1,vp0,ccs)
  Deg13ADD(u0,v0,up2,up1,up0,vp2,vp1,vp0,ccs)
  Deg23ADD(u1,u0,v1,v0,up2,up1,up0,vp2,vp1,vp0,ccs)

byte-for-byte the genus-2 ramified Deg12ADD and genus-3 ramified Deg23ADD
parameter lists. Before this, genus-3 split was the last family where the digits
in Deg<i><j>ADD were merely sorted rather than positional, so a caller who read
the name and passed operands in the genus-2 order got a silently wrong answer.

Two different edits, deliberately not conflated. Inside each function body a
prefix swap u <-> up and v <-> vp with subscripts untouched, the prefix being the
divisor identity and the subscript the coefficient index. At the call sites no
renaming at all, only the two argument groups exchanged, because the dispatcher's
variables keep their meaning. The signature needs both.

Done in one regex pass with a lookup rather than three passes through a sentinel,
so u0 -> up0 and up0 -> u0 cannot interfere: re.sub replaces each match
independently and never rescans its output. The alternation lists up|vp before
u|v, since Python matches leftmost-first rather than longest, and the other order
matches the u of up0, fails the digit test and skips the token -- a half-applied
swap, which is the wrong-answer case.

Scoped to the nine function bodies rather than the file. That is load-bearing:
up3 is not a divisor coefficient but a live scratch temporary in Deg3ADD holding
up2^2, and a file-wide pass would have renamed it to u3.

Self-checks before any gate: within the nine bodies the token counts exchange
exactly, u 543 <-> 597 and v 602 <-> 495, and the diff is balanced at 1106
insertions and 1106 deletions.

Gates: opcount IDENTICAL across all 42 shapes, which is the acceptance test for a
reorder; whitebox 7043/7043 with every branch label unchanged; driver --strict
55236/55236; dominance clean on 39 files; selftest 19/0/0; Magma 30 testers
0 failures 0 skips in 4m56s.

DEVIATION from the plan, stated rather than glossed: the plan named an _OLD
same-session differential as the per-file acceptance test. It was attempted in
Python and abandoned after ten minutes without completing -- interpreting nine
functions of a 10,000-line file at that volume is too slow. Relying instead on
three things that together cover it: the frozen whitebox corpus was extracted
from the OLD code and records both returned divisors and branch labels, and the
new code reproduces every case exactly; driver --strict agrees with an
independent Cantor implementation over 55,236 operations across every degree
combination, so old and new agree with each other through it; and the specific
failure this edit risks, a missed call site feeding old-order arguments to a
reordered function, is precisely what the corpus catches because its cases drive
the dispatcher rather than the leaf functions.
The same nine functions and eighteen call sites in each, by the same script that
did arb, unchanged. Genus-3 split was the last family in the repository whose
Deg<i><j>ADD digits were sorted rather than positional; all twelve families now
agree, and the signatures match the genus-2 ramified and genus-3 ramified
parameter lists byte for byte.

  nch2: u tokens 1279 -> 1342, up 1272 -> 1209, diff 1113 / 1113
  ch2:  u tokens 1259 -> 1305, up 1211 -> 1165, diff 1087 / 1087

Unlike C4, the specialisations needed no separate derivation here. The reorder
depends only on the parameter list, which all three files share, where C4's
trade depended on how each file had inlined t3 and so differed per file.

Gates: opcount IDENTICAL across all 42 shapes in both families, whitebox
7043/7043 with every branch label unchanged, driver --strict 55236/55236,
dominance clean on 39 files, selftest 19/0/0, Magma 30 testers 0 failures
0 skips in 4m52s.

The _OLD differential deviation recorded on the arb commit applies here too, on
the same reasoning and with the same evidence standing in for it.
Five signatures and nine call sites in each of the three files. Deg02ADDDWN,
Deg02ADDUP, Deg12ADD, Deg12ADDUP and Deg2ADD wrote each operand's coefficients
ascending, u0,u1; they now descend, u1,u0, which three of the four families
already did.

The argument is not merely consistency with the other families: this family
contradicted ITSELF. Deg2DBL in the same file reads (u1,u0,v1,v0,ccs) while
Deg2ADD read (u0,u1,v0,v1,...), so a reader moving between a doubling and an
addition in one file met two conventions. They now agree, and Deg12ADD's
parameter list is byte-identical to genus-2 ramified's.

Positional only: no renaming, no token substitution, no body edits. The
parameter names keep their meanings and only their order moves, so signature and
call sites had to move together -- miss one and u0/u1 arrive swapped, which is a
wrong answer with no compile error. Both were rewritten from the same derived run
structure, grouping consecutive parameters by prefix and reversing each run, so
they cannot disagree.

Doubling is untouched, already descending.

Gates: opcount IDENTICAL in all three families across every shape, whitebox
7043/7043, driver --strict 55236/55236, dominance clean on 39 files, selftest
19/0/0, Magma 30 testers 0 failures 0 skips.
Five signatures and nine call sites in each of the three files, by the same
derived run structure that did posReduced. The two bases now agree with each
other and with the other three families, so every Deg12ADD in the repository
reads (u0,v0,up1,up0,vp1,vp0,...).

Gates as for posReduced, run over the combined state: opcount IDENTICAL in all
three splitneg/g2 families, whitebox 7043/7043, driver --strict 55236/55236,
dominance clean on 39 files, selftest 19/0/0, Magma 30 testers 0 failures
0 skips.

Timing note for anyone comparing runs: that Magma pass took 24m55s of wall clock
against the usual five minutes, but the suite's own reported times sum to 283s
against a 297s baseline. The formulas did the same work at the same speed; the
wall clock was host contention, not a regression.
README's naming section described the state this PR ended. It called Deg22ADD
"the lone holdout" whose collapse "is queued", and its table said genus-3 split
binds the larger divisor first. Both are now false, which is why the two
Deg22ADD mentions were deliberately held back from the rename commit rather than
substituted -- a blind pass would have produced "collapsing Deg2ADD to Deg2ADD".

The table now states the convention as a fact and lists all four families
agreeing, noting the two Deg12ADD forms differ only in their curve-constant tail.
The "Genus-3 split still binds the larger divisor first" bullet leaves Current
and planned work, that work being done.

NEW_WORK.md N34 records the transferable part, which is not the convention but
the observation that this was three kinds of edit with different risk: a pure
rename, verifiable by copy-and-diff; a prefix swap inside a function body; and an
argument reorder at the call site with no renaming at all. Conflating the last
two corrupts the dispatcher, and doing the second without the third is a wrong
answer with no compile error.

It also records why the swap is one regex pass with a lookup rather than three
through a sentinel -- re.sub never rescans its output, so the ordering hazard
becomes structurally impossible -- and the two things that would each have
produced a half-applied swap: the alternation must list up|vp before u|v because
Python matches leftmost-first rather than longest, and the substitution must be
scoped to the target bodies because up3 is a live scratch temporary in Deg3ADD
rather than a divisor coefficient.

And it records the honest limit: the plan's _OLD differential was abandoned as
too slow to interpret, with the frozen corpus, driver --strict and the corpus's
dispatcher-level coverage standing in for it.
@salindne
salindne merged commit 15d0679 into master Aug 27, 2026
6 checks passed
@salindne
salindne deleted the pr10-degree-order branch August 27, 2026 10:33
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