Skip to content

perf(gfql): index low-cardinality columns so label and type predicates compare codes - #2087

Merged
lmeyerov merged 10 commits into
masterfrom
perf/gfql-category-index
Sep 17, 2026
Merged

lmeyerov merged 10 commits into
masterfrom
perf/gfql-category-index

Conversation

@lmeyerov

@lmeyerov lmeyerov commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

Problem

A label or edge-type predicate is a scalar equality on a column with a handful of distinct values. On the seeded bindings path each one still cost a frame filter: select the predicate column, gather the candidate rows, filter, read the survivors back. On LDBC SNB IC8 recent-replies that is seven such filters, about 1.7 ms of a 6.0 ms query.

Measured in isolation, the same predicate answered from a coded column costs 0.011 ms against 0.179 for a node label, and 0.004 against 0.145 for an edge type.

Change

gfql_index_all now builds a CategoryIndex for each eligible column: a per-row integer code plus the value-to-code map. The array bindings path answers a predicate by comparing the candidates' codes, which is one array gather.

Three decisions carry the correctness:

  • Nulls get a reserved code, never handed out for a queried value, so a null row matches no scalar predicate. That is exactly what the canonical filter's three-valued logic already decides, and it is what makes the index useful in practice: the SNB label columns are null-heavy after their concat, and a null-refusing version skipped every one of them.
  • A value the column never holds matches nothing, answered here rather than deferred, because that is the same answer the filter gives.
  • Boolean and integer values never share a code. True == 1 in Python; the canonical filter does not conflate them, so the code lookup matches on type as well as value.

Everything else declines to the canonical filter, which is the same answer: other engines, float and other dtypes, and cardinality above 64.

Index build is a declared setup step like the adjacency build, not lazy per-query work. On SF0.1 it takes gfql_index_all from 101.5 ms to 144.7 ms.

Measured

LDBC SNB SF0.1, H684 index lane recipe, local box, interleaved against the base branch over three rounds:

query base this branch
recent-replies 6.087 / 5.930 / 6.033 4.595 / 4.658 / 4.997
message-replies 2.716 / 2.812 / 2.649 2.175 / 2.122 / 2.232

About -23% and -20%. Point queries are unchanged.

DGX A/B on the benchmark recipe

Three runs per arm against merged master 65c359b, canonical rows identical in every cell.
This measures the whole stack (#2084 + #2086 + this), since each builds on the last.

scale engine query master stack delta
SF0.1 polars recent-replies 29.291 5.528 -81.1%
SF0.1 polars message-replies 8.102 1.465 -81.9%
SF0.1 polars seed-lookup 1.068 0.930 -12.9%
SF0.1 pandas recent-replies 44.743 27.376 -38.8%

Every other cell is flat or inside its own noise. SF1 pandas new-topics reads +10.3%, which is
the cell whose identical-code A/A control spans 472.8 to 567.5 ms — see #2084 for that
investigation; its GFQL surface reaches none of this code.

This branch carries three commits: category indexes, then an endpoint-row index that resolves each
edge endpoint to its node row at build time, then a temporal-text fact that turns the projection's
constructor-text guard into a build-time lookup.

Against the competitor arms, message-replies now beats Kuzu by about sixteen times.
recent-replies has gone from a 5.4x loss against Neo4j to parity: its three runs are
5.122, 5.607 and 5.528 ms against Neo4j's 5.425, which straddle it. That is parity, not a win,
and it should not be quoted as one. The two seed-lookup cells and recent-replies remain losses, budgeted in the
plan's remaining-gap document.

Tests

graphistry/tests/compute/gfql/index/test_category_index.py covers code round-trips across Boolean, String and integer columns with and without nulls; declines for float, high cardinality, absent columns and non-polars engines; staleness against both a cloned frame and a reshaped one; and seven end-to-end predicate shapes checked against the canonical filter, including a value the column never holds and the boolean-versus-integer conflation guard.

Index suite 1127 passed. Broad suite over tests/compute/gfql, chain specializations, chain and hops: 16150 passed, 1 failed — the cuDF zero-hop test that fails identically on master with this box's cuDF version.

One existing test needed a fix rather than a waiver. test_node_property_index_seeds_without_scanning pins that a seeded query gathers its seed from the property index instead of scanning, by recording the widths passed to the frame filter. With the predicate answered positionally that probe recorded nothing, so it was blind rather than false. It now watches both filter seams; the assertion is unchanged.

Stacked on #2086, which is stacked on #2084. Review those first.

🤖 Generated with Claude Code

https://claude.ai/code/session_012Me1E7ZdDuGqJGu3mMEzhp

@lmeyerov

Copy link
Copy Markdown
Contributor Author

Your instinct on boundary testing found a silent wrong answer in this PR. Fixed in 153a7cf2d, and the test that was written for exactly that case was passing vacuously.

The defect

_code_for matched on type(value) is type(expected), and _positions_via_category_index treated "no code found" as matches nothing (positions[:0]) rather than cannot answer (None). A polars filter does coerce across those types, so a cross-type scalar returned an empty result where the canonical route returns rows. Reproduced with engagement proven on the served leg:

predicate served (index) canonical (oracle) at parent commit
{"flag": True} on an Int64 column [] 2 rows 2 rows, correct
{"flag": 1.0} on an Int64 column [] 2 rows 2 rows, correct
{"label__Message": 1} on a Boolean column [] 3 rows 3 rows, correct

All three are correct at the parent, so this branch introduced them. The docstring asserting "the canonical filter does not conflate them" was simply wrong about its own oracle.

Fix: _code_for returns three states — the code; None for a value of the right type the column does not hold, which genuinely matches nothing; and _DECLINE when the scalar's type differs from the column's values, or the column is all-null and offers no type to compare against. The lookup defers to the canonical filter on _DECLINE. Same-type predicates still serve, so the fast path is not lost.

Why the test could not see it

test_boolean_predicate_never_matches_an_integer_column_code existed for this exact case and passed. The whole differential corpus seeded at n({"id": 1}) and walked e_forward — and node 1 has no outgoing edge, so every case compared [] == []. A fast path that drops every row is structurally invisible to an empty-vs-empty corpus.

Rewritten so it cannot recur: the corpus walks the direction that has edges; every case asserts the oracle's row count, so a case cannot silently go vacuous; _both turns point-rows off and asserts that both the specialization and the category lookup actually served; the canonical leg is a real oracle (index_policy="off" with the sibling routes off, not another fast path); and a new parametrized family covers the cross-type scalars plus a unit pin on the three-state verdict.

Verified by reinstating the bug: 5 of the new cases fail with it, 41 pass with the fix.

Your question about more indexes — measured, and it points somewhere specific

I timed the primitives the traversal actually calls, at this head, LDBC SNB SF0.1 polars, as a share of each query's own median:

query median ms searchsorted unique lexsort
recent-replies 3.987 0.662 ms (16.6%) 0.361 ms (9.0%) 0.092 ms (2.3%)
message-replies 1.855 0.030 ms (1.6%) 0.052 ms (2.8%) 0.006 ms (0.3%)
seed-lookup 1.097 0.016 ms (1.5%) 0.028 ms (2.6%) 0
new-topics 68.701 0.049 ms (0.07%) 0.090 ms (0.13%) 0.008 ms (0.01%)

Three conclusions, each killing or backing a candidate:

  1. A pre-sorted adjacency is not worth building. The obvious next index — have the adjacency carry its rows already ordered by (key, tiebreak) so the expansion's lexsort disappears — is worth 2.3% of recent-replies and under 0.5% everywhere else. Rejected before building it.
  2. The lever that is indicated: key the CSR by dense node row position, not by sorted id. searchsorted plus unique is 25.6% of recent-replies, and nearly all of it is mapping ids to positions and de-duplicating a frontier of ids. A CSR whose offsets are indexed by node row (0..N−1) makes every id→position lookup an O(1) array index and lets the frontier be a dense mark instead of a sort-and-unique. That is the same move EndpointRowsFact already made for edge endpoints, generalized to the traversal itself.
  3. No index helps seed-lookup or new-topics. seed-lookup spends 0.043 ms of 1.097 in array work — it is dispatch-bound and needs a one-row materialization path, not an index. new-topics spends 0.15 ms of 68.7; it is polars compute and an index cannot reach it.

Build cost and accounting — two gaps I have not closed yet

Worth stating rather than burying, and I would rather fix them before this merges if you agree:

  • The three new structures are built eagerly and unconditionally by gfql_index_all, and gfql_index_categories defaults to every column of both frames, calling unique() on each. On a wide frame that is many full distinct passes that yield no index because the cardinality cap rejects them.
  • build_endpoint_rows_fact has no engine gate, so a pandas graph builds a fact only the polars path can consume.
  • index_nbytes and show_indexes do not count the new structures, so the memory signal under-reports. The endpoint-rows array is the largest per-edge sidecar added so far.

Local suite at this head: 13289 passed, 24 failed — the same polars-conformance set that fails identically on the untouched parent.

@lmeyerov
lmeyerov force-pushed the perf/gfql-category-index branch 2 times, most recently from 24b3fed to 581ac83 Compare September 16, 2026 01:48
@lmeyerov

Copy link
Copy Markdown
Contributor Author

Two more commits since the comment above, head is now 581ac831f, and the three build-hygiene items I flagged as open are now closed.

f9438d201 — build cost, engine gate, memory accounting.

  • build_category_index ran an exact distinct pass on every admitted-dtype column before the cardinality cap could reject it, so a wide frame paid a full pass per column that yields no index. It now rules out a clearly-over-cap column with approx_n_unique() first; the margin keeps the sketch's error away from the cap and the exact check still decides everything near it, so the cap itself cannot move. On a 200k-row, 30-high-cardinality-string-column frame: gfql_index_categories 230 → 56 ms, whole gfql_index_all 0.490 → 0.376 s against a 0.069 s base.
  • build_endpoint_rows_fact is now polars-only, matching its only consumer. A pandas graph builds zero.
  • index_nbytes and show_indexes now cover all three structures. On a 20k-node/80k-edge graph that is 1.38 MB of a 4.89 MB total that was previously invisible. test_every_kind_carries_per_row_reason pinned an exact kind set and now uses ALL_REPORTED_KINDS, so a future fact that forgets to report fails rather than passing quietly.

Two docstrings asserted the opposite of the code and are corrected: both CategoryIndex and build_category_index claimed columns with nulls are not indexed. Nulls get a reserved code, which is exactly what makes the null-heavy label__* columns indexable.

What I did not optimize, deliberately: gfql_index_endpoint_rows is still 204 ms on 800k edges. That is real per-edge work bought to remove a per-hop search, a declared setup step like the adjacency build. It is now disclosed in show_indexes rather than optimized away on a hunch.

581ac831f — Categorical columns. pl.Categorical is admitted alongside pl.String, and the build joins on a mapping frame it constructs itself, which is the string-cache-sensitive case — but nothing tested it, and nothing tested that pl.Enum declines. Both verified and pinned, including an end-to-end family against the canonical filter with expected row counts.

@lmeyerov

Copy link
Copy Markdown
Contributor Author

Provenance check: do the numbers in the body still hold at the current head?

The DGX A/B table in the description was measured at 6a0da0907. The head is now 581ac831f, which adds a correctness fix that deliberately removes fast-path coverage for cross-type scalars. That could have cost part of the win, so I checked rather than relabelled the numbers.

Engagement is byte-identical. Category lookups served vs declined, on the real LDBC SNB SF0.1 fixture:

query at 6a0da0907 at 581ac831f
recent-replies 6 served / 1 declined 6 served / 1 declined
message-replies 4 served / 1 declined 4 served / 1 declined

The one decline in each was already there before the fix — it is a column with no category index, not a cross-type scalar. No SNB predicate is cross-type, so the fix costs this benchmark nothing.

Timings, interleaved A/B with an A/A control, 5 rounds, arms alternating, local box, medians of per-run medians in ms. Arm A is 6a0da0907, arm B is 581ac831f, arm C is a second checkout of A — so C vs A is the measurement floor and only a B-vs-A delta beyond it would mean anything:

query A 6a0da0907 C copy of A B 581ac831f A/A floor B vs A
message-creator 0.603 0.602 0.593 −0.1% −1.6%
message-replies 1.844 1.856 1.806 +0.7% −2.0%
recent-replies 3.504 3.427 3.482 −2.2% −0.6%
seed-lookup 0.966 1.060 1.049 +9.7% +8.6%

Every B-vs-A delta is at or inside its own A/A floor; seed-lookup's floor is larger than its effect. Canonical rows matched expectations in all 15 runs.

So the published numbers stand at the current head. They are not being relabelled to it: they were measured at 6a0da0907, and this is the evidence that the head since then did not move them.

lmeyerov and others added 7 commits September 15, 2026 20:45
…s compare codes

A label or edge-type predicate is a scalar equality on a column with a handful of
distinct values. gfql_index_all now codes those columns once at build time, and the
array bindings path answers the predicate by comparing the candidates' codes instead
of filtering a frame. Nulls get a reserved code that is never handed out for a queried
value, so they match nothing, exactly as the canonical filter's three-valued logic
already decides; a value the column never holds matches nothing for the same reason.
Boolean and integer values never share a code, because True == 1 in Python and the
canonical filter does not conflate them.

Polars only; every other engine, dtype, null-free requirement and cardinality above
the cap declines to the canonical filter, which is the same answer.

The property-index engagement probe now watches both filter seams. It asserted that
the seed was gathered from the index rather than scanned, and the work moving to the
positional filter had made it blind rather than false.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_012Me1E7ZdDuGqJGu3mMEzhp
A traversal reaches an endpoint as an id and then has to find that node's row to
read its properties, which searches the whole node key array once per hop.
gfql_index_all now resolves both endpoint columns through the node id index once,
so the hop gathers the row instead of searching for it.

The fact declines to build when any endpoint id is absent from the index, since a
row denoting no node would be worse than the search it replaces, and its validity
spans both frames because it is edge values resolved through the node index.
It is an accelerator only: dropping it changes no row, which the tests assert
directly by running the same queries with it disabled.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_012Me1E7ZdDuGqJGu3mMEzhp
The guard that stops raw Cypher temporal-constructor text reaching a caller scans
the projected result every time a projection runs. A projection that copies a
column verbatim inherits that column's own answer, so gfql_index_all now resolves
it once per String column and the array path reads the verdict instead of scanning.
A String literal has no source column and is checked directly.

Without a verdict for a frame the answer is "could leak", which declines to the
canonical route: the fact can only ever avoid a scan, never admit text the scan
would have caught.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_012Me1E7ZdDuGqJGu3mMEzhp
…to "no rows"

The category index answered a scalar predicate by comparing codes. `_code_for` matched
on `type(value) is type(expected)`, and a miss was treated as "the column holds no such
value", returning an empty position set. But a polars filter DOES coerce across these
types, so a cross-type scalar got an empty result where the canonical route returns rows:

  {"flag": True}          on an Int64 column    served []  vs 2 rows canonical
  {"flag": 1.0}           on an Int64 column    served []  vs 2 rows canonical
  {"label__Message": 1}   on a Boolean column   served []  vs 3 rows canonical

All three are correct at the parent commit, so this branch introduced them. The docstring
claiming "the canonical filter does not conflate them" was wrong about its own oracle.

`_code_for` now returns three states: the code; None for a value of the right type the
column does not hold, which genuinely matches nothing; and _DECLINE when the scalar's
type differs from the column's values, or the column is all-null and offers no type to
compare against. _positions_via_category_index defers to the canonical filter on _DECLINE.
Same-type predicates still serve, so the fast path is not lost.

The test written for exactly this case passed anyway, because the whole differential
corpus seeded at n({"id": 1}) and walked e_forward -- and node 1 has no outgoing edge, so
every case compared [] to []. A fast path that drops every row is invisible to that. The
corpus now walks the direction that has edges, every case asserts the oracle's row count
so it cannot silently go vacuous, the helper turns point-rows off and asserts that both
the specialization and the category lookup actually served, the canonical leg is a real
oracle (index_policy="off" with the sibling routes off, not another fast path), and a new
parametrized family covers the cross-type scalars plus a unit pin on the three states.

Verified by reinstating the bug: 5 of the new cases fail with it, 41 pass with the fix.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_012Me1E7ZdDuGqJGu3mMEzhp
…and report what it costs

Three hygiene items on the new index structures, each measured.

Build cost. `build_category_index` ran an exact distinct pass on every admitted-dtype
column before the cardinality cap could reject it, so a wide frame paid a full pass per
column that yields no index. It now rules out a clearly-over-cap column with
`approx_n_unique()` first; the margin keeps the sketch's error away from the cap and the
exact check still decides everything near it, so the cap itself cannot move. On a
200k-row, 30-high-cardinality-string-column frame: `gfql_index_categories` 230 -> 56 ms,
whole `gfql_index_all` 0.490 -> 0.376 s against a 0.069 s base.

Engine gate. `build_endpoint_rows_fact` had none, so a pandas graph built a fact only the
polars array path can read. Polars only now, matching its only consumer.

Memory accounting. `index_nbytes` skipped the new per-row arrays and `show_indexes` did
not report the new structures at all, so the pay-as-you-go memory signal understated
exactly the things that scale with the data. Both now cover categories, endpoint rows and
temporal text: on a 20k-node/80k-edge graph that is 1.38 MB of a 4.89 MB total that was
previously invisible. `test_every_kind_carries_per_row_reason` pinned an exact kind set
and now uses ALL_REPORTED_KINDS, so a future fact that forgets to report fails instead of
passing quietly.

Two docstrings asserted the opposite of the code and are corrected: CategoryIndex and
build_category_index both claimed columns with nulls are not indexed. Nulls get a
reserved code, which is what makes the null-heavy label__* columns indexable at all.

Pins: the exact-pass counter, a 63/64/65/200 cap boundary sweep, the pandas/cuDF decline,
and an nbytes-coverage assertion. The end-to-end cases are marked as engagement pins so
the routes-off lanes report result divergences rather than a disabled route.

The endpoint-rows build remains 204 ms on 800k edges. That is real per-edge work bought
deliberately to remove a per-hop search, a declared setup step like the adjacency build;
it is disclosed, not optimized away.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_012Me1E7ZdDuGqJGu3mMEzhp
`build_category_index` admits `pl.Categorical` alongside `pl.String`, and the build
joins on a mapping frame it constructs itself -- the string-cache-sensitive case --
but nothing tested it. `pl.Enum` is not admitted and nothing tested that either.

A dtype sweep now checks codes against the rows they came from for String and
Categorical and asserts Enum declines, and an end-to-end family compares a
Categorical predicate against the canonical filter, including a value the column
never holds, with the expected row count asserted so a case cannot go vacuous.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_012Me1E7ZdDuGqJGu3mMEzhp
…ull and stale cases

Applying the same boundary standard to the code added in the last two commits, which
had gaps of its own.

The cap boundary was only checked on strings, but the sketch that rules out
clearly-over-cap columns is approximate -- a sketch reading differently on integers
would silently cost those indexes. Now swept 63/64/65/200 across String, Categorical,
Int64 and UInt8; the cap does not move on any of them.

The all-null column had a unit assertion on the empty value map and nothing end to end.
It still builds, every row carrying the reserved null code, and the temptation is to
read an empty map as "matches nothing" -- right here by accident, wrong as a rule. The
lookup declines and the canonical filter decides, now asserted on both.

The new show_indexes rows had no stale or engine-mismatch coverage, which is the half
that matters: a fact reporting itself usable after its frame was rebound is worse than
one that is absent. Rebinding the nodes frame now must stale the node-role facts and
the endpoint fact that spans both frames, while the edge-role facts stay valid, and a
mismatched engine preview must make every new kind unusable with the shared wording.

Verified across all ten routes-off lanes: 68 pass in eight, 49 pass with 19 engagement
pins skipped in indexed-kernel and polars-bindings-select.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_012Me1E7ZdDuGqJGu3mMEzhp
lmeyerov and others added 2 commits September 16, 2026 22:08
…esign

`gfql_index_categories` described eligibility as "no nulls", but nulls are
exactly what this index codes: `build_category_index` gives them a reserved
code, which is what makes the null-heavy `label__*` columns indexable and is
where most of the measured win comes from. The two docstrings in this PR
disagreed with each other.

The contract is already pinned by tests -- `test_category_index.py` codes
Boolean/String/Int64 columns containing None and asserts the reserved code is
never handed out for a queried value -- so the prose is cut to a pointer rather
than restating a rule it got wrong.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_012Me1E7ZdDuGqJGu3mMEzhp
…or it

The endpoint-row resolver was the one place in this PR that decided control
flow from a duck-typed lookup: `getattr(values.dtype, "kind", "")`. Its
neighbour twelve lines up already does `str(keys.dtype.kind)` on the same kind
of value from the same helper, so the probe was defending against a case the
codebase elsewhere states cannot happen -- `col_to_array` returns a numpy or
cupy array and those always carry `dtype.kind`.

mypy agrees across 349 files with the attribute read in place.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_012Me1E7ZdDuGqJGu3mMEzhp
@lmeyerov
lmeyerov changed the base branch from perf/gfql-array-side-hop-loop to master September 17, 2026 05:33
@lmeyerov

Copy link
Copy Markdown
Contributor Author

Retargeted to master, plus two fixes found in a final pass

origin/master is now c5ce5e4f8 (#2086 landed). Base retargeted through the GitHub base field and master merged in — clean, one auto-merge in bin/test-polars.sh.

The coverage baseline was re-checked in both directions after the merge, since a wrong one turns master's lint red and a red lint lane skips every test lane repo-wide: graphistry/compute/gfql/lazy/**/*.py resolves to 24 files and the baseline holds 24 entries, with no target missing from the baseline and no baseline entry outside the target set. This PR adds no lazy-engine file, so it needs no new floor.

1. A docstring that contradicted its own design

gfql_index_categories described eligibility as "no nulls, at most 64 distinct values". build_category_index, in this same PR, says "Nulls ARE coded, under a reserved code" — and the second one is right. It is also the design point the whole change rests on: the null-heavy label__* columns are only indexable because nulls get a reserved code, and that is where most of the measured win comes from.

Verified against the built index rather than the prose: a null-bearing column yields value_codes {'BOUGHT': 0, 'IS_LOCATED_IN': 1} with nulls carrying reserved code 2, and None is never a key — so value_codes.get(None) can never return the null code, and a null predicate declines structurally rather than by luck.

test_category_index.py already pins this (Boolean/String/Int64 columns containing None, asserting the reserved code is never handed out for a queried value), so the prose was cut to a pointer instead of being rewritten into a longer rule it had got wrong.

2. The one dynamic-typing probe this PR added

Scanning only this PR's own added product lines turned up exactly one: getattr(values.dtype, "kind", "") deciding control flow in the endpoint-row resolver. Twelve lines above, the same file already reads str(keys.dtype.kind) directly on the same kind of value from the same helper, with the note "numpy/cupy arrays always carry dtype.kind" — so the probe was guarding against a case the file itself states cannot happen. Replaced with the direct read. mypy clean across 349 files with it in place.

(The sibling probe at build.py:434 is pre-existing on master and is deliberately left alone.)

Verification on the merged tree

  • ruff clean; lint exit 0 with no growth on either guard baseline; mypy 351 source files clean; GFQL index suite 1094 passed.
  • TCK at the pinned harness 84b098c: 4264 passed / 182 skipped / 689 xfailed / 0 failed — byte-identical to master c5ce5e4f8 on the same harness.
  • Bench engagement receipts verified unchanged. The SNB lane proves index engagement by patching _seed_rows_via_property_index, and this PR's category index also answers node-role lookups — so it could plausibly have bypassed that seam and silently degraded every lane receipt to "unverified". Ran the harness's own verify_index_engagement on both arms: identical in all six cells (engaged / built_engaged with matching probe and hit counts; new-topics reaches no seed seam on the baseline too).

@lmeyerov
lmeyerov merged commit 24c0b1e into master Sep 17, 2026
89 checks passed
lmeyerov added a commit that referenced this pull request Sep 17, 2026
…tree

The vendored numbers were pre-release and four runs were 52-61 compute commits
past their measuring commit, against a policy limit of 12 -- this PR's own
drift gate was failing on them. seed-lookup published 1.018 ms at SF0.1 where
the shipped tree measures 0.218, and 1.363 at SF1 where it measures 0.281.

Re-vendored from pyg-bench with SNB measured on pygraphistry f283a30 (master
with #2084, #2086, #2087, #2088, #2090) and GraphBench on 24c0b1e, both under
the DGX idle gate and host perf lock with canonical rows asserted identical
across engines, scales and repetitions.

docs/test_bench_numbers.py now passes: 37 passed, 1 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_012Me1E7ZdDuGqJGu3mMEzhp
pull Bot pushed a commit to admariner/pygraphistry that referenced this pull request Sep 17, 2026
…ing a frame

A seeded point query gathers the seed's incident edges into a frame and then filters
that frame. Attributing seed-lookup's polars calls by call site showed where its time
actually goes: 0.233 ms in the edge filter, 0.132 in the collect underneath it, and
0.175 gathering the candidates -- 46% of a 1.17 ms query spent materializing rows that
the predicate then throws away.

The candidates are row POSITIONS before they are a frame. When the edge predicate is one
the category index from graphistry#2087 can answer, the filter runs on uint8 codes and only the
surviving rows are gathered. That is the same structure graphistry#2087 built for the array
traversal, reused by the point path -- which is the concrete answer to "does the indexing
work hint at more indexes we can use for the others": the index already existed, the
point path just was not asking it.

Anything the index cannot answer falls back to gather-then-filter unchanged, so the
canonical filter stays the authority on 3-valued and dtype behaviour. `_index_edge_rows`
is now a thin wrapper over a new `_index_edge_positions`, so the gather and the lookup
are separable without changing what the gather does.

Measured interleaved, arms alternated, 5 rounds x 41 reps, against its own A/A control
(LDBC SNB SF0.1, polars, local):

  seed-lookup      1.019 -> 0.476 ms  (-53.3%, A/A floor -5.0%, ranges disjoint)
  message-creator  0.572 -> 0.540     (-5.5%, floor +4.1%)
  message-replies  1.774 -> 1.710     (-3.6%, floor -0.6%)
  recent-replies   3.449 -> 3.522     (+2.1%, floor +1.4%)
  message-content  0.395 -> 0.395     ( 0.0%, floor +1.4%)

Only seed-lookup moves beyond its own floor, and it moves by ten times that floor with
non-overlapping ranges. Rows correct in every arm and repetition. NO bench claim is made
here: local and bench are not related by a ratio (same commit, the ratio across queries
spans 0.84x to 2.54x), so the competitor comparison needs its own bench run.

Tests cover both sides of the boundary from the first commit: three indexed predicates
(one survivor, several survivors, a value the column never holds) against gather-then-
filter as oracle with expected row counts asserted; six predicates the index cannot
answer (absent, empty, unindexed column, cross-type scalar, missing column, mixed) which
must fall back and still agree, including agreeing on the exception; an engagement pin
that fails if the narrowing never runs, verified by disabling it; and the no-index case.
Registered in the polars lane and marked route_engaged, verified across all ten
routes-off modes.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_012Me1E7ZdDuGqJGu3mMEzhp
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant