Skip to content

fix(hop): to_fixed_point saturation, edges-only node output, tracked-seed attributes (#1918) - #1920

Open
lmeyerov wants to merge 8 commits into
fix/gfql-1915-temporal-and-unionfrom
fix/gfql-1918-hop-semantics-round2
Open

fix(hop): to_fixed_point saturation, edges-only node output, tracked-seed attributes (#1918)#1920
lmeyerov wants to merge 8 commits into
fix/gfql-1915-temporal-and-unionfrom
fix/gfql-1918-hop-semantics-round2

Conversation

@lmeyerov

Copy link
Copy Markdown
Contributor

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 the label_seeds gate so a labeling flag no longer changes membership. F6/F7 a shared resolve_hop_bounds() gives polars the validation it lacked entirely; hops=None resolves to run-to-closure (released default-engine behavior, and the signature admits None). 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_bounded fails 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 itertuples walks. No board cell traded.

Pins: test_hop_semantics_pins.py 42 → 70, plus 102 new in test_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 shared hops=1 and silently exercising the wrong decline.

label_edge_hops polars 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. But hop_eager.py has 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>=2 still 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): unseeded return_as_wave_front gives NaN attributes to a node that exists in the node table, and the internal __gfqlhop__hop_0__ column leaks into output under min_hops>=2.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi

lmeyerov and others added 2 commits August 15, 2026 07:42
…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
@lmeyerov

Copy link
Copy Markdown
Contributor Author

MERGE BLOCKER — the F4 fix aligned the two arms in the wrong direction. CI caught it via graphistry/tests/compute/test_hop.py::TestMultiHopForward::test_hop_fixedpoint_undirected_does_not_revisit_seed_via_same_edge, outside the gfql tree.

Fixture is the acyclic path a→b→c→d→e, seed {a}, undirected, return_as_wave_front=True. Measured on both commits:

parent c6fc1fcda this PR
to_fixed_point {b,c,d,e} {a,b,c,d,e}
bounded hops=4 {a,b,c,d,e} {a,b,c,d,e}

Returning to 'a' on a path requires traversing edge ab twice. to_fixed_point was the arm enforcing no-edge-reuse; the bounded arm was already violating it. This PR made tfp agree with the broken arm.

The round-011 F4 diagnosis had it backwards. It observed bounded=[0,1,2] vs tfp=[1,2] on a 3-node path and assumed bounded was the reference — same shape as this test, and the same wrong conclusion. "Make X agree with Y" needs establishing which of X and Y is correct first; that step was skipped, by the probe and by me when I briefed the fix.

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, test_hop_one_hop_undirected_keeps_rediscovered_seed_from_other_seed: with seeds {a,b} the answer does include 'a', because it is rediscovered from a different seed rather than by reusing its own edge. Both tests still pass that distinction today — seeds {a,b} correctly gives {a,b,c} on this PR.

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
@lmeyerov

Copy link
Copy Markdown
Contributor Author

F4 reversed — and the reversal found the deeper answer. Blocker lifted.

What the bounded arm's defect actually was: return_as_wave_front=True returns encountered nodes, and walking back along your departure edge is the trip home, not a discovery. A seed is re-encountered only when a walk reusing no edge reaches it. to_fixed_point enforced that; the bounded arm asked only "did the BFS reach it", and BFS re-enters a seed at hop 2 by traversing its departure edge a second time. polars had no seed strip at all, so it shared the defect on both arms — which is why the first cross-engine probe looked consistent and pointed the fix the wrong way.

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 to_fixed_point, and a necessary condition for a bounded window, so it removes the backtracking class without ever dropping a seed a bounded window should keep. One implementation, both arms, module-level and id-sequence-based so pandas/cuDF/polars all call it with no to_pandas() bridge. Verified equal to brute-force edge-disjoint-walk enumeration on 6000 random multigraphs.

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 hops=3 keeps a seed the oracle drops), and min_hops>=2 forward still yields an attribute-less seed.

Anti-vacuity at the true parent c6fc1fcda: 19 F4 cells fail for the right reason — [1] == [0,1], i.e. tfp correct and bounded keeping the seed — across both engines, plus the bounded-window and parallel-edge cells.

