Skip to content

Hyperedge polygons are traced in h.nodes array order, so the filled region can self-intersect #2449

Description

@ysys143

_hyperedge_script in graphify/exporters/html.py maps h.nodes straight to positions
and traces that array in order:

// html.py:78, 88-96 (v8 @ HEAD, and 0.9.32 on PyPI)
const positions = h.nodes.map(nid => network.getPositions([nid])[nid]).filter(...);
...
// Centroid and expanded hull in network coordinates
const cx = positions.reduce((s, p) => s + p.x, 0) / positions.length;
const cy = positions.reduce((s, p) => s + p.y, 0) / positions.length;
const expanded = positions.map(p => ({
    x: cx + (p.x - cx) * 1.15,
    y: cy + (p.y - cy) * 1.15
}));
ctx.moveTo(expanded[0].x, expanded[0].y);
expanded.slice(1).forEach(p => ctx.lineTo(p.x, p.y));   // array order
ctx.closePath();
ctx.fill();

When the members' layout positions are not already in angular order around the centroid,
the traced path crosses itself and fill() renders overlapping wedges rather than one
region.

The 1.15x expansion does not mitigate this. It is a positive homothety about the centroid,
so it preserves segment intersections: (-1,-1) -> (1,1) -> (-1,1) -> (1,-1) is a bow-tie
before and after scaling.

Nor is it ruled out by small member counts. In the graph I hit this on, member counts were
{4: 1, 5: 8, 6: 3, 7: 3} across 15 hyperedges — no triangles, which are the only sizes
that cannot self-intersect.

It is also not a rare corner. Taking 20,000 random point sets of 4-7 members and tracing
them in arbitrary order, 75.3% self-intersect after the 1.15x expansion. Members
landing in angular order is the exception, not the rule.

Before I go further: is the array order intentional?

I want to flag the counter-argument, because it may make this a wontfix.

h.nodes order is not random. Several emitted arrays do follow their flow labels:

  • cagra_build_durable_or_nothing_flow: backend corpus -> daemon build -> atomic persistence
  • artifact_lifecycle_flow: persist -> reload -> validate -> REINDEX
  • snapshot_warmup_restore_flow: snapshot -> warmup -> restore

So if the perimeter is meant to trace that sequence, tracing in array order is the intent
and this issue is invalid.

Against that reading, three things in the code suggest hyperedges are modelled as sets,
not sequences:

  • The extraction prompt (llm.py:475) asks for nodes that "participate together in a
    shared concept, flow, or pattern" but never specifies an ordering, and its own examples
    are order-agnostic: "all classes implementing one protocol", "all functions in one auth
    flow even if they don't all call each other", "all concepts from a paper section
    forming one coherent idea".
  • The schema's relation vocabulary is participate_in | implement | form — membership
    verbs. There is no sequence-typed relation. In my graph the split is participate_in 10,
    form 5.
  • callflow_html.py:1754-1772 renders each hyperedge as a bulleted list headed
    "<relation> — N participants". It preserves array order, but presents membership,
    not order.

And the polygon itself has no way to carry a sequence: globalAlpha 0.12 fill, 0.4 stroke,
one centroid label, no arrowheads or direction cues. A viewer cannot recover
corpus -> build -> persist from a filled translucent blob.

For what it is worth, #334 — the previous rendering fix in this same function — describes
these shapes as "hyperedge convex-hull polygons", which reads like hull semantics were
the intent there too.

That said, whether the ordering is meant to be load-bearing is your call, not mine. If it
is, feel free to close this.

What I observed

On a 2013-node / 3928-edge graph with 15 hyperedges: before changing anything, several
overlapping blades crossed the canvas; after applying an angular sort, one elongated
region remained and the rest rendered as compact filled areas.

To be precise about the evidence: that is a visual before/after observation, not a
measurement. graph.json stores no coordinates — vis.js generates them at runtime — so I
cannot supply a coordinate fixture that reproduces the exact rendering.

The one region that stayed elongated is a data property, not a tracing artifact. Measuring
undirected graph distance between each hyperedge's members gives max finite distances of
inf, 6, 3, 3, 3, 4, 3, 3, 3, 2, 3, 2, 5, 3, 4 — 14 of 15 are connected within 6 hops,
while cagra_build_durable_or_nothing_flow has a member (architecture_fail_closed) in a
different connected component. Its members genuinely sit at opposite ends of the layout.
(Hop distance does not prove layout distance; it is corroboration, not proof.)

Possible fix, with its limits

Sorting the vertices by angle around the centroid before tracing:

expanded.sort((a, b) => Math.atan2(a.y - cy, a.x - cx) - Math.atan2(b.y - cy, b.x - cx));

Sorting before or after the 1.15x expansion is equivalent, since the homothety preserves
each point's angle.

This is an ordinary-case mitigation, not a guarantee:

  • It produces a simple polygon, not a convex one. It is not a convex-hull algorithm,
    despite what the line-88 comment says.
  • No guard for coincident positions, equal angles, NaN coordinates, or a centroid landing
    on a vertex (Math.atan2(0, 0) ties at zero).
  • Degenerate collinear sets such as (-2,0), (-1,0), (1,0), (2,0) still yield a
    zero-area overlapping path.

So I went with a convex hull instead (Andrew's monotone chain, ~12 lines). It matches the
line-88 comment and #334's wording, and it degrades cleanly on the cases above: collinear
and duplicate points collapse to the extremes rather than producing a zero-area path, and
there is no atan2(0, 0) tie to worry about.

PR attached. If you would rather have the one-line angular sort, or if the array order is
intentional and this should be closed, just say so and I will drop it.

Reproduction path

Note that this code is not reached by headless graphify extract, which stops at graph
JSON and analysis (cli.py:3685). It renders via cluster-only (cli.py:1891),
export html (cli.py:2450), and watch mode (watch.py:1487).

Environment

Searched existing issues for hyperedge, polygon, convex hull, self-intersect,
shaded region, afterDrawing, and atan2. The nearest neighbours are all distinct:

I did not find an existing report of the tracing order itself.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions