fix(hop): to_fixed_point saturation, edges-only node output, tracked-seed attributes (#1918) - #1920
Conversation
…seed attributes (#1918) Round-011 re-probe of the direct hop() surface. Seven of the eight findings fixed, one scoped down with a residue pin; every repro pinned on pandas AND polars against hand-computed oracles (109-case pre-registered oracle: 0 mismatches outside two out-of-scope items). F4 (HIGH, unblocks #1893) to_fixed_point == saturated bounded, unfiltered. The undirected+tfp+wavefront seed-strip used topology-only heuristics (keep a seed only if its component holds >1 seed, or it sits on a cycle) where the bounded arm keeps any seed the traversal re-encountered. #1892's F-02 patch intersected that keep-set with the reached set, fixing a LEAK but not the heuristic DROPPING seeds. Every arm now uses the bounded rule, so tfp agrees on 2-node paths, mid-seeded 3-node paths and stars, keeps the triangle and the F-02 filter case correct, and matches polars. Removes two O(E) python itertuples walks (-63% on that arm). F1 (HIGH) hop() on an edges-only graph returned the FULL node table. The node-output block was gated on `self._nodes is not None`, so with synthesized nodes the traversal result was never applied: correctly-filtered edges next to an unfiltered node table. Now unconditional, matching polars' always-semi-join. F2 (HIGH) hop tracking destroyed the seed's attributes and upcast its dtypes. The node output inner-merged against the hop-label ids, dropping the (unlabeled) seed, which the endpoint backfill re-added id-only: NaN attributes, int64->float64. Labels are a left-join now. SCOPED: min_hops>=2 keeps the restriction -- there the label set is the rebuilt retained-path set and dropping unlabeled source-side nodes is load-bearing (widening it admits edge-less nodes and breaks the 400-case polars chain min_hops parity). That residue is pinned as a value test. Corrects the #1888 comment, whose guard never covered this case. F3 (MED) an undirected edge traversed both ways in one wavefront yielded two rows. Missing dedup when seeding edge_hop_records. Differential over 2879 shapes: labeled vs unlabeled pandas edge multiset now diverges 0 times, was 628. F5 (MED) label_seeds, a label-column flag, changed the returned node SET. The wavefront seed-strip was gated on it. Membership is independent of it now. F6/F7 (polars) no bound validation at all; worst case `min_hops=-1, hops=1` RETURNED AN ANSWER. Both engines now share one resolver in compute/hop.py. hops=None reconciled to pandas' run-to-closure (the released default-engine semantics; the signature admits None) rather than polars' ValueError. F8 (LOW, #1787-adjacent) a directed cycle with min_hops=4, max_hops=5 returned empty: the reachable-set closure break fired at hop 3, freezing max_reached_hop below min_hops. Deferred while a lower bound is unmet, on pandas and polars alike. This also fixes the three pinned #1787 undirected starvation cases (un-xfailed). Existing tests changed, each adjudicated against an independent brute-force trail enumerator rather than re-baselined (controls reproduced unchanged): - test_varlen_bounded_engine_parity_1787: dir-min3-exact/window 0 -> 1. The file's own note already named "the one 3-trail" as under-reported; *2..3 = 6 and *1..2 = 12 are unchanged controls. Shapes stay declined on polars. - test_directed_min_hops_3_collapses_to_empty_on_the_oracle -> ..._reports_the_one_ trail_not_empty: it pinned the defect as the oracle. - test_count_and_param_semantics: the #1787 starvation strict-xfail removed; its three oracles (2, 8, 6) are unchanged and independently re-verified. - test_engine_polars_hop unsupported case {"min_hops": 2} -> min_hops 2/max_hops 3: against the shared hops=1 it was CONTRADICTORY bounds, so it silently tested the ValueError path instead of the min_hops>1 decline it was written for. Gates: gfql tree 96 failed / 8726 passed, failure set BYTE-IDENTICAL to base (96 / 8593); TCK 4141 passed / 2 failed, identical to base measured against the same companion-clone HEAD; ruff, mypy, type-hygiene and cypher-surface guards clean. Perf (>=7 reps, 5 interleaved A/B rounds, 200k nodes / 1M edges): seeded 1-hop, 2-hop, undirected 1-hop and edges-only within noise; undirected tfp wavefront 34.7ms -> 12.8ms. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi
…/graphistry/pygraphistry into fix/gfql-1918-hop-semantics-round2
|
MERGE BLOCKER — the F4 fix aligned the two arms in the wrong direction. CI caught it via Fixture is the acyclic path
Returning to The round-011 F4 diagnosis had it backwards. It observed The deleted "topology heuristics" were a real contract, not heuristics. The "component holds more than one seed" rule encodes exactly the distinction pinned by the sibling test immediately below the failing one, Corrected direction: restore the tfp behavior and fix the bounded arm to stop reusing the departure edge, then re-derive the equivalence pin from a hand oracle rather than from either arm. F1/F2/F3/F5/F6/F7/F8 in this PR are unaffected and stand on their own evidence; only F4 needs reversing. Redirecting now. |
… away from it (#1918) CORRECTION to the F4 hunk of 3d62ff4. That commit made `to_fixed_point` agree with the bounded arm; the bounded arm was the wrong reference, and CI caught it via tests/compute/test_hop.py (not covered by this campaign's gate list, now added). Every other fix in that commit (F1, F2, F3, F5, F6, F7, F8) stands unchanged. WHAT THE BOUNDED ARM'S DEFECT ACTUALLY WAS `return_as_wave_front=True` returns ENCOUNTERED nodes. Walking back along the edge you departed on is the trip home, not a discovery, so a seed comes back only when a walk that REUSES NO EDGE reaches it. to_fixed_point enforced that via two topology helpers; the bounded arm applied only "did the BFS reach it", and the BFS re-enters a seed at hop 2 by traversing its departure edge a second time. On the path a-b-c-d-e seeded {a}: tfp gave {b,c,d,e} (correct), bounded hops=4 gave {a,b,c,d,e}. The round-011 probe reported that disagreement as "tfp is wrong" and the previous commit moved tfp. The 72 lines it deleted as ad-hoc heuristics were the contract. ONE RULE, BOTH ARMS `undirected_rediscovered_seed_ids` (module-level, engine-neutral: takes id sequences, so pandas/cuDF/polars all call it with no to_pandas bridge -- the polars lane ships no pyarrow). A seed is re-encountered by an edge-disjoint walk of SOME length iff another seed shares its component (the shortest path between them is simple), or it lies on a cycle. Both arms now intersect that with the reached set: exact for to_fixed_point, and a NECESSARY condition for a bounded window, so intersecting removes the whole backtracking class and can never drop a seed a bounded window should keep. The two arms previously had two implementations of one rule and only one of them was applied; they now share it. Also fixed by the shared rule: the old cycle helper built adjacency as a set of NEIGHBOURS, so two PARALLEL edges collapsed into one, both endpoints peeled as degree-1, and a seed on that length-2 cycle was dropped from tfp. Degree now counts edge ROWS. Pinned. polars had NO seed strip at all and shared the bounded defect; the same rule is applied there. A window of at most one edge cannot backtrack, so it is skipped -- that keeps the dominant chain shape (undirected single hop) at its old cost, and is why the lazy single-hop path needs nothing. ORACLES, NOT ARMS The F4 pins are re-derived on paper (enumerated in the test docstrings) rather than read off either arm -- an equivalence pin alone cannot tell two agreeing-but-wrong arms from two right ones, which is how this got through the first time. The widened parameterization is kept. The rule itself is verified equal to brute-force edge-disjoint-walk enumeration over 6000 random multigraphs. Two residues are pinned as value tests rather than left in prose: the rule is length-blind (a seed whose only cycle home is longer than the window is still kept -- 5-cycle, hops=3), and min_hops>=2 forward still returns an attribute-less seed (unchanged from #1918r1). Existing tests changed, hand-adjudicated (tests/compute/test_hop.py NOT touched): - test_compute_chain.py test_hop_chain_2_undirected / _2_end_undirected: the RAW-HOP expectation only. [a,b,c] -> [b,c] and [b,c,d] -> [b,c]; the edge-disjoint walks from 'a' on a-b-c-d are a-b and a-b-c (and from 'd', d-c and d-c-b). Both lists had been reused verbatim from the CHAIN expectation directly below them, which is unchanged and correctly keeps the seed -- `n({'n':'a'})` BINDS it, a different question from re-encounter. Gates: compute tree (gfql + compute/ + top-level compute tests) 107 failed / 10359 passed vs base 109 / 10347 -- ZERO new failures, the only delta being the two test_hop.py tests the previous commit broke. tests/compute/test_hop.py 61 passed, 4 skipped. TCK 4143 passed, 0 failed. ruff, mypy, type-hygiene and cypher-surface guards clean. Perf (9 reps x 3 interleaved rounds, 200k nodes / 1M edges): undirected wavefront 1-hop, 2-hop, undirected 1-hop, forward wavefront 2-hop and forward 1-hop all within noise (the topology walk was +43% on the single-hop arm before the one-edge-window skip). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi
|
F4 reversed — and the reversal found the deeper answer. Blocker lifted. What the bounded arm's defect actually was: The 72 "topology heuristics" I had them delete were computing exactly the right thing. A seed is re-encountered by an edge-disjoint walk of some length iff another seed shares its component (the shortest path between them is simple, hence edge-disjoint) or it lies on a cycle. Both arms now intersect that with the reached set — exact for It also fixed a latent bug in the old helper: adjacency was a set of neighbours, so two parallel edges collapsed into one, both endpoints peeled as degree-1, and a seed on that length-2 cycle was wrongly dropped. Degree now counts edge rows. Pinned. Pins re-derived from paper, not from either arm, with the enumeration in the docstrings. Two residues are pinned as value tests rather than prose: the rule is length-blind (5-cycle at Anti-vacuity at the true parent Two existing chain tests changed, and I verified the oracle independently before accepting it. Perf: the rule initially cost +43% on undirected single-hop wavefront; a one-edge window cannot backtrack, so it is skipped there. After that, 9 reps × 3 interleaved rounds at 200k nodes / 1M edges show every affected shape within noise. No board cell traded. Gates: |
Cascading base update; this branch was 23 commits behind its own base, which is why the temporal test files never reached it. Conflicts in graphistry/compute/hop.py (5 hunks) plus one SILENT bad auto-merge in test_hop_semantics_pins.py. graphistry/compute/hop.py, hunk by hunk: - typing import: took the base (strict superset -- it adds Callable for _endpoint_ids_without_node_rows on top of this branch's Hashable/Set). - module-level helpers (merge base EMPTY, both sides added code -- Rule G): kept BOTH. This branch's resolve_hop_bounds / _host_list / undirected_rediscovered_seed_ids are imported by lazy/engine/polars/hop_eager.py; the base's _reached_node_ids / _endpoint_ids_without_node_rows are imported by tests/compute/gfql/test_hop_kernel_contracts.py and called at the unbacked-endpoint block. Neither side was droppable. - nested _undirected_rediscovered_seed_ids wrapper (base) vs the restructured rich_nodes block (this branch): took THIS BRANCH. The base's wrapper is a pure extract-method over the pre-#1918 frame-based helpers (_undirected_component_seed_keep_ids / _undirected_cycle_nodes) which this branch deletes and replaces with the multigraph-correct id-sequence implementation. Keeping it would have left a caller-less wrapper over deleted functions. `git grep` for all three names in hop.py is now empty. - unbacked-endpoint narration: took the base's deletion (Rule D). - the undirected wavefront seed strip: took THIS BRANCH -- the seed-EXCLUDING #1918 form that applies the edge-disjoint rediscovery rule to the BOUNDED arm too and reconciles onto to_fixed_point, not away from it. The base still gated on `to_fixed_point` and used the set-based adjacency that collapses parallel edges. Verified with the two direct regression detectors: test_hop.py -k 'fixedpoint_undirected_does_not_revisit_seed_via_same_edge or fixedpoint_undirected_excludes_unrediscovered_seeds_in_disconnected_components' -> 2 passed. Seeding {a} on the path a-b-c-d-e yields {b,c,d,e}. graphistry/tests/compute/gfql/test_hop_semantics_pins.py: git merged this file without a conflict and produced an INCOHERENT test -- this branch's widened parametrization (filt gains `{}`, seeds gains [0]/[1]/[2]) spliced onto the base's newly-added `== [1]` literal, which is only the oracle for the base's narrower parameters (filtered, seeds [0,1]). Restored this branch's equality-only assertion on the widened matrix and re-added the base's value pin unchanged as its own test at the base's own parameters (test_hop_undirected_tfp_wavefront_filtered_values). Both intents kept; no literal invented. graphistry/tests/compute/gfql/test_hop_boundary_matrix.py: 21 cells were strict-xfail with reason "#1918: ..." -- i.e. the base recorded exactly the defects this PR fixes, and they XPASS(strict) after the merge. Emptied REDISCOVERY_XFAIL / TFP_EQUALS_BOUNDED_XFAIL and dropped the stale test_negative_hops_rejected_polars marker so all 395 cases assert the hand oracle unconditionally. This STRENGTHENS the file; nothing was skipped, xfailed or weakened. bin/ci_type_hygiene_baseline.json: hop.py dropped 6 -> 4 findings; --update-baseline to lock it. Gates: no conflict markers, ruff clean, type-hygiene guard clean, cypher surface guard pass, mypy shows only the 4 known polars-skew errors. The temporal files the coverage floors need are present on the head: tests/compute/gfql/cypher/test_temporal_arithmetic_folding_branches.py, tests/compute/gfql/cypher/test_native_temporal_resolution.py and tests/compute/gfql/coverage_baselines/ci-pandas-py3.12.json. Whole-suite A/B: graphistry/tests/compute is 105 failed on the merge and 105 failed on ghhttps/fix/gfql-1915-temporal-and-union alone, with IDENTICAL node id sets (21 non-GPU: umap/dask/polars-skew; 84 cudf). Zero new failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi
Cascading base update after #1920 took its base and, critically, finally picked up the 23 commits it was behind. ZERO conflicted files -- the earlier abort on this branch was correct: the hop.py conflict belonged in #1920, and once resolved there this merge is mechanical. This is the merge that should clear the test-gfql-core (3.12) coverage-floor failure: the temporal test files the per-file floors need are now on the head (tests/compute/gfql/cypher/test_temporal_arithmetic_folding_branches.py, tests/compute/gfql/cypher/test_native_temporal_resolution.py) along with tests/compute/gfql/coverage_baselines/ci-pandas-py3.12.json. Gates: no conflict markers, ruff clean, type-hygiene guard clean, cypher surface guard pass, mypy shows only the 4 known polars-skew errors. The two undirected-fixed-point regression detectors in test_hop.py pass. Fast-path + hop-semantics + boundary-matrix + temporal + trail suites = 1048 passed, 1 failed ([cudf], no GPU here). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi
…e guard One conflicted file, plus this branch's own comment-guard debt. graphistry/compute/gfql/row/pipeline.py Both sides edited the same seam: this branch ADDED _gfql_is_duration_text_scalar above _gfql_cypher_numeric_kind while the base RETYPED that neighbour to `object -> Optional[CypherNumericKind]`. Kept BOTH -- the new helper with the base's retyped neighbour -- and carried the base's typing convention into the new helper (`object`, no hygiene-ok escape: it only isinstance-checks). Guard debt (this branch predates bin/ci_comment_density_guard.py), cleared without raising any cap: graphistry/compute/hop.py Stripped the "#1918 F1..F8" tracker tags -- every fix is already pinned by name in graphistry/tests/compute/gfql/test_hop_semantics_1918.py (test_f1_..., test_f2_..., ..., test_f8_...), so the tags carried nothing the test names do not. Cut the multi-line rationale blocks to one-line constraints (labels are a left-join never a filter; the retained-path arm's drop is load-bearing; the closure break defers while min_hops is unmet; seed survives only on edge-disjoint rediscovery). Docstring contracts kept, citations gone. graphistry/compute/gfql/lazy/engine/polars/hop_eager.py Same treatment: tags stripped, runs collapsed to one line each; the bounds-resolver ordering, seed-strip rule, and is_in dtype-pin constraints each kept as a single line. Gates: no conflict markers; ruff clean; all three guards clean; mypy error set identical to the base (same 4 errors, two shifted line numbers in hop_eager.py from the comment edits). Suites: merged 98 failed / 10190 passed / 34 xfailed vs base 98 failed / 10022 passed / 58 xfailed -- failure SETS are exactly identical; the xfail delta is this PR's own hop-semantics fixes turning pinned expected-failures into passes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AjbKuKheqDu78oapRT5AYm
…) into #1920 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AjbKuKheqDu78oapRT5AYm
Round-005 mutation audit: min>max must raise from the bound check itself (a wide output_max_hops masked it into an empty answer), and to_fixed_point must ignore the hops bound entirely (every pin saturated within the passed bound, so a bound that leaked into the loop went unnoticed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AjbKuKheqDu78oapRT5AYm
) Kills the tfp-keeps-bound mutant: the resolver's min>max cross-check must never fire against a bound fixed-point traversal cannot use. Pins the pre-existing residual as-is (pandas ignores min_hops under tfp, polars declines typed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AjbKuKheqDu78oapRT5AYm
Round-005 test amplification / first formal mutation auditAudited head Verdict: SAFE TO LAND (no introduced defects) at
|
Stacked on #1919. Unblocks #1893 — this fixes F4, the case where that PR's own headline invariant was still false.
F4. The tfp+undirected wavefront strip decided seed retention by topology heuristics (component holds >1 seed, or the seed is on a cycle), while the bounded arm uses "was the seed re-encountered". #1892's F-02 patch fixed the resulting leak but never the drop. Now one rule serves every arm — keep a seed iff it is in
matches_nodes— which let 72 lines of heuristics be deleted.F1 node output was gated on
self._nodes is not None, so edges-only graphs returned the full materialized table; the block is now unconditional. F2 labels are a left-join instead of an inner-merge, so a tracked seed keeps its attributes and dtypes. F3 dedups the undirected double-emission. F5 removes thelabel_seedsgate so a labeling flag no longer changes membership. F6/F7 a sharedresolve_hop_bounds()gives polars the validation it lacked entirely;hops=Noneresolves to run-to-closure (released default-engine behavior, and the signature admitsNone). F8 the closure break is deferred while a finite bound is unmet, on both engines.Anti-vacuity check — the mistake that hid F4 in the first place: the new pins produce 46 failures at base, and the widened
test_hop_undirected_tfp_wavefront_matches_saturated_boundedfails at base on exactly the 3 unfiltered single-seed cells the old parameterization omitted.Performance improved. Board hop path untouched (200k nodes/1M edges, 15 reps, 5 interleaved rounds — all within noise), and undirected tfp wavefront went 34.7 ms → 12.8 ms (−63%) from the removed
itertupleswalks. No board cell traded.Pins:
test_hop_semantics_pins.py42 → 70, plus 102 new intest_hop_semantics_1918.py. Gates: full-tree failure set byte-identical (+133 passing, −3 xfail = un-xfailed #1787 cases), typecheck 329, ruff + guards clean, zero TCK regression.Existing tests changed — four, each hand-adjudicated against an independent brute-force trail enumerator (validated first against the files' own unchanged controls). Two pinned the #1787 defect as the oracle: the file's own note already conceded the eccentricity prune "under-reports the one 3-trail" (
0→4, 4→5, 5→6). One polars case was testing contradictory bounds against the sharedhops=1and silently exercising the wrong decline.label_edge_hopspolars decline: its stated reason is gone, but the gate stays. The decline cites pandas duplicating undirected edges under labels; re-running that differential over 2879 shapes gives 628 → 0 divergences. Buthop_eager.pyhas no edge-label output path at all, so lifting it is now a feature with a clean pandas oracle to port against — the comment records this rather than removing the gate.Honest residuals: F2 under
min_hops>=2still returns an attribute-less seed — there the label set is the rebuilt retained-path set and the restriction is load-bearing (widening it admits nodes with no retained incident edge, breaking 17/400 polars chain parity cases), so lifting it is a decision about the chain contract; pinned as a value test so the boundary cannot rot. Two pre-existing defects found and left unfixed (identical at base, so not regressions): unseededreturn_as_wave_frontgives NaN attributes to a node that exists in the node table, and the internal__gfqlhop__hop_0__column leaks into output undermin_hops>=2.🤖 Generated with Claude Code
https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi