Skip to content

refactor(engine): ask the data, not the declarations - #520

Merged
FBumann merged 45 commits into
mainfrom
refactor/simplify-relational-engine
Aug 11, 2026
Merged

FBumann merged 45 commits into
mainfrom
refactor/simplify-relational-engine

Conversation

@FBumann

@FBumann FBumann commented Aug 9, 2026 •

Copy link
Copy Markdown
Collaborator

Read the performance section before the rest. This PR opened as a simplification, the simplification was measured, and most of it was reversed. What is left is a smaller and different change than the title suggests, and it still costs emit time at the m rungs.

25 files, +1179/−1385.

What it actually changes

The engine reads the data instead of reasoning about the declarations. TermFragment used to carry keyed, label_dims and mapping — a log of every operator that had moved its labels — so _needs_aggregate and may_share_a_column could decide statically whether a constraint's terminal SUM(coeff) GROUP BY row, col had anything to collapse, down to "is there a line whose two ends are one bus?". That is gone. _matrix_share asks the assembled share two linear probes instead — is it ordered, does a cell repeat — and sorts or aggregates only on a yes. The objective asks n_unique() == height. Same answers, ~250 lines less of the subtlest reasoning in the tree, and nothing that has to be kept in step with how a fragment was reshaped.

Labeller becomes labels.frame, a function with no state, and the label order is verified rather than re-established: in_position_order is one linear is_sorted against a single witness column, and all three orderings in the lane go through it. The factored-prefix path survives as a guarded branch of that one function rather than a second method — a mask that cannot see the leading dims leaves the survivors a rectangle, and materialising the full product instead cost +177 MB on sector/m.

Masks restrict by semi-join. A joining predicate is evaluated over the product of only the dims it reads, and the full product is semi-joined against the truth set — so the mask's parameter columns never touch the full product, and left order survives, which is what keeps labelling's verify a verify. A pointwise predicate stays a plain filter.

Smaller, self-contained: _remap_fragment written once for sum(group_by=) and its adjoint at; the shift's presence rule named travelled_presence; ModelTables._spans so the budget=None rule is spelled once; Model._in_our_tree so the two validating doors cannot answer differently; data_validation asking the question it means instead of a cheap probe then a naming pass.

Three fixes, not refactors: plan.children never descended into At, so divisor_parameters could not name a divisor under one; Builtin.value_kwargs was a field no helper declared; relational/__init__ listed an engine.py that does not exist.

One dependency claim withdrawn: polars>=1.36 was raised for explode(empty_as_null=) in a rendering that got reverted, and is back at 1.30.

Performance — measured at both rungs, and it does not amortize

Do not merge this on the current numbers without deciding to accept them.

CodSpeed, against a base that now contains #511 and #568, test_emit (which times build and emit together):

rung range worst
m −6.4% … −16.9% dispatch-m-highs −16.9%
l −6.8% … −13.0% dispatch-l-highs −13.0%

plus two memory regressions at s: transport-s-lp −13.6%, sector-s-lp −8.2%.

The amortization argument is refuted. 0f3614c added the l rung on the claim that fixed per-declaration overhead "reads as −5..−10% at m and amortizes to within ±4% at l", measured locally. With a real l baseline in the series that is not what happens: l lands at −6.8% to −13%, and on sector and transport it is worse than m, not better. Whatever the cost is, it scales with the model rather than sitting on top of it.

That local measurement was mine and it was wrong — a single machine, one arm at a time, against a tree that has since taken three engine changes. The metered series is the instrument that settles this and it disagrees.

A third measurement agrees with CodSpeed rather than with the local one: profiled/l build alone is 1.037 s on this branch against 0.901 s on main, warm, median of five.

What that means for this PR

The change buys readability — the static avoid-work machinery is gone, replaced by probes that ask the data — and it costs roughly a tenth of the pipeline across the whole ladder, at every rung, on both sinks. That is the trade, stated without the amortization that was supposed to soften it.

The suspects, in the order I would look: _matrix_share's two linear probes per constraint share; labelling's _row_major plus the in_position_order verify per declaration; and the semi-join mask path. fleet carries 19 declarations per coordinate and is −10.5%, dispatch carries 3 and is −16.9%, so "fixed per declaration" does not fit the shape either — which is exactly why it needs profiling rather than another argument.

