_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.
_hyperedge_scriptingraphify/exporters/html.pymapsh.nodesstraight to positionsand traces that array in order:
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 oneregion.
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-tiebefore 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 sizesthat 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.nodesorder is not random. Several emitted arrays do follow their flow labels:cagra_build_durable_or_nothing_flow: backend corpus -> daemon build -> atomic persistenceartifact_lifecycle_flow: persist -> reload -> validate -> REINDEXsnapshot_warmup_restore_flow: snapshot -> warmup -> restoreSo 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:
llm.py:475) asks for nodes that "participate together in ashared 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".
relationvocabulary isparticipate_in | implement | form— membershipverbs. There is no sequence-typed relation. In my graph the split is
participate_in10,form5.callflow_html.py:1754-1772renders 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:
globalAlpha0.12 fill, 0.4 stroke,one centroid label, no arrowheads or direction cues. A viewer cannot recover
corpus -> build -> persistfrom 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.jsonstores no coordinates — vis.js generates them at runtime — so Icannot 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_flowhas a member (architecture_fail_closed) in adifferent 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:
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:
despite what the line-88 comment says.
on a vertex (
Math.atan2(0, 0)ties at zero).(-2,0), (-1,0), (1,0), (2,0)still yield azero-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 graphJSON and analysis (
cli.py:3685). It renders viacluster-only(cli.py:1891),export html(cli.py:2450), and watch mode (watch.py:1487).Environment
uv tool install; PyPI reports 0.9.32 as latestv8(repo default branch) at HEAD via the GitHubcontents API
MAX_NODES_FOR_VIZ, so no community aggregation: the emittedgraph.htmlcarries all 2013 raw nodes and all 83 hyperedge members resolve againstthem. (Checked because of Bug: hyperedges always empty in graph.html when graph exceeds viz node limit (aggregated meta-graph drops them) #1005.)
Searched existing issues for
hyperedge,polygon,convex hull,self-intersect,shaded region,afterDrawing, andatan2. The nearest neighbours are all distinct:the viz node limit.
rather than a replacement for the vis-network path, so this still applies.
I did not find an existing report of the tracing order itself.