Skip to content

fix(hop)!: filter-domain invariant + to_fixed_point==saturated (#1892) - #1893

Merged
lmeyerov merged 2 commits into
masterfrom
fix/gfql-1892-hop-semantics
Aug 15, 2026
Merged

fix(hop)!: filter-domain invariant + to_fixed_point==saturated (#1892)#1893
lmeyerov merged 2 commits into
masterfrom
fix/gfql-1892-hop-semantics

Conversation

@lmeyerov

Copy link
Copy Markdown
Contributor

First umbrella of the fix cycle (owner: iterate after fixes). Fixes #1892's two HIGH classes with the round-003 RED pins as the acceptance gate — all 8 flipped, 42/42 green both engines; gates stash-verified against pre-existing env failures. Stacked on #1883. Behavior changes CHANGELOG'd: id-only seeds now work at hops=1; stale seed columns can't flip values; undirected tfp no longer leaks unencountered seeds and equals the saturated bounded arm.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi

Source filters read the node table at EVERY hops value (seed semi-join
preserves the #917 shortcut's perf intent, which blame shows was its only
intent); undirected tfp keep-set intersects the traversal's reached set so
filtered fixed-point equals the saturated bounded arm and engines agree.
All 8 RED pins flip; 42/42 green both engines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi
@lmeyerov

Copy link
Copy Markdown
Contributor Author

Hold this PR — its headline invariant is still false in an arm the pin does not cover. Found by a round-011 re-probe of the direct hop() surface (109 hand-oracled cases, oracles written before execution): full detail in #1918.

test_hop_undirected_tfp_wavefront_matches_saturated_bounded parameterizes only over {source_node_match}/{destination_node_match} with seeds [0,1]. Run its exact fixture and kwargs unfiltered with a single seed:

pandas seeds=[0] filt=none : bounded(hops=3)=[0,1,2]  tfp=[1,2]   VIOLATED
pandas seeds=[1] filt=none : bounded(hops=3)=[0,1,2]  tfp=[0,2]   VIOLATED
pandas seeds=[0,1] filt=none: bounded=[0,1,2] tfp=[0,1,2] OK  <- the arm the pin happened to pick
polars: correct in all six arms

The F-02 patch in this PR intersected the undirected keep-set with matches_nodes, which removed the leak it was aimed at. It did not address the upstream heuristic — _undirected_component_seed_keep_ids keeps a seed only when its component holds >1 seed, and _undirected_cycle_nodes only when it is on a cycle — dropping seeds the bounded arm keeps. Any acyclic single-seed component reproduces (2-node path, 3-node path with the seed in the middle, star); a triangle does not, which is why the cycle-shaped fixtures pass.

The same probe found three further pandas silent-wrongs on this surface (edges-only graphs returning the entire node table; hop-tracking flags destroying seed attributes and upcasting int64→float64; undirected edges doubled under tracking) plus two cross-engine gaps. All in #1918, sequenced F4-first so this PR can go green on its own claim.

Nothing here says the fix is wrong — it is incomplete, and the pin's parameterization hid the incomplete part. That is exactly what a re-probe is for.

@lmeyerov

Copy link
Copy Markdown
Contributor Author

Hold lifted on the correctness side. F4 — this PR's headline to_fixed_point == saturated bounded invariant being false in the unfiltered single-seed arm — is fixed in #1920, and its pin here is widened to cover exactly the three cells the old parameterization omitted (they fail at base, so the pin is not vacuous).

Root cause worth recording: the tfp+undirected wavefront strip decided seed retention by topology heuristics (does the component hold more than one seed, is the seed on a cycle) while the bounded arm asks "was the seed re-encountered". This PR's F-02 patch fixed the resulting leak but never the drop. #1920 replaces both with the single rule the bounded arm already used, deleting 72 lines of heuristics — and undirected tfp got 63% faster as a side effect (34.7 → 12.8 ms) from the itertuples walks that went with them.

Still open before I can recommend sign-off: the whole-board A/B found a pandas q1/q2 regression (+11.3%/+10.0% @20k, +6.9%/+6.7% @100k, pandas-only, Bonferroni-significant) somewhere in this twelve-PR stack. A bisect over the PR heads is running to attribute it; hop is a plausible suspect since q1/q2 are degree-shaped, so I want that answer before signing this one.

@lmeyerov
lmeyerov changed the base branch from fix/gfql-release-blockers to master August 15, 2026 16:44
Comment thread graphistry/compute/gfql/lazy/engine/polars/hop_eager.py Outdated
Comment thread graphistry/compute/gfql/lazy/engine/polars/hop_eager.py Outdated
Comment thread graphistry/compute/hop.py Outdated
keep_seed_ids = _undirected_component_seed_keep_ids(final_edges, wavefront_seed_ids_df)
keep_seed_ids |= _undirected_cycle_nodes(final_edges)
# #1892 F-02: the topology-only heuristics ignore source/dest filter pruning;
# keep only seeds the traversal re-encountered so tfp == saturated bounded.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

asymptotics belong more in pyg-bench than these comments afaict, audit & fix, incl root cause on why authoring & review rules let through

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

clear correctness belong in clear code, tests, tck-gfql tests, etc, not comments

…g its cost (#1893 review)

REVIEW POINTS (hop_eager.py:243, :285, hop.py:1154 x2)
The four flagged comments are gone. The rule they narrated now lives in names:

- `only_seeds_can_be_sources` (hop_eager.py twice, hop.py once) replaces the two
  "#1892 F-01 ... keeps the single-hop small-frame cost" notes AND the third,
  unflagged instance of the same comment in hop.py that the owner did not see.
  The predicate is the whole content: the source-filter domain is the node
  table, narrowed to the seed ids exactly when only seeds can depart.
- `_undirected_rediscovered_seed_ids(edges, seeds, reached)` replaces the
  "#1892 F-02: the topology-only heuristics ignore ... filter pruning" narration
  and folds the inline reached-set intersection into `_reached_node_ids()`.
  A seed comes back only via a walk that REUSES NO EDGE -- another seed in its
  component, or a cycle -- intersected with what the traversal reached.
- The "NOTE: both helpers are O(E) python itertuples walks; vectorize if this
  arm gets hot" perf claim is deleted outright. If that cost matters it is a
  pyg-bench case, not a note nobody re-measures.

AUDIT (this PR's whole diff vs its base, 85a05a9)
Zero comment lines remain added in product code:
  git diff 85a05a9 -- 'graphistry/compute/**/*.py' | grep -nE '^\+\s*#'   -> empty
  ... | grep -nE '^\+.*(Any|type: ignore|hygiene-ok|cast\(|getattr\()'      -> import line only
  ... | grep -nEi '^\+.*(O\(|cost|faster|cheap|regress|vectoriz|hot|A/B)'   -> empty
No `hygiene-ok` pin was added: the two new helpers are typed `Set[Hashable]`,
so hop.py's bare-generic count is unchanged at its baseline of 6.

BOUNDARY MATRIX (test_hop_boundary_matrix.py, 395 cells, both engines)
The PR previously net-REMOVED 51 lines of test code and had no single-seed,
self-loop or parallel-edge case. The new matrix crosses seed cardinality (zero,
one, two-same-component, two-different-components, all), topology (path, cycle,
self-loop, PARALLEL EDGES, star, disconnected, isolated), hops (0, 1, <diameter,
=diameter, >diameter, to_fixed_point, min/max windows, negative, inverted),
direction, and filters (none/source/dest/both), positive and negative. Every
expected value is hand-derived in the docstrings from the two documented rules,
never read off the other arm or the other engine.
Anti-vacuity: 134 of 395 cells FAIL at the base commit (124 filter-domain,
10 fixed-point). The unfiltered fixed-point cells pass at base and are marked
as such -- honest information about what this change does not touch.

FINDINGS, PINNED AS STRICT XFAILS AGAINST #1918 (not fixed here)
1. `to_fixed_point == saturated bounded`, this PR's own headline, is FALSE on
   the UNFILTERED undirected wavefront: 10 of 16 cells diverge because the
   bounded arm re-enters a seed over its own departure edge. The existing pin
   passed only because its filters removed the seed from the reached set. The
   fixed-point arm is the correct one.
2. polars applies no undirected-wavefront seed strip at all: 9 of 16 unfiltered
   cells disagree with pandas and with the hand oracle.
3. pandas' cycle helper builds adjacency as a set of NEIGHBOURS, so two parallel
   edges collapse, both endpoints peel as degree 1, and a seed on that length-2
   cycle is dropped.
4. polars accepts hops=-1 and returns empty where pandas raises ValueError.
The CHANGELOG entry is corrected to claim parity on the FILTERED arm only.

ROOT CAUSE (why authoring and review let this through)
The review skill's self-review gate says to grep the ADDED-comment diff "on the
full stack range not just the last commit". Run that way on a stacked branch it
audits the tip and never the ancestors, so a parallel lane removed these exact
notes downstream while #1893 -- which the owner reviews individually -- kept
shipping them. The gate needs to run per-PR, base..head, on every branch in the
stack. Two aggravating factors: the `# #1892 F-01:` prefix made prose look like
traceability metadata and read as exempt, and the notes existed to justify
keeping an optimization while its semantics changed -- the point at which the
rule should be "name the predicate", not "defend it in prose".

GATES (base = 85a05a9, this PR's merge-base with master)
- graphistry/tests/compute/gfql: 93 failed / 8296 passed / 707 skipped / 77
  xfailed, vs base 93 / 7958 / 707 / 64. Failure sets diff BYTE-IDENTICAL
  (93 names each; all pre-existing cudf-absent and polars-conformance).
- test_hop_semantics_pins.py 42 passed; test_hop_boundary_matrix.py 374 passed,
  21 xfailed; whole-compute-tree list (compute/test_hop.py,
  test_compute_hops.py, test_compute_chain.py, test_polars_lane_completeness.py)
  252 passed / 4 skipped / 2 failed -- the same 2 cudf-absent failures as base.
- ruff clean; mypy "Success: no issues found in 328 source files";
  type-hygiene guard OK (no growth, 7 files below baseline);
  cypher-surface guard pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi
@lmeyerov

Copy link
Copy Markdown
Contributor Author

changes seemingly look good, likely pyg-bench etc obligations for the removals

lmeyerov added a commit that referenced this pull request Aug 15, 2026
The base advanced with #1893's review remediation and the PR went
CONFLICTING, so GitHub could not build refs/pull/1894/merge and stopped
creating pull_request workflow runs entirely — the "stalled CI" was a
conflict, not a dropped webhook.

Only CHANGELOG.md conflicted: both sides carry the #1892 entry. Kept
this PR's #1891 entry plus the base's newer #1892 text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi
@lmeyerov

Copy link
Copy Markdown
Contributor Author

Both comments on hop.py:1154 are addressed in 009c52dc6 ("name the source-filter domain rule instead of narrating its cost").

The two comments you objected to are deleted, not moved. git show 009c52dc6:graphistry/compute/hop.py | grep -n "itertuples walks\|F-02\|topology-only heuristics" returns nothing.

On "correctness belongs in clear code and tests, not comments." The # #1892 F-02: the topology-only heuristics ignore source/dest filter pruning; keep only seeds the traversal re-encountered so tfp == saturated bounded comment was narrating a rule that the code did not state. The three-statement inline block is now one named call:

keep_seed_ids = _undirected_rediscovered_seed_ids(
    final_edges, wavefront_seed_ids_df, matches_nodes)

The re-encounter intersection that the comment described is now inside that helper (& _reached_node_ids(reached_nodes, node_col)), so it cannot be read as optional, and the predicate itself is named _reached_node_ids rather than described.

The correctness claim is pinned by tests, not prose — in test_hop_boundary_matrix.py:

  • test_undirected_tfp_equals_saturated_bounded
  • test_undirected_tfp_equals_saturated_bounded_filtered
  • test_undirected_tfp_wavefront_rediscovered_seeds
  • test_undirected_tfp_wavefront_filtered_rediscovered_seeds

That matrix is 395 hand-oracled cells, 134 of which fail at this PR's base.

On "asymptotics belong in pyg-bench." Agreed — the # NOTE: both helpers are O(E) python itertuples walks; vectorize if this arm gets hot line is gone and no cost claim replaced it.

On "root cause on why authoring & review rules let through" — that part is not fixed by this PR, and I filed it as two:

  • docs(review): encode the rejection categories the GFQL stack kept hitting #1924 adds the missing rules to agents/skills/review/SKILL.md: a verdict table that sends multi-line narration to an extracted named helper and a "why this fix was made" comment to the pin's test name, with perf/asymptotics claims classified as a BLOCKER that belongs in pyg-bench; plus a self-review gate that greps <base>..HEAD before pushing, and a rule that a justified keep is still a keep — the bar is "can a test or a name carry this instead?", not "is there a reason?".
  • ci: enforce comment-encoding rules with a guard #1926 makes it mechanical rather than a matter of reviewer attention: bin/ci_comment_density_guard.py fails on a perf-claim comment (this exact O(E) line is the pattern it matches), on 2+ adjacent narration lines, and on issue-number rationale comments. Ratcheted per-file so master's existing debt is frozen rather than reopened. I checked it against this PR and the other two under review: the guard would have failed all three.

The honest root cause is that the review skill had no rule for either category, so both were authored and reviewed as normal — the guard exists so it stops depending on whether the rule gets remembered.

@lmeyerov
lmeyerov merged commit 19709f2 into master Aug 15, 2026
77 checks passed
@lmeyerov
lmeyerov deleted the fix/gfql-1892-hop-semantics branch August 15, 2026 18:23
lmeyerov added a commit that referenced this pull request Aug 15, 2026
… strip (#1895 round 4)

Round-4 amplification of the endpoint-closure contract against the two
surfaces master gained after rounds 1-3 (#1894 OPTIONAL MATCH
null-extension, #1893 hop filter-domain / to_fixed_point saturation).

Three cells, 8 engine arms on this box, 5 of which fail at the merge-base:

- OPTIONAL MATCH (forward and undirected) must NULL-extend a driving row
  whose only candidate edge dangles, not bind the optional alias to the
  unbacked id. pandas and cuDF bound it to the unbacked id at the
  merge-base; polars was already the correct side and is the control.
- The undirected seed strip writes NA into the hop-label column, and the
  gate made the endpoint backfill (which used to materialize that column)
  conditional. With no surviving edges the strip is the first writer:
  pandas creates the column on assignment, cuDF raises. Pins the cuDF arm
  against the pandas oracle.

Every expected value is hand-walked from the fixture tables, not read off
a run; cross-engine agreement is not used as an oracle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi
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.

bug(hop): source-filter domain flips with hops value (silent value flip); undirected tfp leaks unencountered seeds

1 participant