I have not found it yet. Until it is found, the honest options are to accept ~10%, or to hold the PR.

What this PR is no longer

The original description claimed the aggregate always runs, that three labelling paths collapsed to one, and that every mask name is left-joined. All three were true when written and none is true now — measurement reversed them, and the recovery reached the same capabilities by other means. The commit history keeps that arc; this description does not.

The style: inline comments become docstrings sweep that was the bulk of the churn is out, landed separately as #560.

Verification

938 passed, 5 skipped; ruff check, ruff format --check, pyrefly strict (0 errors), mkdocs build --strict. test_a_constraint_row_left_with_no_variables is no longer xfail — #561 resolved what the two lanes were disagreeing about.

🤖 Generated with Claude Code

FBumann added 4 commits August 9, 2026 21:13
The relational lane carried three pieces of machinery whose only job was to
avoid work: a `keyed`/`label_dims`/`mapping` triple on every term fragment so
the assembly could skip its terminal aggregate, three routes through the
labeller depending on how much of the coordinate product a mask left, and an
inner-vs-left join choice for the names a where is certain of. Each was correct
and each was a second thing a reader had to hold.

They are gone. The matrix and the objective always aggregate — the one place
duplicates were ever meant to collapse — labels are always the sorted product
numbered from the next free index, and a mask always left-joins. Order is now
established where it is needed (`sort('var_label')`, `sort('row')`) rather than
inherited from a cross-join fold and verified after the fact.

The LP writer's constraint section follows: the interleaving integer-key sort
becomes a group into a list per row and an explode, with the two sorts that
make the bytes reproducible stated on either side of the join.

Same models, same labels, same bytes.
Three smaller pieces of the same kind of machinery.

`ModelTables` now promises its sinks a matrix sorted by `(row, col)`, which
the engine establishes once; the solver hand-off and the LP writer read that
instead of each re-sorting a frame that already had the order.

`check_one_row_per_coordinate` asked a cheap boolean first and only paid for
the group-by on the path about to raise, and the single-valued check rode in
the caller's aggregate through a scratch-column protocol between two modules.
Both now just ask the question they mean.

Also: `plan.children` never descended into `At`, so `divisor_parameters` could
not name a divisor under one; `Builtin.value_kwargs` was a field no helper
declared; `_helper_sum` had three branches doing one thing.
Every constraint's share of the matrix now arrives from one concat of term
pieces, so it always carries `coeff` — the column-presence test in front of
the null check was a leftover from the branch that could return early.
@read-the-docs-community

read-the-docs-community Bot commented Aug 9, 2026 •

Copy link
Copy Markdown

Documentation build overview

📚 lpspec | 🛠️ Build #34013917 | 📁 Comparing d81b5dc against latest (e3eff99)

  🔍 Preview build  

6 files changed · ± 6 modified

± Modified

@FBumann FBumann added the trigger:bench Run the benchmark comparison on this PR; removed when the run finishes label Aug 9, 2026
The comment #517 rewrote sat above `_constraint_blocks`'s presort of the
matrix. This branch deletes that function: the constraint section groups terms
per row instead of interleaving them by sorting one integer key, and the
`(row, col)` order it needs is now `ModelTables`' contract rather than a local
sort. So the question #517 left open — whether the presort pays for itself —
has no code left to attach to here.
@codspeed

codspeed Bot commented Aug 9, 2026 •

Copy link
Copy Markdown

Merging this PR will degrade performance by 7.27%

❌ 17 regressed benchmarks
✅ 25 untouched benchmarks
⏩ 10 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
❌ WallTime test_emit[profiled-l-lpspec-highs] 6.4 s 7.2 s -11.17%
❌ WallTime test_emit[nodal-m-lpspec-highs] 200.6 ms 223.8 ms -10.35%
❌ WallTime test_emit[nodal-l-lpspec-highs] 1.6 s 1.7 s -9.32%
❌ WallTime test_emit[fleet-m-lpspec-lp] 1.1 s 1.2 s -8.47%
❌ WallTime test_emit[transport-l-lpspec-highs] 5.1 s 5.5 s -8.46%
❌ WallTime test_emit[profiled-m-lpspec-highs] 866.6 ms 944.7 ms -8.26%
❌ WallTime test_emit[nodal-m-lpspec-lp] 289.5 ms 315.5 ms -8.24%
❌ WallTime test_emit[transport-m-lpspec-highs] 545.6 ms 594.4 ms -8.2%
❌ WallTime test_emit[fleet-m-lpspec-highs] 811.7 ms 869.3 ms -6.63%
❌ WallTime test_emit[storage-m-lpspec-highs] 606.2 ms 646.7 ms -6.26%
❌ WallTime test_emit[fleet-l-lpspec-lp] 8.7 s 9.3 s -6.19%
❌ WallTime test_emit[dispatch-m-lpspec-highs] 318.5 ms 337.6 ms -5.67%
❌ WallTime test_emit[profiled-m-lpspec-lp] 1.2 s 1.3 s -5.49%
❌ WallTime test_emit[nodal-l-lpspec-lp] 2.4 s 2.5 s -5.2%
❌ WallTime test_emit[fleet-l-lpspec-highs] 6.7 s 7.1 s -5.16%
❌ WallTime test_emit[storage-m-lpspec-lp] 858.8 ms 904.8 ms -5.08%
❌ WallTime test_emit[transport-l-lpspec-lp] 7.1 s 7.5 s -5.06%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing refactor/simplify-relational-engine (d81b5dc) with main (4fc661a)

Open in CodSpeed

Footnotes

  1. 10 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

The objective now refuses an undefined divisor the way constraints do, on
both lanes — its aggregate was the one place a null coefficient could still
be read as a zero. The duplicate-row check's count column is named '#rows',
which no dim can be, where 'n' crashed every build of a model with a dim of
that name. The polars floor rises to 1.36, where `explode(empty_as_null=)`
exists — the LP writer relies on it. The constraint-section docstring now
cites the within-group order guarantee #109 rests on, and a test docstring
still naming the deleted Labeller says labels.frame.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@FBumann
FBumann marked this pull request as draft August 9, 2026 20:02
The divisor-null count rides the aggregating group_by instead of forcing
the unaggregated stack eager first: each constraint share and the
objective are one lazy streaming pipeline again, and the check reads a
count the aggregate already carried. And the assembly-time sort is gone —
each share leaves sorted and owns the next run of rows, so ascending
shares concatenate into an ascending whole, and the sort was copying the
model's largest frame at the peak of the build to reorder nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
FBumann and others added 4 commits August 10, 2026 07:04
* perf(engine): validation asks its cheap questions in one pass

