Skip to content

prep + fixes: shared graph-json loader, --graph=PATH parsing, watch root, hyperedge pruning, weight validation - #3022

Open
StuartMVG wants to merge 4 commits into
Graphify-Labs:v8from
StuartMVG:prep/track-a
Open

prep + fixes: shared graph-json loader, --graph=PATH parsing, watch root, hyperedge pruning, weight validation#3022
StuartMVG wants to merge 4 commits into
Graphify-Labs:v8from
StuartMVG:prep/track-a

Conversation

@StuartMVG

@StuartMVG StuartMVG commented Aug 24, 2026

Copy link
Copy Markdown

First extracted PR from the #2134 split: the Track A prep refactor and standalone correctness fixes. It deliberately introduces no cluster spec or commands and no user-facing multigraph mode.

Scope: four focused, independently reviewable commits across 11 files. Of 813 added lines, 482 are regression tests.

Why

merge-graphs and the global graph had three local copies of "load node-link JSON, normalize the legacy edges key, preserve direction." Those copies had drifted across the fixes for #738, #2261, #2309, and #2484. The duplicated CLI parsers had drifted too: only affected recognized --graph=PATH.

This PR consolidates those contracts and separates several standalone fixes from the cluster and multigraph feature work in #2134.

What changed

1. Shared graph-json loader

build.py gains two shared helpers, and the existing global-graph and merge-graphs callers shrink onto them:

  • load_graph_json(path, *, preserve_type, directed, preserve_direction) applies the graph-file size cap, normalizes legacy edges to links, validates malformed structure with an actionable ValueError, preserves _src/_tgt markers without clobbering existing direction, restores top-level-only hyperedges, and defaults to a plain Graph so mixed graph types cannot break nx.compose (merge-graphs: unhandled NetworkXError on mixed directed/multigraph inputs, and exit code 0 on crash #1606).
  • merge_prefixed_into(G, prefixed) centralizes the external-library dedup-by-label merge previously embedded in global_add.

The existing #3007 shared-type hook and the compose/hyperedge behavior in merge-graphs are unchanged. Direct loader tests now cover malformed payloads, legacy keys, direction preservation, hyperedge restoration, simple-graph coercion, and keyed parallel edges in both MultiGraph and MultiDiGraph.

2. Consistent --graph parsing

query, path, and explain silently ignored --graph=PATH and queried the default graph; only affected parsed that form.

A shared _parse_graph_option pre-pass now supports both --graph PATH and --graph=PATH across all four commands. A trailing --graph was previously ignored by all four commands. Empty --graph= was ignored by three and reached a less-useful file-type error in affected. Both valueless forms now exit 2 with error: --graph requires a path.

Each command still parses its own flags afterward. The existing space-separated behavior is pinned with characterization tests alongside the new equals-form and error cases.

3. Standalone correctness fixes

  • Watch root (Semantic extractors emit mixed absolute/relative source_file paths #932): the update path now passes project_root into build_from_json, so absolute semantic source_file values are normalized the same way as a direct build.
  • Orphaned hyperedges: stale-source pruning now removes a hyperedge when its own source is stale or any member node was removed. Both serialized slots, top-level hyperedges and nested graph.hyperedges, are filtered, and a nested-only change bypasses the no-change write guard.
  • Partition edge weights: _partition repairs non-numeric, NaN, infinite, or negative weights to 1.0 before Leiden/Louvain sees them. This matches the existing build_from_json contract for graphs that passed through the builder.

Compatibility and non-goals

  • Existing simple-graph behavior remains the default.
  • No graph schema migration is introduced.
  • No cluster spec, cluster command, cross-repo link policy, or --cluster selection lands here.
  • No --multigraph or --no-multigraph CLI surface lands here.
  • Legacy edges payloads remain supported.

Local verification

  • Focused loader, pruning, and graph-option suites: 58 passed.
  • Full suite at 3ef1005: 5,088 passed, 11 skipped, 1 known pre-existing order-sensitive failure. test_label_communities_batches_when_over_batch_size received the same batch sizes in a different order ([100, 50, 100] instead of [100, 100, 50]); neither that test nor its implementation differs from the base branch.
  • uv run --frozen python -m tools.skillgen --check: 134 artifacts clean.
  • git diff --check: clean.
  • Manual smoke: both --graph forms and exit-2 cases across all four commands; merge-graphs direction and hyperedge preservation; global add dedup and skip-unchanged behavior.

Follow-up slices

C1, the cluster-spec and member-resolution slice, is independent of this loader. B1, the multigraph build core, consumes it. C2 will later combine C1 with this loader for member composition; B1 is additionally required for C2's multi mode.

…s multi-repo paths

merge-graphs and the global graph each carried their own copy of "load
node-link JSON, normalize the legacy edges key, stash direction markers" —
and each copy had a different subset of the Graphify-Labs#738/Graphify-Labs#2261/Graphify-Labs#2309/Graphify-Labs#2484 fixes.

build.py gains two shared helpers:

- load_graph_json(path, *, preserve_type, directed, preserve_direction):
  applies the graph-file size cap, normalizes the legacy "edges" spelling
  (Graphify-Labs#738), validates structure (malformed input becomes a single actionable
  ValueError instead of a NetworkX traceback), preserves stored direction via
  _src/_tgt without clobbering pre-existing markers (Graphify-Labs#2261, Graphify-Labs#2309), and
  restores top-level-only hyperedges (Graphify-Labs#2484/Graphify-Labs#2485). Directed/multi inputs are
  coerced to a plain Graph by default so nx.compose never sees mixed types
  (Graphify-Labs#1606); preserve_type opts out.
- merge_prefixed_into(G, prefixed): the external-library dedup-by-label merge
  lifted verbatim from global_add, with incident edges rewired onto the shared
  node and remap-introduced self-loops skipped.

global_graph.py shrinks onto both helpers; cli.py merge-graphs shrinks onto
load_graph_json(preserve_direction=True), now rejecting a malformed input
graph with exit 1. The Graphify-Labs#3007 shared-type hook and the compose/hyperedge
handling are untouched.

New direct tests in tests/test_load_graph_json.py pin the loader contract:
every validation branch, both key spellings, the direction modes, marker
preservation, hyperedge restoration, type coercion, the size cap, and the
external-dedup merge.
…less --graph

query, path, explain, and affected each carried their own copy of the --graph
option loop, in two different parse styles, and only affected ever handled the
--graph=PATH form. On the other three the token fell through the loop's else
arm and was silently dropped: the explicitly selected graph was ignored and
the command ran against the default graph with no warning.

A valueless --graph (trailing, or the empty --graph=) was silently dropped by
all four — the same silent-selection-loss class. It now exits 2 with
"error: --graph requires a path". The empty form cannot fall through to the
file check instead: an empty path resolves to the cwd, a directory, and only
query/affected guard that with a .json suffix check — path and explain would
crash reading it.

One shared pre-pass (_parse_graph_option) replaces the four inline loops. It
strips only the graph tokens and returns the rest in order, so each command's
own flag loop (--budget/--context, --depth/--relation,
--directed/--undirected) is unchanged. The returned graph_given flag is
unused today but part of the contract for a future option that needs
mutual-exclusion against an explicit --graph.

tests/test_graph_option_cli.py pins the contract across all four commands:
the space form (characterization — passed before this change), the = form,
the valueless error, and that per-command flags still parse after the
pre-pass.
… weights

Three independent small fixes:

- watch.py: the update-path rebuild called build_from_json without root, so
  absolute source_file paths from semantic fragments were persisted
  machine-absolute where `graphify build` writes root-relative ones (Graphify-Labs#932).
  Pass project_root through, aligning the two build paths.

- cli.py: extract _filter_payload_sources from _prune_graph_json_sources and,
  while there, drop hyperedges whose MEMBERS were removed, not only those
  whose own source_file is stale. Previously a hyperedge could outlive every
  node it references, persisting dangling member ids in graph.json.

- cluster.py: _partition passed edge attrs to Leiden/Louvain unvalidated, so
  a non-numeric, NaN, infinite, or negative weight in a hand-edited or
  LLM-produced graph.json reached the partitioner as-is. Repair to 1.0 —
  the same `not isfinite or < 0` contract build_from_json already enforces
  when normalizing edge attrs (a path graphs loaded from disk bypass).

Each fix carries a pinning test: the watch test fails without the root
argument (verified by reverting), the hyperedge test exercises the new
member check, and the weight tests cover all four invalid classes plus a
valid weight passing through unchanged.
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