Skip to content

test: port native_groupagg to pytest, with a cost arm that can fail (#289, #432) - #1167

Merged
jdatcmd merged 1 commit into
commandprompt:mainfrom
OffgridwithJD:test/432-native-groupagg
Sep 20, 2026
Merged

jdatcmd merged 1 commit into
commandprompt:mainfrom
OffgridwithJD:test/432-native-groupagg

Conversation

@OffgridwithJD

Copy link
Copy Markdown
Collaborator

Port of native_groupagg.sh (#289, #432). 33 names plus one template, missing: 0, two
extras — both premises the shell suite does not have, explained below.

Two oracles, and neither substitutes for the other

A heap mirror holds the same rows, so exact aggregates must agree byte for byte. It
cannot judge float summation order: the two storage types do not scan in the same order and
float addition is not associative.

A toggle differential runs the same query over the SAME columnar rows with the path off
and on. Both arms read in the same order, so even float sums must be identical — which is
what validates the order-preserving accumulators.

Every comparison carries a node premise. A query the node quietly rejects runs the
scalar Agg in BOTH arms, so the comparison is two identical runs agreeing with each other.
That is how sum(real) returning 0 got through an earlier version of this suite.

One arm is stronger than the original, because the original's cannot fail (#1162)

native_groupagg.sh asks ten aggregates over ten DIFFERENT columns to cost more than one
over one. A scan projecting ten columns costs more than one projecting one whatever the
folding charge is.
Measured with the charge stripped of its aggregate-count factor — the
exact defect #349 fixed:

shape control mutant
1 aggregate over 1 column 350.50 350.50
10 aggregates over 10 columns (what the shell suite asks) 1253.01 803.01 — still green
10 aggregates over 1 column, path forced 800.50 350.50 — reddens

This file holds the projection fixed and forces the path, so the difference is exactly the
term under test: 450.00 = cpu_operator_cost × 20,000 rows × 9 extra aggregates.

Forcing is not a convenience. With the charge applied the planner prices ten avg(a)
over one column OUT of the grouped path and picks core's Agg; removing the charge makes the
grouped path win it. Without enable_hashagg=off; enable_sort=off the comparison silently
changes which node it reads. The two extra names are the premises that both costed plans
ARE the grouped node.

Independent at every seam

shell port
the node premise grep EXPLAIN text for the marker line EXPLAIN (FORMAT JSON), asking whether a node carries the PROPERTY, via plan_marker, which refuses an empty plan rather than reading it as absence
the oracle md5(string_agg(...)) computed by the server sorted row tuples compared in Python, so a disagreement prints the rows
the GUC set on the DATABASE, because every q() opens a connection set on the SESSION, which holds one

Removal proof

Five mutations of src/columnar_vector.c on the Debian PostgreSQL 18 the CI cluster job is
given. Each asserted its anchor matched exactly once and that the file changed; each
restored and re-hashed byte-identical. The .so hash is recorded per cell, because a
mutation that never reached the binary is the failure a red arm cannot show.

cell mutation .so what reddens
A control 37d535c9dd69 85 pass, 0 fail
B the deterministic-collation gate on a group key 77a1d36c89a0 plan: non-deterministic collation key falls back
C the folding charge stops scaling with the aggregate count f9dc2a9d5faa ten aggregates cost more than one (#349)no [1=350.5 10=350.5]
D the group-estimate bound is never applied 7d25ab46e1d9 the bound is accurate ... (got 299940 want 12) and while a matching-type predicate still gets one
E the pseudoconstant (one-time) qual rejection f0ef8381416d regress B2: gating WHERE falls back (not the node)

The gate, and a change to its classification you should know about

PG15   2507 pass + 0 fail + 3 unrun   rc=67
PG16   2507 pass + 0 fail + 3 unrun   rc=67
PG17   2507 pass + 0 fail + 3 unrun   rc=67
PG18   2539 pass + 0 fail + 1 unrun   rc=67   <- was rc=0 before this branch
PG19   2542 pass + 0 fail + 1 unrun   rc=67   <- was rc=0 before this branch
Debian PG18 (what the CI cluster job runs)
       2541 pass + 0 fail + 0 unrun   rc=0    ALL GREEN

The local five-major gate no longer reaches ALL GREEN on 18 and 19. The ICU-gated
collation test declines there, because all five /usr/local/pg*a builds are
--without-icu. Nothing failed — 0 fail on every leg — but anyone running the local gate
will now see 67 on six legs instead of three, and should not read that as a regression.

CI is unaffected. The pg_config the cluster job is given is --with-icu, so those
arms RUN there and the leg is rc=0 with 0 unrun. Measured both ways rather than
reasoned:

/usr/local/pg{15,16,17,18,19}a   --without-icu   declines, run exits 67
/usr/lib/postgresql/18           --with-icu      runs, 85 checks, rc 0

I had this wrong first: pg_config --configure | grep -c icu returns 1 for --without-icu,
and I reported ICU as available on all five builds before a CREATE COLLATION said
otherwise.

Why the collation pair is its own test

cannot_run declares a whole TEST unrunnable while check_skip declines one check. Putting
the ICU-gated pair in a function with anything else would make the refusal wider than the
shell suite's, and a refusal that takes unrelated arms down with it is a worse report than
the one it replaces.

Bookkeeping

cluster_tests 442 → 458, derived by collection. This branch stated 457 before the
rebase — correctly, against a main at c050f84. #1161 then moved the key 441 → 442
underneath it and the rebase conflicted, because both sides had moved from 441. Neither
number survives and adding the deltas reaches a value no tree collects, which
expected_tests.txt says in six places and which was true again here.

The TESTS.md section number is derived from the document's own highest heading rather
than typed: #1161 took 70 while this was open and both claimed it. guard_tests re-derived
in the same run and unmoved at 382.

Every arm in this file carries its measurement into the failure message (#1164) — checked
with the guard from #1166, which reports 0 lossy arms here.

🤖 Generated with Claude Code

https://claude.ai/code/session_012RSw4qMHS7ByE7PY8Ns4cs

…ommandprompt#289, commandprompt#432)

33 names plus one template, `missing: 0`.

The grouped vectorized aggregate is held to two oracles that are not substitutes
for each other. A heap mirror holds the same rows, so exact aggregates must agree
byte for byte, but it cannot judge float summation order -- the two storage types
do not scan in the same order and float addition is not associative. A toggle
differential runs the same query over the SAME columnar rows with the path off and
on, and both arms read in the same order, so even float sums must be identical.
That is what validates the order-preserving accumulators.

Every comparison carries a node premise. A query the node quietly rejects runs the
scalar Agg in BOTH arms, so the comparison is two identical runs agreeing with each
other -- which is how sum(real) returning 0 got through an earlier version of the
shell suite.

ONE ARM IS STRONGER THAN THE ORIGINAL, BECAUSE THE ORIGINAL'S CANNOT FAIL (commandprompt#1162).
`native_groupagg.sh` asks ten aggregates over ten DIFFERENT columns to cost more
than one over one, and a scan projecting ten columns costs more whatever the
folding charge is. Measured with the charge stripped of its aggregate-count factor,
the exact defect commandprompt#349 fixed:

    10 aggregates over 10 columns   control 1253.01   mutant 803.01   still green
    10 aggregates over 1 column     control  800.50   mutant 350.50   reddens

This file holds the projection fixed and forces the path, so the difference is
exactly the term under test: 450.00 = cpu_operator_cost x 20,000 rows x 9 extra
aggregates. Forcing matters -- with the charge applied the planner prices that shape
out of the grouped path, so an unforced comparison changes which node it reads. Two
premises assert both costed plans ARE the grouped node.

Independent at every seam: the shell greps EXPLAIN text for the marker line while
this reads FORMAT JSON and asks whether a node carries the PROPERTY; the shell
compares server-side md5 while this compares sorted row tuples in Python; the shell
sets the GUC on the DATABASE because each q() opens a connection, while this sets it
on the session it holds.

Proved by removal against src/columnar_vector.c on the Debian PostgreSQL 18 the CI
cluster job uses. Control 76 checks passed; each mutation asserted to have matched
its anchor exactly once and restored byte-identical; .so hash per cell:

    A control                             3e77f7b94928   76 pass
    B deterministic-collation gate        45b0b7871974   plan: non-deterministic collation key falls back
    C folding charge's naggs factor       048828ac8797   ten aggregates cost more than one (commandprompt#349)
    D group-estimate bound                bec883463f75   the bound is accurate ... (got 299940 want 12), +1
    E pseudoconstant qual rejection       718fa73c1167   regress B2: gating WHERE falls back (not the node)

The ICU-gated collation pair is its own test, because cannot_run declares a whole
TEST unrunnable while check_skip declines one check. The five local source builds
are --without-icu and it declines there; the pg_config the cluster job is given is
--with-icu, so CI runs those arms.

`cluster_tests` 441 -> 457, derived by collection. `guard_tests` re-derived in the
same run and did NOT move: 382.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012RSw4qMHS7ByE7PY8Ns4cs
@jdatcmd

jdatcmd commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator

Your headline claim reproduces exactly, and I ran it rather than reading it. The .so moved 23c0b7d1d8e7 -> 3445dd801b19 under the mutation and back, so each side measured its own binary.

Stripping the aggregate-count factor — cpu_operator_cost * serialScanRows * (naggs > 0 ? naggs : 1)cpu_operator_cost * serialScanRows, the exact #349 defect:

THE PORT     ten aggregates cost more than one (#349):
               got 'no [1=350.5 10=350.5]' want 'yes'          <- REDDENS
             82 pass + 1 fail + 1 unrun

THE ORIGINAL 72 passed + 0 failed + 1 skipped                  <- GREEN

native_groupagg.sh is green on the regression it names, and the port catches it. That is #1162 confirmed from a second direction, and it is the second suite today whose shell half passes the defect it was written for.

Note also what the failure says: got 'no [1=350.5 10=350.5]'. Both costs, in the message, because of #1165. Under the old form this would have read got [no] want [yes] and the next person would have had to rebuild to learn what 350.5 was. The two changes compose exactly as intended.

Restored: md5 back to 23c0b7d1d8e7, 83 pass + 0 fail + 1 unrun.

One number in the PR body does not match the tool

33 names plus one template, missing: 0, two extras

compare_to_bash reports four:

extra    premise: and so is ten, so the two costs are comparable
extra    premise: one aggregate is costed as the grouped node
extra    premise: the cost fixture holds the rows the 450.00 is computed from
extra    premise: the pair loaded in full, so the mirror compares equal populations

The prose names the two costing premises and omits the two fixture ones. Not a defect in the change — all four are legitimate and the parity verdict is unaffected — but it is a count retyped into prose rather than printed from the tool, which is the thing that bit me twice today in the other direction. I would take the tool's four.

A question rather than a finding

You said you would put a tripwire in this port — "a premise that the fixtures this file creates are this run's and not a leftover". I do not see one. The five premises are about loading, costing and the node, not ownership.

I think that may be correct rather than forgotten, and I would rather ask than assume: pgc_conn gives each test a private schema and drops it at teardown, so a leftover table is not reachable the way it is in the shell harness. hilbert_cluster's tripwire guards a ROLE, which is cluster-global and genuinely leaks. If this port creates nothing cluster-global, the tripwire has nothing to watch and adding one would be a premise that cannot fail.

If that is your reasoning, say so in the file — the next person will ask the same question, and "not needed here, and here is the property that makes it unnecessary" is worth more than silence.

Everything else checked

parity          33 literal + 1 template, missing 0, re-derived here
port on PG17    83 pass + 0 fail + 1 unrun (the collation gate, correctly declined)
two oracles     the heap mirror cannot judge float order, the toggle differential can
                because both arms read the same rows in the same order -- that is the
                argument that makes the accumulators testable at all
node premises   every comparison carries one, which is what stops two identical
                scalar-Agg runs agreeing with each other
forcing         enable_hashagg=off / enable_sort=off, with the reason stated: with the
                charge applied the planner prices the grouped path OUT of the very
                query under test

Holding the approval only for the extras count and your answer on the tripwire. Neither is a blocker on the code.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NhwXKAgSmYDUjteWkfajHK

@jdatcmd jdatcmd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving e2ba533d. 14 of 14, CLEAN.

The headline is verified from my side rather than read: stripping the aggregate-count factor reddens the port with got 'no [1=350.5 10=350.5]' want 'yes' while native_groupagg.sh reports 72 passed + 0 failed on the same binary. The .so moved 23c0b7d1d8e7 -> 3445dd801b19 and back, so each side measured its own build. Detail in my comment above.

Not blocking on either of my two points, and here is why. The extras count is PR-body prose, and the correct number is recorded in my review comment, so the history is accurate through that even if the body is not — correct it if you are pushing again anyway. The tripwire is a question whose likely answer is that it would be vacuous here, since pgc_conn drops a private schema per test and this port creates nothing cluster-global. Blocking a verified, green change on a premise that probably cannot fail would be the wrong trade.

If the tripwire reasoning is what I think it is, a sentence in the file is worth more than the premise would have been — the next person will ask the same question, and the answer is a property of the harness rather than of this port.

@jdatcmd
jdatcmd merged commit 460a7a3 into commandprompt:main Sep 20, 2026
14 checks passed
OffgridwithJD pushed a commit to OffgridwithJD/pgcolumnar that referenced this pull request Sep 20, 2026
…pt#432)

56 names, `missing: 0`, no extras.

Core ANALYZE samples 30,000 rows and those rows are spread across every row group, so
every group is decoded for every column. pgcolumnar.analyze() reads ONE column, and
the statistics from a full read are EXACT where core's are estimates. Exactness is the
observable a sampled implementation cannot fake, which is what gets asserted.

THE DISCRIMINATION DOES NOT DEPEND ON LUCK. `k` is one row in ten NULL, and 0.1 is a
number a sampler reaches whenever it is lucky. `k7` is one row in SEVEN, so the truth
is 0.142856 and core's estimate is always a whole number of sampled rows over 30,000 --
there is no whole k with k/30000 = 0.142856. Core cannot report that fraction whatever
it draws. An earlier version of the shell suite gated on core being WRONG and failed a
correct suite about one run in 130 (commandprompt#487).

ONE NAMED REFUSAL, THREE UNNAMED. Four tests, split by fixture so only the first pays
the 500,000-row build. The shell prints ONE check_skip and exits, so exactly one test
names it; naming all four would publish three checks the original does not have.

VERIFIED AT RUNTIME. compare_to_bash grades the NAME and proves the string is PRESENT,
never that anything emits it. Spying on Expect._record: PG18 emits 55 of 56, and PG17
emits `pgcolumnar.analyze()` with verdict UNRUN.

Proved by removal against pgcolumnar--1.0-alpha4.sql; control 55 checks passed:

    per-column target -> global default   histogram honours the column's target
                                          (101 want 11), and the positional stride
    SET STATISTICS 0 skip disabled        ran without raising (22012: division by zero)
    null_frac / non-null count            null_frac exactly (0.11111111 want 0.1),
                                          and the DELETE arm (0.153846 want 0.133333)

The .so hash is NOT the instrument for a SQL mutation and is deliberately not quoted:
the C is unchanged, so it hashes identically in all four cells and four identical
hashes would look like evidence and be none. The .sql md5 moves per cell, and .sql is
in pgc_fingerprint's ROOT_SUFFIXES so the fingerprint moves and build_once reinstalls
-- measured 542af67fbc25 unmutated, 1cd8f666717b mutated, restored.

cluster_tests 458 -> 462, derived by collection; the branch stated 446 before the
rebase, correctly, against a main that commandprompt#1167 has since moved. guard_tests unmoved at
382. The
TESTS.md section number is derived from the document's highest heading rather than
typed, after two branches claimed 70 in the same afternoon.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012RSw4qMHS7ByE7PY8Ns4cs
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.

2 participants