Data validation probes a parameter once — any duplicate coordinate, any
unknown label — and pays the passes that name offenders only on a path
about to raise; a healthy build of a large model no longer runs the
group_by that was the single most expensive step of its build. And an
explicit dimension index is collected once, with the single-valued check
and the coordinate grouping both reading that one collect, instead of
each re-scanning a source that may be a file (#273).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf(lp): the writer's peak is bounded again (#530)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…rged PR

The stack merged out of order — #529's squash (carrying #530) reached
perf/one-pass-assembly after #528 had already merged that branch here —
so the validation and writer recoveries were stranded on a branch with no
open PR. This merge brings them down; the assembly half is already here
as #528's squash, which is content-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…inery

Two collisions, both spelling against deletion. The Model rename wins
where both sides touched a line (MathSchema is gone everywhere else), and
the branch's tests win where main's still probed _needs_aggregate and
may_share_a_column — machinery this branch removes, its question now
answered by the aggregate always running. The _parameter_names and
_variable_names wrappers main added have no caller left on this branch's
check_divisors_cover, so they go too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
FBumann and others added 10 commits August 10, 2026 09:59
The explanatory inline comment is retired: what narrated code is gone,
what carried a fact — a measured number, an issue reference, a library
subtlety — now lives in the nearest docstring, and the one expression
complex enough to deserve a name (the null-counting matrix aggregate,
written twice) is the _summed_shares helper. Kept inline: pragmas with
their reasons, #: attribute docs on constants, and section banners.
CLAUDE.md states the rule so it holds for new code too.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
… never repair it (#534)

* style: inline comments become docstrings, and one helper

The explanatory inline comment is retired: what narrated code is gone,
what carried a fact — a measured number, an issue reference, a library
subtlety — now lives in the nearest docstring, and the one expression
complex enough to deserve a name (the null-counting matrix aggregate,
written twice) is the _summed_shares helper. Kept inline: pragmas with
their reasons, #: attribute docs on constants, and section banners.
CLAUDE.md states the rule so it holds for new code too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf(engine): ask the data, not the declarations — and produce order, never repair it

Interleaved A/B against main (l rungs) had the branch at 2.1-4.7x on
build. Four recoveries, none of them the deleted machinery:

- the coordinate product folds in reverse again (#433): the streaming
  engine walks a cross join right-major, so the product arrives in label
  order and everything downstream can read position instead of sorting.
- labelling is one path: row-major position by arithmetic, a linear
  is_sorted verify, a single-key sort only when a join actually lost the
  order. The unconditional multi-key sort was 0.26 s of a 0.73 s build.
- order-bearing joins say so: maintain_order='left' on bounds and mask
  joins (+12 ms at 10M rows, against ~110 ms to sort afterwards),
  'left_right' on a lone term's placement.
- the matrix aggregate runs only when a linear adjacency probe finds a
  repeated cell, and the writer stamps set_sorted('row') — the order
  ModelTables already promises — so the streaming engine groups adjacent
  runs instead of hashing: 0.14 s against 0.89 s at 10M nonzeros.

After: transport/l 1.10x, storage/l ~1.3x, dispatch/l 2.2x (its vacuous
where pays one single-key sort main's factored-prefix machinery avoided;
that trade is the point of #520 and is documented, not resurrected).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Conflicts were #533 (a Model validates itself) against this branch's
docstring sweep and the aggregate-avoidance deletion:

- language/piecewise.py: keep #533's known_variables threading and
  model_validate-with-context; keep this branch's docstrings.
- linopy/__init__.py: take main — extend's explicit validate_expressions
  call is gone, coords variable keeps main's existing_coords name.
- engines/polars/compiler.py: merge both docstring halves (this branch's
  phrasing, main's measured numbers).
- engines/polars/executor.py: main only trimmed docstrings of
  _needs_aggregate/_in_row_order/_has_repeated_entry; this branch deletes
  the functions, so the deletion stands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three findings from the #533 review, addressed here since the merge
brought that code onto this branch:

- extend() now threads its known_variables into expand_piecewise, so an
  extension whose piecewise links borrow a model variable validates
  against the namespace it will run in instead of being refused with
  "'p' not found". Regression test proves the failure without the fix.
- A validated Model keeps the expansion its own validation built, keyed
  by the namespace it was expanded against; expand_piecewise returns it
  rather than building — and fully re-validating — a second Model. Every
  piecewise consumer (build, extend, lowering, typeset) drops from two
  validation passes to one.
- The SPEC piecewise example is checked again: doctest skip becomes
  wrap=piecewise, so a typo'd key in the example fails CI instead of
  shipping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A four-angle cleanup pass (reuse, simplification, efficiency, altitude)
over this branch's diff:

- TermFragment.variable had lost its last reader when may_share_a_column
  went; the field, its threading through four constructors, and the test
  asserting it go too.
- _group_fragment and _at_fragment had converged to the same four lines
  read in opposite directions; one _remap_fragment states the join once.
- parameter_join's maintain_order knob had one setting at every call
  site; the join now always keeps order and says why.
- The VariableDefined branch spells its null-reads-as-false through
  _falsy_if_null like every other mask site.
- _build_variable projects (var_label, lb, ub) before collecting, so the
  dim strings and bound aliases stay in the lazy pipeline instead of
  materialising to be dropped — and its verify-then-sort goes through
  labels.in_position_order rather than restating it.
- The matrix's sorted flag is stamped where the order is established
  (executor assembly), not by one sink; the LP writer's per-chunk
  full-matrix filter becomes ModelTables.matrix_block, the binary-search
  slice row_blocks already used, so both chunked readers share one
  answer to which entries a row range owns.
- The piecewise expansion cache is owned entirely by expand_piecewise —
  write, read, and key in one module, misses repopulate — with Model
  keeping only the slot.
- _build_constraint's docstring described the intermediate always-
  aggregate design; it now states the shipped probe-then-collapse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e key per line

Codspeed on the branch still showed -30..-60% against main: memory on
small models, wall on the lp sink. Interleaved A/B (median of 5 paired
subprocess runs) reproduced both and profiling put them in two places.

Labelling a variable whose mask cannot see its leading dims walked the
full coordinate product through the mask join: +177 MB transient and
17 ms for sector/m's one masked variable, where main's factored path
paid for the suffix. That split returns, as one guarded branch of the
one labels.frame path: a mask that reads none of the leading dims
removes the same coordinates under every one of their values, so the
survivors are a rectangle — rank the suffix set, cross-join the head
product on the left (the streaming engine emits the right side fastest,
which is label order), and the label is arithmetic again. Restrictions
and masks that read the first dim still count the slow way.

The writer's group-into-list-and-explode paid per list element: 3x main
on sector/m emit. The one-key-per-line union sort returns — header,
placeholder, terms, footer each pick a slot in a chunk-relative integer
key and one single-column sort interleaves them — now fed by
ModelTables.matrix_block, so the terms arrive as a pre-sorted slice
rather than a per-chunk scan.

A/B after (branch/main wall, peak): sector-m-lp 1.01/1.08 was
1.81/1.31; sector-s-lp 1.09/1.02; nodal-m-lp 0.98/1.03; fleet-m-lp
1.09/0.98; storage-m-lp 1.09/1.00; sector-m-highs 1.06/1.09.
dispatch-m-highs stays 1.24 wall: its mask reads both dims, so no
prefix is free and the counted path pays its sort — the documented
residual of deleting the static machinery, now confined to that class.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One conflict: model.py's pydantic import block — this branch adds
PrivateAttr (the expansion-cache slot), #538 on main adds
ValidationError. Both stay. #542's extend fix and #541's Enum dims
auto-merged; the branch's expansion memoisation, which main's #542
does not carry, is kept.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…vives

The mask path carried a caveat: joins keep the frame's order, except a
value join over the lazy cross product, which is why a mask reading the
first dim forced labelling's verify into a real sort (dispatch 1.24x).
The caveat dies instead of being documented harder. A predicate is a
function of only the dims it reads, so it is evaluated once over their
product and the full product is semi-joined against the truth set —
the mask's parameter columns never touch the full product, and a
semi-join leaves left order alone, so 'every frame is produced in
order; sorts are verified fallbacks' now holds without exceptions.

Pointwise predicates (dimension comparisons, constants) stay a plain
filter, detected by _predicate handing the carrier back unchanged.
predicate_dims moves from labels.py to the compiler: the semi-join and
the factored prefix now read one answer to 'which dims does a where
read'.

Interleaved A/B vs main after: dispatch-m-highs 1.19x wall / 0.97x
peak (was 1.24/1.02), dispatch-m-lp 1.13/1.00, sector-m-lp 0.99/0.92,
sector-s-lp 1.05/0.99, fleet-m-lp 1.06/0.92 — peaks now at or below
main everywhere measured; dispatch's residual is the per-declaration
probe passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One conflict, in _translate_fragment: #546's nested-shift presence fix
(presence keeps its own dims; a shift along a dim it is silent about
widens it first) lands over this branch's pre-fix one-liner. The fix
wins; its inline commentary moves into _translate_fragment's docstring
per this branch's convention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s waiting for

The job's own comment deferred l until a series showed whether m
resolves what we want to see. #520 answered: fixed per-declaration
overhead reads as -5..-10% at m and amortizes to within ±4% at l,
where peak memory — the product claim — moves the other way (0.88-0.99
of main). The two rungs disagree, which is what the metered rung
exists to catch. Timeout widened to fit the second rung.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
FBumann and others added 3 commits August 11, 2026 13:06
…inty returns

Two reversals, one reachable only behind the other, measured together by a
six-arm interleaved A/B (median of paired ratios vs main): sector/m
1.075 -> 1.009, the only delta in the corpus above the harness's ~5% noise
floor. Credit to a parallel review session for the attribution and the
patches; docstrings and their numbers are this commit's.

A mask that reads every frame dim has a truth set as wide as the product
itself, so the semi-join built the coordinate product twice to save no
width — sector's balance mask is exactly that shape and paid 6.6% of the m
pipeline for it. It returns to the plain filter, which keeps order the same
way.

Behind that, the one-strategy uniformity stops paying: on the direct path
the rows a left join keeps are rows the filter then drops, so a name the
mask is certain of is inner-joined and a certain variable semi-joined again
(_certain_parameters, back with its Or/Not asymmetry stated). Behind a
semi-join's key product the two strategies measured the same, which is why
uniformity looked free when it landed.

parameter_join takes how/maintain_order again; only the bounds ask for
order (cols is positional), every other consumer verifies where it reads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Only a mask or a restriction leaves gaps in the row-major positions. With
neither, every coordinate survives and start + position is the label, so
the unmasked path stops materialising a position column only to renumber it
with a row index and drop it — one arithmetic expression, as before the
paths merged, but inside the single labels.frame.

Phase-measured, not wall-measured: ~3 ms per unmasked declaration at the m
rungs (fleet carries 19 and no where at all), 1.5-2% of build at the l
rungs — consistent in sign across cases but below the end-to-end noise
floor of the local harness. Landed for the shape as much as the number: the
renumber pass existing only where a row was dropped is the honest form of
the one-rule labeller.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The one conflict was the Builtin docstring: main's wording still counted a
value_kwargs field both sides have deleted, this branch's names the one
kind that exists. plan.children merged to the identical one-word fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
6dc3c24 and 725cd0e are performance-only branches: delete either and every
one of the 941 tests still passes, every model still solves to the same
answer, and sector quietly gives back the 6.6% the guard recovered. The
fallbacks are semantically identical by construction — a full-width mask
filters where it would have semi-joined, a certain name is left-joined where
it would have inner-joined — so nothing that reads an answer can hold them.

Verified as pins rather than as decoration: both new assertions fail at
43a7109 (the branch before the recovery) and pass at 90d87b3.

test_compiler.py already exists for exactly this ("the only place query
*shape* is asserted", and every property there "can regress while the whole
suite still passes"), so its invariant list gains the two entries rather than
a new file appearing beside it.

test_a_parameter_predicate_needs_a_left_join is renamed: the predicate it
compiles is one the mask is certain of, so 6dc3c24 made it an inner join and
the name had stopped being true. Its left-join half moves to the same
predicate under an Or, where certainty does not survive.

Not pinned, for want of an observable: 3a0619e's objective probe. keep_order
threads maintain_order into _join_mul, which polars does not print in a query
plan, so deleting the linear branch and reverting to n_unique on every build
is invisible to a shape assertion as well as to an answer.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
FBumann added a commit that referenced this pull request Aug 11, 2026
#574 asked whether the assembly probes could be skipped by propagating the
sortedness labelling already establishes. They cannot: polars clears the
sorted flag on both `concat` and `join(maintain_order='left')`, which are the
two operations between labelling and assembly, and re-asserting it after a
join is the move `set_sorted` cannot check and the verify exists to avoid.

Measuring that premise found the real cost, which is not the comparison but
the chunking. A streaming collect returns its morsels as chunks -- 104 for
dispatch/l's 10M-entry share, 136 for transport/l's 12.6M -- and `shift(1)`
pays at every boundary. `is_sorted` does not: 1.18 ms chunked against 1.14 ms
flat. In situ the probe cost 28 ms and 35 ms where a synthetic contiguous
frame of the same shape said 5 ms, and that 6x gap is the whole finding.

Rechunk, then probe: 12 ms and 19 ms including the rechunk. A single combined
key probed with `is_sorted` was also tried and is worse (21 ms and 26 ms) --
building the key gathers across the same chunks.

The copy is not new work. #550 already requires the matrix to be contiguous
or every block's `to_numpy` is a gather, so the assembly rechunks regardless;
this moves that copy earlier, and where one declaration owns the whole matrix
the later rechunk becomes a no-op. Interleaved A/B against the branch head,
7 rounds, min of 3, two independent runs:

  dispatch/l  wall 0.960 / 0.927   peak 0.944 / 0.942  (-142 MB)
  transport/l wall 0.960 / 0.978   peak 0.969 / 0.993
  fleet/l     wall 0.963 / 0.960   peak 0.993 / 0.996
  sector/m    wall 1.007           peak 1.000

fleet is the shape that does not benefit in peak, as expected: 19 declarations
mean 19 small rechunks and a `_stack` that re-fragments to 19 chunks, so its
later rechunk still copies.

The objective's stack takes the same `concat().collect()` and is deliberately
left alone: rechunking it too measured no consistent wall gain and cost
dispatch 2% of peak (0.963 against 0.942).

Like the two branches #573 pins, this one is invisible to the suite -- a
rechunk changes no answer -- so the docstring carries what a future reader
needs to not delete the rechunk. The A/B ratios stay here and in the PR: they
compare against a branch head, which stops naming anything once #520 merges.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FBumann and others added 2 commits August 11, 2026 14:06
#574 asked whether the assembly probes could be skipped by propagating the
sortedness labelling already establishes. They cannot: polars clears the
sorted flag on both `concat` and `join(maintain_order='left')`, which are the
two operations between labelling and assembly, and re-asserting it after a
join is the move `set_sorted` cannot check and the verify exists to avoid.

Measuring that premise found the real cost, which is not the comparison but
the chunking. A streaming collect returns its morsels as chunks -- 104 for
dispatch/l's 10M-entry share, 136 for transport/l's 12.6M -- and `shift(1)`
pays at every boundary. `is_sorted` does not: 1.18 ms chunked against 1.14 ms
flat. In situ the probe cost 28 ms and 35 ms where a synthetic contiguous
frame of the same shape said 5 ms, and that 6x gap is the whole finding.

Rechunk, then probe: 12 ms and 19 ms including the rechunk. A single combined
key probed with `is_sorted` was also tried and is worse (21 ms and 26 ms) --
building the key gathers across the same chunks.

The copy is not new work. #550 already requires the matrix to be contiguous
or every block's `to_numpy` is a gather, so the assembly rechunks regardless;
this moves that copy earlier, and where one declaration owns the whole matrix
the later rechunk becomes a no-op. Interleaved A/B against the branch head,
7 rounds, min of 3, two independent runs:

  dispatch/l  wall 0.960 / 0.927   peak 0.944 / 0.942  (-142 MB)
  transport/l wall 0.960 / 0.978   peak 0.969 / 0.993
  fleet/l     wall 0.963 / 0.960   peak 0.993 / 0.996
  sector/m    wall 1.007           peak 1.000

fleet is the shape that does not benefit in peak, as expected: 19 declarations
mean 19 small rechunks and a `_stack` that re-fragments to 19 chunks, so its
later rechunk still copies.

The objective's stack takes the same `concat().collect()` and is deliberately
left alone: rechunking it too measured no consistent wall gain and cost
dispatch 2% of peak (0.963 against 0.942).

Like the two branches #573 pins, this one is invisible to the suite -- a
rechunk changes no answer -- so the docstring carries what a future reader
needs to not delete the rechunk. The A/B ratios stay here and in the PR: they
compare against a branch head, which stops naming anything once #520 merges.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
… twins (#575)

No behavior change — every query leaves expression-identical — only shape:

- keep_order stops being threaded: product and quotient never read self, so
  they move into expression()'s walk as closures and capture the flag where
  it is stated. No signature carries it anymore.
- labels.frame's two numbering branches become one skeleton with a single
  'dropped' decision, so 'renumber only when a row was dropped' is now the
  code rather than a docstring claim about two code paths.
- The full-width-mask guard turns positive: the semi-join states when it
  applies and the filter is the fall-through, instead of three negated
  disjuncts guarding an early return.
- The objective probe's nested branches flatten to one 'repeated' answer.
- The certainty story is told once, in _predicate, where the decision is
  made; parameter_join points at it instead of retelling it.

Net -22 lines on the PR's diff against main.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
FBumann added a commit that referenced this pull request Aug 11, 2026
…red (#577)

A release commit is a strict duplicate. release-please writes CHANGELOG.md and
.release-please-manifest.json and touches no source -- the git tag is the
version -- so the metered macro runner reproduces the previous commit's number
at full price. Over the last 40 merges that is 17 of them, 43%, every one a
release.

The rule is what cannot change what the benchmark executes, which is narrower
than "not code" and deliberately excludes entries that look safe: pyproject.toml
and uv.lock decide which polars is installed and a floor moving under us is what
this series exists to catch, bench/ is the workload, and this file is the
instrument. Checked against the last 40 merges: nothing touching src/, bench/,
pyproject.toml or uv.lock is skipped.

The concurrency note calls a missing number a hole the next pull request falls
into, and it is right in general. It does not apply to these paths: the hole
sits at a commit whose executable tree is identical to its parent, so the
fallback compares against the same code it would have. The cost is that a pull
request merged after one is footnoted `unexpected-base`, which reads like a
fault -- it misled this session for one round trip on #520 -- so the comment
says so where somebody will find it.

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

Labels

trigger:bench Run the benchmark comparison on this PR; removed when the run finishes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant