Hi Hannu,

On Sun, Oct 25, 2026 at 12:48 PM Hannu Krosing <[email protected]>
wrote:
> Did you try to reach the limits where UNION DISTINCT ON fails to finish ?

Not to exhaustion, but the largest shapes I tried all finish on v3,
one run each with a 120 s cap:

  grid 1000x1000 (1M nodes): 62.5 s, 1999 iterations
  random digraph, 1M nodes, 4M edges: 18.5 s
  adversarial DAG, 800 nodes: 11.0 s

The DAG is the slow one per node: edge i->j costs (j-i)^2, so the
cheapest path is the unit chain and every node improves once per
wave; time grows about n^3 (27 ms at 100 nodes, 1.4 s at 400).

The one shape that does not finish is a negative cycle: with no
fixpoint it keeps improving costs until statement_timeout, as
Bellman-Ford without cycle detection. If there are shapes you care
about, I can run them.

> Did you have you any insights about why the reversal of relative
> performance happens at 200k groups ?
>
> Is it about Hash vs Ordered grouping behaviour or something else?

Both: hash vs ordered, plus an estimate problem on the UNION side,
which the attached diff fixes. I re-measured on master 126976a8ff with
v3, on a ladder of 1k to 500k groups, in two independent passes with
all cells alternated. Percentages below are first pass / second pass;
the tables are in measurements.txt.

UNION DISTINCT ON plans its HashAggregate for as many groups as there
are input rows. build_setop_child_paths() estimates each branch over
its whole target list (prepunion.c:683 with v3 applied), and with a
unique id every branch looks fully distinct, so the estimate is
2,000,000 whatever the key and the hash table is sized for it: 40 MB
with 200k actual groups, against 16 MB with the diff, which takes the
estimate over the DISTINCT ON key only, summed over the branches as
for a plain UNION. Against the classic rewrite over UNION ALL, at
200k groups:

  v3 as posted: +6.9% / -1.5% (the second within noise)
  v3 with the diff: -14.5% / -21.2%

and with the diff -44% / -46% at 100k groups, -68% / -68% at 1k. The
other callers of build_setop_child_paths() pass NIL and behave as
before. make check and isolation pass on -O2 and on cassert+ASan, and
the oracle from my first mail gives 4,500 of 4,500 cases OK.

On the inline side the reversal from my October 17 mail does not
reproduce on 126976a8ff with this data: the HashAgg path is
-25.1% / -30.6% against classic DISTINCT ON at 200k groups, and
roughly even at 500k (+5.2% / +0.1%).

Where hash still loses, it is hash vs ordered:

  UNION at 500k groups, with the diff: +26% / +22%
  inline at 200k, work_mem = 4MB (the table spills): +14% / +10%
  inline at 500k, work_mem = 4MB: +36% / +34%

Counted with perf stat, the hash path runs fewer instructions than the
sort but takes more cache misses once the table outgrows the L3 (16 MB
here; 500k groups is 36 MB even with the diff). The planner cannot see
that: in cost_agg() (costsize.c:2889-2900) the in-memory hashed cost
grows with the input rows only, so it picks HashAggregate at every
size on the ladder, at a fifth to a third of the Sort+Unique cost. A
plain GROUP BY on master is costed the same way, so I would treat the
remaining crossover as master's costing rather than something for v3.

> I wonder if there is a nice way to fix it ?
>
> What would be a good name for the unnamed first branch to use in error
> messages ?

It turns out not to come from v3. With ge(src, dst, cost), plain
master gives the same HINT for

  SELECT 1 AS node, 0 AS c UNION SELECT dst, src + cost FROM ge
  ORDER BY cost;

The name comes from 585e31fcb6d (September 2025), which replaced the
"*SELECT* n" aliases with generated names. A set operation's branches
can never be referenced from its ORDER BY, so I think the hint should
not offer them at all, and a DETAIL could name the branch by position
("the second UNION branch"). That is master code, a small thread of
its own; I can put a patch together if you think it is worth it.

> Thanks, I will very likely ask for them at some point.

It is attached as harness.txt: the differential oracle, the ladder
above, a small runner for alternated rounds, and the recursive cases.

The diff is yours to fold into your next version however you like.

Regards,
Manu
diff --git a/src/backend/optimizer/prep/prepunion.c 
b/src/backend/optimizer/prep/prepunion.c
index aea0677d2b..33b62561ef 100644
--- a/src/backend/optimizer/prep/prepunion.c
+++ b/src/backend/optimizer/prep/prepunion.c
@@ -55,6 +55,7 @@ static RelOptInfo *generate_recursion_path(SetOperationStmt 
*setOp,
 static void build_setop_child_paths(PlannerInfo *root, RelOptInfo *rel,
                                                                        bool 
trivial_tlist, List *child_tlist,
                                                                        List 
*interesting_pathkeys,
+                                                                       List 
*groupList,
                                                                        double 
*pNumGroups);
 static RelOptInfo *generate_union_paths(SetOperationStmt *op, PlannerInfo 
*root,
                                                                                
List *refnames_tlist,
@@ -399,7 +400,7 @@ generate_recursion_path(SetOperationStmt *setOp, 
PlannerInfo *root,
                                                                  
&lpath_trivial_tlist);
        if (lrel->rtekind == RTE_SUBQUERY)
                build_setop_child_paths(root, lrel, lpath_trivial_tlist, 
lpath_tlist,
-                                                               NIL, NULL);
+                                                               NIL, NIL, NULL);
        lpath = lrel->cheapest_total_path;
        /* The right path will want to look at the left one ... */
        root->non_recursive_path = lpath;
@@ -411,7 +412,7 @@ generate_recursion_path(SetOperationStmt *setOp, 
PlannerInfo *root,
                                                                  
&rpath_trivial_tlist);
        if (rrel->rtekind == RTE_SUBQUERY)
                build_setop_child_paths(root, rrel, rpath_trivial_tlist, 
rpath_tlist,
-                                                               NIL, NULL);
+                                                               NIL, NIL, NULL);
        rpath = rrel->cheapest_total_path;
        root->non_recursive_path = NULL;
 
@@ -486,13 +487,18 @@ generate_recursion_path(SetOperationStmt *setOp, 
PlannerInfo *root,
  *
  * interesting_pathkeys: if not NIL, also include paths that suit these
  * pathkeys, sorting any unsorted paths as required.
+ * groupList: if not NIL, the SortGroupClauses (tleSortGroupRef = output
+ * column number) that define a group, e.g. the DISTINCT ON key of a UNION
+ * DISTINCT ON; the estimate below is then made over those columns only.
+ * NIL means the whole target list.
  * *pNumGroups: if not NULL, we estimate the number of distinct groups
  * in the result, and store it there.
  */
 static void
 build_setop_child_paths(PlannerInfo *root, RelOptInfo *rel,
                                                bool trivial_tlist, List 
*child_tlist,
-                                               List *interesting_pathkeys, 
double *pNumGroups)
+                                               List *interesting_pathkeys, 
List *groupList,
+                                               double *pNumGroups)
 {
        RelOptInfo *final_rel;
        List       *setop_pathkeys = rel->subroot->setop_pathkeys;
@@ -680,11 +686,26 @@ build_setop_child_paths(PlannerInfo *root, RelOptInfo 
*rel,
                        subquery->hasAggs)
                        *pNumGroups = rel->cheapest_total_path->rows;
                else
+               {
+                       List       *groupExprs = 
get_tlist_exprs(subroot->parse->targetList,
+                                                                               
                         false);
+
+                       if (groupList != NIL)
+                       {
+                               List       *keyExprs = NIL;
+
+                               foreach_node(SortGroupClause, sgc, groupList)
+                                       keyExprs = lappend(keyExprs,
+                                                                          
list_nth(groupExprs,
+                                                                               
                sgc->tleSortGroupRef - 1));
+                               groupExprs = keyExprs;
+                       }
                        *pNumGroups = estimate_num_groups(subroot,
-                                                                               
          get_tlist_exprs(subroot->parse->targetList, false),
+                                                                               
          groupExprs,
                                                                                
          rel->cheapest_total_path->rows,
                                                                                
          NULL,
                                                                                
          NULL);
+               }
        }
 }
 
@@ -786,6 +807,7 @@ generate_union_paths(SetOperationStmt *op, PlannerInfo 
*root,
                if (rel->rtekind == RTE_SUBQUERY)
                        build_setop_child_paths(root, rel, trivial_tlist, 
child_tlist,
                                                                        
union_pathkeys,
+                                                                       
op->sortClauses ? groupList : NIL,
                                                                        op->all 
? NULL : &childGroups);
                else
                        childGroups = rel->rows;
@@ -1167,12 +1189,12 @@ generate_nonunion_paths(SetOperationStmt *op, 
PlannerInfo *root,
         */
        if (lrel->rtekind == RTE_SUBQUERY)
                build_setop_child_paths(root, lrel, lpath_trivial_tlist, 
lpath_tlist,
-                                                               
nonunion_pathkeys, &dLeftGroups);
+                                                               
nonunion_pathkeys, NIL, &dLeftGroups);
        else
                dLeftGroups = lrel->rows;
        if (rrel->rtekind == RTE_SUBQUERY)
                build_setop_child_paths(root, rrel, rpath_trivial_tlist, 
rpath_tlist,
-                                                               
nonunion_pathkeys, &dRightGroups);
+                                                               
nonunion_pathkeys, NIL, &dRightGroups);
        else
                dRightGroups = rrel->rows;
 
Many-groups follow-up: where HashAgg DISTINCT ON wins and loses

Builds: master 126976a8ff + Hannu's v3 0001..0005 ("v3"), the same plus the
attached nocfbot-union-distinct-on-estimate-on-v3.diff.txt ("v3+fix"), and
master alone ("master", for the classic forms).  -O2.  All in one lane:
4 Zen 5 cores sharing a 16 MB L3; rounds whose calibration micro-benchmark
ran >8% slower than idle were discarded and retried.
Data: two tables of 1,000,000 rows; gK has K distinct values (uniform);
zK is skewed (K * random()^3).  work_mem = 64MB unless noted, no parallel.
Inline:  SELECT DISTINCT ON (gK ORDER BY s DESC, id) gK, s, id FROM big1
  vs classic SELECT DISTINCT ON (gK) ... ORDER BY gK, s DESC, id (master).
UNION:   big1 UNION DISTINCT ON (gK ORDER BY s DESC, id) big2
  vs classic DISTINCT ON over big1 UNION ALL big2 (master).
Two independent passes, every cell alternated with all the others; each
number below is a median in ms, first pass then second (10 / 12 clean
rounds).  The second pass ran ~4% faster across the board, so compare
within a pass; the deltas are median differences with a bootstrap 95% CI,
per pass.

Inline DISTINCT ON (1M rows), ms        v3 hash        v3 sort       classic
  g1000                              91     90     310    306     310    306
  g10000                            102    100     280    273     282    276
  g50000                            115    112     267    257     265    256
  g100000                           142    127     264    258     265    257
  g200000                           193    175     258    254     258    252
  g500000                           267    246     252    247     254    246
  z10000                            102     99     300    289     293    287
  z200000                           158    144     267    262     269    261
  work_mem 4MB (the hash table spills):
  g200000                           203    193                   178    175
  g500000                           253    239                   186    179
  192-byte text in the output:
  g1000                             102     97                   483    569
  g200000                           350    343                   420    412

UNION DISTINCT ON (2 x 1M rows), ms     v3 hash      v3+fix hash       classic
  g1000                             201    197     186    188     590    590
  g10000                            231    222     206    204     533    527
  g50000                            291    263     233    229     491    490
  g100000                           385    333     275    263     490    486
  g200000                           511    475     409    380     478    483
  g500000                           643    606     587    561     466    461
  z10000                            226    217     202    200     558    552
  z200000                           442    388     313    295     502    496

Deltas (new vs reference), first pass / second pass:
  inline 1k, v3 hash vs classic:
      -70.5% [-72.4..-70.1]  /  -70.5% [-70.9..-70.3]
  inline 200k, v3 hash vs classic:
      -25.1% [-32.1..-13.6]  /  -30.6% [-31.8..-29.6]
  inline 500k, v3 hash vs classic:
      +5.2% [+0.4..+10.4]  /  +0.1% [-0.9..+1.6]
  inline 200k, 4MB, v3 hash vs classic:
      +14.2% [+8.9..+22.5]  /  +10.4% [+8.1..+12.7]
  inline 500k, 4MB, v3 hash vs classic:
      +35.9% [+30.8..+39.5]  /  +33.6% [+31.6..+35.1]
  UNION 1k, v3+fix vs classic:
      -68.4% [-69.7..-62.8]  /  -68.1% [-68.3..-67.6]
  UNION 100k, v3+fix vs classic:
      -43.8% [-46.7..-41.4]  /  -45.9% [-47.3..-45.3]
  UNION 200k, v3 vs classic:
      +6.9% [+4.6..+16.8]  /  -1.5% [-4.4..+3.4]
  UNION 200k, v3+fix vs classic:
      -14.5% [-18.1..-3.6]  /  -21.2% [-23.3..-15.8]
  UNION 200k, v3+fix vs v3:
      -20.0% [-27.1..-9.8]  /  -20.0% [-24.1..-14.1]
  UNION 500k, v3+fix vs classic:
      +26.0% [+20.6..+35.5]  /  +21.8% [+20.5..+24.1]
  UNION z200k, v3+fix vs classic:
      -37.6% [-40.2..-26.8]  /  -40.5% [-41.4..-37.4]

Tried and dropped, each on top of v3+fix (same passes; the fix does not
touch the inline path, so v3 is the inline reference):
  Bump instead of AllocSet for the tuples, inline 200k:
      -5.2% [-19.4..+9.3]  /  -2.6% [-6.6..+0.7]
  sort keys cached in the entry, inline 1k:
      -6.9% [-7.9..-5.2]  /  -13.0% [-13.8..-11.4]
  sort keys cached in the entry, inline 200k:
      +7.5% [-7.1..+20.5]  /  +10.8% [+9.0..+13.4]
  charging the tie-break comparisons in create_agg_path(): no plan changes
  anywhere on the ladder (HashAggregate stays at ~1/4 of the Sort+Unique cost).

Planner choice: HashAggregate at every point of the ladder, for v3 and
v3+fix alike; its estimated cost is a fifth to a third of Sort+Unique.

UNION DISTINCT ON, estimated groups and hash table memory (EXPLAIN ANALYZE):
  groups K   est. v3   est. fix     actual   memory v3   memory fix
  --------   -------   --------   --------   ---------   ----------
      1000   2000000       2000       1000    32857 kB       153 kB
     10000   2000000      19931      10000    33305 kB      1049 kB
     50000   2000000      96801      50000    34841 kB      4121 kB
    100000   2000000     184533     100000    36889 kB      8217 kB
    200000   2000000     338055     199991    40985 kB     16409 kB
    500000   2000000     678786     490827    53273 kB     36889 kB

Recursive UNION DISTINCT ON (node ORDER BY cost), shortest path from node 1,
one run each on v3, other lane (8 Zen 5c cores, 8 MB L3), 120 s cap:
  grid 200x200 (40k nodes): 441 ms, 399 iterations
  grid 500x500 (250k nodes): 7.1 s, 999 iterations
  grid 1000x1000 (1M nodes): 62.5 s, 1999 iterations
  random, 100k nodes, 400k edges: 1.2 s, 28 iterations
  random, 1M nodes, 4M edges: 18.5 s, 33 iterations
  adversarial DAG, 100 nodes: 27 ms, 100 iterations
  adversarial DAG, 200 nodes: 187 ms, 200 iterations
  adversarial DAG, 400 nodes: 1.4 s, 400 iterations
  adversarial DAG, 800 nodes: 11.0 s, 800 iterations
  negative cycle (3 nodes): never finishes (statement_timeout 20 s)
  (adversarial DAG: edge i->j costs (j-i)^2, so the cheapest path is the unit
  chain and every node improves once per wave; time grows ~n^3)
Harness for the DISTINCT ON / UNION DISTINCT ON measurements (plain text 
bundle).
Split at the '===== file:' lines.  Python 3, psql and the builds' bin 
directories:
  PGBIN_<name>=/path/to/bin for every build you want to compare.

diffgen.py  differential oracle: inline / UNION / recursive DISTINCT ON against
            row_number(), classic DISTINCT ON over UNION ALL, and Bellman-Ford
perf24.py   the many-groups ladder (data, queries, one timed run per call)
ladder.sh   alternated rounds over a list of build:query cells, median + CI95
rec24.py    recursive limits (grids, random graphs, adversarial DAG, negative
            cycle) and the error-HINT cases

===== file: diffgen.py =====
#!/usr/bin/env python3
"""Differential / metamorphic harness for the v3 DISTINCT ON series (Hannu 
Krosing).

Every case builds random tables (NULLs, varlenas around the 1-byte header
boundary, incompressible TOASTed text, numeric with varying scale), runs the
new operator, and checks it against a formulation PostgreSQL already computes:

  inline   SELECT DISTINCT ON (k ORDER BY s)   vs  row_number() OVER (PARTITION 
BY k ORDER BY s)
  union    A UNION DISTINCT ON (k ORDER BY s) B vs  classic DISTINCT ON over A 
UNION ALL B
  recursive UNION DISTINCT ON (node ORDER BY cost) vs Dijkstra / Bellman-Ford 
in Python

Ties are legal: the tolerant check only demands one row per key, that the row
exists in the input, and that its sort tuple equals the per-key optimum.  When
the spec is tie-free (id appended) the result is also compared row for row.

usage: diffgen.py --bindir B --variant V --out DIR --seed0 S --cases N
                  [--workers W] [--budget SEC] [--port0 P] [--families a,b]
       diffgen.py --bindir B --replay SEED          (print one case's SQL)
"""
import argparse
import json
import os
import random
import re
import shutil
import subprocess
import sys
import time
from decimal import Decimal
from multiprocessing import Process

FAMILIES = [('inline', 22), ('classic', 5), ('inline_where', 8), ('union', 18),
            ('union3', 10), ('union_mixed', 10), ('union_where', 7),
            ('recursive', 15), ('recursive_neg', 5),
            ('inline_spill', 0), ('union_spill', 0),      # targeted: only via 
--families
            ('union_merge', 0)]

GUCS = [
    {},
    {'enable_hashagg': 'off'},
    {'enable_sort': 'off'},
    {'enable_sort': 'off', 'work_mem': "'64kB'"},
    {'enable_sort': 'off', 'work_mem': "'64kB'", 'hash_mem_multiplier': '1'},
    {'enable_hashagg': 'off', 'work_mem': "'64kB'"},
    {'enable_seqscan': 'off'},
    {'max_parallel_workers_per_gather': '4', 'parallel_setup_cost': '0',
     'parallel_tuple_cost': '0', 'min_parallel_table_scan_size': '0'},
]

K2VALS = ["''", "'a'", "'A'", "'b'", "'ab'", "'ä'", "repeat('k', 125)",
          "repeat('k', 126)", "repeat('k', 127)", "repeat('k', 3000)"]

SETUP_SQL = r"""
CREATE OR REPLACE FUNCTION public.rlen(m int) RETURNS int VOLATILE LANGUAGE 
plpgsql AS $f$
DECLARE mm int := m;
BEGIN
  IF mm < 0 THEN mm := floor(random() * 4)::int; END IF;
  RETURN CASE mm WHEN 0 THEN floor(random() * 11)::int
                 WHEN 1 THEN 100 + floor(random() * 41)::int
                 WHEN 2 THEN 1500 + floor(random() * 1001)::int
                 ELSE 2500 + floor(random() * 6501)::int END;
END $f$;
CREATE OR REPLACE FUNCTION public.rtxt(n int) RETURNS text VOLATILE LANGUAGE 
sql AS $f$
  SELECT coalesce(left(string_agg(md5(random()::text), ''), n), '')
  FROM generate_series(0, n / 32) $f$;
"""

CRASH_MARKS = ('server closed the connection unexpectedly', 'connection to 
server was lost',
               'terminating connection because of crash', 'no connection to the 
server')
LAST_SPEC = None
LOG_MARKS = ('AddressSanitizer', 'runtime error:', 'TRAP:', 'terminated by 
signal',
             'LeakSanitizer', 'UndefinedBehaviorSanitizer')


# ---------------------------------------------------------------- generation

def gen_table(rng, name, off, spill=False, small=False):
    n = rng.choice([0, 1, 2, 5, 20, 100, 300, 1000, 2000])
    if spill:
        n = rng.choice([5000, 20000])
    pn = rng.choice([0, 0.05, 0.2, 0.5])
    dk1 = rng.choice([1, 2, 5, 20, 1000])
    if spill:
        dk1 = rng.choice([500, 5000, 50000])
    ds1 = rng.choice([1, 3, 10, 1000000])
    ds3 = rng.choice([1, 10, 1000])
    k2 = rng.sample(K2VALS, rng.randint(1, len(K2VALS)))
    s2mode = rng.choice([-1, 0, 1, 2, 3])
    plmode = rng.choice([-1, 0, 0, 1, 2, 3])
    storage = rng.choice(['', '', 'EXTERNAL', 'MAIN', 'PLAIN'])
    fseed = rng.random() * 2 - 1
    sql = [f"CREATE TABLE {name} (id int, k1 int, k2 text, s1 int, s2 text, s3 
numeric, "
           f"pl text, b bool);"]
    if small:                                # indexed sort columns: btree rows 
must stay < 8 kB
        s2mode = 0 if s2mode in (-1, 2, 3) else s2mode
        plmode = 0 if plmode in (-1, 2, 3) else plmode
    if storage == 'PLAIN':
        plmode = 0 if plmode != 1 else 1      # PLAIN cannot hold > 8kB rows
        s2mode = 0 if s2mode not in (0, 1) else s2mode
    if storage:
        sql.append(f"ALTER TABLE {name} ALTER COLUMN pl SET STORAGE {storage}, "
                   f"ALTER COLUMN s2 SET STORAGE {storage};")
    sql.append(f"SELECT setseed({fseed:.6f}) \\g /dev/null")
    sql.append(
        f"INSERT INTO {name} SELECT g + {off}, "
        f"CASE WHEN random() < {pn} THEN NULL ELSE floor(random() * {dk1})::int 
END, "
        f"CASE WHEN random() < {pn} THEN NULL ELSE (ARRAY[{', '.join(k2)}])"
        f"[1 + floor(random() * {len(k2)})::int] END, "
        f"CASE WHEN random() < {pn} THEN NULL ELSE floor(random() * {ds1})::int 
END, "
        f"CASE WHEN random() < {pn} THEN NULL ELSE 
public.rtxt(public.rlen({s2mode})) END, "
        f"CASE WHEN random() < {pn} THEN NULL ELSE "
        f"round((random() * {ds3})::numeric, floor(random() * 4)::int) END, "
        f"public.rtxt(public.rlen({plmode})), random() < 0.5 "
        f"FROM generate_series(1, {n}) g;")
    if spill:
        # payload length is a function of the tie-breaker: successive "better" 
rows for a
        # key are sometimes shorter (in-place overwrite) and sometimes longer 
(realloc)
        sql.append(f"UPDATE {name} SET pl = public.rtxt(((coalesce(s1, 
0)::bigint * 7919) % 2500)::int), "
                   f"s2 = CASE WHEN s2 IS NULL THEN NULL ELSE 
public.rtxt((coalesce(s1, 0) * 31) % 300) END;")
    if rng.random() < 0.3:
        cols = rng.sample(['k1', 'k2', 's1', 's3', 'b'], rng.randint(1, 2))
        sql.append(f"CREATE INDEX ON {name} ({', '.join(cols)});")
    return '\n'.join(sql)


def sort_item(rng, e):
    return e + rng.choice(['', ' ASC', ' DESC']) + rng.choice(['', '', ' NULLS 
FIRST', ' NULLS LAST'])


def gen_spec(rng, key_pool, sort_pool, tie_free):
    keys = rng.sample(key_pool, rng.choice([1, 1, 1, 2]))
    if rng.random() < 0.05:
        keys.append(keys[0])
    sorts = [(e, sort_item(rng, e)) for e in rng.sample(sort_pool, 
rng.choice([1, 1, 2, 3]))]
    if tie_free and not any(e == 'id' for e, _ in sorts):
        sorts.append(('id', rng.choice(['id', 'id DESC'])))
    return keys, sorts


def spec_sql(keys, sorts, order=True):
    if order and sorts:
        return f"DISTINCT ON ({', '.join(keys)} ORDER BY {', '.join(s for _, s 
in sorts)})"
    return f"DISTINCT ON ({', '.join(keys)})"


def tolerant_check(T, K, sorts, sel, with_best=True):
    """sel: select list producing r's columns from T rows."""
    Kl = ', '.join(K)
    Sx = ', '.join(e for e, _ in sorts)
    So = ', '.join(s for _, s in sorts)
    best = 'true'
    if with_best and sorts:
        mine = f"SELECT {Kl}, {Sx} FROM ({T}) t WHERE t.id IN (SELECT r.id FROM 
r)"
        orc = (f"SELECT {Kl}, {Sx} FROM (SELECT t.*, row_number() OVER 
(PARTITION BY {Kl} "
               f"ORDER BY {So}) AS rn__ FROM ({T}) t) t WHERE rn__ = 1")
        best = (f"(NOT EXISTS (({mine}) EXCEPT ALL ({orc})) AND "
                f"NOT EXISTS (({orc}) EXCEPT ALL ({mine})))")
    return (f"SELECT 'CHK|' || n_r || '|' || n_k || '|' || ids_ok || '|' || 
rows_ok || '|' || best_ok "
            f"FROM (SELECT (SELECT count(*) FROM r) AS n_r, "
            f"(SELECT count(*) FROM (SELECT DISTINCT {Kl} FROM ({T}) t) x) AS 
n_k, "
            f"(SELECT count(DISTINCT id) = count(*) FROM r) AS ids_ok, "
            f"NOT EXISTS (SELECT * FROM r EXCEPT ALL SELECT {sel} FROM ({T}) t "
            f"WHERE t.id IN (SELECT r.id FROM r)) AS rows_ok, "
            f"{best} AS best_ok) z;")


def exact_check(O):
    return (f"SELECT 'EXA|' || (SELECT count(*) FROM r) || '|' || (SELECT 
count(*) FROM ({O}) o) "
            f"|| '|' || (NOT EXISTS (SELECT * FROM r EXCEPT ALL SELECT * FROM 
({O}) o) AND "
            f"NOT EXISTS (SELECT * FROM ({O}) o EXCEPT ALL SELECT * FROM r));")


def rand_pred(rng, cols):
    c = rng.choice([x for x in cols if x != 'id'] or ['id'])
    return {
        'k1': f"k1 {rng.choice(['=', '<', '>', '<>'])} {rng.randint(0, 5)}",
        'k2': rng.choice(["k2 IS NULL", "k2 > 'a'", "length(k2) > 100", "k2 = 
''"]),
        's1': rng.choice(["s1 IS NULL", f"s1 < {rng.randint(0, 10)}", "s1 % 2 = 
0"]),
        's2': rng.choice(["s2 IS NULL", "length(s2) > 120", "s2 < '8'"]),
        's3': rng.choice(["s3 IS NOT NULL", f"s3 > {rng.randint(0, 10)}"]),
        'pl': rng.choice(["length(pl) > 2000", "pl < '5'"]),
        'b': rng.choice(["b", "NOT b", "b IS NULL"]),
        'lpl': "lpl > 100", 's2x': "s2x IS NULL", 's3d': "s3d > 3",
    }.get(c, 'true')


def build_inline(rng, family):
    tie_free = family == 'inline_where' or rng.random() < 0.3
    keys, sorts = gen_spec(rng, ['k1', 'k2', 'b', '(k1 % 3)', 'lower(k2)'],
                           ['s1', 's2', 's3', 'lower(s2)', 'length(pl)', 'pl', 
'k1', 'k2', 'id', 'b'],
                           tie_free)
    extra = [('k1', 'k1'), ('k2', 'k2'), ('s1', 's1'), ('s2', 's2'), ('s3', 
's3'), ('pl', 'pl'),
             ('b', 'b'), ('length(pl)', 'lpl'), ("s2 || '#'", 's2x'), ('s3 * 
2', 's3d')]
    proj = rng.sample(extra, rng.randint(0, 5)) + [('id', 'id')]
    rng.shuffle(proj)
    sel = ', '.join(f"{e} AS {a}" for e, a in proj)
    where = rng.choice(['', '', f" WHERE {rand_pred(rng, ['k1', 's1', 's2', 
'b'])}"])
    T = f"SELECT * FROM a{where}"
    keys_sql = list(keys)
    for i, k in enumerate(keys_sql):          # occasional positional reference
        pos = [j for j, (e, _) in enumerate(proj) if e == k]
        if pos and rng.random() < 0.15:
            keys_sql[i] = str(pos[0] + 1)
    if family == 'classic':
        ks = list(dict.fromkeys(keys))
        q = (f"SELECT DISTINCT ON ({', '.join(ks)}) {sel} FROM a{where} ORDER 
BY "
             f"{', '.join(ks + [s for _, s in sorts])}")
        keys = ks
    else:
        outer = rng.choice(['', '', f" ORDER BY {rng.choice(proj)[1]}"])
        q = f"SELECT {spec_sql(keys_sql, sorts)} {sel} FROM a{where}{outer}"
    checks = [tolerant_check(T, keys, sorts, sel)]
    orc = (f"SELECT {sel} FROM (SELECT t.*, row_number() OVER (PARTITION BY {', 
'.join(keys)} "
           f"ORDER BY {', '.join(s for _, s in sorts)}) AS rn__ FROM ({T}) t) t 
WHERE rn__ = 1")
    if family == 'inline_where':
        pred = rand_pred(rng, [a for _, a in proj])
        q = f"SELECT * FROM ({q}) s WHERE {pred}"
        checks = [exact_check(f"SELECT * FROM ({orc}) s WHERE {pred}")]
    elif tie_free:
        checks.append(exact_check(orc))
    return ['a'], q, checks


def build_union(rng, family):
    nt = 3 if family in ('union3', 'union_mixed') else 2
    tie_free = family in ('union_mixed', 'union_where') or rng.random() < 0.3
    keys, sorts = gen_spec(rng, ['k1', 'k2', 'b'], ['s1', 's2', 's3', 'pl', 
'k1', 'k2', 'id', 'b'],
                           tie_free)
    need = set(keys) | {e for e, _ in sorts} | {'id'}
    pool = [c for c in ['k1', 'k2', 's1', 's2', 's3', 'pl', 'b'] if c not in 
need]
    cols = sorted(need) + rng.sample(pool, min(rng.randint(0, 3), len(pool)))   
# sorted: no hash order
    rng.shuffle(cols)
    cl = ', '.join(cols)
    tabs = ['a', 'b', 'c'][:nt]
    br = {t: f"SELECT {cl} FROM {t}" + rng.choice(['', '', f" WHERE 
{rand_pred(rng, cols)}"])
          for t in tabs}
    keys_sql = [str(cols.index(k) + 1) if rng.random() < 0.15 else k for k in 
keys]
    with_order = rng.random() < 0.9
    global LAST_SPEC
    LAST_SPEC = (keys, sorts)
    sp = spec_sql(keys_sql, sorts, with_order)
    T2 = f"{br['a']} UNION ALL {br['b']}"
    O1 = (f"SELECT DISTINCT ON ({', '.join(keys)}) * FROM ({T2}) o ORDER BY "
          f"{', '.join(keys + [s for _, s in sorts])}")
    if family == 'union':
        outer = rng.choice(['', '', f" ORDER BY {rng.choice(cols)}"])
        q = f"{br['a']} UNION {sp} {br['b']}{outer}"
        checks = [tolerant_check(T2, keys, sorts, cl, with_order)]
        if tie_free and with_order:
            checks.append(exact_check(O1))
        return tabs, q, checks
    if family == 'union_where':
        pred = rand_pred(rng, cols)
        q = f"SELECT * FROM ({br['a']} UNION {sp} {br['b']}) s WHERE {pred}"
        if not with_order:
            return tabs, f"{br['a']} UNION {sp} {br['b']}", [tolerant_check(T2, 
keys, sorts, cl, False)]
        return tabs, q, [exact_check(f"SELECT * FROM ({O1}) s WHERE {pred}")]
    if family == 'union3':
        if rng.random() < 0.5:                   # same spec, left- or 
right-nested
            T3 = f"{T2} UNION ALL {br['c']}"
            if rng.random() < 0.5:
                q = f"{br['a']} UNION {sp} {br['b']} UNION {sp} {br['c']}"
            else:
                q = f"{br['a']} UNION {sp} ({br['b']} UNION {sp} {br['c']})"
            return tabs, q, [tolerant_check(T3, keys, sorts, cl, with_order)]
        # inner spec tie-free, outer spec different (needs exact inner oracle)
        if not with_order:
            sp = spec_sql(keys_sql, sorts, True)
        if not any(e == 'id' for e, _ in sorts):
            sorts = sorts + [('id', 'id')]
            sp = spec_sql(keys_sql, sorts, True)
            O1 = (f"SELECT DISTINCT ON ({', '.join(keys)}) * FROM ({T2}) o 
ORDER BY "
                  f"{', '.join(keys + [s for _, s in sorts])}")
        keys2 = [k for k in rng.sample(['k1', 'k2', 'b'], 1) if k in cols] or 
[keys[0]]
        sorts2 = [(e, sort_item(rng, e)) for e in rng.sample([c for c in cols], 
1)]
        q = f"({br['a']} UNION {sp} {br['b']}) UNION {spec_sql(keys2, sorts2)} 
{br['c']}"
        T3 = f"SELECT * FROM ({O1}) x1 UNION ALL {br['c']}"
        return tabs, q, [tolerant_check(T3, keys2, sorts2, cl, True)]
    # union_mixed: tie-free inner, composed with an ordinary set operation
    if not with_order:
        sp = spec_sql(keys_sql, sorts, True)
    op = rng.choice(['UNION ALL', 'UNION', 'INTERSECT', 'INTERSECT ALL', 
'EXCEPT', 'EXCEPT ALL'])
    other = rng.choice([br['c'], br['a'], f"SELECT {cl} FROM a"])
    if rng.random() < 0.5:
        q = f"({br['a']} UNION {sp} {br['b']}) {op} {other}"
        O = f"SELECT * FROM ({O1}) x1 {op} {other}"
    else:
        q = f"{other} {op} ({br['a']} UNION {sp} {br['b']})"
        O = f"{other} {op} SELECT * FROM ({O1}) x1"
    if rng.random() < 0.5:                   # outer qual over the nested set 
operation
        pred = rand_pred(rng, cols)
        q = f"SELECT * FROM ({q}) s WHERE {pred}"
        O = f"SELECT * FROM ({O}) s WHERE {pred}"
    return tabs, q, [exact_check(O)]


def gen_graph(rng, neg):
    n = rng.choice([2, 3, 5, 10, 20, 40, 80])
    m = rng.randint(0, n * rng.choice([1, 2, 4]))
    textkey = rng.random() < 0.3
    numeric = rng.random() < 0.3
    names = ([f"n{i}{'x' * rng.choice([0, 0, 5, 130])}" for i in range(n)] if 
textkey
             else list(range(1, n + 1)))
    h = [rng.randint(-20, 20) for _ in range(n)]
    edges = []
    for _ in range(m):
        u, v = rng.randrange(n), rng.randrange(n)
        w = Decimal(rng.randint(0, 30)) if not numeric else \
            Decimal(rng.randint(0, 3000)) / Decimal(rng.choice([1, 10, 100]))
        if neg:
            w = w + h[u] - h[v]           # potentials: negative edges, no 
negative cycle
        edges.append((names[u], names[v], w))
    seeds = []
    for _ in range(rng.choice([1, 1, 1, 2, 3])):
        seeds.append((names[rng.randrange(n)], Decimal(rng.choice([0, 0, 5, 
100]))))
    return names, edges, seeds, textkey, numeric


def shortest(names, edges, seeds):
    """Bellman-Ford (handles the potential-shifted negative edges)."""
    dist = {}
    for s, c in seeds:
        if s not in dist or c < dist[s]:
            dist[s] = c
    for _ in range(len(names) + 1):
        changed = False
        for u, v, w in edges:
            if u in dist and (v not in dist or dist[u] + w < dist[v]):
                dist[v] = dist[u] + w
                changed = True
        if not changed:
            return dist
    raise RuntimeError('negative cycle in generator')


def build_recursive(rng, family):
    names, edges, seeds, textkey, numeric = gen_graph(rng, family == 
'recursive_neg')
    kt = 'text' if textkey else 'int'
    ct = 'numeric' if numeric else 'int'
    if not numeric:
        edges = [(u, v, int(w)) for u, v, w in edges]
        seeds = [(s, int(c)) for s, c in seeds]
    lit = (lambda x: f"'{x}'") if textkey else str
    sql = [f"CREATE TABLE e (src {kt}, dst {kt}, w {ct});",
           f"CREATE TABLE seeds (node {kt}, cost {ct});"]
    if edges:
        sql.append("INSERT INTO e VALUES " + ', '.join(f"({lit(u)}, {lit(v)}, 
{w})"
                                                       for u, v, w in edges) + 
';')
    sql.append("INSERT INTO seeds VALUES " + ', '.join(f"({lit(s)}, {c})" for 
s, c in seeds) + ';')
    if rng.random() < 0.3:
        sql.append("CREATE INDEX ON e (src);")
    path = rng.random() < 0.6
    bound = None
    if family == 'recursive' and rng.random() < 0.2:
        bound = rng.randint(5, 200)
    pcol = ", path" if path else ""
    pseed = f", ARRAY[node]" if path else ""
    pstep = ", sp.path || e.dst" if path else ""
    join = rng.choice(["FROM sp JOIN e ON sp.node = e.src",
                       "FROM e JOIN sp ON e.src = sp.node",
                       "FROM sp, e WHERE sp.node = e.src"])
    bw = ""
    if bound is not None:
        bw = (" AND " if "WHERE" in join else " WHERE ") + f"sp.cost + e.w < 
{bound}"
    order = rng.choice(["cost", "cost ASC", "cost ASC NULLS LAST"])
    q = (f"WITH RECURSIVE sp(node, cost{pcol}) AS (SELECT node, cost{pseed} 
FROM seeds "
         f"UNION DISTINCT ON (node ORDER BY {order}) SELECT e.dst, sp.cost + 
e.w{pstep} "
         f"{join}{bw})")
    if rng.random() < 0.25:                  # SEARCH / CYCLE are accepted by 
v3: same distances
        q += rng.choice([" SEARCH DEPTH FIRST BY node SET seq__",
                         " SEARCH BREADTH FIRST BY node SET seq__",
                         " CYCLE node SET is_cycle__ USING cpath__"])
    q += f" SELECT node, cost{', path' if path else ''} FROM sp"
    dist = shortest(names, edges, seeds)
    if bound is not None:
        seedset = {s for s, _ in seeds}
        dist = {k: v for k, v in dist.items() if v < bound or k in seedset}
        # seeds keep their best seed cost unless a cheaper in-bound path exists
        sc = {}
        for s, c in seeds:
            sc[s] = min(sc.get(s, c), c)
        d2 = shortest(names, [(u, v, w) for u, v, w in edges], seeds)
        dist = {}
        # bounded search: Bellman-Ford restricted to relaxations under the bound
        dist = dict(sc)
        for _ in range(len(names) + 1):
            ch = False
            for u, v, w in edges:
                if u in dist and dist[u] + w < bound and (v not in dist or 
dist[u] + w < dist[v]):
                    dist[v] = dist[u] + w
                    ch = True
            if not ch:
                break
        del d2
    expect = {'dist': {str(k): str(v) for k, v in dist.items()}, 'path': path,
              'edges': [(str(u), str(v), str(w)) for u, v, w in edges],
              'seeds': [(str(s), str(c)) for s, c in seeds]}
    return '\n'.join(sql), q, expect


def build_case(seed, families=None):
    rng = random.Random(seed)
    fams = [f for f in FAMILIES if not families or f[0] in families]
    # zero-weight (targeted) families only count when asked for explicitly; 
with no filter the
    # cumulative weights equal the original list, so a seed maps to the same 
case everywhere
    family = rng.choices([f for f, _ in fams], [(w or 1) if families else w for 
_, w in fams])[0]
    guc = dict(rng.choice(GUCS))
    merge = family == 'union_merge'
    if merge:                                # pre-sorted branches: MergeAppend 
-> Unique
        family = 'union'
        guc = {'enable_hashagg': 'off', 'enable_seqscan': 'off', 
'enable_bitmapscan': 'off'}
    spill = family.endswith('_spill')
    if spill:
        family = family[:-len('_spill')]
        guc = {'enable_sort': 'off', 'work_mem': "'64kB'", 
'hash_mem_multiplier': '1'}
    if family.startswith('recursive'):
        guc = dict(rng.choice([{}, {'enable_hashagg': 'off'}, {'work_mem': 
"'64kB'"}]))
        setup, q, expect = build_recursive(rng, family)
        return family, guc, setup, q, None, expect
    if family in ('inline', 'classic', 'inline_where'):
        tabs, q, checks = build_inline(rng, family)
    else:
        tabs, q, checks = build_union(rng, family)
    setup = '\n'.join(gen_table(rng, t, i * 1000000, spill, merge) for i, t in 
enumerate(tabs))
    if merge:
        ks, ss = LAST_SPEC
        icols = list(dict.fromkeys(ks)) + [x for _, x in ss]
        setup += ''.join(f"\nCREATE INDEX ON {t} ({', '.join(icols)});" for t 
in tabs)
    view = rng.random() < 0.15
    return family, guc, setup, q, checks, {'view': view}


def case_sql(seed, families=None):
    family, guc, setup, q, checks, expect = build_case(seed, families)
    if os.environ.get('DIFFGEN_MUTATE'):     # harness self-test: break q, keep 
the oracle
        if family.startswith('recursive'):
            q = q.replace('sp.cost + e.w', 'sp.cost + e.w + 1', 1)
        elif family != 'classic':
            q = q.replace(' ORDER BY ', ' ORDER BY id DESC, ', 1)
    gs = '\n'.join(f"SET {k} = {v};" for k, v in guc.items())
    out = ["\\set VERBOSITY verbose", "\\echo @@PHASE setup",
           "SET client_min_messages = warning;", "DROP SCHEMA IF EXISTS c 
CASCADE;",
           "CREATE SCHEMA c;", "SET search_path = c, public;", setup, 
"ANALYZE;",
           "SET statement_timeout = '20s';", gs]
    view = checks is not None and expect.get('view')
    if view:
        out += ["\\echo @@PHASE view", f"CREATE VIEW v AS {q};",
                "DO $d$ BEGIN EXECUTE 'CREATE VIEW v2 AS ' || 
pg_get_viewdef('v'::regclass); END $d$;",
                "SELECT 'RT|' || (pg_get_viewdef('v'::regclass) = 
pg_get_viewdef('v2'::regclass));"]
        qrun = "SELECT * FROM v"
    else:
        qrun = q
    out += ["\\echo @@PHASE explain",
            f"EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) {qrun};"]
    if checks is None:
        out += ["\\echo @@PHASE query", f"{qrun};"]
    else:
        out += ["\\echo @@PHASE query", f"CREATE TEMP TABLE r AS {qrun};",
                "\\echo @@PHASE check", "RESET ALL;", "SET search_path = c, 
public;",
                "SET statement_timeout = '60s';"] + checks
        if view:
            out += ["DROP TABLE r;", "CREATE TEMP TABLE r AS SELECT * FROM 
v2;"] + checks
    out.append("\\echo @@PHASE end")
    return family, guc, q, '\n'.join(out) + '\n', expect


# ------------------------------------------------------------------- checking

def parse_phases(text):
    ph, cur = {}, 'pre'
    for line in text.splitlines():
        if line.startswith('@@PHASE '):
            cur = line.split()[1]
            ph.setdefault(cur, [])
            continue
        ph.setdefault(cur, []).append(line)
    return ph


ERR_RE = re.compile(r'ERROR:\s+([0-9A-Z]{5}):\s*(.*)')


def check_recursive(lines, expect):
    rows = [l for l in lines if '|' in l and not l.startswith('psql:')]
    got, problems = {}, []
    for l in rows:
        parts = l.split('|')
        node, cost = parts[0], parts[1]
        if node in got:
            problems.append(f'duplicate key {node}')
        got[node] = (Decimal(cost), parts[2] if len(parts) > 2 else None)
    want = {k: Decimal(v) for k, v in expect['dist'].items()}
    if set(got) != set(want):
        problems.append(f'key set differs: missing={sorted(set(want) - 
set(got))[:5]} '
                        f'extra={sorted(set(got) - set(want))[:5]}')
    for k in set(got) & set(want):
        if got[k][0] != want[k]:
            problems.append(f'cost {k}: got {got[k][0]} want {want[k]}')
    if expect['path']:
        wmin = {}
        for u, v, w in expect['edges']:
            wmin[(u, v)] = min(wmin.get((u, v), Decimal(w)), Decimal(w))
        seedc = {}
        for s, c in expect['seeds']:
            seedc[s] = min(seedc.get(s, Decimal(c)), Decimal(c))
        for k, (c, p) in got.items():
            nodes = p.strip('{}').split(',')
            if nodes[-1] != k or nodes[0] not in seedc:
                problems.append(f'path {k}: bad endpoints {p[:80]}')
                continue
            tot = seedc[nodes[0]]
            ok = True
            for x, y in zip(nodes, nodes[1:]):
                if (x, y) not in wmin:
                    ok = False
                    break
                tot += wmin[(x, y)]
            if not ok or tot > c:
                problems.append(f'path {k}: invalid or costlier than reported 
({p[:80]})')
    return problems


def judge(family, text, expect, logtail, elapsed):
    ph = parse_phases(text)
    res = {'plan': [], 'errors': []}
    plan = '\n'.join(ph.get('explain', []))
    for tag in ('HashAggregate', 'Unique', 'Sort', 'Incremental Sort', 'Merge 
Append', 'Append',
                'Gather', 'Recursive Union', 'SetOp', 'Disk Usage', 'Batches', 
'WindowAgg',
                'Limit', 'Index'):
        if tag in plan:
            res['plan'].append(tag)
    if any(m in text or m in logtail for m in ('Disk quota exceeded', 'No space 
left on device')):
        res['status'] = 'ENV_ERROR'          # tmpfs full: an environment 
fault, rerun the seed
        return res
    if any(m in logtail for m in LOG_MARKS):
        res['status'] = 'SANITIZER_OR_TRAP'
        return res
    if any(m in text for m in CRASH_MARKS):
        res['status'] = 'CRASH'
        return res
    for name in ('setup', 'view', 'explain', 'query', 'check'):
        for l in ph.get(name, []):
            m = ERR_RE.search(l)
            if m:
                res['errors'].append((name, m.group(1), m.group(2)[:200]))
    errs = res['errors']
    if any(e[1].startswith('XX') for e in errs):
        res['status'] = 'INTERNAL_ERROR'
        return res
    if any(e[0] == 'setup' for e in errs):
        res['status'] = 'HARNESS_ERROR'
        return res
    if any(e[1] == '57014' for e in errs):
        res['status'] = 'TIMEOUT'
        return res
    if any(e[0] in ('view', 'explain', 'query') for e in errs):
        res['status'] = 'REJECTED'
        return res
    if any(e[0] == 'check' for e in errs):
        res['status'] = 'HARNESS_ERROR'
        return res
    if family.startswith('recursive'):
        probs = check_recursive(ph.get('query', []), expect)
        res['status'] = 'DIVERGE' if probs else 'OK'
        if probs:
            res['detail'] = probs[:10]
        return res
    bad = []
    nchk = 0
    for l in ph.get('check', []) + ph.get('view', []):
        if l.startswith('CHK|'):
            nchk += 1
            _, n_r, n_k, ids_ok, rows_ok, best_ok = l.split('|')
            if not (n_r == n_k and ids_ok == 'true' and rows_ok == 'true' and 
best_ok == 'true'):
                bad.append(l)
        elif l.startswith('EXA|'):
            nchk += 1
            if not l.endswith('|true'):
                bad.append(l)
        elif l.startswith('RT|'):
            if l != 'RT|true':
                bad.append('viewdef round trip differs')
    if nchk == 0:
        res['status'] = 'HARNESS_ERROR'
        res['detail'] = ['no check output']
        return res
    res['status'] = 'DIVERGE' if bad else 'OK'
    if bad:
        res['detail'] = bad
    return res


# -------------------------------------------------------------------- cluster

class Cluster:
    def __init__(self, bindir, data, port):
        self.bindir, self.data, self.port = bindir, data, port
        self.sock = data + '-sock'
        self.log = data + '.log'
        self.env = dict(os.environ,
                        
ASAN_OPTIONS='detect_leaks=0:abort_on_error=1:print_stacktrace=1'
                                     ':disable_coredump=1' + 
os.environ.get('DIFFGEN_ASAN_EXTRA', ''),
                        UBSAN_OPTIONS='print_stacktrace=1:halt_on_error=1',
                        PGHOST=self.sock, PGPORT=str(port), PGUSER='postgres',
                        PGDATABASE='postgres', LC_ALL='C')

    def b(self, x):
        return os.path.join(self.bindir, x)

    def init(self):
        shutil.rmtree(self.data, ignore_errors=True)
        shutil.rmtree(self.sock, ignore_errors=True)
        os.makedirs(self.sock)
        subprocess.run([self.b('initdb'), '-D', self.data, '-U', 'postgres', 
'--no-sync',
                        '-E', 'UTF8', '--locale=C', '--no-instructions'],
                       env=self.env, check=True, stdout=subprocess.DEVNULL)
        with open(os.path.join(self.data, 'postgresql.conf'), 'a') as f:
            f.write(f"port = {self.port}\nunix_socket_directories = 
'{self.sock}'\n"
                    "listen_addresses = ''\nfsync = off\nsynchronous_commit = 
off\n"
                    "full_page_writes = off\nshared_buffers = 
128MB\nmax_connections = 10\n"
                    "log_min_messages = warning\nlog_line_prefix = '%m [%p] '\n"
                    "max_parallel_workers = 4\nmax_worker_processes = 8\n")

    def start(self):
        subprocess.run([self.b('pg_ctl'), '-D', self.data, '-l', self.log, 
'-w', '-t', '120',
                        'start'], env=self.env, check=True, 
stdout=subprocess.DEVNULL)

    def stop(self):
        subprocess.run([self.b('pg_ctl'), '-D', self.data, '-m', 'immediate', 
'-w', 'stop'],
                       env=self.env, stdout=subprocess.DEVNULL, 
stderr=subprocess.DEVNULL)

    def wait_ready(self):
        for _ in range(240):
            r = subprocess.run([self.b('pg_isready'), '-q'], env=self.env)
            if r.returncode == 0:
                return True
            time.sleep(0.5)
        return False

    def psql(self, sql, timeout=300):
        try:
            p = subprocess.run([self.b('psql'), '-X', '-q', '-A', '-t', '-F', 
'|',
                                '-v', 'ON_ERROR_STOP=0', '-f', '-'],
                               input=sql, env=self.env, stdout=subprocess.PIPE,
                               stderr=subprocess.STDOUT, text=True,   # one 
pipe: errors stay
                               timeout=timeout, errors='replace')     # inside 
their phase
            return p.stdout
        except subprocess.TimeoutExpired:
            return '@@PHASE end\nERROR:  57014: harness wall-clock timeout'


def worker(args, w):
    # unique per port: a second run of the same variant must never touch a live 
cluster
    # (measured 2026-10-17: a one-seed test with the same path wiped NO5 worker 
0 mid-run)
    data = f"/dev/shm/hannu-{args.variant}-p{args.port0 + w}"
    cl = Cluster(args.bindir, data, args.port0 + w)
    cl.init()
    cl.start()
    cl.psql(SETUP_SQL)
    fails = os.path.join(args.out, 'fail')
    os.makedirs(fails, exist_ok=True)
    resf = open(os.path.join(args.out, f'results-w{w}.jsonl'), 'a')
    t0 = time.time()
    fams = args.families.split(',') if args.families else None
    for seed in range(args.seed0 + w, args.seed0 + args.cases, args.workers):
        if args.budget and time.time() - t0 > args.budget:
            break
        try:
            family, guc, q, sql, expect = case_sql(seed, fams)
        except Exception as ex:                  # generator bug: record it, 
keep the worker alive
            resf.write(json.dumps({'seed': seed, 'variant': args.variant, 
'family': '?',
                                   'status': 'HARNESS_ERROR', 'errors': 
[['gen', '', repr(ex)]],
                                   'ms': 0, 'plan': [], 'guc': {}}) + '\n')
            resf.flush()
            continue
        logpos = os.path.getsize(cl.log) if os.path.exists(cl.log) else 0
        ts = time.time()
        out = cl.psql(sql)
        el = time.time() - ts
        with open(cl.log, errors='replace') as f:
            f.seek(logpos)
            tail = f.read()
        res = judge(family, out, expect, tail, el)
        rec = {'seed': seed, 'variant': args.variant, 'family': family, 'guc': 
guc,
               'status': res['status'], 'ms': int(el * 1000), 'plan': 
res['plan'],
               'errors': res['errors'][:3]}
        if 'detail' in res:
            rec['detail'] = res['detail']
        if res['status'] not in ('OK', 'REJECTED'):
            base = os.path.join(fails, f"{args.variant}-{family}-{seed}")
            with open(base + '.sql', 'w') as f:
                f.write(f"-- seed {seed} family {family} variant 
{args.variant}\n"
                        f"-- query: {q}\n" + sql)
            with open(base + '.out', 'w') as f:
                f.write(out)
            if tail.strip():
                with open(base + '.log', 'w') as f:
                    f.write(tail)
        resf.write(json.dumps(rec) + '\n')
        resf.flush()
        if res['status'] in ('CRASH', 'SANITIZER_OR_TRAP', 'HARNESS_ERROR'):
            # fresh cluster after any crash: fsync=off + full_page_writes=off 
make crash
            # recovery unreliable, and one damaged catalog would poison every 
later seed
            cl.stop()
            cl.init()
            cl.start()
            cl.psql(SETUP_SQL)
    cl.stop()
    shutil.rmtree(data, ignore_errors=True)
    shutil.rmtree(cl.sock, ignore_errors=True)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('--bindir', required=True)
    ap.add_argument('--variant', default='x')
    ap.add_argument('--out')
    ap.add_argument('--seed0', type=int, default=0)
    ap.add_argument('--cases', type=int, default=100)
    ap.add_argument('--workers', type=int, default=4)
    ap.add_argument('--budget', type=float, default=0)
    ap.add_argument('--port0', type=int, default=61500)
    ap.add_argument('--families', default='')
    ap.add_argument('--replay', type=int)
    args = ap.parse_args()
    if args.replay is not None:
        fams = args.families.split(',') if args.families else None
        print(case_sql(args.replay, fams)[3])
        return
    os.makedirs(args.out, exist_ok=True)
    ps = [Process(target=worker, args=(args, w)) for w in range(args.workers)]
    for p in ps:
        p.start()
    for p in ps:
        p.join()


if __name__ == '__main__':
    main()

===== file: perf24.py =====
#!/usr/bin/env python3
"""Cardinality ladder for the many-groups follow-up (2026-10-24).

psql's \\timing of the measured statement, a fresh backend per run.  Each 
build's bin
directory comes from the environment: PGBIN_base, PGBIN_v3, PGBIN_d1, ... (any 
names).
Data directories go under $PGDATA_ROOT (default /tmp).

  perf24.py start <build>          initdb + start + load
  perf24.py one   <build> <qid>    one timed run; prints ms as last field
  perf24.py plan  <build> <qid>    EXPLAIN (costs) with the query's GUCs
  perf24.py explain <build> <qid>  EXPLAIN ANALYZE (TIMING OFF)
  perf24.py stop  <build>
  perf24.py list [base]            query ids (base: only those valid on master)

Data: big1, big2: 1M rows each.  gK: K distinct values, uniform.  zK: K values, 
skewed
(K * random()^3: a few huge groups and a long tail).  bigw: 1M rows with a 
192-byte text
column w, for the wide-tuple variant.  Queries return count(*) and sum(s).
"""
import os
import re
import shutil
import subprocess
import sys

BUILDS = sorted(k[len('PGBIN_'):] for k in os.environ if k.startswith('PGBIN_'))
PORT = {b: int(os.environ.get('PGPORT0', '62010')) + i for i, b in 
enumerate(BUILDS)}
UNIFORM = [1000, 10000, 50000, 100000, 200000, 500000]
SKEWED = [10000, 200000]

LOAD = """
SET client_min_messages = warning;
SELECT setseed(0.17);
CREATE TABLE big1 AS SELECT g AS id, %(cols)s, (random() * 1000000)::int AS s, 
md5(g::text) AS pl
  FROM generate_series(1, 1000000) g;
CREATE TABLE big2 AS SELECT g + 1000000 AS id, %(cols)s, (random() * 
1000000)::int AS s,
  md5(g::text) AS pl FROM generate_series(1, 1000000) g;
SELECT setseed(0.29);
CREATE TABLE bigw AS SELECT g AS id, (random() * 999)::int AS g1000,
  (random() * 199999)::int AS g200000, (random() * 1000000)::int AS s,
  repeat(md5(g::text), 6) AS w FROM generate_series(1, 1000000) g;
VACUUM ANALYZE;
""" % {'cols': ', '.join([f"(random() * {k - 1})::int AS g{k}" for k in 
UNIFORM] +
                         [f"(({k} - 1) * power(random(), 3))::int AS z{k}" for 
k in SKEWED])}

W = {'work_mem': "'64MB'", 'max_parallel_workers_per_gather': '0'}


def queries():
    q = {}
    cols = [f'g{k}' for k in UNIFORM] + [f'z{k}' for k in SKEWED]
    for c in cols:
        for h in ('on', 'off'):
            gu = dict(W, enable_hashagg=h)
            q[f'inline_{c}_hash{h}'] = (gu, f"SELECT count(*), sum(s) FROM 
(SELECT DISTINCT ON "
                                           f"({c} ORDER BY s DESC, id) {c}, s, 
id FROM big1) x")
            q[f'union_new_{c}_hash{h}'] = (gu, f"SELECT count(*), sum(s) FROM 
(SELECT {c}, s, id "
                                              f"FROM big1 UNION DISTINCT ON 
({c} ORDER BY s DESC, id) "
                                              f"SELECT {c}, s, id FROM big2) x")
        q[f'classic_{c}'] = (W, f"SELECT count(*), sum(s) FROM (SELECT DISTINCT 
ON ({c}) {c}, s, id "
                                f"FROM big1 ORDER BY {c}, s DESC, id) x")
        q[f'union_classic_{c}'] = (W, f"SELECT count(*), sum(s) FROM (SELECT 
DISTINCT ON ({c}) {c}, s, "
                                      f"id FROM (SELECT {c}, s, id FROM big1 
UNION ALL SELECT {c}, s, id "
                                      f"FROM big2) u ORDER BY {c}, s DESC, id) 
x")
    # wide tuple: the representative carries a 192-byte text
    for k in (1000, 200000):
        c = f'g{k}'
        for h in ('on', 'off'):
            q[f'wide_{c}_hash{h}'] = (dict(W, enable_hashagg=h),
                                      f"SELECT count(*), sum(s), sum(length(w)) 
FROM (SELECT DISTINCT ON "
                                      f"({c} ORDER BY s DESC, id) {c}, s, id, w 
FROM bigw) x")
        q[f'wide_classic_{c}'] = (W, f"SELECT count(*), sum(s), sum(length(w)) 
FROM (SELECT DISTINCT ON "
                                     f"({c}) {c}, s, id, w FROM bigw ORDER BY 
{c}, s DESC, id) x")
    # small work_mem: the hash table spills (0005 changed that path)
    S = {'work_mem': "'4MB'", 'max_parallel_workers_per_gather': '0'}
    for k in (200000, 500000):
        c = f'g{k}'
        for h in ('on', 'off'):
            q[f'inline_{c}_wm4_hash{h}'] = (dict(S, enable_hashagg=h),
                                            f"SELECT count(*), sum(s) FROM 
(SELECT DISTINCT ON "
                                            f"({c} ORDER BY s DESC, id) {c}, s, 
id FROM big1) x")
            q[f'union_new_{c}_wm4_hash{h}'] = (dict(S, enable_hashagg=h),
                                               f"SELECT count(*), sum(s) FROM 
(SELECT {c}, s, id FROM big1 "
                                               f"UNION DISTINCT ON ({c} ORDER 
BY s DESC, id) "
                                               f"SELECT {c}, s, id FROM big2) 
x")
        q[f'classic_{c}_wm4'] = (S, f"SELECT count(*), sum(s) FROM (SELECT 
DISTINCT ON ({c}) {c}, s, id "
                                    f"FROM big1 ORDER BY {c}, s DESC, id) x")
        q[f'union_classic_{c}_wm4'] = (S, f"SELECT count(*), sum(s) FROM 
(SELECT DISTINCT ON ({c}) {c}, "
                                          f"s, id FROM (SELECT {c}, s, id FROM 
big1 UNION ALL SELECT {c}, "
                                          f"s, id FROM big2) u ORDER BY {c}, s 
DESC, id) x")
    return q


BASE_OK = ('classic_', 'union_classic_', 'wide_classic_')


def env(b):
    d = os.path.join(os.environ.get('PGDATA_ROOT', '/tmp'), f"ladder-{b}")
    return d, dict(os.environ, PGHOST=d + '-sock', PGPORT=str(PORT[b]), 
PGUSER='postgres',
                   PGDATABASE='postgres', LC_ALL='C')


def binp(b, x):
    return os.path.join(os.environ[f'PGBIN_{b}'], x)


def psql(b, script, timeout=None):
    d, e = env(b)
    p = subprocess.run([binp(b, 'psql'), '-X', '-q', '-A', '-t', '-v', 
'ON_ERROR_STOP=0', '-f', '-'],
                       input=script, env=e, stdout=subprocess.PIPE, 
stderr=subprocess.STDOUT,
                       text=True, timeout=timeout)
    return p.stdout


def start(b):
    d, e = env(b)
    shutil.rmtree(d, ignore_errors=True)
    shutil.rmtree(d + '-sock', ignore_errors=True)
    os.makedirs(d + '-sock')
    subprocess.run([binp(b, 'initdb'), '-D', d, '-U', 'postgres', '--no-sync', 
'-E', 'UTF8',
                    '--locale=C', '--no-instructions'], env=e, check=True,
                   stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    with open(d + '/postgresql.conf', 'a') as f:
        f.write(f"port = {PORT[b]}\nunix_socket_directories = 
'{d}-sock'\nlisten_addresses = ''\n"
                "shared_buffers = 1GB\nfsync = off\nsynchronous_commit = 
off\nmax_wal_size = 8GB\n"
                "jit = off\nautovacuum = off\nmax_parallel_workers_per_gather = 
0\n"
                "temp_file_limit = '4GB'\n")
    subprocess.run([binp(b, 'pg_ctl'), '-D', d, '-l', d + '.log', '-w', 
'start'], env=e, check=True,
                   stdout=subprocess.DEVNULL)
    out = psql(b, LOAD)
    if 'ERROR' in out:
        print(out)
        sys.exit(1)
    psql(b, "SELECT count(*) FROM big1; SELECT count(*) FROM big2; SELECT 
count(*) FROM bigw;")
    print('started', b)


def stop(b):
    d, e = env(b)
    subprocess.run([binp(b, 'pg_ctl'), '-D', d, '-m', 'fast', '-w', 'stop'], 
env=e,
                   stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    shutil.rmtree(d, ignore_errors=True)
    shutil.rmtree(d + '-sock', ignore_errors=True)
    if os.path.exists(d + '.log'):
        os.remove(d + '.log')


def script(gucs, sql, cap):
    s = ''.join(f"SET {k} = {val};\n" for k, val in gucs.items())
    if cap:
        s += f"SET statement_timeout = '{cap}s';\n"
    return s + "\\timing on\n" + sql + ";\n"


TIME_RE = re.compile(r'^Time: ([0-9.]+) ms', re.M)


def one(b, qid):
    gucs, sql = queries()[qid]
    out = psql(b, script(gucs, sql, 600))
    t = TIME_RE.findall(out)
    if not t or 'ERROR' in out:
        print('FAILED', out.strip().replace('\n', ' ')[:300])
        sys.exit(1)
    res = [l for l in out.splitlines() if '|' in l]
    print(qid, res[-1] if res else '', t[-1])


def plan(b, qid, analyze=False):
    gucs, sql = queries()[qid]
    opt = "ANALYZE, BUFFERS OFF, TIMING OFF" if analyze else "COSTS ON"
    out = psql(b, script(gucs, f"EXPLAIN ({opt}) " + sql, 300))
    print(f"== {b} {qid}")
    print('\n'.join(l for l in out.splitlines() if not l.startswith('Time:')))


if __name__ == '__main__':
    cmd = sys.argv[1]
    if cmd == 'list':
        for k in queries():
            if len(sys.argv) < 3 or k.startswith(BASE_OK):
                print(k)
    elif cmd == 'start':
        start(sys.argv[2])
    elif cmd == 'stop':
        stop(sys.argv[2])
    elif cmd == 'one':
        one(sys.argv[2], sys.argv[3])
    elif cmd == 'plan':
        plan(sys.argv[2], sys.argv[3])
    elif cmd == 'explain':
        plan(sys.argv[2], sys.argv[3], analyze=True)

===== file: ladder.sh =====
#!/bin/bash
# Run every cell of a ladder N times, alternated (one sample per cell per 
round, the order
# rotated by one each round, so drift hits all cells alike), then print median 
and a
# bootstrap 95% CI per cell.  Each cell is "build:query_id" as listed by 
perf24.py.
#   PGBIN_base=... PGBIN_v3=... ./ladder.sh OUTDIR N base:classic_g200000 
v3:inline_g200000_hashon ...
# Our runs additionally pinned everything to one CCX and discarded rounds whose
# calibration micro-benchmark ran >8% slower than idle; that sentinel is not 
included.
set -u
H=$(cd "$(dirname "$0")" && pwd); OUT=$1; N=$2; shift 2; CELLS=("$@"); mkdir -p 
"$OUT"
BS=$(printf '%s\n' "${CELLS[@]}" | cut -d: -f1 | sort -u)
trap 'for b in $BS; do python3 "$H/perf24.py" stop $b; done' EXIT
for b in $BS; do python3 "$H/perf24.py" start $b || exit 1; done
n=${#CELLS[@]}
for r in $(seq 0 $((N - 1))); do
  for i in $(seq 0 $((n - 1))); do
    c=${CELLS[$(( (i + r) % n ))]}
    python3 "$H/perf24.py" one "${c%%:*}" "${c#*:}" | awk '{print $NF}' >> 
"$OUT/${c/:/-}.txt"
  done
done
python3 - "$OUT" "${CELLS[@]}" <<'PY'
import random, statistics, sys
out, cells = sys.argv[1], sys.argv[2:]
rng = random.Random(0)
for c in cells:
    xs = [float(l) for l in open(f"{out}/{c.replace(':', '-', 1)}.txt") if 
l.strip()]
    meds = sorted(statistics.median(rng.choices(xs, k=len(xs))) for _ in 
range(10000))
    print(f"{c:40s} median {statistics.median(xs):9.2f} ms  CI95 
{meds[250]:.2f}..{meds[9750]:.2f}  n={len(xs)}")
PY

===== file: rec24.py =====
#!/usr/bin/env python3
"""Where does recursive UNION DISTINCT ON stop finishing?  And the 
unnamed_subquery HINT.
Order of magnitude only (one run each, 120 s cap, temp_file_limit 4GB); not 
timing claims.
  rec24.py <build> <outfile>        build as in perf24.py (PGBIN_<build>)
"""
import os
import re
import sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import perf24  # noqa: E402

# own data directories and ports, so it can run next to ladder.sh
_env = perf24.env


def _rec_env(b):
    d, e = _env(b)
    d = d.replace('ladder-', 'rec-')
    e = dict(e, PGHOST=d + '-sock', PGPORT=str(62150 + 
list(perf24.BUILDS).index(b)))
    return d, e


perf24.env = _rec_env

LOAD = r"""
SET client_min_messages = warning;
CREATE TABLE grid_e (n int, src int, dst int, w int);
DO $$ DECLARE n int; BEGIN
  FOREACH n IN ARRAY ARRAY[200, 500, 1000] LOOP
    INSERT INTO grid_e SELECT n, r * n + c + 1, (r + m.dr) * n + (c + m.dc) + 1,
           ((r * 7 + c * 13 + m.d * 5) % 9) + 1
    FROM generate_series(0, n - 1) r, generate_series(0, n - 1) c,
         (VALUES (1, 0, 1), (0, 1, 2)) AS m(dc, dr, d)
    WHERE r + m.dr <= n - 1 AND c + m.dc <= n - 1;
  END LOOP; END $$;
INSERT INTO grid_e SELECT n, dst, src, w FROM grid_e;
CREATE INDEX ON grid_e (n, src);
SELECT setseed(0.42);
CREATE TABLE rand_e (v int, src int, dst int, w int);
DO $$ DECLARE v int; BEGIN
  FOREACH v IN ARRAY ARRAY[100000, 300000, 1000000] LOOP
    INSERT INTO rand_e SELECT v, 1 + floor(random() * v)::int, 1 + 
floor(random() * v)::int,
                              1 + floor(random() * 100)::int FROM 
generate_series(1, v * 4);
  END LOOP; END $$;
CREATE INDEX ON rand_e (v, src);
-- adversarial: complete DAG, edge i->j costs (j-i)^2, so the cheapest path to 
j is the
-- unit chain and every earlier wave delivers a worse cost: node j improves ~j 
times
CREATE TABLE dag_e (n int, src int, dst int, w int);
INSERT INTO dag_e SELECT n, i, j, (j - i) * (j - i)
  FROM unnest(ARRAY[100, 200, 400, 800]) n, generate_series(1, n) i, 
generate_series(1, n) j
  WHERE i < j;
CREATE INDEX ON dag_e (n, src);
-- negative cycle 1 -> 2 -> 3 -> 1, total -1
CREATE TABLE neg_e (n int, src int, dst int, w int);
INSERT INTO neg_e VALUES (1, 1, 2, 1), (1, 2, 3, 1), (1, 3, 1, -3);
CREATE TABLE ge (src int, dst int, cost int);
INSERT INTO ge VALUES (1, 2, 5);
VACUUM ANALYZE;
"""


def sp(tab, col, val):
    return (f"WITH RECURSIVE sp(node, cost) AS (SELECT 1 AS node, 0 AS cost "
            f"UNION DISTINCT ON (node ORDER BY cost) SELECT e.dst, sp.cost + 
e.w "
            f"FROM sp JOIN {tab} e ON e.{col} = {val} AND e.src = sp.node) "
            f"SELECT count(*), sum(cost) FROM sp")


CASES = ([(f'grid{n}', 'grid_e', 'n', n, 120) for n in (200, 500, 1000)] +
         [(f'rand{v}', 'rand_e', 'v', v, 120) for v in (100000, 300000, 
1000000)] +
         [(f'dag{n}', 'dag_e', 'n', n, 120) for n in (100, 200, 400, 800)] +
         [('negcycle', 'neg_e', 'n', 1, 20)])

HINTS = [
    ('H1 recursive, seed without names',
     "WITH RECURSIVE s(node, cost) AS (SELECT 1, 0 UNION DISTINCT ON (node 
ORDER BY cost) "
     "SELECT e.dst, s.cost + e.cost FROM s JOIN ge e ON s.node = e.src) SELECT 
* FROM s"),
    ('H2 non-recursive, branch 2 has a column "cost"',
     "SELECT 1 AS node, 0 AS c UNION DISTINCT ON (node ORDER BY cost) SELECT 
dst, cost FROM ge"),
    ('H3 plain UNION ... ORDER BY, same branches',
     "SELECT 1 AS node, 0 AS c UNION SELECT dst, cost FROM ge ORDER BY cost"),
    ('H4 non-recursive, branch 2 has no "cost"',
     "SELECT 1 AS node, 0 AS c UNION DISTINCT ON (node ORDER BY cost) SELECT 
dst, src + cost FROM ge"),
    ('H5 plain UNION ... ORDER BY, branch 2 has no "cost"',
     "SELECT 1 AS node, 0 AS c UNION SELECT dst, src + cost FROM ge ORDER BY 
cost"),
]


def main():
    b, outf = sys.argv[1], sys.argv[2]
    perf24.LOAD = LOAD          # start() loads perf24.LOAD
    perf24.start(b)
    out = []
    try:
        for name, sql in HINTS:
            r = perf24.psql(b, "\\set VERBOSITY default\n" + sql + ";\n", 60)
            out.append(f"== {b} {name}\n{sql}\n{r.strip()}\n")
        if b != 'base':
            for name, tab, col, val, cap in CASES:
                s = f"SET temp_file_limit = '4GB';\nSET statement_timeout = 
'{cap}s';\n\\timing on\n"
                r = perf24.psql(b, s + sp(tab, col, val) + ";\n", cap + 30)
                t = re.findall(r'^Time: ([0-9.]+) ms', r, re.M)
                res = [l for l in r.splitlines() if '|' in l]
                if 'statement timeout' in r:
                    line = f"TIMEOUT >{cap}s"
                elif 'temp_file_limit' in r:
                    line = "TEMP >4GB"
                elif 'ERROR' in r:
                    line = "ERROR " + r.strip().replace('\n', ' ')[:200]
                else:
                    line = f"{t[-1] if t else '?'} ms result={res[-1] if res 
else ''}"
                    ex = perf24.psql(b, s + "EXPLAIN (ANALYZE, TIMING OFF, 
BUFFERS OFF) " +
                                     sp(tab, col, val) + ";\n", cap + 30)
                    wt = re.search(r'WorkTable Scan on sp .*?\(actual 
rows=([0-9.]+) loops=(\d+)\)', ex)
                    ru = re.search(r'Recursive Union \(actual rows=([0-9.]+)', 
ex)
                    if wt:
                        line += (f" iterations={wt.group(2)} worktable_rows="
                                 f"{int(float(wt.group(1)) * int(wt.group(2)))}"
                                 f" union_rows={ru.group(1) if ru else '?'}")
                out.append(f"{name:10s} {line}")
                print(out[-1], flush=True)
    finally:
        perf24.stop(b)
    open(outf, 'w').write('\n'.join(out) + '\n')


if __name__ == '__main__':
    main()

Reply via email to