Skip to content

fix: enforce the 3-member hyperedge invariant at every persistence boundary - #3298

Open
egarcia74 wants to merge 3 commits into
Graphify-Labs:v8from
egarcia74:fix/hyperedge-minimum-cardinality
Open

fix: enforce the 3-member hyperedge invariant at every persistence boundary#3298
egarcia74 wants to merge 3 commits into
Graphify-Labs:v8from
egarcia74:fix/hyperedge-minimum-cardinality

Conversation

@egarcia74

@egarcia74 egarcia74 commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes #3297.

Establishes the 3-member hyperedge invariant and applies it at every path that persists hyperedges. Enforcement is one shared gate rather than a check per site, because the audit in the issue found twelve independent writers. Review found a thirteenth, and a fourteenth site applies it on the read side, so a pre-gate cache entry cannot replay forever.

Summary

Enforce Graphify's documented hyperedge contract: a hyperedge models a group relationship, so it needs at least three distinct, resolvable members. A pair belongs in the ordinary edge set.

The original bug: valid 3+ member input could degrade to a pair once unresolved members were pruned, and then persist in the semantic cache and in graph.json. Fixing it properly meant fixing the shape problem underneath first — alias keys, object members, duplicate ids, numeric/string-equivalent ids and unusable ids all have to be settled before a count can mean anything.

The one gate, and every boundary that applies it

graphify.build.canonical_hyperedge(he) settles the shape, on a copy, so no caller's data is mutated:

  1. fold the members / node_ids aliases onto nodes
  2. coerce object members {"id": "a"} and numeric ids to canonical scalar ids, deduped in order
  3. reject a non-list nodes, and members that can never name a node (None, "", booleans)
  4. keep it only if three distinct members remain

gate_hyperedges(hyperedges, nodes) and gate_hyperedges_against_graph(hyperedges, G) add membership on top, and every writer goes through one of them. Membership is deliberately not in canonical_hyperedge: it needs the id-space bridge below, and having two membership rules for one invariant is how several of the defects listed further down arose. Boundaries with no node set — the semantic cache, the pre-manifest-stamp gate — call canonical_hyperedge directly.

Member refs are coerced so they can be compared at all (7 and "7" are one member, not two), which means every id set a member is compared against has to be coerced too — and whatever survives has to be handed back in the id space the caller is about to persist, or the written file names members no node has. Both halves of that are now in one place: _id_map maps a coerced id back to the id its container actually carries, and a single _gate core takes that prebuilt map. Getting this wrong at individual sites accounted for five of the defects below.

flowchart TD
    LLM["semantic extraction<br/>LLM or cache replay"]
    SKILL["merge-chunks skill<br/>raw JSON extend"]
    DISK[("existing graph.json")]
    OUT[("graph.json")]
    CACHE[("semantic cache")]

    LLM --> SEM["sem_result"]
    SKILL -->|"② save_semantic_cache<br/>both write gates"| CACHE
    SEM -->|"②"| CACHE
    CACHE -.->|"⑭ replay<br/>canonicalize on read"| SEM

    SEM -->|"① before manifest stamping<br/>shape + cardinality"| MERGED["merged"]
    MERGED --> MODE{"--no-cluster ?"}

    MODE -->|"no"| BUILD["build()"]
    BUILD -->|"③ normalize before dedup"| DEDUP["deduplicate_entities"]
    DEDUP -->|"④ all four exit paths"| BFJ["build_from_json"]
    BFJ -->|"⑤ distinct count, reject malformed"| BM["build_merge"]
    DISK --> BM
    BM -->|"⑥ final gate, prune or not"| TOJSON["to_json"]
    TOJSON -->|"⑦ last boundary"| OUT

    MODE -->|"yes"| RAW["cli raw write path"]
    RAW -->|"⑧ vs final node ids"| OUT
    DISK -->|"⑨ exclusion-only prune<br/>both JSON slots"| OUT

    DISK --> PREFIX["prefix_graph_for_global"]
    PREFIX -->|"⑩ normalize before prefixing"| ATTACH["attach_hyperedges"]
    ATTACH -->|"⑪ vs composed graph"| OUT

    UPD["graphify update --no-cluster<br/>watch._rebuild_code"] -->|"⑫ vs candidate node ids"| OUT
    DISK --> MD["git merge<br/>cli merge-driver"]
    MD -->|"⑬ vs composed nodes"| OUT
    DISK --> UPD
Loading
# Where What it catches
cli pre-merge / pre-stamp a doc whose only output is an under-cardinality group being stamped as extracted
cache.save_semantic_cache ×2 alias-keyed groups silently dropped; duplicate-, null- and numeric-padded groups cached as valid
build.build alias-keyed group reaching dedup un-rewired
dedup.deduplicate_entities three early returns skipped the hyperedge pass; unusable entries counted as members
build.build_from_json re-key, doc-twin and norm_to_id remaps collapsing two ids into one; malformed shapes persisted verbatim
build.build_merge carried group degraded by an incremental update, prune or no prune
export.to_json the public writer took G.graph["hyperedges"] verbatim
cli raw --no-cluster block never reaches build_from_json / to_json at all
cli._prune_graph_json_sources members left dangling by a prune; stale nested slot
build.prefix_graph_for_global unprefixed members discarded on cross-repo merge
export.attach_hyperedges merge-graphs metadata never sees build_from_json
watch._rebuild_code raw writer graphify update --no-cluster carried a legacy pair through every rebuild
cli merge-driver git-merged graph attrs serialized straight back, so a legacy branch's pair or dangling group survives
cache.check_semantic_cache (read) an entry written before the gate existed replaying as a hit forever

Defects found during review

The first pass fixed the reported symptom at three sites. Review across CodeRabbit, Codex and Copilot then found defects at the boundaries that pass had missed — several of them introduced by the fix itself, which is the honest cost of adding a cross-cutting invariant to twelve separate write paths:

Write paths the first pass missed

  • watch's raw writer had no gate at all. _rebuild_code spreads result into the candidate JSON with hyperedges included, and _reconcile_existing_graph evicts only by source and dangling members, so graphify update --no-cluster preserved a legacy pair on every rebuild.
  • export.to_json took G.graph["hyperedges"] verbatim — public API, and the function that actually writes the file.
  • The raw --no-cluster path and the exclusion-only prune both write graph.json without ever building a graph.

Defects introduced by the fix, found in review

  • to_json mutated the caller's graph. node_link_data returns the same graph-attrs dict G owns, so writing the sorted list edited the caller's object. Harmless while it only reordered; once filtered it would have deleted their hyperedges.
  • A nested-only hyperedge slot was wiped. to_json persists both slots; a node_link_data-only file has just the nested one. The prune read the empty top level, found nothing, then overwrote the nested slot with that empty result.
  • merge-graphs discarded groups by member shape. prefix_graph_for_global prefixed only a canonical nodes list, so alias-keyed and object-member groups kept unprefixed ids while every node gained a repo:: prefix, and the attach gate then dropped them.
  • A malformed node id aborted the prune with TypeError: unhashable type — the build path deliberately tolerates a persisted list/dict id.
  • The member usability rule drifted four times between the bare and object branches: None, then bare booleans, then object-wrapped booleans, then the numeric coercion itself, so [7, "7", "b"] passed as a group and collapsed to a pair on replay. Both branches now share one _is_usable_member_ref predicate and the same _coerce_id coercion.
  • dedup counted positions, not usable members[None, None, None] cleared the minimum.
  • build_from_json persisted malformed shapes ({"nodes": "a,b,c"}, a bare string) that its own gate rejects, into metadata the report, wiki, html exporters and watch all read back.
  • Optimistic manifest stamping counted a hyperedge as output before the gate ran.
  • The gate compared ids in one space and wrote them back in another. Member refs are coerced (7"7") so they can be compared at all, but the writers persist their node records unchanged. Coercing only the comparison side left graph.json holding nodes [7, 8, 9] and members ["7", "8", "9"] — a dangling reference of exactly the shape fix(cache): prune edges/hyperedges referencing never-written node groups #1916 removed, written by the gate that exists to prevent it. It took three rounds to find every site: first the node-list path, then the graph-container path, then to_json, then the three writers that persist node records (the raw --no-cluster writer, watch's raw writer, and the graph.json pruner). The two spaces are now bridged in one place — _id_map maps a coerced id back to the id its container actually carries, and a single _gate core indexes it for every survivor.
  • The gate rebuilt its id map per candidate, twice. Gating one hyperedge at a time re-walked every node for each group: measured 1060 _coerce_id calls for 50 nodes and 20 groups where linear is 110. This turned merge-graphs from linear into O(nodes × hyperedges) on exactly the thousands-of-groups corpora the invariant was added for. Fixed once in prefix_graph_for_global, then reintroduced one commit later in attach_hyperedges via a helper that built its map internally — so the map is no longer built inside any per-candidate path, and both call sites gate whole lists. A _coerce_id counter now guards each.
  • Four more comparisons disagreed with the gate about what resolves. Same cause as the prefixer above, in four more places: semantic_cleanup's member filter and watch's reconciliation each dropped a group the gate keeps, and both of the cache's dangling prunes — the skipped-node one and scope_semantic_result's replay one — did the opposite, keeping a group whose member named a node deliberately never written. Under-pruning is the harder direction to notice, because nothing looks missing. Rather than fix four comparisons, there is now one definition of what a member names: _member_keys yields the two lookup keys in priority order, resolve_member_ref returns the raw id for callers writing members back out, and member_in_id_space answers yes/no for callers that only decide. semantic_cleanup's filter is deleted in favour of calling the gate outright.
  • The cross-repo prefixer disagreed with the gate about what resolves. Once the gate learned to resolve a member that drifted in casing or punctuation, prefix_graph_for_global still keyed its relabel table on the coerced spelling alone, so drifted members stayed unprefixed while every node gained the repo:: prefix and attach_hyperedges discarded a group the gate calls valid. merge-graphs lost it silently. The relabel table is keyed through _id_map now, so both halves resolve identically.
  • Two collision cases from expanding the key space. Adding normalized lookup keys to the shared id sets broke two things downstream. The cache's duplicate-attribution step subtracts one id set from another, so a skipped node foo_bar and a distinct written node Foo-Bar shared the key foo_bar and the skipped node vanished from the set, leaving a member that names it undetected. And with distinct nodes 7 and "7", a member 7 coerced to "7" and was restored onto the string node — silently rebound from the node it named to a colliding one, which is worse than a dangling member because it looks correct. Duplicate attribution is now decided in the exact space before aliases are added, and a member's own raw form wins over the map's choice.
  • A non-dict graph value aborted the exclusion-only prune. Reading the nested hyperedge slot as (data.get("graph") or {}).get(...) raises AttributeError on a str, list or int, and the surrounding try covers only the JSON load, so the exception left the function and the whole prune stopped. The nested-slot sync a few lines below already guarded with isinstance, making this an inconsistency inside one function — on a hand-editable graph.json, the least trusted input in the feature.
  • A raw set was trusted as already coerced, so numeric ids behaved differently from a list or a graph: members became "7" and then failed membership against {7, 8, 9}, dropping a valid group. Removed with the node_ids parameter above, with a test across set, frozenset and list so the exemption cannot return as an optimization.
  • Two membership rules for one invariant. Found by auditing my own change rather than by a reviewer. Once the gate took over membership resolution, canonical_hyperedge's node_ids parameter had no production caller left — verified by AST — while its semantics had diverged from the gate's: exact matching only, no normalized fallback, members returned coerced rather than in the container's id space. The weaker rule was reachable only from tests, and it was the one this description previously advertised as the gate. Removed, so membership has exactly one implementation; the tests that covered the parameter now exercise the path production actually uses.
  • The raw gate dropped groups the clustered path heals. build_from_json rewires a member that drifted in casing or punctuation through norm_to_id, so a group naming Foo-Bar for node foo_bar survives on the clustered path. The raw --no-cluster and watch writers never reach it, so the exact-membership test in the new gate discarded a perfectly resolvable group — silently lost by the gate added to protect it. Members now resolve through the normalized space too, with exact keys always winning and a second dedupe pass so two refs onto one node cannot pass as a group.
  • Direct deduplicate_entities callers got their groups mutated into dangling references. With numeric node ids and nothing to merge, the remap pass coerced every member to "7" while the returned node records kept 7. Introduced by consolidating dedup's four exits: before that, the empty-remap return skipped the hyperedge pass entirely. Members are restored to the returned nodes' id space now; unresolved members are left alone, because that function remaps and does not gate membership.
  • The git merge-driver had no gate at all. It composes two graph.json files and serializes the result directly, reaching neither build_from_json nor to_json, so a legacy branch's pair or dangling group was written straight back. nx.compose also takes graph attributes from one side only, so the valid groups on the current side were already being discarded before serialization — pre-existing compose behaviour, left alone deliberately rather than folded into this PR.
  • The cache's skipped-node prune compared raw ids against coerced members. save_semantic_cache drops a group that names a node from a deliberately skipped source (fix(cache): prune edges/hyperedges referencing never-written node groups #1916), but collected those ids raw while the members had already been coerced, so "7" in {7} was False. Same group and same skipped source, member id changed: a string id was pruned, a numeric id was cached and dangled on every replay. Edge endpoints reach the cache raw and were matching a raw set, so they are coerced at the lookup now too.
  • The cache gated writes but not reads. An entry written before the gate existed, holding only a two-member group, replayed as a hit forever; check_semantic_cache now canonicalizes on read, so a fragment with no nodes and no surviving group is a miss.
  • semantic_cleanup built its surviving-id set raw while members are coerced, so a valid group over numeric node ids lost every member before build_from_json could heal the ids. Third site with that same root cause.
  • to_json raised TypeError before writing anything when a caller left the hyperedge slot as None, losing the whole export rather than just the hyperedges.
  • A cache test had gone vacuous. Its two-member fixture was dropped by the cardinality gate before the skipped-node prune it exists to test ever ran, so it passed for the wrong reason.

Validation

Check Result
CI test (3.10) / test (3.12) pass
CI skillgen-check / security-scan pass
The 14 test files this PR touches 615 passed, 3 skipped
Full suite (local) 5,319 passed, 56 skipped
ruff check graphify/ pass
bandit -ll over changed files no issues
CodeRabbit CLI, full PR diff 0 findings
Docstring coverage, functions touched 100% (190)

7 production files, 14 test files. Local runs also show 6 failures in tests/test_ollama*.py; these are environmental (an exported OPENAI_API_KEY with the openai package absent) and reproduce identically on an untouched checkout. CI does not set that variable and passes.

Known shortcomings

Reviewed and consciously accepted rather than overlooked. Each was measured, not assumed.

A dangling-member group leaves a doc stamped, and the #2927 heal cannot clear it

Pinned by test_dangling_member_hyperedge_only_doc_stamps_then_re_queues_from_cache.

A group can pass shape and cardinality — three distinct, well-formed member ids — while none of those ids resolves to a node. Membership cannot be checked before stamping or caching (see below), so the doc is stamped on the strength of it, and the graph gate then drops the group. Observed across two runs:

RUN1: llm_calls=1  stamped=True  hyperedges=[]
RUN2: llm_calls=1  stamped=True  hyperedges=[]
      "re-queuing 1 manifest-stamped semantic file(s) ... (#2927)"
      "semantic cache: 1 hit / 0 miss"

The heal does fire and re-queue the doc, but the group was already cached, so the re-queue is served from cache, the group is dropped again, and the doc is re-stamped. The heal therefore re-fires on every subsequent run without ever resolving.

Impact: a recurring #2927 re-queue line and a re-queue that never completes. No LLM spend (it is a cache hit) and no invalid data in graph.json. Noise, not corruption.

Fixing it needs a design change, not another gate — either stamp after the graph is built so the real node set is available, or have the heal invalidate the cache entry it re-queues, which trades a free loop for a paid one. Both are larger than this PR. An earlier revision of this description claimed the heal recovered this in one run; that was wrong and is corrected here.

A third instance of the same limitation: with --no-cluster and no changed, deleted or stale files, extract prints "no incremental changes detected" and exits before any gate runs, so a pre-upgrade pair already on disk survives indefinitely and keeps its doc counted as output. Healing it would mean rewriting graph.json on a run the tool has just reported as a no-op, which is a migration of pre-existing files rather than an application of this invariant — and the same argument would apply to every legacy shape the gates reject, not just hyperedges. Raised by review and answered rather than fixed; happy to include the migration if maintainers would rather have it here.

Why membership is not validated before stamping or caching

Not an oversight — a raw member id absent from the merged node set can still resolve to a surviving node, because build_from_json applies _semantic_id_remap and norm_to_id afterwards. Measured:

pre-build view (raw ids):        canonical_hyperedge(he, raw_ids) -> DROPPED
build-time reality (norm_to_id): graph.json -> [{"id":"grp","nodes":["m_foo","m_bar","m_baz"]}]

Gating on raw ids would re-dispatch docs whose groups actually survive, on every run — a permanent cost regression, worse than the noise above. On the cache path there are two further blockers: fragments are stored per file and a group may legitimately span files, so validating against a fragment's own nodes would drop every cross-file group; and llm._checkpoint_chunk calls save_semantic_cache mid-extraction, where no merged node set exists at all. CodeRabbit independently reached the same conclusion and withdrew its finding here.

Smaller gaps, all pre-existing

  • to_graphml serializes G.graph verbatim, so a sub-minimum group in graph metadata still reaches a GraphML export. graph.json is gated; GraphML is not.
  • dedupe_nodes raises TypeError on a persisted malformed list/dict node id, before the raw --no-cluster gate is reached. The CLI prune path now tolerates such ids; this one still aborts.
  • merge_raw_extraction writes its internal _unverified_semantic_shrink key into graph.json and never pops it.

Structural note

The invariant is enforced at thirteen write boundaries plus one read boundary rather than one choke point, because hyperedges are persisted from several places that do not share a code path. The shared canonical_hyperedge gate makes each site one line, but nothing structurally prevents a future writer from missing it — watch's raw writer and the git merge-driver were exactly that omission, found only by review — as were to_json, the raw --no-cluster block and the exclusion-only prune. Two of the fourteen boundaries were found by reviewers rather than by the audit, which is the measured version of this concern rather than a hypothetical one. The sharper measurement is the id-space rule underneath it: twelve sites compared member refs against node ids, and each time the cause was adding a coercion or resolution rule for one comparison without asking which other places compare the same values. An earlier revision of this description said eight of those twelve defects were created by this change. That was wrong, and the correction matters because it overstated the damage: checked out at v8 and run directly, semantic_cleanup already persisted a two-member group, the cross-repo prefixer already wrote a dangling one, and both cache prunes already failed to prune. Roughly half were pre-existing defects of this same family, surfaced because this change built the one shared rule that makes them visible; the other half — listed below — were genuinely mine. There is now exactly one implementation of that rule (_member_keys, reached through _id_map, resolve_member_ref and member_in_id_space), which is what makes a thirteenth site structurally hard rather than merely unlikely. A single persistence funnel would be the durable fix.

Notes for review

  • Applies cleanly — branched from v8 @ 33362d9, which is current v8 head at the time of writing.
  • Reviewed by CodeRabbit, Codex and GitHub Copilot across several rounds on my fork (fix: enforce minimum hyperedge cardinality egarcia74/graphify#3 and Your project was featured on AI Digital Crew 🎉 #6: 46 threads resolved (40 on Wroked out examples missing graph.html #3, 6 on Your project was featured on AI Digital Crew 🎉 #6), 1 left open by design — see the no-change exit under Known shortcomings). Roughly half the defects they found were in this fix rather than the original code; those are listed above rather than quietly folded in, because they are the argument for the structural note at the end.
  • Commits are authored by me with Co-Authored-By: Claude trailers — the work was done with AI assistance and the history says so.
  • Squashed to a single commit. The branch reached 33 commits, many of them fixing defects in earlier commits of the same branch, which is not a series worth reading. The content is unchanged — verified byte-identical to the 33-commit version before the force-push. I tried splitting it into a layered series first; build.py alone leaves 24 tests failing because the boundaries genuinely depend on the shared gate, so a multi-commit series would have had broken intermediate states. One honest commit beat five fabricated ones.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 5 advisory finding(s) below merit a look before merge.

Formal verification. 2 change(s) alter behavior, breaking input(s) attached.

Behavior changes: \_coerce\_hyperedge\_member\_refs changes behavior, here is the input that shows it.

The verifier found a concrete input on which \_coerce\_hyperedge\_member\_refs behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"he":"\{'n': 0, 's': 'x', 'l': \[1, 2\]\}","members":"\[1, 2, 3\]"\}, the old code produced \[1, 2, 3\] but the new code produces \['1', '2', '3'\]. Paste that input straight into a regression test.

Behavior changes: attach\_hyperedges changes behavior, here is the input that shows it.

The verifier found a concrete input on which attach\_hyperedges behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"G":"\(lambda \_g: \(\_g\.add\_nodes\_from\(\[\('a', \{'label': 'A', 'kind': 'fn'\}\), \('b', \{'label': 'B'\}\)\]\), \_g\.add\_edges\_from\(\[\('a', 'b', \{'weight': 2, 'kind': 'calls'\}\)\]\), \_g\)\[\-1\]\)\(\_\_import\_\_\('networkx'\)\.Graph\(\)\)","hyperedges":"\[0, 0, 0, 0\]"\}, the old code produced raises AttributeError but the new code produces None. Paste that input straight into a regression test.


Graphify review — findings

Enforces a minimum of three distinct members for every hyperedge across all persistence boundaries, so a group relationship that prunes down to a pair or singleton is dropped rather than written to graph.json as a degraded or dangling edge. Routes shape settling and cardinality through a single canonical_hyperedge gate (with an optional node_ids/graph filter) and a shared _is_usable_member_ref rule that coerces and dedupes numeric ids identically in both member branches, closing a cache/replay mismatch where junk or duplicate members inflated a pair into a cached "group" that vanished on reload. Adds a build_merge final cardinality gate that runs whether or not a prune fired, and canonicalizes members before dedup in build so alias-keyed hyperedges get rewired onto survivors instead of passed through un-remapped.

Worth a look

  • Top-level import creates a build/dedup circular importgraphify/dedup.py:15 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • New build import creates a circular import with build's dedup dependencygraphify/dedup.py:15 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • build_from_json now drops previously legal single-member hyperedgesgraphify/build.py:1417 · Escalate · medium · 2 independent checks
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • _normalize_hyperedge_members called on non-dict hyperedge in build()graphify/build.py:1502 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • build() assumes combined['hyperedges'] key existsgraphify/build.py:1502 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 3367 functions depend on the 1606 functions this change touches.

Health — this change adds coupling hotspots:

  • new: extract() — 520 callers, 43 callees
  • new: _rebuild_code() — 114 callers, 51 callees
  • new: build_from_json() — 195 callers, 18 callees
  • new: detect() — 108 callers, 15 callees
  • new: deduplicate_entities() — 67 callers, 21 callees
  • new: build_merge() — 65 callers, 14 callees
  • new: save_semantic_cache() — 64 callers, 10 callees
  • new: to_obsidian() — 36 callers, 13 callees
  • …and 68 more — each is listed as a finding

Verification — 3367 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 2854 function(s) in the blast radius were not formally verified this run

Formal verification

Behavior changes: \_coerce\_hyperedge\_member\_refs changes behavior, here is the input that shows it.

The verifier found a concrete input on which \_coerce\_hyperedge\_member\_refs behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"he":"\{'n': 0, 's': 'x', 'l': \[1, 2\]\}","members":"\[1, 2, 3\]"\}, the old code produced \[1, 2, 3\] but the new code produces \['1', '2', '3'\]. Paste that input straight into a regression test.

Behavior changes: attach\_hyperedges changes behavior, here is the input that shows it.

The verifier found a concrete input on which attach\_hyperedges behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"G":"\(lambda \_g: \(\_g\.add\_nodes\_from\(\[\('a', \{'label': 'A', 'kind': 'fn'\}\), \('b', \{'label': 'B'\}\)\]\), \_g\.add\_edges\_from\(\[\('a', 'b', \{'weight': 2, 'kind': 'calls'\}\)\]\), \_g\)\[\-1\]\)\(\_\_import\_\_\('networkx'\)\.Graph\(\)\)","hyperedges":"\[0, 0, 0, 0\]"\}, the old code produced raises AttributeError but the new code produces None. Paste that input straight into a regression test.

Could not verify: Could not verify build.

The verifier did not have enough to check build, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 9 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly AttributeError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify build\_from\_json.

The verifier did not have enough to check build\_from\_json, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 6 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly NameError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify build\_merge.

The verifier did not have enough to check build\_merge, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `graph_path` is annotated `str | Path | None` — outside the synthesizable primitive/collection set

Could not verify: Could not verify prefix\_graph\_for\_global.

The verifier did not have enough to check prefix\_graph\_for\_global, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 200 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous

Could not verify: Could not verify save\_semantic\_cache.

The verifier did not have enough to check save\_semantic\_cache, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify dispatch\_command.

The verifier did not have enough to check dispatch\_command, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 23 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly SystemExit — names the real obstacle, not a sampling gap)

Could not verify: Could not verify \_prune\_graph\_json\_sources.

The verifier did not have enough to check \_prune\_graph\_json\_sources, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `graph_path` is annotated `Path` — outside the synthesizable primitive/collection set

No difference found (not proven): No behavior difference found in \_reenter\_main (not a proof).

The verifier ran both versions of \_reenter\_main on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

Could not verify: Could not verify deduplicate\_entities.

The verifier did not have enough to check deduplicate\_entities, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 9 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly TypeError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify \_remap\_hyperedge\_members.

The verifier did not have enough to check \_remap\_hyperedge\_members, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: the input domain has 54 values but only 18 distinct were tested — a small finite domain must be EXHAUSTED, not sampled (an untested input could invert the result)

No difference found (not proven): No behavior difference found in to\_json (not a proof).

The verifier ran both versions of to\_json on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

No difference found (not proven): No behavior difference found in sanitize\_semantic\_fragment (not a proof).

The verifier ran both versions of sanitize\_semantic\_fragment on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

Could not verify: Could not verify \_rebuild\_code.

The verifier did not have enough to check \_rebuild\_code, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `watch_path` is annotated `Path` — outside the synthesizable primitive/collection set

· 4 grounded finding(s) anchored inline below; 72 more finding(s) on lines outside this diff (see the check run).

Comment thread graphify/cli.py
main()


def dispatch_command(cmd: str) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressiondispatch_command()

fans out to 124 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/dedup.py
hyperedges[:] = kept


def deduplicate_entities(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressiondeduplicate_entities()

fans out to 21 callees (efferent coupling); 67 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/export.py
@@ -264,6 +281,16 @@ def existing_graph_node_count(path: "str | Path"):


def to_json(G: nx.Graph, communities: dict[int, list[str]], output_path: str, *, force: bool = False, built_at_commit: str | None = None, community_labels: dict[int, str] | None = None) -> bool:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressionto_json()

fans out to 8 callees (efferent coupling); 55 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/watch.py
]


def _reconcile_existing_graph(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression_reconcile_existing_graph()

fans out to 8 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

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.

I think this one is anchored on the wrong function. _reconcile_existing_graph()'s body is untouched by this PR — its callee set is unchanged under two independent measurements (all AST calls: 31 → 31; first-party callees only: 14 → 14).

What changed at that location is that a new module-level function, _gated_hyperedges(), was inserted immediately above it, which shifted def _reconcile_existing_graph( from line 717 to line 741 — the line this comment is anchored to. The diff hunk header reads @@ -714,6 +714,30 @@ def _reconcile_markdown_links(, i.e. the insertion lands between _reconcile_markdown_links and _reconcile_existing_graph.

So the new fan-out belongs to _gated_hyperedges (2 first-party callees: _hashable, canonical_hyperedge), not to _reconcile_existing_graph. Flagging in case the line-shift heuristic is worth a look — happy to be corrected if the metric is measuring something I am not seeing.

@egarcia74

Copy link
Copy Markdown
Contributor Author

Thanks — responding to the four coupling-delta findings from the health check, with measured deltas rather than assertions. I measured each function's callee set at the PR base (33362d9) and at the head two ways: all AST call expressions, and first-party callees only (calls resolving to a name defined somewhere in graphify/).

Function first-party callees all calls added
to_json() 6 → 7 (+1) 26 → 27 canonical_hyperedge
deduplicate_entities() 22 → 23 (+1) 45 → 46 _finish
dispatch_command() 70 → 70/71 217 → 219 _canonical_he, _canonical_he_early (local aliases of canonical_hyperedge)
_reconcile_existing_graph() 14 → 14 (+0) 31 → 31 — none

On each:

to_json +1 is the PR's whole point. It is the function that writes graph.json and it previously took G.graph["hyperedges"] verbatim, so the one added callee is the gate. I do not think this one can be avoided without leaving the final writer ungated.

deduplicate_entities +1 is a duplication-reducing refactor that reads as coupling growth. It had three early returns that skipped the hyperedge pass; two had already grown their own inline copy of the guard. All four returns now go through one nested _finish(). Net: one more callee, two fewer duplicated blocks, and the cardinality contract no longer depends on which exit is taken.

dispatch_command +1/+2 is real, and it is the one I would like to act on. The two additions are function-local import aliases inside the raw --no-cluster block and the pre-manifest-stamp gate. I would rather not grow a 3,600-line dispatcher at all, so I am happy to extract both blocks into module-level helpers in cli.py — mirroring watch._gated_hyperedges(), which this PR already adds for exactly that reason. That moves the fan-out off dispatch_command and makes both gates directly unit-testable. Say the word and I will push it.

_reconcile_existing_graph +0 — I believe this is a line-shift misattribution; details in the inline reply on that thread.

More broadly: the PR description's closing section makes the same point the health check is circling. The invariant now sits at twelve boundaries because hyperedges are persisted from twelve places that share no code path, and a single persistence funnel would be the durable fix. That is a larger refactor than this bug warranted, but if you would prefer that shape instead, I am glad to take direction.

@egarcia74

Copy link
Copy Markdown
Contributor Author

Pushed the extraction in 81fc53b — and I need to correct my own claim above, because I measured it afterwards and it does not do what I said it would.

I said extracting the gate blocks would "move the fan-out off dispatch_command". It does not, materially:

metric before 81fc53b after
dispatch_command first-party callees 70 71
dispatch_command all call expressions 219 218
dispatch_command LOC 3,699 3,685

First-party coupling went up by one, for a slightly embarrassing reason: the two things it called before were renamed import aliases (_canonical_he, _canonical_he_early), which a first-party counter cannot resolve back to canonical_hyperedge. A properly named helper is visible to it. So relative to the base the whole PR is +1 on that metric either way, and the extraction did not buy the reduction I promised.

What it does buy, which I think is still worth having:

  • 14 lines out of the dispatcher, 12 out of _prune_graph_json_sources, 7 out of watch's helper.
  • One implementation of the surviving-id rule instead of three. node_id_set() now holds it. Three writers had each grown their own copy, each with its own comment explaining the same two hazards (an id-less node, and a persisted malformed id that raises when put in a set).
  • Both gates are directly unit-testable rather than only reachable through the CLI — five new tests cover node_id_set and gate_hyperedges in isolation, including the shape-only mode the cache and pre-stamp callers use.

gate_hyperedges(hyperedges, nodes=None) returns (kept, dropped_count) rather than logging, because the three writers word their messages differently about what a drop means.

If the health check's real target is the size and fan-out of dispatch_command itself, that is a fair thing to want and this PR is not the place I can honestly fix it — it was ~3,600 lines before I touched it. Happy to take direction if you would like it split, either here or as a follow-up.

Full suite still green (5,294 passed, 56 skipped locally; upstream CI on the previous head was green) and the CodeRabbit CLI reports 0 findings on the full diff.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 5 advisory finding(s) below merit a look before merge.

Formal verification. 2 change(s) alter behavior, breaking input(s) attached.

Behavior changes: \_coerce\_hyperedge\_member\_refs changes behavior, here is the input that shows it.

The verifier found a concrete input on which \_coerce\_hyperedge\_member\_refs behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"he":"\{'n': 0, 's': 'x', 'l': \[1, 2\]\}","members":"\[1, 2, 3\]"\}, the old code produced \[1, 2, 3\] but the new code produces \['1', '2', '3'\]. Paste that input straight into a regression test.

Behavior changes: attach\_hyperedges changes behavior, here is the input that shows it.

The verifier found a concrete input on which attach\_hyperedges behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"G":"\(lambda \_g: \(\_g\.add\_nodes\_from\(\[\('a', \{'label': 'A', 'kind': 'fn'\}\), \('b', \{'label': 'B'\}\)\]\), \_g\.add\_edges\_from\(\[\('a', 'b', \{'weight': 2, 'kind': 'calls'\}\)\]\), \_g\)\[\-1\]\)\(\_\_import\_\_\('networkx'\)\.Graph\(\)\)","hyperedges":"\[0, 0, 0, 0\]"\}, the old code produced raises AttributeError but the new code produces None. Paste that input straight into a regression test.


Graphify review — findings

Enforces a minimum of three distinct members for every hyperedge across all persistence boundaries, replacing the previous "one surviving member" rule so any relationship that prunes down to a pair or singleton is dropped into the ordinary edge set instead. Centralizes the gating in canonical_hyperedge, gate_hyperedges, and node_id_set, which normalize member aliases (members/node_ids), dedupe members on a shallow copy so callers' dicts stay intact, optionally filter members against a live node set or nx.Graph, and reject malformed nodes shapes before they can reach graph.json. Unifies member usability under _is_usable_member_ref so both the bare and object branches apply the same coercion and reject None, "", booleans, and unhashable ids identically — fixing cache hits that stamped a file as covered but yielded nothing on graph-backed replay because 7 and "7" counted as two members at cache time and collapsed to one on rebuild.

Worth a look

  • build_from_json now drops previously legal singleton/pair hyperedgesgraphify/build.py:1457 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • canonical_hyperedge called with single argument but signature requires graphgraphify/cache.py:1477 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Top-level import from build creates a circular dependencygraphify/dedup.py:15 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Two-member hyperedges are now silently droppedgraphify/export.py · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • canonical_hyperedge does not dedupe/normalize distinct-count the way build_from_json does, so a hyperedge with duplicate members after remap passes the shape gate but counts non-distinct membersgraphify/build.py · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 3383 functions depend on the 1622 functions this change touches.

Health — this change adds coupling hotspots:

  • new: extract() — 520 callers, 43 callees
  • new: _rebuild_code() — 114 callers, 51 callees
  • new: build_from_json() — 195 callers, 18 callees
  • new: detect() — 108 callers, 15 callees
  • new: deduplicate_entities() — 67 callers, 21 callees
  • new: build_merge() — 65 callers, 14 callees
  • new: save_semantic_cache() — 64 callers, 10 callees
  • new: to_obsidian() — 36 callers, 13 callees
  • …and 68 more — each is listed as a finding

Verification — 3383 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 2870 function(s) in the blast radius were not formally verified this run

Formal verification

Behavior changes: \_coerce\_hyperedge\_member\_refs changes behavior, here is the input that shows it.

The verifier found a concrete input on which \_coerce\_hyperedge\_member\_refs behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"he":"\{'n': 0, 's': 'x', 'l': \[1, 2\]\}","members":"\[1, 2, 3\]"\}, the old code produced \[1, 2, 3\] but the new code produces \['1', '2', '3'\]. Paste that input straight into a regression test.

Behavior changes: attach\_hyperedges changes behavior, here is the input that shows it.

The verifier found a concrete input on which attach\_hyperedges behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"G":"\(lambda \_g: \(\_g\.add\_nodes\_from\(\[\('a', \{'label': 'A', 'kind': 'fn'\}\), \('b', \{'label': 'B'\}\)\]\), \_g\.add\_edges\_from\(\[\('a', 'b', \{'weight': 2, 'kind': 'calls'\}\)\]\), \_g\)\[\-1\]\)\(\_\_import\_\_\('networkx'\)\.Graph\(\)\)","hyperedges":"\[0, 0, 0, 0\]"\}, the old code produced raises AttributeError but the new code produces None. Paste that input straight into a regression test.

Could not verify: Could not verify build.

The verifier did not have enough to check build, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 9 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly AttributeError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify build\_from\_json.

The verifier did not have enough to check build\_from\_json, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 6 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly NameError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify build\_merge.

The verifier did not have enough to check build\_merge, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `graph_path` is annotated `str | Path | None` — outside the synthesizable primitive/collection set

Could not verify: Could not verify prefix\_graph\_for\_global.

The verifier did not have enough to check prefix\_graph\_for\_global, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 200 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous

Could not verify: Could not verify save\_semantic\_cache.

The verifier did not have enough to check save\_semantic\_cache, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify dispatch\_command.

The verifier did not have enough to check dispatch\_command, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 23 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly SystemExit — names the real obstacle, not a sampling gap)

Could not verify: Could not verify \_prune\_graph\_json\_sources.

The verifier did not have enough to check \_prune\_graph\_json\_sources, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `graph_path` is annotated `Path` — outside the synthesizable primitive/collection set

No difference found (not proven): No behavior difference found in \_reenter\_main (not a proof).

The verifier ran both versions of \_reenter\_main on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

Could not verify: Could not verify deduplicate\_entities.

The verifier did not have enough to check deduplicate\_entities, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 9 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly TypeError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify \_remap\_hyperedge\_members.

The verifier did not have enough to check \_remap\_hyperedge\_members, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: the input domain has 54 values but only 18 distinct were tested — a small finite domain must be EXHAUSTED, not sampled (an untested input could invert the result)

No difference found (not proven): No behavior difference found in to\_json (not a proof).

The verifier ran both versions of to\_json on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

No difference found (not proven): No behavior difference found in sanitize\_semantic\_fragment (not a proof).

The verifier ran both versions of sanitize\_semantic\_fragment on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

Could not verify: Could not verify \_rebuild\_code.

The verifier did not have enough to check \_rebuild\_code, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `watch_path` is annotated `Path` — outside the synthesizable primitive/collection set

· 5 grounded finding(s) anchored inline below; 71 more finding(s) on lines outside this diff (see the check run).

Comment thread graphify/cli.py
return _gate(hyperedges, nodes)


def _prune_graph_json_sources(graph_path: Path, stale_sources: list[str]) -> int:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression_prune_graph_json_sources()

10 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

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.

This one I can account for precisely, and I think it points at something worth knowing about the metric rather than at the change.

_prune_graph_json_sources had no test coverage at all before this PR. Counting references:

graphify/ tests/
base 33362d9 2 (the def + its single call site) 0
this PR 2 (unchanged) 24, across 6 tests

It still has exactly one production caller, cli.py:4156. The afferent coupling reported here went from ~1 to 10 because this PR gave the function its first unit tests — six of them, covering the stale-source member drop, the nested-slot reconcile, a legacy {"hyperedges": null} slot, an id-less node, an unhashable node id, and the no-op case.

If afferent coupling counts test call sites, then covering a previously untested function can only ever register as a health regression, which I suspect is the opposite of what the check is for.

One other observation across the two runs. The four findings from the 18:35 review reappeared at 23:02 with byte-identical numbers (124; 21/67; 8/55; 8) even though 81fc53b changed the code between them — it moved ~33 lines out of three of those functions and altered dispatch_command's call list. So these read as absolute coupling values reported whenever a function appears in the diff, rather than deltas attributable to the PR, notwithstanding the "coupling-delta finding" label. That would also explain the _reconcile_existing_graph() finding I replied to earlier, whose body this PR never touches.

Measured deltas for the four, base → current, if useful:

function first-party callees all call expressions
to_json() 6 → 7 26 → 27
deduplicate_entities() 22 → 23 45 → 46
dispatch_command() 70 → 71 217 → 218
_reconcile_existing_graph() 14 → 14 31 → 31

Entirely possible I am misreading what the metric measures — happy to be corrected, and happy to act on any of these if you read them differently.

Comment thread graphify/cli.py
main()


def dispatch_command(cmd: str) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressiondispatch_command()

fans out to 124 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/dedup.py
hyperedges[:] = kept


def deduplicate_entities(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressiondeduplicate_entities()

fans out to 21 callees (efferent coupling); 67 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/export.py
@@ -264,6 +281,16 @@ def existing_graph_node_count(path: "str | Path"):


def to_json(G: nx.Graph, communities: dict[int, list[str]], output_path: str, *, force: bool = False, built_at_commit: str | None = None, community_labels: dict[int, str] | None = None) -> bool:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressionto_json()

fans out to 8 callees (efferent coupling); 55 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/watch.py
return kept


def _reconcile_existing_graph(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression_reconcile_existing_graph()

fans out to 8 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 5 advisory finding(s) below merit a look before merge.

Formal verification. 2 change(s) alter behavior, breaking input(s) attached.

Behavior changes: \_coerce\_hyperedge\_member\_refs changes behavior, here is the input that shows it.

The verifier found a concrete input on which \_coerce\_hyperedge\_member\_refs behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"he":"\{'n': 0, 's': 'x', 'l': \[1, 2\]\}","members":"\[1, 2, 3\]"\}, the old code produced \[1, 2, 3\] but the new code produces \['1', '2', '3'\]. Paste that input straight into a regression test.

Behavior changes: attach\_hyperedges changes behavior, here is the input that shows it.

The verifier found a concrete input on which attach\_hyperedges behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"G":"\(lambda \_g: \(\_g\.add\_nodes\_from\(\[\('a', \{'label': 'A', 'kind': 'fn'\}\), \('b', \{'label': 'B'\}\)\]\), \_g\.add\_edges\_from\(\[\('a', 'b', \{'weight': 2, 'kind': 'calls'\}\)\]\), \_g\)\[\-1\]\)\(\_\_import\_\_\('networkx'\)\.Graph\(\)\)","hyperedges":"\[0, 0, 0, 0\]"\}, the old code produced raises AttributeError but the new code produces None. Paste that input straight into a regression test.


Graphify review — findings

Enforces a 3-distinct-member minimum on hyperedges at every persistence boundary, so pairwise relationships get dropped rather than stored as degenerate groups. Adds canonical_hyperedge, node_id_set, and gate_hyperedges as the single shared gate that normalizes member aliases/shapes, coerces ids into one space (7 and "7" become one member), dedupes members, and optionally filters against a node set — with _is_usable_member_ref unifying the previously-drifting bare/object member checks so junk members no longer pad the count. The build path now also rejects malformed (nodes not a list) hyperedges outright instead of letting them reach graph.json, and counts distinct survivors so remaps that collapse two ids onto one can't sneak a pair through.

Worth a look

  • build_from_json now drops single/two-member hyperedges that were previously legalgraphify/build.py:1442 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • build_from_json drops previously valid single/pair hyperedgesgraphify/build.py:1464 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Top-level build import creates cache/build circular importgraphify/cache.py:15 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Cold import cycle between dedup and buildgraphify/dedup.py:15 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • New top-level import creates a circular dependency between dedup and buildgraphify/dedup.py:15 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 3387 functions depend on the 1626 functions this change touches.

Health — this change adds coupling hotspots:

  • new: extract() — 520 callers, 43 callees
  • new: _rebuild_code() — 114 callers, 51 callees
  • new: build_from_json() — 195 callers, 18 callees
  • new: detect() — 108 callers, 15 callees
  • new: deduplicate_entities() — 67 callers, 21 callees
  • new: build_merge() — 65 callers, 14 callees
  • new: save_semantic_cache() — 64 callers, 10 callees
  • new: to_obsidian() — 36 callers, 13 callees
  • …and 69 more — each is listed as a finding

Verification — 3387 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 2874 function(s) in the blast radius were not formally verified this run

Formal verification

Behavior changes: \_coerce\_hyperedge\_member\_refs changes behavior, here is the input that shows it.

The verifier found a concrete input on which \_coerce\_hyperedge\_member\_refs behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"he":"\{'n': 0, 's': 'x', 'l': \[1, 2\]\}","members":"\[1, 2, 3\]"\}, the old code produced \[1, 2, 3\] but the new code produces \['1', '2', '3'\]. Paste that input straight into a regression test.

Behavior changes: attach\_hyperedges changes behavior, here is the input that shows it.

The verifier found a concrete input on which attach\_hyperedges behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"G":"\(lambda \_g: \(\_g\.add\_nodes\_from\(\[\('a', \{'label': 'A', 'kind': 'fn'\}\), \('b', \{'label': 'B'\}\)\]\), \_g\.add\_edges\_from\(\[\('a', 'b', \{'weight': 2, 'kind': 'calls'\}\)\]\), \_g\)\[\-1\]\)\(\_\_import\_\_\('networkx'\)\.Graph\(\)\)","hyperedges":"\[0, 0, 0, 0\]"\}, the old code produced raises AttributeError but the new code produces None. Paste that input straight into a regression test.

Could not verify: Could not verify build.

The verifier did not have enough to check build, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 9 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly AttributeError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify build\_from\_json.

The verifier did not have enough to check build\_from\_json, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 6 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly NameError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify build\_merge.

The verifier did not have enough to check build\_merge, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `graph_path` is annotated `str | Path | None` — outside the synthesizable primitive/collection set

Could not verify: Could not verify prefix\_graph\_for\_global.

The verifier did not have enough to check prefix\_graph\_for\_global, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 200 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous

Could not verify: Could not verify save\_semantic\_cache.

The verifier did not have enough to check save\_semantic\_cache, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify dispatch\_command.

The verifier did not have enough to check dispatch\_command, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 23 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly SystemExit — names the real obstacle, not a sampling gap)

Could not verify: Could not verify \_prune\_graph\_json\_sources.

The verifier did not have enough to check \_prune\_graph\_json\_sources, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `graph_path` is annotated `Path` — outside the synthesizable primitive/collection set

No difference found (not proven): No behavior difference found in \_reenter\_main (not a proof).

The verifier ran both versions of \_reenter\_main on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

Could not verify: Could not verify deduplicate\_entities.

The verifier did not have enough to check deduplicate\_entities, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 9 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly TypeError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify \_remap\_hyperedge\_members.

The verifier did not have enough to check \_remap\_hyperedge\_members, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: the input domain has 54 values but only 18 distinct were tested — a small finite domain must be EXHAUSTED, not sampled (an untested input could invert the result)

No difference found (not proven): No behavior difference found in to\_json (not a proof).

The verifier ran both versions of to\_json on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

No difference found (not proven): No behavior difference found in sanitize\_semantic\_fragment (not a proof).

The verifier ran both versions of sanitize\_semantic\_fragment on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

Could not verify: Could not verify \_rebuild\_code.

The verifier did not have enough to check \_rebuild\_code, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `watch_path` is annotated `Path` — outside the synthesizable primitive/collection set

· 5 grounded finding(s) anchored inline below; 72 more finding(s) on lines outside this diff (see the check run).

Comment thread graphify/cli.py
return _gate(hyperedges, nodes)


def _prune_graph_json_sources(graph_path: Path, stale_sources: list[str]) -> int:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression_prune_graph_json_sources()

10 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/cli.py
main()


def dispatch_command(cmd: str) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressiondispatch_command()

fans out to 124 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/dedup.py
hyperedges[:] = kept


def deduplicate_entities(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressiondeduplicate_entities()

fans out to 21 callees (efferent coupling); 67 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/export.py
@@ -264,6 +281,16 @@ def existing_graph_node_count(path: "str | Path"):


def to_json(G: nx.Graph, communities: dict[int, list[str]], output_path: str, *, force: bool = False, built_at_commit: str | None = None, community_labels: dict[int, str] | None = None) -> bool:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressionto_json()

fans out to 8 callees (efferent coupling); 55 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/watch.py
return kept


def _reconcile_existing_graph(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression_reconcile_existing_graph()

fans out to 8 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

@egarcia74

egarcia74 commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Updated 32527abcbdb55f. Three commits, all of them fixes to defects the review found in this PR's own fix rather than new functionality. The description now records them alongside the earlier ones; this is the short version.

The gate compared ids in one space and wrote them back in another. Member refs are coerced (7"7") so they can be compared at all, but the writers persist their node records unchanged. Coercing only the comparison side left graph.json holding nodes [7, 8, 9] and members ["7", "8", "9"] — a dangling reference of exactly the shape #1916 removed, produced by the gate that exists to prevent it:

raw --no-cluster writer     nodes [7, 8, 9]  members ['7', '8', '9']  DANGLING
watch raw writer            members ['7', '8', '9']
_prune_graph_json_sources   nodes [7, 8, 9]  members ['7', '8', '9']  DANGLING

It took three rounds to find every site, and the reason is worth stating: I kept fixing it one call path at a time instead of asking where the two id spaces meet. They are now bridged in exactly one place — _id_map maps a coerced id back to the id its container actually carries, and a single _gate core indexes it for every survivor. The index is direct rather than a .get(m, m) fallback, which could only ever put a coerced id back into a raw space.

The gate rebuilt its id map per candidate. 1060 _coerce_id calls for 50 nodes and 20 groups where linear is 110 — O(nodes × hyperedges) on exactly the thousands-of-groups corpora this invariant was added for. I fixed this in prefix_graph_for_global and then reintroduced it one commit later in attach_hyperedges, via a helper that built its map internally. No per-candidate path builds a map now, both sites gate whole lists, and a _coerce_id counter guards each — a counter being the only thing that catches a reintroduction of this class, which is what happened here.

Three smaller ones: the cache gated writes but not reads, so a pre-gate entry replayed as a hit forever; semantic_cleanup built its surviving-id set raw while members are coerced; and to_json raised TypeError before writing anything when a caller left the hyperedge slot as None, losing the whole export rather than just the hyperedges. Plus one test that had gone vacuous — its two-member fixture was dropped by the cardinality gate before the prune it exists to test ever ran.

Every one of these was reproduced before being fixed, and each fix has a test that was red first. Full suite 5,306 passed / 56 skipped; the 14 touched test files 602 passed / 3 skipped; ruff and bandit clean; CodeRabbit CLI 0 findings over the full diff; docstring coverage 100% across the 153 touched functions. The 6 local test_ollama* failures remain environmental and reproduce on an untouched checkout.

Codex reviewed cbdb55f and reported no issues. @copilot the id-space and map-once changes are the ones worth verifying — particularly that no writer can emit a member naming an id absent from the nodes it writes.

🤖 Generated with Claude Code

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 5 advisory finding(s) below merit a look before merge.

Formal verification. 2 change(s) alter behavior, breaking input(s) attached.

Behavior changes: \_coerce\_hyperedge\_member\_refs changes behavior, here is the input that shows it.

The verifier found a concrete input on which \_coerce\_hyperedge\_member\_refs behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"he":"\{'n': 0, 's': 'x', 'l': \[1, 2\]\}","members":"\[1, 2, 3\]"\}, the old code produced \[1, 2, 3\] but the new code produces \['1', '2', '3'\]. Paste that input straight into a regression test.

Behavior changes: attach\_hyperedges changes behavior, here is the input that shows it.

The verifier found a concrete input on which attach\_hyperedges behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"G":"\(lambda \_g: \(\_g\.add\_nodes\_from\(\[\('a', \{'label': 'A', 'kind': 'fn'\}\), \('b', \{'label': 'B'\}\)\]\), \_g\.add\_edges\_from\(\[\('a', 'b', \{'weight': 2, 'kind': 'calls'\}\)\]\), \_g\)\[\-1\]\)\(\_\_import\_\_\('networkx'\)\.Graph\(\)\)","hyperedges":"\[0, 0, 0, 0\]"\}, the old code produced raises AttributeError but the new code produces None. Paste that input straight into a regression test.


Graphify review — findings

Consolidates hyperedge validation into one gate (canonical_hyperedge / gate_hyperedges / gate_hyperedges_against_graph) that normalizes member aliases, dedupes and coerces member ids, drops members no longer backed by a node, and cuts any group left with fewer than three distinct members — returning survivors in the caller's own id space so writers persist references that resolve. Unifies the two member-shape checks behind _is_usable_member_ref, so None, "", booleans and unhashable refs never pad cardinality in the object or bare branch — closing the case where a junk-padded pair got cached and stamped as covered but yielded nothing on graph-backed replay. Bridges the coerced-vs-raw id spaces via _id_map/node_id_map/node_id_set, and builds the id map once per gate call to keep gating linear in the number of hyperedges rather than O(nodes × hyperedges).

Worth a look

  • New top-level import creates a build/dedup circular importgraphify/dedup.py:15 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • New build↔dedup import cycle can fail clean importsgraphify/dedup.py:16 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • build_from_json now drops previously legal singleton hyperedgesgraphify/build.py:1548 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • deduplicate_entities now rewrites/prunes hyperedges even on no-op dedup pathsgraphify/dedup.py:586 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • attach_hyperedges now silently drops two-member hyperedgesgraphify/export.py:192 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 3418 functions depend on the 1657 functions this change touches.

Health — this change adds coupling hotspots:

  • new: extract() — 520 callers, 43 callees
  • new: _rebuild_code() — 114 callers, 51 callees
  • new: build_from_json() — 195 callers, 18 callees
  • new: detect() — 108 callers, 15 callees
  • new: deduplicate_entities() — 67 callers, 21 callees
  • new: build_merge() — 65 callers, 14 callees
  • new: save_semantic_cache() — 64 callers, 10 callees
  • new: to_json() — 59 callers, 8 callees
  • …and 71 more — each is listed as a finding

Verification — 3418 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 2905 function(s) in the blast radius were not formally verified this run

Formal verification

Behavior changes: \_coerce\_hyperedge\_member\_refs changes behavior, here is the input that shows it.

The verifier found a concrete input on which \_coerce\_hyperedge\_member\_refs behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"he":"\{'n': 0, 's': 'x', 'l': \[1, 2\]\}","members":"\[1, 2, 3\]"\}, the old code produced \[1, 2, 3\] but the new code produces \['1', '2', '3'\]. Paste that input straight into a regression test.

Behavior changes: attach\_hyperedges changes behavior, here is the input that shows it.

The verifier found a concrete input on which attach\_hyperedges behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"G":"\(lambda \_g: \(\_g\.add\_nodes\_from\(\[\('a', \{'label': 'A', 'kind': 'fn'\}\), \('b', \{'label': 'B'\}\)\]\), \_g\.add\_edges\_from\(\[\('a', 'b', \{'weight': 2, 'kind': 'calls'\}\)\]\), \_g\)\[\-1\]\)\(\_\_import\_\_\('networkx'\)\.Graph\(\)\)","hyperedges":"\[0, 0, 0, 0\]"\}, the old code produced raises AttributeError but the new code produces None. Paste that input straight into a regression test.

Could not verify: Could not verify build.

The verifier did not have enough to check build, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 9 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly AttributeError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify build\_from\_json.

The verifier did not have enough to check build\_from\_json, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 6 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly NameError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify build\_merge.

The verifier did not have enough to check build\_merge, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `graph_path` is annotated `str | Path | None` — outside the synthesizable primitive/collection set

Could not verify: Could not verify prefix\_graph\_for\_global.

The verifier did not have enough to check prefix\_graph\_for\_global, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 200 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous

Could not verify: Could not verify check\_semantic\_cache.

The verifier did not have enough to check check\_semantic\_cache, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify save\_semantic\_cache.

The verifier did not have enough to check save\_semantic\_cache, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify dispatch\_command.

The verifier did not have enough to check dispatch\_command, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 23 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly SystemExit — names the real obstacle, not a sampling gap)

Could not verify: Could not verify \_prune\_graph\_json\_sources.

The verifier did not have enough to check \_prune\_graph\_json\_sources, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `graph_path` is annotated `Path` — outside the synthesizable primitive/collection set

No difference found (not proven): No behavior difference found in \_reenter\_main (not a proof).

The verifier ran both versions of \_reenter\_main on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

Could not verify: Could not verify deduplicate\_entities.

The verifier did not have enough to check deduplicate\_entities, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 9 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly TypeError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify \_remap\_hyperedge\_members.

The verifier did not have enough to check \_remap\_hyperedge\_members, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: the input domain has 54 values but only 18 distinct were tested — a small finite domain must be EXHAUSTED, not sampled (an untested input could invert the result)

No difference found (not proven): No behavior difference found in to\_json (not a proof).

The verifier ran both versions of to\_json on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

Could not verify: Could not verify sanitize\_semantic\_fragment.

The verifier did not have enough to check sanitize\_semantic\_fragment, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 6 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly NameError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify \_rebuild\_code.

The verifier did not have enough to check \_rebuild\_code, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `watch_path` is annotated `Path` — outside the synthesizable primitive/collection set

· 6 grounded finding(s) anchored inline below; 73 more finding(s) on lines outside this diff (see the check run).

Comment thread graphify/build.py Outdated
)


def canonical_hyperedge(he: object, node_ids: object = None) -> "dict | None":

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressioncanonical_hyperedge()

20 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

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.

This one is accurate and it is the PR's central design tradeoff, so it deserves a straight answer rather than a dismissal.

canonical_hyperedge is new in this PR, and it is depended on by many sites on purpose. The issue's audit found twelve independent places that persist hyperedges. The alternative to one shared gate is twelve near-identical checks, which is what the first pass did — and the review found the copies had already drifted apart four times on a single rule (None, bare booleans, object-wrapped booleans, then the numeric coercion), so [7, "7", "b"] passed as a group at some sites and not others. Concentrating that into one function raises its afferent coupling by construction; that number going up is the fix working.

Measured references, consistent method (git grep -c -w) on both revisions:

graphify/ tests/
base 33362d9 0 (did not exist) 0
this PR 7 (the def + 6 call sites) 24

Six production call sites, one per persistence boundary that needs the gate, and they are one line each.

The description's "Structural note" already concedes the real version of this concern, and I would rather state it than have it read as unnoticed: twelve boundaries sharing a gate is not the same as a single persistence funnel. Nothing structurally stops a future writer from forgetting the call — watch's raw writer was exactly that omission, found only by review. A funnel is the durable fix and is larger than this PR.

Two notes on the metric itself, offered as observations rather than objections:

  1. It appears to count test references. _prune_graph_json_sources is reported at 12 callers here, up from 10, while its production references are unchanged at 2 (the def plus its single call site at cli.py:4156); what changed is 0 → 18 test references, because this PR gave a previously untested function its first six tests. On that reading, covering untested code can only ever register as a health regression.
  2. The label says "coupling-delta", but the four findings from the 18:35 run reappeared at 23:02 and 23:43 with byte-identical numbers (124; 21/67; 8/55; 8) across commits that changed those functions' call lists. They read as absolute values re-reported whenever a function appears in the diff.

Happy to be corrected on either if the metric is measuring something I am not seeing.

Comment thread graphify/cli.py
return _gate(hyperedges, nodes)


def _prune_graph_json_sources(graph_path: Path, stale_sources: list[str]) -> int:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression_prune_graph_json_sources()

12 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/cli.py
main()


def dispatch_command(cmd: str) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressiondispatch_command()

fans out to 124 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/dedup.py
hyperedges[:] = kept


def deduplicate_entities(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressiondeduplicate_entities()

fans out to 21 callees (efferent coupling); 67 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/export.py
@@ -264,6 +279,16 @@ def existing_graph_node_count(path: "str | Path"):


def to_json(G: nx.Graph, communities: dict[int, list[str]], output_path: str, *, force: bool = False, built_at_commit: str | None = None, community_labels: dict[int, str] | None = None) -> bool:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressionto_json()

fans out to 8 callees (efferent coupling); 59 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/watch.py
return kept


def _reconcile_existing_graph(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression_reconcile_existing_graph()

fans out to 8 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

@egarcia74

Copy link
Copy Markdown
Contributor Author

Updated cbdb55f7b9d6aa, and the description is refreshed with it.

Codex found a fifth instance of the same root cause, this time in the cache’s skipped-node prune. save_semantic_cache drops a group naming a node from a deliberately skipped source (#1916), but collected those ids raw while the members had already been coerced. Same group, same skipped source, only the member’s id type changed:

string-id member  -> hyperedges []            (pruned correctly)
numeric-id member -> ["7", "a", "b"]          (cached; node 7 was never written)

Both sets are built with node_id_set now. Edge endpoints reach the cache raw and were matching raw-against-raw, so coercing only the sets would have broken numeric endpoints — a regression introduced by the fix — and they are coerced at the lookup instead, with a test pinning it.

The description changes worth knowing about, since they alter claims rather than just adding text:

  • The count was wrong. It is twelve write boundaries plus one read boundary — check_semantic_cache canonicalizes on read, which is a distinct site and was missing from both the table and the diagram. Both now show it as ⑬.
  • The gate section now states the id-space contract explicitly: coerce every set a member is compared against, and hand survivors back in the space the caller is about to persist. That is the property five separate defects violated, and the description previously described the gate without ever saying it.
  • Thread count corrected to 35 resolved across fix: enforce minimum hyperedge cardinality egarcia74/graphify#3 and Your project was featured on AI Digital Crew 🎉 #6.
  • Numbers refreshed: full suite 5,307 passed / 56 skipped, the 14 touched test files 603 passed / 3 skipped, docstring coverage 100% across 160 touched functions, CodeRabbit CLI 0 findings over the full diff.

@codex another pass when you get a chance.

🤖 Generated with Claude Code

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 5 advisory finding(s) below merit a look before merge.

Formal verification. 2 change(s) alter behavior, breaking input(s) attached.

Behavior changes: \_coerce\_hyperedge\_member\_refs changes behavior, here is the input that shows it.

The verifier found a concrete input on which \_coerce\_hyperedge\_member\_refs behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"he":"\{'n': 0, 's': 'x', 'l': \[1, 2\]\}","members":"\[1, 2, 3\]"\}, the old code produced \[1, 2, 3\] but the new code produces \['1', '2', '3'\]. Paste that input straight into a regression test.

Behavior changes: attach\_hyperedges changes behavior, here is the input that shows it.

The verifier found a concrete input on which attach\_hyperedges behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"G":"\(lambda \_g: \(\_g\.add\_nodes\_from\(\[\('a', \{'label': 'A', 'kind': 'fn'\}\), \('b', \{'label': 'B'\}\)\]\), \_g\.add\_edges\_from\(\[\('a', 'b', \{'weight': 2, 'kind': 'calls'\}\)\]\), \_g\)\[\-1\]\)\(\_\_import\_\_\('networkx'\)\.Graph\(\)\)","hyperedges":"\[0, 0, 0, 0\]"\}, the old code produced raises AttributeError but the new code produces None. Paste that input straight into a regression test.


Graphify review — findings

Consolidates hyperedge validation behind a single canonical_hyperedge gate that every persistence boundary shares: it normalizes member aliases (members/node_ids), dedups, coerces ids to one space, optionally filters members against a node set, and drops anything left with fewer than three distinct members so pairwise relationships fall back to the ordinary edge set. Exposes gate_hyperedges, gate_hyperedges_against_graph, and the node_id_map/node_id_set helpers so writers gate whole lists against a prebuilt id map (walking nodes once) and get survivors back in the caller's own id space, keeping graph.json's {"id": 7} and "7" member refs from diverging into dangling references. Unifies the member-usability check in _is_usable_member_ref so bare and object member shapes apply the same rule — rejecting None, "", booleans, and unhashable values — which stops a junk padding member from making a two-member group cache and stamp as valid only to be dropped on graph-backed replay.

Worth a look

  • New top-level import creates a circular import between dedup and buildgraphify/dedup.py:15 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Circular import risk: dedup.py imports from build.py which imports dedupgraphify/dedup.py:15 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • New build import creates a circular module dependencygraphify/dedup.py:15 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Top-level import creates a build/dedup circular dependencygraphify/dedup.py:15 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • canonical_hyperedge mutates via _normalize but doc claims member coercion for countgraphify/build.py · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 3420 functions depend on the 1659 functions this change touches.

Health — this change adds coupling hotspots:

  • new: extract() — 520 callers, 43 callees
  • new: _rebuild_code() — 114 callers, 51 callees
  • new: build_from_json() — 195 callers, 18 callees
  • new: detect() — 108 callers, 15 callees
  • new: deduplicate_entities() — 67 callers, 21 callees
  • new: build_merge() — 65 callers, 14 callees
  • new: save_semantic_cache() — 65 callers, 12 callees
  • new: to_json() — 59 callers, 8 callees
  • …and 71 more — each is listed as a finding

Verification — 3420 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 2907 function(s) in the blast radius were not formally verified this run

Formal verification

Behavior changes: \_coerce\_hyperedge\_member\_refs changes behavior, here is the input that shows it.

The verifier found a concrete input on which \_coerce\_hyperedge\_member\_refs behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"he":"\{'n': 0, 's': 'x', 'l': \[1, 2\]\}","members":"\[1, 2, 3\]"\}, the old code produced \[1, 2, 3\] but the new code produces \['1', '2', '3'\]. Paste that input straight into a regression test.

Behavior changes: attach\_hyperedges changes behavior, here is the input that shows it.

The verifier found a concrete input on which attach\_hyperedges behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"G":"\(lambda \_g: \(\_g\.add\_nodes\_from\(\[\('a', \{'label': 'A', 'kind': 'fn'\}\), \('b', \{'label': 'B'\}\)\]\), \_g\.add\_edges\_from\(\[\('a', 'b', \{'weight': 2, 'kind': 'calls'\}\)\]\), \_g\)\[\-1\]\)\(\_\_import\_\_\('networkx'\)\.Graph\(\)\)","hyperedges":"\[0, 0, 0, 0\]"\}, the old code produced raises AttributeError but the new code produces None. Paste that input straight into a regression test.

Could not verify: Could not verify build.

The verifier did not have enough to check build, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 9 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly AttributeError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify build\_from\_json.

The verifier did not have enough to check build\_from\_json, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 6 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly NameError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify build\_merge.

The verifier did not have enough to check build\_merge, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `graph_path` is annotated `str | Path | None` — outside the synthesizable primitive/collection set

Could not verify: Could not verify prefix\_graph\_for\_global.

The verifier did not have enough to check prefix\_graph\_for\_global, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 200 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous

Could not verify: Could not verify check\_semantic\_cache.

The verifier did not have enough to check check\_semantic\_cache, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify save\_semantic\_cache.

The verifier did not have enough to check save\_semantic\_cache, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify dispatch\_command.

The verifier did not have enough to check dispatch\_command, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 23 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly SystemExit — names the real obstacle, not a sampling gap)

Could not verify: Could not verify \_prune\_graph\_json\_sources.

The verifier did not have enough to check \_prune\_graph\_json\_sources, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `graph_path` is annotated `Path` — outside the synthesizable primitive/collection set

No difference found (not proven): No behavior difference found in \_reenter\_main (not a proof).

The verifier ran both versions of \_reenter\_main on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

Could not verify: Could not verify deduplicate\_entities.

The verifier did not have enough to check deduplicate\_entities, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 9 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly TypeError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify \_remap\_hyperedge\_members.

The verifier did not have enough to check \_remap\_hyperedge\_members, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: the input domain has 54 values but only 18 distinct were tested — a small finite domain must be EXHAUSTED, not sampled (an untested input could invert the result)

No difference found (not proven): No behavior difference found in to\_json (not a proof).

The verifier ran both versions of to\_json on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

Could not verify: Could not verify sanitize\_semantic\_fragment.

The verifier did not have enough to check sanitize\_semantic\_fragment, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 6 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly NameError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify \_rebuild\_code.

The verifier did not have enough to check \_rebuild\_code, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `watch_path` is annotated `Path` — outside the synthesizable primitive/collection set

· 6 grounded finding(s) anchored inline below; 73 more finding(s) on lines outside this diff (see the check run).

Comment thread graphify/build.py Outdated
)


def canonical_hyperedge(he: object, node_ids: object = None) -> "dict | None":

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressioncanonical_hyperedge()

20 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/cli.py
return _gate(hyperedges, nodes)


def _prune_graph_json_sources(graph_path: Path, stale_sources: list[str]) -> int:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression_prune_graph_json_sources()

12 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/cli.py
main()


def dispatch_command(cmd: str) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressiondispatch_command()

fans out to 124 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/dedup.py
hyperedges[:] = kept


def deduplicate_entities(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressiondeduplicate_entities()

fans out to 21 callees (efferent coupling); 67 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/export.py
@@ -264,6 +279,16 @@ def existing_graph_node_count(path: "str | Path"):


def to_json(G: nx.Graph, communities: dict[int, list[str]], output_path: str, *, force: bool = False, built_at_commit: str | None = None, community_labels: dict[int, str] | None = None) -> bool:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressionto_json()

fans out to 8 callees (efferent coupling); 59 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/watch.py
return kept


def _reconcile_existing_graph(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression_reconcile_existing_graph()

fans out to 8 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 5 advisory finding(s) below merit a look before merge.

Formal verification. 2 change(s) alter behavior, breaking input(s) attached.

Behavior changes: \_coerce\_hyperedge\_member\_refs changes behavior, here is the input that shows it.

The verifier found a concrete input on which \_coerce\_hyperedge\_member\_refs behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"he":"\{'n': 0, 's': 'x', 'l': \[1, 2\]\}","members":"\[1, 2, 3\]"\}, the old code produced \[1, 2, 3\] but the new code produces \['1', '2', '3'\]. Paste that input straight into a regression test.

Behavior changes: attach\_hyperedges changes behavior, here is the input that shows it.

The verifier found a concrete input on which attach\_hyperedges behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"G":"\(lambda \_g: \(\_g\.add\_nodes\_from\(\[\('a', \{'label': 'A', 'kind': 'fn'\}\), \('b', \{'label': 'B'\}\)\]\), \_g\.add\_edges\_from\(\[\('a', 'b', \{'weight': 2, 'kind': 'calls'\}\)\]\), \_g\)\[\-1\]\)\(\_\_import\_\_\('networkx'\)\.Graph\(\)\)","hyperedges":"\[0, 0, 0, 0\]"\}, the old code produced raises AttributeError but the new code produces None. Paste that input straight into a regression test.


Graphify review — findings

Consolidates hyperedge validation behind a single canonical_hyperedge gate that normalizes member aliases, coerces id spaces, dedupes, and enforces the 3-member minimum, returning a copy so callers' original dicts stay intact. Exposes writer-facing wrappers — gate_hyperedges and gate_hyperedges_against_graph — that filter members against a node set built once via node_id_map/node_id_set, resolving through a coerced-plus-normalized fallback layer so groups over numeric or case-drifted ids survive rather than dropping their members. Centralizes the usable-member rule in _is_usable_member_ref so junk members can no longer pad the count past the minimum on paths (like the semantic cache) that have no node set to filter against.

Worth a look

  • MIN_HYPEREDGE_MEMBERS raises minimum from 1 to a pair, dropping previously-legal single-member hyperedgesgraphify/build.py:1585 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Top-level build import can create circular import with graphify.buildgraphify/dedup.py:15 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Top-level import from graphify.build creates a circular importgraphify/dedup.py:15 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Hyperedge minimum members raised from 2 to 3 is a backward-incompatible contract changegraphify/semantic_cleanup.py:292 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Raw numeric node-id sets drop all hyperedge membersgraphify/build.py:104 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 3434 functions depend on the 1673 functions this change touches.

Health — this change adds coupling hotspots:

  • new: extract() — 520 callers, 43 callees
  • new: _rebuild_code() — 114 callers, 51 callees
  • new: build_from_json() — 195 callers, 18 callees
  • new: detect() — 108 callers, 15 callees
  • new: deduplicate_entities() — 68 callers, 21 callees
  • new: build_merge() — 65 callers, 14 callees
  • new: save_semantic_cache() — 65 callers, 12 callees
  • new: to_json() — 59 callers, 8 callees
  • …and 71 more — each is listed as a finding

Verification — 3434 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 2921 function(s) in the blast radius were not formally verified this run

Formal verification

Behavior changes: \_coerce\_hyperedge\_member\_refs changes behavior, here is the input that shows it.

The verifier found a concrete input on which \_coerce\_hyperedge\_member\_refs behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"he":"\{'n': 0, 's': 'x', 'l': \[1, 2\]\}","members":"\[1, 2, 3\]"\}, the old code produced \[1, 2, 3\] but the new code produces \['1', '2', '3'\]. Paste that input straight into a regression test.

Behavior changes: attach\_hyperedges changes behavior, here is the input that shows it.

The verifier found a concrete input on which attach\_hyperedges behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"G":"\(lambda \_g: \(\_g\.add\_nodes\_from\(\[\('a', \{'label': 'A', 'kind': 'fn'\}\), \('b', \{'label': 'B'\}\)\]\), \_g\.add\_edges\_from\(\[\('a', 'b', \{'weight': 2, 'kind': 'calls'\}\)\]\), \_g\)\[\-1\]\)\(\_\_import\_\_\('networkx'\)\.Graph\(\)\)","hyperedges":"\[0, 0, 0, 0\]"\}, the old code produced raises AttributeError but the new code produces None. Paste that input straight into a regression test.

Could not verify: Could not verify build.

The verifier did not have enough to check build, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 9 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly AttributeError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify build\_from\_json.

The verifier did not have enough to check build\_from\_json, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 6 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly NameError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify build\_merge.

The verifier did not have enough to check build\_merge, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `graph_path` is annotated `str | Path | None` — outside the synthesizable primitive/collection set

Could not verify: Could not verify prefix\_graph\_for\_global.

The verifier did not have enough to check prefix\_graph\_for\_global, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 200 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous

Could not verify: Could not verify check\_semantic\_cache.

The verifier did not have enough to check check\_semantic\_cache, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify save\_semantic\_cache.

The verifier did not have enough to check save\_semantic\_cache, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify dispatch\_command.

The verifier did not have enough to check dispatch\_command, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 23 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly SystemExit — names the real obstacle, not a sampling gap)

Could not verify: Could not verify \_prune\_graph\_json\_sources.

The verifier did not have enough to check \_prune\_graph\_json\_sources, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `graph_path` is annotated `Path` — outside the synthesizable primitive/collection set

No difference found (not proven): No behavior difference found in \_reenter\_main (not a proof).

The verifier ran both versions of \_reenter\_main on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

Could not verify: Could not verify deduplicate\_entities.

The verifier did not have enough to check deduplicate\_entities, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 9 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly TypeError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify \_remap\_hyperedge\_members.

The verifier did not have enough to check \_remap\_hyperedge\_members, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: the input domain has 54 values but only 18 distinct were tested — a small finite domain must be EXHAUSTED, not sampled (an untested input could invert the result)

No difference found (not proven): No behavior difference found in to\_json (not a proof).

The verifier ran both versions of to\_json on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

Could not verify: Could not verify sanitize\_semantic\_fragment.

The verifier did not have enough to check sanitize\_semantic\_fragment, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 6 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly NameError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify \_rebuild\_code.

The verifier did not have enough to check \_rebuild\_code, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `watch_path` is annotated `Path` — outside the synthesizable primitive/collection set

· 7 grounded finding(s) anchored inline below; 72 more finding(s) on lines outside this diff (see the check run).

Comment thread graphify/build.py Outdated
)


def canonical_hyperedge(he: object, node_ids: object = None) -> "dict | None":

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressioncanonical_hyperedge()

20 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/cli.py
return _gate(hyperedges, nodes)


def _prune_graph_json_sources(graph_path: Path, stale_sources: list[str]) -> int:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression_prune_graph_json_sources()

12 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/cli.py
main()


def dispatch_command(cmd: str) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressiondispatch_command()

fans out to 125 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/dedup.py
he["nodes"] = restored


def _remap_hyperedge_members(hyperedges: list[dict], remap: dict[str, str]) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression_remap_hyperedge_members()

12 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/dedup.py
hyperedges[:] = kept


def deduplicate_entities(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressiondeduplicate_entities()

fans out to 21 callees (efferent coupling); 68 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/export.py
@@ -264,6 +279,16 @@ def existing_graph_node_count(path: "str | Path"):


def to_json(G: nx.Graph, communities: dict[int, list[str]], output_path: str, *, force: bool = False, built_at_commit: str | None = None, community_labels: dict[int, str] | None = None) -> bool:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressionto_json()

fans out to 8 callees (efferent coupling); 59 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/watch.py
return kept


def _reconcile_existing_graph(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression_reconcile_existing_graph()

fans out to 8 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

@egarcia74

Copy link
Copy Markdown
Contributor Author

Updated b746d610801bd7.

This one is not a reviewer finding — I went looking for an eighth instance of the id-space root cause myself, because seven had surfaced one at a time and Codex has hit its review quota. What I found instead was a structural problem in my own previous commit.

Once _gate took over membership resolution, canonical_hyperedge's node_ids parameter had no production caller left (verified by AST rather than grep), and its semantics had quietly diverged from the real gate: exact matching only, no normalized fallback, members returned coerced rather than in the container's own id space. So the module carried two membership rules for one invariant, the weaker one was reachable only from tests, and it was the one this description advertised as the gate.

That is exactly the drift that produced several of this PR's own defects — _is_usable_member_ref diverged four times between two branches meant to agree. Leaving a second, weaker copy in place invites the same outcome, so the parameter is gone: canonical_hyperedge is shape, coercion, dedupe and cardinality; the two gate functions own membership. The tests that exercised the parameter now go through the gate, which means they test the path that ships rather than one that no longer exists in production.

The description's gate section is rewritten to match, since it described the old split.

Also fixed a docstring CodeRabbit was right about: a test helper claimed to return captured output and returns None.

Full suite 5,308 passed / 56 skipped (the 6 pre-existing test_ollama* env failures), the 14 touched test files 604 passed / 3 skipped, ruff clean over graphify/ and tests/, docstring coverage 100% over 172 touched functions.

One process note for maintainers: Codex is now rate-limited on this account, so 0801bd7 has had no third-party review — only CI, the CodeRabbit CLI, and my own verification. Worth knowing when weighing how much external review this branch has actually had.

🤖 Generated with Claude Code

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 5 advisory finding(s) below merit a look before merge.

Formal verification. 2 change(s) alter behavior, breaking input(s) attached.

Behavior changes: \_coerce\_hyperedge\_member\_refs changes behavior, here is the input that shows it.

The verifier found a concrete input on which \_coerce\_hyperedge\_member\_refs behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"he":"\{'n': 0, 's': 'x', 'l': \[1, 2\]\}","members":"\[1, 2, 3\]"\}, the old code produced \[1, 2, 3\] but the new code produces \['1', '2', '3'\]. Paste that input straight into a regression test.

Behavior changes: attach\_hyperedges changes behavior, here is the input that shows it.

The verifier found a concrete input on which attach\_hyperedges behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"G":"\(lambda \_g: \(\_g\.add\_nodes\_from\(\[\('a', \{'label': 'A', 'kind': 'fn'\}\), \('b', \{'label': 'B'\}\)\]\), \_g\.add\_edges\_from\(\[\('a', 'b', \{'weight': 2, 'kind': 'calls'\}\)\]\), \_g\)\[\-1\]\)\(\_\_import\_\_\('networkx'\)\.Graph\(\)\)","hyperedges":"\[0, 0, 0, 0\]"\}, the old code produced raises AttributeError but the new code produces None. Paste that input straight into a regression test.


Graphify review — findings

Consolidates hyperedge validation into a single canonicalization path via canonical_hyperedge, gate_hyperedges, and gate_hyperedges_against_graph, which enforce the shared shape rules (member-alias folding, dedupe, 3-member minimum) at every persistence boundary while returning survivors in the caller's own id space. Bridges the numeric/string and normalized id spaces through _id_map/node_id_map/node_id_set so members compare in a coerced space but write back the exact ids nodes actually carry, healing groups the raw --no-cluster writers previously dropped. Unifies the member-usability rule in _is_usable_member_ref so unhashable, empty, null, and boolean refs no longer pad cardinality and get cached-then-dropped on replay.

Worth a look

  • build_from_json now drops single-member hyperedges that were previously legalgraphify/build.py:1571 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Top-level build import creates an import cyclegraphify/dedup.py:15 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Top-level import from graphify.build creates circular import with build's dedup dependencygraphify/dedup.py:15 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • _finish drops hyperedges even when no dedup occurs (len(nodes)<=1 short-circuit)graphify/dedup.py:597 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • canonical_hyperedge now drops pairwise hyperedgesgraphify/build.py:88 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 3434 functions depend on the 1673 functions this change touches.

Health — this change adds coupling hotspots:

  • new: extract() — 520 callers, 43 callees
  • new: _rebuild_code() — 114 callers, 51 callees
  • new: build_from_json() — 195 callers, 18 callees
  • new: detect() — 108 callers, 15 callees
  • new: deduplicate_entities() — 68 callers, 21 callees
  • new: build_merge() — 65 callers, 14 callees
  • new: save_semantic_cache() — 65 callers, 12 callees
  • new: to_json() — 59 callers, 8 callees
  • …and 70 more — each is listed as a finding

Verification — 3434 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 2921 function(s) in the blast radius were not formally verified this run

Formal verification

Behavior changes: \_coerce\_hyperedge\_member\_refs changes behavior, here is the input that shows it.

The verifier found a concrete input on which \_coerce\_hyperedge\_member\_refs behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"he":"\{'n': 0, 's': 'x', 'l': \[1, 2\]\}","members":"\[1, 2, 3\]"\}, the old code produced \[1, 2, 3\] but the new code produces \['1', '2', '3'\]. Paste that input straight into a regression test.

Behavior changes: attach\_hyperedges changes behavior, here is the input that shows it.

The verifier found a concrete input on which attach\_hyperedges behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"G":"\(lambda \_g: \(\_g\.add\_nodes\_from\(\[\('a', \{'label': 'A', 'kind': 'fn'\}\), \('b', \{'label': 'B'\}\)\]\), \_g\.add\_edges\_from\(\[\('a', 'b', \{'weight': 2, 'kind': 'calls'\}\)\]\), \_g\)\[\-1\]\)\(\_\_import\_\_\('networkx'\)\.Graph\(\)\)","hyperedges":"\[0, 0, 0, 0\]"\}, the old code produced raises AttributeError but the new code produces None. Paste that input straight into a regression test.

Could not verify: Could not verify build.

The verifier did not have enough to check build, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 9 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly AttributeError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify build\_from\_json.

The verifier did not have enough to check build\_from\_json, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 6 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly NameError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify build\_merge.

The verifier did not have enough to check build\_merge, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `graph_path` is annotated `str | Path | None` — outside the synthesizable primitive/collection set

Could not verify: Could not verify prefix\_graph\_for\_global.

The verifier did not have enough to check prefix\_graph\_for\_global, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 200 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous

Could not verify: Could not verify check\_semantic\_cache.

The verifier did not have enough to check check\_semantic\_cache, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify save\_semantic\_cache.

The verifier did not have enough to check save\_semantic\_cache, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify dispatch\_command.

The verifier did not have enough to check dispatch\_command, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 23 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly SystemExit — names the real obstacle, not a sampling gap)

Could not verify: Could not verify \_prune\_graph\_json\_sources.

The verifier did not have enough to check \_prune\_graph\_json\_sources, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `graph_path` is annotated `Path` — outside the synthesizable primitive/collection set

No difference found (not proven): No behavior difference found in \_reenter\_main (not a proof).

The verifier ran both versions of \_reenter\_main on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

Could not verify: Could not verify deduplicate\_entities.

The verifier did not have enough to check deduplicate\_entities, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 9 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly TypeError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify \_remap\_hyperedge\_members.

The verifier did not have enough to check \_remap\_hyperedge\_members, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: the input domain has 54 values but only 18 distinct were tested — a small finite domain must be EXHAUSTED, not sampled (an untested input could invert the result)

No difference found (not proven): No behavior difference found in to\_json (not a proof).

The verifier ran both versions of to\_json on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

Could not verify: Could not verify sanitize\_semantic\_fragment.

The verifier did not have enough to check sanitize\_semantic\_fragment, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 6 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly NameError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify \_rebuild\_code.

The verifier did not have enough to check \_rebuild\_code, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `watch_path` is annotated `Path` — outside the synthesizable primitive/collection set

· 6 grounded finding(s) anchored inline below; 72 more finding(s) on lines outside this diff (see the check run).

Comment thread graphify/cli.py
return _gate(hyperedges, nodes)


def _prune_graph_json_sources(graph_path: Path, stale_sources: list[str]) -> int:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression_prune_graph_json_sources()

12 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/cli.py
main()


def dispatch_command(cmd: str) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressiondispatch_command()

fans out to 125 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/dedup.py
he["nodes"] = restored


def _remap_hyperedge_members(hyperedges: list[dict], remap: dict[str, str]) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression_remap_hyperedge_members()

12 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/dedup.py
hyperedges[:] = kept


def deduplicate_entities(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressiondeduplicate_entities()

fans out to 21 callees (efferent coupling); 68 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/export.py
@@ -264,6 +279,16 @@ def existing_graph_node_count(path: "str | Path"):


def to_json(G: nx.Graph, communities: dict[int, list[str]], output_path: str, *, force: bool = False, built_at_commit: str | None = None, community_labels: dict[int, str] | None = None) -> bool:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressionto_json()

fans out to 8 callees (efferent coupling); 59 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/watch.py
return kept


def _reconcile_existing_graph(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression_reconcile_existing_graph()

fans out to 8 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 5 advisory finding(s) below merit a look before merge.

Formal verification. 2 change(s) alter behavior, breaking input(s) attached.

Behavior changes: \_coerce\_hyperedge\_member\_refs changes behavior, here is the input that shows it.

The verifier found a concrete input on which \_coerce\_hyperedge\_member\_refs behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"he":"\{'n': 0, 's': 'x', 'l': \[1, 2\]\}","members":"\[1, 2, 3\]"\}, the old code produced \[1, 2, 3\] but the new code produces \['1', '2', '3'\]. Paste that input straight into a regression test.

Behavior changes: attach\_hyperedges changes behavior, here is the input that shows it.

The verifier found a concrete input on which attach\_hyperedges behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"G":"\(lambda \_g: \(\_g\.add\_nodes\_from\(\[\('a', \{'label': 'A', 'kind': 'fn'\}\), \('b', \{'label': 'B'\}\)\]\), \_g\.add\_edges\_from\(\[\('a', 'b', \{'weight': 2, 'kind': 'calls'\}\)\]\), \_g\)\[\-1\]\)\(\_\_import\_\_\('networkx'\)\.Graph\(\)\)","hyperedges":"\[0, 0, 0, 0\]"\}, the old code produced raises AttributeError but the new code produces None. Paste that input straight into a regression test.


Graphify review — findings

Consolidates every writer's hyperedge validation into one shared gate that canonicalizes a hyperedge's shape (canonical_hyperedge), bridges the id spaces members and nodes may live in (_id_map, node_id_map, node_id_set), and filters members by membership while returning them in the caller's own id space (gate_hyperedges, gate_hyperedges_against_graph). Enforces a group as 3+ distinct usable members — coercing members/node_ids aliases and numeric/case-drifted refs, deduping refs that collapse onto one node, and rejecting null, empty, boolean, and unhashable member refs via one shared _is_usable_member_ref rule so the semantic cache no longer stamps a padded group as valid only to drop it on replay. Boundaries with no node set (the cache, the pre-manifest-stamp gate) pass None and get shape-and-cardinality checks only, with members left in coerced form.

Worth a look

  • Hyperedge cardinality tightened from >=1 to MIN_HYPEREDGE_MEMBERS silently drops previously-valid single/pair hyperedgesgraphify/build.py:1571 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • deduplicate_entities members coerced to strings while node ids stay numeric, producing dangling referencesgraphify/dedup.py · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Top-level build import can break fresh module importsgraphify/dedup.py:15 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Top-level import from build creates a circular importgraphify/dedup.py:15 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Empty normalized id can incorrectly resolve unrelated hyperedge membersgraphify/build.py:148 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 3438 functions depend on the 1677 functions this change touches.

Health — this change adds coupling hotspots:

  • new: extract() — 520 callers, 43 callees
  • new: _rebuild_code() — 114 callers, 51 callees
  • new: build_from_json() — 195 callers, 18 callees
  • new: detect() — 108 callers, 15 callees
  • new: deduplicate_entities() — 68 callers, 21 callees
  • new: build_merge() — 65 callers, 14 callees
  • new: save_semantic_cache() — 65 callers, 12 callees
  • new: to_json() — 59 callers, 8 callees
  • …and 70 more — each is listed as a finding

Verification — 3438 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 2925 function(s) in the blast radius were not formally verified this run

Formal verification

Behavior changes: \_coerce\_hyperedge\_member\_refs changes behavior, here is the input that shows it.

The verifier found a concrete input on which \_coerce\_hyperedge\_member\_refs behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"he":"\{'n': 0, 's': 'x', 'l': \[1, 2\]\}","members":"\[1, 2, 3\]"\}, the old code produced \[1, 2, 3\] but the new code produces \['1', '2', '3'\]. Paste that input straight into a regression test.

Behavior changes: attach\_hyperedges changes behavior, here is the input that shows it.

The verifier found a concrete input on which attach\_hyperedges behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"G":"\(lambda \_g: \(\_g\.add\_nodes\_from\(\[\('a', \{'label': 'A', 'kind': 'fn'\}\), \('b', \{'label': 'B'\}\)\]\), \_g\.add\_edges\_from\(\[\('a', 'b', \{'weight': 2, 'kind': 'calls'\}\)\]\), \_g\)\[\-1\]\)\(\_\_import\_\_\('networkx'\)\.Graph\(\)\)","hyperedges":"\[0, 0, 0, 0\]"\}, the old code produced raises AttributeError but the new code produces None. Paste that input straight into a regression test.

Could not verify: Could not verify build.

The verifier did not have enough to check build, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 9 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly AttributeError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify build\_from\_json.

The verifier did not have enough to check build\_from\_json, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 6 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly NameError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify build\_merge.

The verifier did not have enough to check build\_merge, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `graph_path` is annotated `str | Path | None` — outside the synthesizable primitive/collection set

Could not verify: Could not verify prefix\_graph\_for\_global.

The verifier did not have enough to check prefix\_graph\_for\_global, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 200 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous

Could not verify: Could not verify check\_semantic\_cache.

The verifier did not have enough to check check\_semantic\_cache, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify save\_semantic\_cache.

The verifier did not have enough to check save\_semantic\_cache, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify dispatch\_command.

The verifier did not have enough to check dispatch\_command, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 23 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly SystemExit — names the real obstacle, not a sampling gap)

Could not verify: Could not verify \_prune\_graph\_json\_sources.

The verifier did not have enough to check \_prune\_graph\_json\_sources, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `graph_path` is annotated `Path` — outside the synthesizable primitive/collection set

No difference found (not proven): No behavior difference found in \_reenter\_main (not a proof).

The verifier ran both versions of \_reenter\_main on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

Could not verify: Could not verify deduplicate\_entities.

The verifier did not have enough to check deduplicate\_entities, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 9 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly TypeError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify \_remap\_hyperedge\_members.

The verifier did not have enough to check \_remap\_hyperedge\_members, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: the input domain has 54 values but only 18 distinct were tested — a small finite domain must be EXHAUSTED, not sampled (an untested input could invert the result)

No difference found (not proven): No behavior difference found in to\_json (not a proof).

The verifier ran both versions of to\_json on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

Could not verify: Could not verify sanitize\_semantic\_fragment.

The verifier did not have enough to check sanitize\_semantic\_fragment, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 6 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly NameError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify \_rebuild\_code.

The verifier did not have enough to check \_rebuild\_code, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `watch_path` is annotated `Path` — outside the synthesizable primitive/collection set

· 6 grounded finding(s) anchored inline below; 72 more finding(s) on lines outside this diff (see the check run).

Comment thread graphify/cli.py
return _gate(hyperedges, nodes)


def _prune_graph_json_sources(graph_path: Path, stale_sources: list[str]) -> int:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression_prune_graph_json_sources()

13 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/cli.py
main()


def dispatch_command(cmd: str) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressiondispatch_command()

fans out to 125 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/dedup.py
he["nodes"] = restored


def _remap_hyperedge_members(hyperedges: list[dict], remap: dict[str, str]) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression_remap_hyperedge_members()

12 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/dedup.py
hyperedges[:] = kept


def deduplicate_entities(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressiondeduplicate_entities()

fans out to 21 callees (efferent coupling); 68 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/export.py
@@ -264,6 +279,16 @@ def existing_graph_node_count(path: "str | Path"):


def to_json(G: nx.Graph, communities: dict[int, list[str]], output_path: str, *, force: bool = False, built_at_commit: str | None = None, community_labels: dict[int, str] | None = None) -> bool:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressionto_json()

fans out to 8 callees (efferent coupling); 59 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/watch.py
return kept


def _reconcile_existing_graph(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression_reconcile_existing_graph()

fans out to 8 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 5 advisory finding(s) below merit a look before merge.

Formal verification. 2 change(s) alter behavior, breaking input(s) attached.

Behavior changes: \_coerce\_hyperedge\_member\_refs changes behavior, here is the input that shows it.

The verifier found a concrete input on which \_coerce\_hyperedge\_member\_refs behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"he":"\{'n': 0, 's': 'x', 'l': \[1, 2\]\}","members":"\[1, 2, 3\]"\}, the old code produced \[1, 2, 3\] but the new code produces \['1', '2', '3'\]. Paste that input straight into a regression test.

Behavior changes: attach\_hyperedges changes behavior, here is the input that shows it.

The verifier found a concrete input on which attach\_hyperedges behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"G":"\(lambda \_g: \(\_g\.add\_nodes\_from\(\[\('a', \{'label': 'A', 'kind': 'fn'\}\), \('b', \{'label': 'B'\}\)\]\), \_g\.add\_edges\_from\(\[\('a', 'b', \{'weight': 2, 'kind': 'calls'\}\)\]\), \_g\)\[\-1\]\)\(\_\_import\_\_\('networkx'\)\.Graph\(\)\)","hyperedges":"\[0, 0, 0, 0\]"\}, the old code produced raises AttributeError but the new code produces None. Paste that input straight into a regression test.


Graphify review — findings

Introduces a single shared hyperedge-gating layer that canonicalizes group edges at every persistence boundary: canonical_hyperedge folds members/node_ids aliases, coerces and dedupes member refs, and rejects anything with fewer than three distinct usable members, while gate_hyperedges and gate_hyperedges_against_graph additionally filter members against a node set. Bridges the numeric/string and normalized id spaces via _id_map so members are compared in a coerced space but written back in the caller's own ids, avoiding both O(nodes×hyperedges) rescans and dangling references in written graph files. Unifies the usable-member test in _is_usable_member_ref — one rule for bare and object refs — so junk members can no longer pad a pair up to a cached-and-stamped "valid" group that then evicts to nothing on replay.

Worth a look

  • Two-member hyperedges are now rejected by the public canonicalization pathgraphify/build.py:50 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • build_from_json now drops previously legal singleton/pair hyperedgesgraphify/build.py:1568 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • build_from_json now drops previously-legal single-member hyperedges (contract change)graphify/build.py:1571 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • _remap_hyperedge_members coerces numeric member ids to strings while node records keep intsgraphify/dedup.py · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Top-level build import can create an import cyclegraphify/dedup.py:15 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 3442 functions depend on the 1681 functions this change touches.

Health — this change adds coupling hotspots:

  • new: extract() — 520 callers, 43 callees
  • new: _rebuild_code() — 114 callers, 51 callees
  • new: build_from_json() — 195 callers, 18 callees
  • new: detect() — 108 callers, 15 callees
  • new: deduplicate_entities() — 68 callers, 21 callees
  • new: build_merge() — 65 callers, 14 callees
  • new: save_semantic_cache() — 65 callers, 12 callees
  • new: to_json() — 59 callers, 8 callees
  • …and 70 more — each is listed as a finding

Verification — 3442 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 2929 function(s) in the blast radius were not formally verified this run

Formal verification

Behavior changes: \_coerce\_hyperedge\_member\_refs changes behavior, here is the input that shows it.

The verifier found a concrete input on which \_coerce\_hyperedge\_member\_refs behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"he":"\{'n': 0, 's': 'x', 'l': \[1, 2\]\}","members":"\[1, 2, 3\]"\}, the old code produced \[1, 2, 3\] but the new code produces \['1', '2', '3'\]. Paste that input straight into a regression test.

Behavior changes: attach\_hyperedges changes behavior, here is the input that shows it.

The verifier found a concrete input on which attach\_hyperedges behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"G":"\(lambda \_g: \(\_g\.add\_nodes\_from\(\[\('a', \{'label': 'A', 'kind': 'fn'\}\), \('b', \{'label': 'B'\}\)\]\), \_g\.add\_edges\_from\(\[\('a', 'b', \{'weight': 2, 'kind': 'calls'\}\)\]\), \_g\)\[\-1\]\)\(\_\_import\_\_\('networkx'\)\.Graph\(\)\)","hyperedges":"\[0, 0, 0, 0\]"\}, the old code produced raises AttributeError but the new code produces None. Paste that input straight into a regression test.

Could not verify: Could not verify build.

The verifier did not have enough to check build, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 9 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly AttributeError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify build\_from\_json.

The verifier did not have enough to check build\_from\_json, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 6 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly NameError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify build\_merge.

The verifier did not have enough to check build\_merge, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `graph_path` is annotated `str | Path | None` — outside the synthesizable primitive/collection set

Could not verify: Could not verify prefix\_graph\_for\_global.

The verifier did not have enough to check prefix\_graph\_for\_global, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 200 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous

Could not verify: Could not verify check\_semantic\_cache.

The verifier did not have enough to check check\_semantic\_cache, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify save\_semantic\_cache.

The verifier did not have enough to check save\_semantic\_cache, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify dispatch\_command.

The verifier did not have enough to check dispatch\_command, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 23 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly SystemExit — names the real obstacle, not a sampling gap)

Could not verify: Could not verify \_prune\_graph\_json\_sources.

The verifier did not have enough to check \_prune\_graph\_json\_sources, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `graph_path` is annotated `Path` — outside the synthesizable primitive/collection set

No difference found (not proven): No behavior difference found in \_reenter\_main (not a proof).

The verifier ran both versions of \_reenter\_main on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

Could not verify: Could not verify deduplicate\_entities.

The verifier did not have enough to check deduplicate\_entities, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 9 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly TypeError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify \_remap\_hyperedge\_members.

The verifier did not have enough to check \_remap\_hyperedge\_members, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: the input domain has 54 values but only 18 distinct were tested — a small finite domain must be EXHAUSTED, not sampled (an untested input could invert the result)

No difference found (not proven): No behavior difference found in to\_json (not a proof).

The verifier ran both versions of to\_json on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

Could not verify: Could not verify sanitize\_semantic\_fragment.

The verifier did not have enough to check sanitize\_semantic\_fragment, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 6 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly NameError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify \_rebuild\_code.

The verifier did not have enough to check \_rebuild\_code, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `watch_path` is annotated `Path` — outside the synthesizable primitive/collection set

· 6 grounded finding(s) anchored inline below; 72 more finding(s) on lines outside this diff (see the check run).

Comment thread graphify/cli.py
return _gate(hyperedges, nodes)


def _prune_graph_json_sources(graph_path: Path, stale_sources: list[str]) -> int:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression_prune_graph_json_sources()

13 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/cli.py
main()


def dispatch_command(cmd: str) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressiondispatch_command()

fans out to 125 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/dedup.py
he["nodes"] = restored


def _remap_hyperedge_members(hyperedges: list[dict], remap: dict[str, str]) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression_remap_hyperedge_members()

12 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/dedup.py
hyperedges[:] = kept


def deduplicate_entities(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressiondeduplicate_entities()

fans out to 21 callees (efferent coupling); 68 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/export.py
@@ -264,6 +279,16 @@ def existing_graph_node_count(path: "str | Path"):


def to_json(G: nx.Graph, communities: dict[int, list[str]], output_path: str, *, force: bool = False, built_at_commit: str | None = None, community_labels: dict[int, str] | None = None) -> bool:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressionto_json()

fans out to 8 callees (efferent coupling); 59 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/watch.py
return kept


def _reconcile_existing_graph(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression_reconcile_existing_graph()

fans out to 8 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 5 advisory finding(s) below merit a look before merge.

Formal verification. 2 change(s) alter behavior, breaking input(s) attached.

Behavior changes: \_coerce\_hyperedge\_member\_refs changes behavior, here is the input that shows it.

The verifier found a concrete input on which \_coerce\_hyperedge\_member\_refs behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"he":"\{'n': 0, 's': 'x', 'l': \[1, 2\]\}","members":"\[1, 2, 3\]"\}, the old code produced \[1, 2, 3\] but the new code produces \['1', '2', '3'\]. Paste that input straight into a regression test.

Behavior changes: attach\_hyperedges changes behavior, here is the input that shows it.

The verifier found a concrete input on which attach\_hyperedges behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"G":"\(lambda \_g: \(\_g\.add\_nodes\_from\(\[\('a', \{'label': 'A', 'kind': 'fn'\}\), \('b', \{'label': 'B'\}\)\]\), \_g\.add\_edges\_from\(\[\('a', 'b', \{'weight': 2, 'kind': 'calls'\}\)\]\), \_g\)\[\-1\]\)\(\_\_import\_\_\('networkx'\)\.Graph\(\)\)","hyperedges":"\[0, 0, 0, 0\]"\}, the old code produced raises AttributeError but the new code produces None. Paste that input straight into a regression test.


Graphify review — findings

Adds a single canonicalization pipeline for hyperedges in build.py, centered on canonical_hyperedge, gate_hyperedges, and gate_hyperedges_against_graph, so every persistence boundary (raw writers, watch reconciliation, semantic cache, cross-repo prefixing, pre-manifest gate) enforces one rule: a hyperedge survives only with 3+ distinct members that resolve to real nodes. Bridges the two id spaces via _id_map/_member_keys/resolve_member_ref, comparing members in a coerced/normalized space while writing survivors back in the container's own ids, which heals numeric ids, casing/punctuation drift, and duplicate/collapsed members that previously inflated a pair into an apparent group or dropped valid groups. Gating builds the id map once per container so the check stays linear on large merged corpora, and returns the drop count rather than logging so each call site can word its own message.

Worth a look

  • Top-level build import introduces circular module initializationgraphify/dedup.py:15 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • New top-level build import can break public module imports via a circular dependencygraphify/dedup.py:16 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • member_in_id_space / resolve_member_ref skip normalized fallback for coerced non-string membersgraphify/build.py · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Unhashable unresolved hyperedge member can crash dedupgraphify/build.py · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • build_from_json now drops previously legal singleton/pair hyperedgesgraphify/build.py:1618 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 3464 functions depend on the 1703 functions this change touches.

Health — this change adds coupling hotspots:

  • new: extract() — 520 callers, 43 callees
  • new: _rebuild_code() — 114 callers, 51 callees
  • new: build_from_json() — 195 callers, 18 callees
  • new: detect() — 108 callers, 15 callees
  • new: deduplicate_entities() — 68 callers, 21 callees
  • new: build_merge() — 65 callers, 14 callees
  • new: save_semantic_cache() — 66 callers, 13 callees
  • new: to_json() — 59 callers, 8 callees
  • …and 70 more — each is listed as a finding

Verification — 3464 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 2951 function(s) in the blast radius were not formally verified this run

Formal verification

Behavior changes: \_coerce\_hyperedge\_member\_refs changes behavior, here is the input that shows it.

The verifier found a concrete input on which \_coerce\_hyperedge\_member\_refs behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"he":"\{'n': 0, 's': 'x', 'l': \[1, 2\]\}","members":"\[1, 2, 3\]"\}, the old code produced \[1, 2, 3\] but the new code produces \['1', '2', '3'\]. Paste that input straight into a regression test.

Behavior changes: attach\_hyperedges changes behavior, here is the input that shows it.

The verifier found a concrete input on which attach\_hyperedges behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"G":"\(lambda \_g: \(\_g\.add\_nodes\_from\(\[\('a', \{'label': 'A', 'kind': 'fn'\}\), \('b', \{'label': 'B'\}\)\]\), \_g\.add\_edges\_from\(\[\('a', 'b', \{'weight': 2, 'kind': 'calls'\}\)\]\), \_g\)\[\-1\]\)\(\_\_import\_\_\('networkx'\)\.Graph\(\)\)","hyperedges":"\[0, 0, 0, 0\]"\}, the old code produced raises AttributeError but the new code produces None. Paste that input straight into a regression test.

Could not verify: Could not verify build.

The verifier did not have enough to check build, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 9 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly AttributeError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify build\_from\_json.

The verifier did not have enough to check build\_from\_json, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 6 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly NameError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify build\_merge.

The verifier did not have enough to check build\_merge, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `graph_path` is annotated `str | Path | None` — outside the synthesizable primitive/collection set

Could not verify: Could not verify prefix\_graph\_for\_global.

The verifier did not have enough to check prefix\_graph\_for\_global, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 200 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous

Could not verify: Could not verify check\_semantic\_cache.

The verifier did not have enough to check check\_semantic\_cache, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify save\_semantic\_cache.

The verifier did not have enough to check save\_semantic\_cache, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify scope\_semantic\_result.

The verifier did not have enough to check scope\_semantic\_result, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify dispatch\_command.

The verifier did not have enough to check dispatch\_command, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 23 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly SystemExit — names the real obstacle, not a sampling gap)

Could not verify: Could not verify \_prune\_graph\_json\_sources.

The verifier did not have enough to check \_prune\_graph\_json\_sources, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `graph_path` is annotated `Path` — outside the synthesizable primitive/collection set

No difference found (not proven): No behavior difference found in \_reenter\_main (not a proof).

The verifier ran both versions of \_reenter\_main on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

Could not verify: Could not verify deduplicate\_entities.

The verifier did not have enough to check deduplicate\_entities, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 9 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly TypeError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify \_remap\_hyperedge\_members.

The verifier did not have enough to check \_remap\_hyperedge\_members, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: the input domain has 54 values but only 18 distinct were tested — a small finite domain must be EXHAUSTED, not sampled (an untested input could invert the result)

No difference found (not proven): No behavior difference found in to\_json (not a proof).

The verifier ran both versions of to\_json on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

Could not verify: Could not verify sanitize\_semantic\_fragment.

The verifier did not have enough to check sanitize\_semantic\_fragment, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 6 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly NameError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify \_rebuild\_code.

The verifier did not have enough to check \_rebuild\_code, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `watch_path` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify \_reconcile\_existing\_graph.

The verifier did not have enough to check \_reconcile\_existing\_graph, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `existing_graph` is annotated `Path` — outside the synthesizable primitive/collection set

· 6 grounded finding(s) anchored inline below; 72 more finding(s) on lines outside this diff (see the check run).

Comment thread graphify/cli.py
return _gate(hyperedges, nodes)


def _prune_graph_json_sources(graph_path: Path, stale_sources: list[str]) -> int:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression_prune_graph_json_sources()

13 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/cli.py
main()


def dispatch_command(cmd: str) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressiondispatch_command()

fans out to 125 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/dedup.py
he["nodes"] = restored


def _remap_hyperedge_members(hyperedges: list[dict], remap: dict[str, str]) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression_remap_hyperedge_members()

12 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/dedup.py
hyperedges[:] = kept


def deduplicate_entities(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressiondeduplicate_entities()

fans out to 21 callees (efferent coupling); 68 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/export.py
@@ -264,6 +279,16 @@ def existing_graph_node_count(path: "str | Path"):


def to_json(G: nx.Graph, communities: dict[int, list[str]], output_path: str, *, force: bool = False, built_at_commit: str | None = None, community_labels: dict[int, str] | None = None) -> bool:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressionto_json()

fans out to 8 callees (efferent coupling); 59 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/watch.py
return kept


def _reconcile_existing_graph(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression_reconcile_existing_graph()

fans out to 11 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

…undary

Fixes Graphify-Labs#3297.

A hyperedge models a group relationship, so it needs at least three distinct,
resolvable members; a pair belongs in the ordinary edge set. Valid input could
degrade to a pair once unresolved members were pruned, and then persist in the
semantic cache and in graph.json.

Fixing it properly meant settling the SHAPE problem underneath first. Member
alias keys (members/node_ids), object members {"id": "a"}, duplicate ids,
numeric ids equivalent to their string form, and unusable ids (None, "",
booleans) all have to be resolved before a count can mean anything.

canonical_hyperedge is the shared gate: alias fold, member coercion, dedupe,
malformed-shape rejection, cardinality. Membership sits above it in
gate_hyperedges and gate_hyperedges_against_graph, because it needs the id-space
bridge, and one invariant must not have two membership rules.

The audit in the issue found twelve independent writers. Review found a
thirteenth (the git merge-driver) and a fourteenth site applies the gate on the
READ side, so a pre-gate cache entry cannot replay forever.

The id-space rule, which accounts for most of this diff. Member refs are
coerced so they can be compared at all, which means every id set they are
compared against must be coerced too, and whatever survives must be handed
back in the id space the caller is about to persist. Twelve places compared
member refs against node ids and each resolved slightly differently; some
dropped valid groups, some kept invalid ones. There is now one definition:
_member_keys yields the two lookup keys in priority order, _id_map bridges the
spaces, resolve_member_ref returns the raw id for callers writing members back
out, member_in_id_space answers yes/no for callers that only decide.
Duplicate-attribution decisions are made in the exact space before aliases are
added, and a member's own raw form wins over the map's choice, so a member is
never rebound from the node it named to one that merely collides with it.

Pre-existing defects this uncovered, each demonstrated against v8 (33362d9):

- semantic_cleanup filtered members with an exact test, so a ref the graph
  builder heals was removed and the group persisted as a PAIR — issue Graphify-Labs#3297
  itself, reached by a different trigger.
- prefix_graph_for_global left such a ref unprefixed while every node gained
  the repo:: prefix, writing a dangling group after a cross-repo merge.
- watch's reconciliation evicted a group whose members resolve.
- both cache dangling prunes failed to prune a group naming a node
  deliberately never written.
- watch's raw --no-cluster writer, to_json, the raw CLI block and the
  exclusion-only prune had no gate at all.

Defects introduced by this change and found in review, listed because they are
the argument for the structural note below rather than something to hide: the
gate returning coerced members into a raw id space (four writers); the cache's
skipped-node prune breaking once members were coerced; dedup's consolidated
exit coercing members where the old early return had skipped them; the id map
being rebuilt per candidate, O(nodes x hyperedges) on a merged corpus; a second
weaker membership rule left reachable only from tests; a non-dict graph value
raising AttributeError out of the prune; and two collision cases where distinct
ids share a coerced or normalized key.

Structurally: thirteen write boundaries plus one read boundary share a gate,
which is not the same as a single persistence funnel. Two of the fourteen were
found by reviewers rather than by the audit. A funnel is the durable fix and is
larger than this change.

Known shortcomings are documented in the pull request rather than papered over:
a dangling-member group still leaves a doc manifest-stamped and the Graphify-Labs#2927 heal
cannot clear it; membership is deliberately not validated before stamping or
caching, with measurements showing why; and a no-change --no-cluster run exits
before any gate, so a pre-upgrade pair already on disk is not migrated.

Verification: 5,319 passed, 56 skipped. Ruff clean over graphify/ and tests/.
CodeRabbit CLI reports no findings over the full diff. Docstring coverage 100%
across the 190 functions touched. Six local test_ollama failures are
environmental (an exported OPENAI_API_KEY with the openai package absent) and
reproduce identically on an untouched checkout; CI does not set that variable
and passes on 3.10 and 3.12.

Reviewed across many rounds by CodeRabbit, Codex and GitHub Copilot on
#3 and #6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@egarcia74
egarcia74 force-pushed the fix/hyperedge-minimum-cardinality branch from 6bdd145 to 8fbc8e0 Compare September 3, 2026 06:00
@egarcia74

Copy link
Copy Markdown
Contributor Author

Squashed to a single commit, 8fbc8e0, and I owe a correction on this PR's own description.

The squash. The branch had reached 33 commits, many of them fixing defects introduced by earlier commits on the same branch. That is not a series anyone should have to read. The content is unchanged — I verified it byte-identical to the 33-commit version before force-pushing. I did try a layered series first, and build.py on its own leaves 24 tests failing because the boundaries genuinely depend on the shared gate, so a multi-commit split would have shipped broken intermediate states. One honest commit beat five fabricated ones.

The correction. Earlier revisions of this description said eight of the twelve id-space defects were created by this change. That was wrong, and it overstated the damage. I checked out v8 (33362d9) and ran the cases directly:

semantic_cleanup     -> [{'id': 'g', 'nodes': ['b', 'c']}]      a two-member group PERSISTED
cross-repo prefixer  -> members unprefixed, group accepted       a dangling group WRITTEN
cache skipped prune  -> group cached with the unresolved ref     not pruned

The first of those is issue #3297 itself, reached through a different trigger. So roughly half of the twelve were pre-existing defects of the same family, surfaced because this change built the one shared rule that makes them visible — not regressions I introduced. The half that genuinely were mine are still listed in the description, unchanged; I would rather the count be right in both directions.

Two fixes in this push, both mine, both collision cases from expanding the shared key space with normalized lookup keys — the second round of that class:

  • the cache's duplicate-attribution subtraction conflated a skipped node with a distinct written node sharing its normalized key, so a member naming the skipped node went undetected;
  • with distinct nodes 7 and "7", a member 7 was restored onto the string node — silently rebound from the node it named, which is worse than dangling because it looks correct.

Both are decided in the exact id space now, before aliases exist, and a member's own raw form wins over the id map's choice.

Where I am drawing the line. From here I will only change this branch for a defect in issue #3297's family or a live crash. Anything else I will file as a follow-up issue and name here rather than growing this PR further — it is already 898 production lines for a bug whose original fix was 85, and reviewability is now the binding constraint rather than correctness. If a third round of collision cases appears, I think the right response is to reconsider the normalized-resolution feature as a whole rather than keep patching its edges, and I would rather hear a maintainer's view on that than decide it unilaterally.

Verification on 8fbc8e0: 5,319 passed / 56 skipped, ruff clean over graphify/ and tests/, CodeRabbit CLI no findings over the full diff, docstring coverage 100% across the 190 functions touched. The 6 local test_ollama failures remain environmental and reproduce on an untouched checkout.

🤖 Generated with Claude Code

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 5 advisory finding(s) below merit a look before merge.

Formal verification. 2 change(s) alter behavior, breaking input(s) attached.

Behavior changes: \_coerce\_hyperedge\_member\_refs changes behavior, here is the input that shows it.

The verifier found a concrete input on which \_coerce\_hyperedge\_member\_refs behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"he":"\{'n': 0, 's': 'x', 'l': \[1, 2\]\}","members":"\[1, 2, 3\]"\}, the old code produced \[1, 2, 3\] but the new code produces \['1', '2', '3'\]. Paste that input straight into a regression test.

Behavior changes: attach\_hyperedges changes behavior, here is the input that shows it.

The verifier found a concrete input on which attach\_hyperedges behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"G":"\(lambda \_g: \(\_g\.add\_nodes\_from\(\[\('a', \{'label': 'A', 'kind': 'fn'\}\), \('b', \{'label': 'B'\}\)\]\), \_g\.add\_edges\_from\(\[\('a', 'b', \{'weight': 2, 'kind': 'calls'\}\)\]\), \_g\)\[\-1\]\)\(\_\_import\_\_\('networkx'\)\.Graph\(\)\)","hyperedges":"\[0, 0, 0, 0\]"\}, the old code produced raises AttributeError but the new code produces None. Paste that input straight into a regression test.


Graphify review — findings

Consolidates hyperedge validation into a single set of helpers in build.py that canonicalize a hyperedge's shape, dedupe/normalize its members, and reject anything with fewer than three distinct members via canonical_hyperedge, gate_hyperedges, and _gate. Bridges the two id spaces so member refs (coerced, or normalized-for-drift) resolve against node records through _id_map/resolve_member_ref/member_in_id_space, and returns survivors in the caller's own id space so raw --no-cluster writers persist ids that match their nodes. Reuses this one rule across the writers' gate, the cross-repo prefixer, semantic-cache fragments, watch reconciliation, and the skipped-node prune, replacing per-site membership checks that had drifted out of step.

Worth a look

  • Unterminated triple-quoted docstring makes module unparsablegraphify/build.py:284 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Unterminated docstring breaks module parsinggraphify/build.py:287 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • gate_hyperedges_against_graph raises MIN_HYPEREDGE_MEMBERS-based drop on carried hyperedges that previously survived as singletonsgraphify/build.py:1611 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • _remap_hyperedge_members now drops hyperedges below MIN_HYPEREDGE_MEMBERS even when no dedup occurredgraphify/dedup.py · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Top-level build import can create a circular importgraphify/dedup.py:15 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 3473 functions depend on the 1712 functions this change touches.

Health — this change adds coupling hotspots:

  • new: extract() — 520 callers, 43 callees
  • new: _rebuild_code() — 114 callers, 51 callees
  • new: build_from_json() — 195 callers, 18 callees
  • new: detect() — 108 callers, 15 callees
  • new: deduplicate_entities() — 69 callers, 21 callees
  • new: save_semantic_cache() — 67 callers, 14 callees
  • new: build_merge() — 65 callers, 14 callees
  • new: to_json() — 59 callers, 8 callees
  • …and 70 more — each is listed as a finding

Verification — 3473 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 2960 function(s) in the blast radius were not formally verified this run

Formal verification

Behavior changes: \_coerce\_hyperedge\_member\_refs changes behavior, here is the input that shows it.

The verifier found a concrete input on which \_coerce\_hyperedge\_member\_refs behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"he":"\{'n': 0, 's': 'x', 'l': \[1, 2\]\}","members":"\[1, 2, 3\]"\}, the old code produced \[1, 2, 3\] but the new code produces \['1', '2', '3'\]. Paste that input straight into a regression test.

Behavior changes: attach\_hyperedges changes behavior, here is the input that shows it.

The verifier found a concrete input on which attach\_hyperedges behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"G":"\(lambda \_g: \(\_g\.add\_nodes\_from\(\[\('a', \{'label': 'A', 'kind': 'fn'\}\), \('b', \{'label': 'B'\}\)\]\), \_g\.add\_edges\_from\(\[\('a', 'b', \{'weight': 2, 'kind': 'calls'\}\)\]\), \_g\)\[\-1\]\)\(\_\_import\_\_\('networkx'\)\.Graph\(\)\)","hyperedges":"\[0, 0, 0, 0\]"\}, the old code produced raises AttributeError but the new code produces None. Paste that input straight into a regression test.

Could not verify: Could not verify build.

The verifier did not have enough to check build, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 9 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly AttributeError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify build\_from\_json.

The verifier did not have enough to check build\_from\_json, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 6 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly NameError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify build\_merge.

The verifier did not have enough to check build\_merge, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `graph_path` is annotated `str | Path | None` — outside the synthesizable primitive/collection set

Could not verify: Could not verify prefix\_graph\_for\_global.

The verifier did not have enough to check prefix\_graph\_for\_global, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 200 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous

Could not verify: Could not verify check\_semantic\_cache.

The verifier did not have enough to check check\_semantic\_cache, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify save\_semantic\_cache.

The verifier did not have enough to check save\_semantic\_cache, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify scope\_semantic\_result.

The verifier did not have enough to check scope\_semantic\_result, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify dispatch\_command.

The verifier did not have enough to check dispatch\_command, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 23 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly SystemExit — names the real obstacle, not a sampling gap)

Could not verify: Could not verify \_prune\_graph\_json\_sources.

The verifier did not have enough to check \_prune\_graph\_json\_sources, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `graph_path` is annotated `Path` — outside the synthesizable primitive/collection set

No difference found (not proven): No behavior difference found in \_reenter\_main (not a proof).

The verifier ran both versions of \_reenter\_main on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

Could not verify: Could not verify deduplicate\_entities.

The verifier did not have enough to check deduplicate\_entities, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 9 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly TypeError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify \_remap\_hyperedge\_members.

The verifier did not have enough to check \_remap\_hyperedge\_members, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: the input domain has 54 values but only 18 distinct were tested — a small finite domain must be EXHAUSTED, not sampled (an untested input could invert the result)

No difference found (not proven): No behavior difference found in to\_json (not a proof).

The verifier ran both versions of to\_json on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

Could not verify: Could not verify sanitize\_semantic\_fragment.

The verifier did not have enough to check sanitize\_semantic\_fragment, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 6 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly NameError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify \_rebuild\_code.

The verifier did not have enough to check \_rebuild\_code, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `watch_path` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify \_reconcile\_existing\_graph.

The verifier did not have enough to check \_reconcile\_existing\_graph, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `existing_graph` is annotated `Path` — outside the synthesizable primitive/collection set

· 7 grounded finding(s) anchored inline below; 71 more finding(s) on lines outside this diff (see the check run).

Comment thread graphify/cache.py
return out


def scope_semantic_result(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressionscope_semantic_result()

fans out to 9 callees (efferent coupling); 8 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/cli.py
return _gate(hyperedges, nodes)


def _prune_graph_json_sources(graph_path: Path, stale_sources: list[str]) -> int:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression_prune_graph_json_sources()

13 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/cli.py
main()


def dispatch_command(cmd: str) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressiondispatch_command()

fans out to 125 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/dedup.py
he["nodes"] = restored


def _remap_hyperedge_members(hyperedges: list[dict], remap: dict[str, str]) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression_remap_hyperedge_members()

12 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/dedup.py
hyperedges[:] = kept


def deduplicate_entities(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressiondeduplicate_entities()

fans out to 21 callees (efferent coupling); 69 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/export.py
@@ -264,6 +279,16 @@ def existing_graph_node_count(path: "str | Path"):


def to_json(G: nx.Graph, communities: dict[int, list[str]], output_path: str, *, force: bool = False, built_at_commit: str | None = None, community_labels: dict[int, str] | None = None) -> bool:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressionto_json()

fans out to 8 callees (efferent coupling); 59 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/watch.py
return kept


def _reconcile_existing_graph(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression_reconcile_existing_graph()

fans out to 11 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

egarcia74 and others added 2 commits September 3, 2026 20:24
`_finish` captures each hyperedge's raw member forms BEFORE the remap, so a
member can be restored onto the node it actually named rather than whichever
node the id map prefers for its coerced key. That capture was indexed by
position — and `_remap_hyperedge_members`, which runs in between, drops
undersized groups and rewrites the list in place with `hyperedges[:] = kept`.

After the first drop every surviving group was therefore restored through a
DIFFERENT group's capture. With distinct nodes `7` and `"7"` present, a group
naming the string node was resolved through a dropped group's `"7" -> 7` entry
and silently rebound to the int node — reintroducing exactly the failure the
capture exists to prevent, and the worse half of it: not a dangling member, a
wrong one that looks correct.

Keyed by `id(he)` instead. The entries are mutated in place, so a survivor
keeps its identity across the remap; a dropped entry leaves a key nothing ever
looks up, because the map is only queried for an entry still in the list.

Reachable from the public `deduplicate_entities(..., hyperedges=)` API, which
the new test drives; it needs one group that falls under the minimum ahead of
one that survives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`member_in_id_space` is the yes/no half of the shared gate, and its three
callers read members straight off a persisted group that has NOT been through
`_normalize_hyperedge_members` — watch's reconciliation drop reads
`edge["nodes"]` verbatim out of graph.json. So both member shapes the feature
tolerates arrived raw, and `_member_keys` assumed a caller had already
canonicalized:

- an object-shaped member (`{"id": "a"}`) is unhashable, so it named nothing
  and watch DELETED, on an unrelated rebuild, a group every one of whose
  members is alive. `_coerce_hyperedge_member_refs` exists precisely because
  backends emit that shape, so this is a live loss of valid content;
- an uncoerced numeric member missed the `_coerce_id`-coerced key
  `node_id_set` holds, so the cache's skipped-node prune left a group
  referencing a node deliberately not written.

`canonical_member_ref` unwraps an object member to its `id` and coerces, and
`member_in_id_space` applies it to its own argument. An id-less object
collapses to `None`, which resolves to nothing — canonicalizing must not
invent a member. It is idempotent, so the two callers that pre-coerced are
unaffected; both now pass the member as emitted, and watch's `_coerce_member`
seam is gone with its only caller's need for it.

The watch test drives the real `_rebuild_code` path end to end: the group
survives, and the writers' gate flattens its members to bare ids on the way
out, so the persisted shape stays canonical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 5 advisory finding(s) below merit a look before merge.

Formal verification. 2 change(s) alter behavior, breaking input(s) attached.

Behavior changes: \_coerce\_hyperedge\_member\_refs changes behavior, here is the input that shows it.

The verifier found a concrete input on which \_coerce\_hyperedge\_member\_refs behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"he":"\{'n': 0, 's': 'x', 'l': \[1, 2\]\}","members":"\[1, 2, 3\]"\}, the old code produced \[1, 2, 3\] but the new code produces \['1', '2', '3'\]. Paste that input straight into a regression test.

Behavior changes: attach\_hyperedges changes behavior, here is the input that shows it.

The verifier found a concrete input on which attach\_hyperedges behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"G":"\(lambda \_g: \(\_g\.add\_nodes\_from\(\[\('a', \{'label': 'A', 'kind': 'fn'\}\), \('b', \{'label': 'B'\}\)\]\), \_g\.add\_edges\_from\(\[\('a', 'b', \{'weight': 2, 'kind': 'calls'\}\)\]\), \_g\)\[\-1\]\)\(\_\_import\_\_\('networkx'\)\.Graph\(\)\)","hyperedges":"\[0, 0, 0, 0\]"\}, the old code produced raises AttributeError but the new code produces None. Paste that input straight into a regression test.


Graphify review — findings

Introduces a single canonical model for hyperedges centered on canonical_hyperedge, which normalizes member shapes (aliases, wrapped objects, numeric coercion) on a shallow copy and keeps only groups with 3+ distinct usable members, treating pairwise relationships as ordinary edges. Consolidates all membership decisions onto one lookup order via _member_keys, _id_map/node_id_map/node_id_set and helpers like resolve_member_ref, member_in_id_space and canonical_member_ref, so writers, the cross-repo prefixer, the semantic-fragment filter, watch reconciliation and the cache prune compare member refs against node ids in one coerced-plus-normalized space and write survivors back in the container's own ids. Bridges numeric and casing/punctuation-drifted ids with an exact-match-wins alias layer (disable-able via normalized=False for set-vs-set comparisons), and _gate takes a prebuilt id map to keep gating linear rather than O(nodes × hyperedges).

Worth a look

  • resolve_member_ref lookup key mismatch with _coerced_relabel keysgraphify/build.py · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • build_from_json now drops single-member hyperedges that were previously legalgraphify/build.py:1655 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Top-level import from build creates a circular import with build's dedup dependencygraphify/dedup.py:15 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Top-level import from graphify.build can make graphify.build/graphify.dedup unimportable via a circular dependencygraphify/dedup.py:15 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • canonical_hyperedge dedups members but count uses raw list length in helpergraphify/build.py · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 3483 functions depend on the 1722 functions this change touches.

Health — this change adds coupling hotspots:

  • new: extract() — 520 callers, 43 callees
  • new: _rebuild_code() — 115 callers, 51 callees
  • new: build_from_json() — 195 callers, 18 callees
  • new: detect() — 108 callers, 15 callees
  • new: deduplicate_entities() — 70 callers, 21 callees
  • new: save_semantic_cache() — 67 callers, 14 callees
  • new: build_merge() — 65 callers, 14 callees
  • new: to_json() — 59 callers, 8 callees
  • …and 70 more — each is listed as a finding

Verification — 3483 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 2970 function(s) in the blast radius were not formally verified this run

Formal verification

Behavior changes: \_coerce\_hyperedge\_member\_refs changes behavior, here is the input that shows it.

The verifier found a concrete input on which \_coerce\_hyperedge\_member\_refs behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"he":"\{'n': 0, 's': 'x', 'l': \[1, 2\]\}","members":"\[1, 2, 3\]"\}, the old code produced \[1, 2, 3\] but the new code produces \['1', '2', '3'\]. Paste that input straight into a regression test.

Behavior changes: attach\_hyperedges changes behavior, here is the input that shows it.

The verifier found a concrete input on which attach\_hyperedges behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"G":"\(lambda \_g: \(\_g\.add\_nodes\_from\(\[\('a', \{'label': 'A', 'kind': 'fn'\}\), \('b', \{'label': 'B'\}\)\]\), \_g\.add\_edges\_from\(\[\('a', 'b', \{'weight': 2, 'kind': 'calls'\}\)\]\), \_g\)\[\-1\]\)\(\_\_import\_\_\('networkx'\)\.Graph\(\)\)","hyperedges":"\[0, 0, 0, 0\]"\}, the old code produced raises AttributeError but the new code produces None. Paste that input straight into a regression test.

Could not verify: Could not verify build.

The verifier did not have enough to check build, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 9 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly AttributeError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify build\_from\_json.

The verifier did not have enough to check build\_from\_json, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 6 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly NameError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify build\_merge.

The verifier did not have enough to check build\_merge, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `graph_path` is annotated `str | Path | None` — outside the synthesizable primitive/collection set

Could not verify: Could not verify prefix\_graph\_for\_global.

The verifier did not have enough to check prefix\_graph\_for\_global, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 200 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous

Could not verify: Could not verify check\_semantic\_cache.

The verifier did not have enough to check check\_semantic\_cache, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify save\_semantic\_cache.

The verifier did not have enough to check save\_semantic\_cache, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify scope\_semantic\_result.

The verifier did not have enough to check scope\_semantic\_result, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify dispatch\_command.

The verifier did not have enough to check dispatch\_command, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 23 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly SystemExit — names the real obstacle, not a sampling gap)

Could not verify: Could not verify \_prune\_graph\_json\_sources.

The verifier did not have enough to check \_prune\_graph\_json\_sources, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `graph_path` is annotated `Path` — outside the synthesizable primitive/collection set

No difference found (not proven): No behavior difference found in \_reenter\_main (not a proof).

The verifier ran both versions of \_reenter\_main on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

Could not verify: Could not verify deduplicate\_entities.

The verifier did not have enough to check deduplicate\_entities, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 9 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly TypeError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify \_remap\_hyperedge\_members.

The verifier did not have enough to check \_remap\_hyperedge\_members, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: the input domain has 54 values but only 18 distinct were tested — a small finite domain must be EXHAUSTED, not sampled (an untested input could invert the result)

No difference found (not proven): No behavior difference found in to\_json (not a proof).

The verifier ran both versions of to\_json on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

Could not verify: Could not verify sanitize\_semantic\_fragment.

The verifier did not have enough to check sanitize\_semantic\_fragment, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 6 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly NameError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify \_rebuild\_code.

The verifier did not have enough to check \_rebuild\_code, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `watch_path` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify \_reconcile\_existing\_graph.

The verifier did not have enough to check \_reconcile\_existing\_graph, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `existing_graph` is annotated `Path` — outside the synthesizable primitive/collection set

· 7 grounded finding(s) anchored inline below; 71 more finding(s) on lines outside this diff (see the check run).

Comment thread graphify/cache.py
return out


def scope_semantic_result(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressionscope_semantic_result()

fans out to 9 callees (efferent coupling); 8 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/cli.py
return _gate(hyperedges, nodes)


def _prune_graph_json_sources(graph_path: Path, stale_sources: list[str]) -> int:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression_prune_graph_json_sources()

13 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/cli.py
main()


def dispatch_command(cmd: str) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressiondispatch_command()

fans out to 125 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/dedup.py
he["nodes"] = restored


def _remap_hyperedge_members(hyperedges: list[dict], remap: dict[str, str]) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression_remap_hyperedge_members()

12 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/dedup.py
hyperedges[:] = kept


def deduplicate_entities(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressiondeduplicate_entities()

fans out to 21 callees (efferent coupling); 70 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/export.py
@@ -264,6 +279,16 @@ def existing_graph_node_count(path: "str | Path"):


def to_json(G: nx.Graph, communities: dict[int, list[str]], output_path: str, *, force: bool = False, built_at_commit: str | None = None, community_labels: dict[int, str] | None = None) -> bool:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressionto_json()

fans out to 8 callees (efferent coupling); 59 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/watch.py
return kept


def _reconcile_existing_graph(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression_reconcile_existing_graph()

fans out to 10 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant