Skip to content

fix(circle_layout): all five #1968 entry guards (finishes #2008) - #2118

Merged
lmeyerov merged 7 commits into
masterfrom
fix/circle-layout-entry-guards
Oct 3, 2026
Merged

lmeyerov merged 7 commits into
masterfrom
fix/circle-layout-entry-guards

Conversation

@lmeyerov

@lmeyerov lmeyerov commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Linked issue(s): Closes #1968
  • Scope is explicit (non-goals included when relevant)

Finishes @SAY-5's #2008, which fixed defects 1–3 of #1968 and declared 4–5 non-goals while still claiming to close the issue. Their commit is cherry-picked here with authorship intact; the rest is review of it plus the two remaining defects, so all five are now covered and Closes #1968 is honest.

From #2008 (unchanged): nodes are materialized before they are counted, so an edge-only graph lays out instead of raising TypeError: object of type 'NoneType' has no len(); the no-argument form on a graph without x/y raises a ValueError naming the documented precondition instead of KeyError: 'x'; a bounding_box frame with duplicate partition_key values raises a ValueError naming the duplicates instead of pandas' IntCastingNaNError from the row-multiplying merge.

Defect 4 — the misleading message. A null value in a partition_by column raised ValueError('Unexpected NaNs in node indices'), which sends the reader to node ids; groupby(...).cumcount() returns NaN for a null group key, so the indices were only the messenger. The partition columns are checked where partition_by is already validated, and the message names the column.

Defect 5 — the dead comparison and the CPU/GPU divergence. engine_concrete in [EngineAbstract.CUDF, 'cudf', 'Engine.CUDF'] is always False (engine_concrete is an Engine, a different enum, and not a str-enum), so only hasattr(node_idx_relative, 'to_pandas') ever selected the cuDF arm. The two arms differed in exactly one thing — the CPU arm's .fillna(0.0) — so a NaN angle became angle 0 on CPU and raised on GPU. They are one expression now (s_pi(PANDAS) is np.pi and cupy.pi is the same float, so nothing numeric changes), and a NaN angle is unreachable: the node indices are checked at the cumcount guard, the per-partition sizes after the merge, and a partition is never empty — leaving the existing Unexpected NaNs in node angles check as the single backstop on both engines. The divergence was labelled INDICATIVE in the issue; it is confirmed latent, not reachable, which is why the A/B below is identical.

Found by reviewing #2008 (.agents/skills/review/SKILL.md, 3 waves to convergence):

  1. 🔴 The duplicate-key message formatted its keys with s_to_arr, whose cuDF arm is to_cupy(), which raises on a string key column. On GPU fix(circle_layout): entry guards for edge-only graphs, missing positions, and duplicate bounding_box keys #2008's guard raised TypeError: cupy does not support object — worse than the error it replaced. Verified by running fix(circle_layout): entry guards for edge-only graphs, missing positions, and duplicate bounding_box keys #2008's own circle.py on a GB10. Now series_to_pylist (graphistry/Engine.py:1002).
  2. The same message used sorted(set(...)), which has no total order when the key column holds a null beside another duplicate: TypeError: '<' not supported between instances of 'str' and 'NoneType'. A guard that exists to replace an untyped crash must not raise one — sorted by str.
  3. The duplicate check sat behind if partition_by in bounding_box_keyed.columns, which made it skippable and left a missing key column to KeyError inside the merge. The required columns are named in a typed error instead.
  4. A partition-keyed frame passed without partition_by reaches the 4-tuple branch, where len(frame) is its row count and center_x, center_y, width, height = frame unpacks column names — ValueError: too many values to unpack (expected 4) for a 5-column frame, a whole frame dumped through an AssertionError for a 2-row one, and center_x = 'partition_key' for a 4×4 one. Typed error naming partition_by=. Beyond layout: circle_layout entry guards — TypeError on edge-only graphs (len() before materialize_nodes), bare KeyError('x') for the no-arg form, IntCastingNaNError on duplicate bounding_box keys #1968's five; same branch, same defect class.
  5. layout: circle_layout entry guards — TypeError on edge-only graphs (len() before materialize_nodes), bare KeyError('x') for the no-arg form, IntCastingNaNError on duplicate bounding_box keys #1968 motivates typed errors by wire reachability (circle_layout is a safelisted GFQL call) and only the happy path was covered there — two execute_call pins now carry the error path and the edge-only path.

Also: the bounding_box / partition_by docstrings state the preconditions the guards enforce, and the dead import numpy as np and dead Engine import go with the collapsed branch (bin/ci/ci_comment_density_baseline.json ratchets circle.py 17 → 16; only that entry, not a blanket --update-baseline).

Non-goals: num_nodes == 0 still returns self, so an empty-edge graph gets _nodes is None back. Sibling layouts disagree with each other on that input (tree_layout → None, fa2_layout → 0-row frame with x/y, layout_igraph/group_in_a_box/modularity_weighted → merge ValueError), so there is no parity contract to meet, and #1968 does not raise it.

Validation

  • Local lint/type/tests run for touched scope

  • CI is green — the only red is pre-existing and repo-wide: docs/test_bench_numbers.py's three staleness assertions (filter-pagerank-20260728 measured 66 days ago, the three graphbench/snb keys 67-68, against policy.max_age_days = 60). They fail identically on origin/master locally, on the RTD build, and on perf(gfql/cypher): multi-seed WHERE a.id IN [...] hop 4,004 ms → 10 ms (vectorized IN, index reaches the row pipeline, IN seeds the pattern, kernel takes a seed set) #2117 — an unrelated PR touching no docs — and they take docs/readthedocs.org, test-docs and both test-minimal-python lanes with them ((3.8) is the matrix sibling cancelled by fail-fast). Fix is a pyg-bench re-measure and republish of published/docs-numbers.json, not a change here. Both test-minimal-python lanes report 2139 passed, 3 failed — the 3 being exactly those.

  • Cross-version: every pin here passes on pandas 2.x (py3.8 lane) and pandas 3.0.5 (py3.14 lane). The first CI run caught one that did not: pandas 3 stores an object-column None as nan, so the duplicate-key message reads ['a', nan] there and [None, 'a'] on 2.3.3. The guard is correct on both — the pin assumed one — so the pandas pin now uses mixed int/str keys ([1, 'a']), which is the property under test (sorted({1, 'a'}) raises the same TypeError the fix removed) in a spelling both majors agree on. The null spelling stays pinned on the cuDF lane, where series_to_pylist goes through arrow and yields None, and where a mixed-type column cannot exist.

  • ruff clean; ./bin/typecheck.sh → mypy clean (352 files); ci_pyright_guard.py, ci_comment_density_guard.py, ci_type_hygiene_guard.py all "no growth"

  • 449 tests green in graphistry/tests/layout/ + test_call_operations.py (19 of them the new test_circle_entry_guards.py), on pandas and on a GB10 (cudf 26.02, TEST_CUDF=1, run through ~/dgx-guard/safe_run.sh)

  • ./bin/test-minimal-lite.sh: 2690 passed, 52 failed — all 52 pre-existing and unrelated (49 CuPy failed to load libnvrtc.so.12 in this box's CPU env, 3 docs/test_bench_numbers.py staleness). No layout failure.

  • Differential A/B, 16 graph shapes × 2 engines, origin/master's circle.py vs this branch: 11/16 byte-identical on pandas and 11/16 on cuDF — every success path, positions included. The 5 pandas / 6 cuDF differences are exactly the defects:

    case master (pandas / cuDF) this branch
    edge_only, one_node TypeError: object of type 'NoneType' has no len() laid out
    no_bb_no_xy KeyError: 'x' requires either bounding_box= or existing x/y node positions
    null_partition Unexpected NaNs in node indices partition_by columns with nulls: ['p']
    dup_bb_keys IntCastingNaNError / CuPy currently does not support masked arrays one typed message on both engines

    bb_missing_partition_row staying identical is the empirical half of the dead-fillna argument: a partition with no bounding-box row still fills 0/1 and still lands in the same place.

Cypher Frontend CI Evidence (when PR touches cypher frontend / IR scope)

Not applicable — layout-only change.

🤖 Generated with Claude Code

SAY-5 and others added 7 commits October 2, 2026 14:10
…ons, and duplicate bounding_box keys (#1968)

Signed-off-by: Sai Asish Y <[email protected]>
…_box columns; one angle expression (#1968)

Completes #1968 on top of #2008 by @SAY-5, which fixed defects 1-3 and
declared 4-5 non-goals.

Defect 4: a null value in a partition_by column raised 'Unexpected NaNs
in node indices', which points at node ids; groupby().cumcount() returns
NaN for a null group key, so the indices were only the messenger. The
partition columns are checked where partition_by is validated.

Defect 5: `engine_concrete in [EngineAbstract.CUDF, 'cudf', 'Engine.CUDF']`
is always False -- Engine is a different enum and not a str-enum -- so
only `hasattr(node_idx_relative, 'to_pandas')` selected the cuDF arm. The
arms differed in one thing, the CPU arm's `.fillna(0.0)`, so a NaN angle
became angle 0 on CPU and raised on GPU. One expression serves both, and
a NaN angle is unreachable (node indices and partition sizes are each
already checked, a partition is never empty), leaving the existing
'Unexpected NaNs in node angles' check as the single backstop.

Also from reviewing #2008: the duplicate-key listing went through
s_to_arr, whose cuDF arm is to_cupy() and raises on a string key column,
so it used series_to_pylist; and the duplicate check sat behind
`if partition_by in bounding_box_keyed.columns`, which made it skippable
and left a missing key column to KeyError inside the merge -- the
required columns are now named in a typed error.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…n the GFQL wire surface

Review wave 1 on this branch.

The duplicate-partition_key guard formatted its keys with
sorted(set(...)), which raises TypeError: '<' not supported between
instances of 'str' and 'NoneType' when the bounding_box key column holds
a null beside another duplicated value -- a guard whose purpose is
replacing an untyped crash was raising one. Sorting by str gives any key
dtype a total order.

#1968 motivates the typed errors by wire reachability -- circle_layout is
a safelisted GFQL call -- and only its happy path was covered there, so
two execute_call pins carry the error path and the edge-only path. The
missing-positions pin also matched the bare substring 'bounding_box',
which the unrelated missing-columns message satisfies too; it now matches
the precondition phrase.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…; document the preconditions

Review waves 2-3 on this branch.

A partition-keyed bounding_box frame passed without partition_by reaches
the 4-tuple branch, where len(frame) is its row count and
`center_x, center_y, width, height = frame` unpacks column NAMES: a
5-column frame raises 'too many values to unpack (expected 4)', a 2-row
frame dumps the whole frame through an AssertionError, and a 4x4 frame
would put the string 'partition_key' in center_x and fail later in
arithmetic. It now raises a ValueError naming partition_by=. Both
in-repo callers are unaffected: fa2_layout passes the tuple when
partition_key is None and the frame only alongside
circle_layout_params={'partition_by': ...}, and gib goes through fa2.

The bounding_box and partition_by docstrings state the preconditions the
guards enforce, and the cuDF parity pin is parametrized per case so a
failure names it. cuDF gets the mixed-sortability message pin too: the
null-bearing key list is identical on both engines.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…das 2 and 3 agree on

pandas 3.0.5 stores an object-column None as nan, so the duplicate-key
message reads ['a', nan] there and [None, 'a'] on pandas 2.3.3 -- the
guard is right on both, the pin assumed one. The pandas pin now uses
mixed int/str keys, which is the property under test (a set with no total
order; sorted({1, 'a'}) raises the same TypeError) in a spelling
identical on both majors. The null spelling stays pinned on the cuDF
lane, where series_to_pylist goes through arrow and yields None, and
where a mixed-type column cannot exist.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
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.

2 participants