Two existing chain tests changed, and I verified the oracle independently before accepting it. test_hop_chain_2_undirected / test_hop_chain_2_end_undirected, raw-hop expectation only: [a,b,c] → [b,c] and [b,c,d] → [b,c]. On the path a-b-c-d, the edge-disjoint walks from a are a-b and a-b-c; returning to a needs edge ab twice. Ran it: both now return [b,c]. The chain expectations directly below were left unchanged and still keep the seed — correctly, because n({'n':'a'}) binds it, which is a different question from re-encounter.

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: test_hop.py 61 passed (not edited); compute tree base 109 failed, 10347 passed107 failed, 10359 passed — zero new, the delta being the two tests round 1 broke. The gate was widened to the whole compute tree, since the narrow gfql-only list is exactly what let this through. TCK 4143 passed / 0 failed. ruff, mypy (329 files), hygiene, surface guard all clean.

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
lmeyerov added a commit that referenced this pull request Aug 15, 2026
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
lmeyerov and others added 4 commits August 16, 2026 03:31
…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
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
@lmeyerov

Copy link
Copy Markdown
Contributor Author

Round-005 test amplification / first formal mutation audit

Audited head f1c4e491c against merge base 42566a644; the base branch moved to 6ac9c22d3 (#1919's audit push) and is folded forward here (clean merge). Confirms the three previously-strict-xfailed boundaries (REDISCOVERY_XFAIL / TFP_EQUALS_BOUNDED_XFAIL) are now EMPTY sets — this PR flipped them: pandas parallel-edge cycle-helper collapse, the missing polars undirected-wavefront seed strip, and tfp==saturated-bounded, all green at head across the 581-cell matrix.

Verdict: SAFE TO LAND (no introduced defects) at 6f07f2953

NULL-endpoint probe battery (null-src/dst edges, null seeds; forward hop, undirected tfp; both engines) head-vs-base found exactly ONE divergence, and it is the intended fix: polars leading-undirected-tfp wavefront kept the seed at base (['a','b']) and now strips it (['b']), matching pandas and the hand-oracle (seed re-entry needs edge ab twice). Null endpoints stay inert on both engines, no NaN node synthesis, null seed matches nothing. cuDF parity spot-checked on the parallel-edge rediscovery oracle (['a','b','c'], tfp == bounded — shares the pandas kernel; cupy remains broken/out of scope on this box).

Mutation audit: 11 mutants, 11 caught (after 2 added killer cells)

All with pytest summary lines against the 581-cell matrix (test_hop_semantics_1918.py + test_hop_boundary_matrix.py + test_hop_semantics_pins.py).

  • Caught outright: rediscovery-returns-none 42F; rediscovery-returns-all 42F; parallel-edges-collapse (the pre-fix set-based adjacency) 6F; self-loop-rule-off 2F; component-seed-rule-off 24F; negative-min-ok 4F; min-default-always-1 8F; polars-seed-strip-off 21F; min-bound-unmet-early-exit-off 4F.
  • Two survived and were killed with new cells:
    1. min>max-check-off survived because the output_max < min_hops cross-check masked it — but an explicit wide output_max_hops=5 un-masks it into a silent EMPTY answer. Added min-gt-max-wide-output to _BAD_BOUNDS (2F verified).
    2. tfp-keeps-the-hops-bound survived two ways: every tfp pin saturated within the passed bound, and all 9 read sites of resolved_max_hops are not to_fixed_point-guarded — but the RESOLVER's own min>max cross-check makes it observable: to_fixed_point=True, hops=1, min_hops=2 must answer, not raise against a bound fixed-point never uses. Added test_f4_tfp_with_min_hops_does_not_trip_the_dead_hops_bound (1F verified), which also pins the pre-existing residual honestly: pandas currently IGNORES min_hops under to_fixed_point (same at this PR's base — category 2, listed) while polars declines the combination typed.
  • Plus a plain saturation cell (hops=1, to_fixed_point=True reaches full closure).

New cells + anti-vacuity

4 new cells (2 killers above + the saturation cell + the residual pin). PR's own matrix: 83 of 581 cells fail at the merge base — the flipped boundaries are real fixes, not decorative flips; 498 base-passers are the previously-green matrix cells and controls. No swallow sites (the matrix asserts values, never skips on decline; the one xfail machinery present takes an explicit set, now empty).

Category 2 (pre-existing, listed)

  • pandas ignores min_hops under to_fixed_point (polars: typed NIE) — pinned as-is so a change is deliberate; unchanged by this PR.

Engines

pandas + polars across the full matrix; cuDF spot-parity on the rediscovery oracle (dataframe kernel; cupy/libnvrtc.so.12 broken here, polars-gpu out of scope).

Gates

ruff clean; all three guards rc=0 from inside the worktree; mypy error set identical to base (4 pre-existing transitive); polars lane: both matrix files registered (test_polars_lane_completeness green); no new source files. Full graphistry/tests/compute: failure SET identical to the folded base (105 = 105, both diffs empty — 102 known env + the 3 GPU cells #1914 added).

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