Skip to content

RDFXML2SVG: fix the force layout's asymptotics and physics - #92

Open
namedgraph wants to merge 1 commit into
masterfrom
rdfxml2svg-layout-optimization
Open

RDFXML2SVG: fix the force layout's asymptotics and physics#92
namedgraph wants to merge 1 commit into
masterfrom
rdfxml2svg-layout-optimization

Conversation

@namedgraph

Copy link
Copy Markdown
Member

The Fruchterman–Reingold positioning in RDFXML2SVG.xsl was unusable beyond a few dozen nodes. This rebuilds the machinery around the same force model and fixes five genuine defects in the physics found along the way.

Performance

Wall clock incl. ~1.1s npx/SEF-compile startup; SaxonJS 3.0.0-beta2, default 30 steps, synthetic flat RDF/XML graphs (~1.3 edges/node):

Input master this branch
50 nodes 8.5 s 1.7 s
100 nodes 506 s 3.1 s (~165×)
250 nodes extrapolates to hours 16.8 s
500 nodes 67.5 s

What changed:

  • positions live in one map keyed by node id (O(1) lookups where the sequence-of-maps predicate scans $seq[?node-id eq $id] made every step O(n³)); adjacency and the circle seed are computed once, and the O(n²) non-adjacent-ids lists are gone along with the per-edge map rebuild + regroup dance;
  • hot-path map parameters are declared as="map(*)", not the precise map(xs:string, map(*)) — SaxonJS validates a declared deep map type entry by entry on every call and parameter binding; on the tunneled positioning pass this alone cost 8.6 s of fixed time at n=250 (the single largest measured share);
  • each step buckets nodes into a spatial grid (0.7 spring lengths per cell, built with for-each-groupmap:merge 'combine' re-copies a packed cell's list per node) and repels against a deterministic, spatially stratified sample of the 3×3 neighborhood (≤3 per cell, ≤16 total) — the customary ~2k-cutoff grid approximation of FR;
  • the per-node displacement is a single XPath expression with sqrt-free Δ·k²/d² repulsion (per-pair XSLT instructions dominated the profile).

Correctness

Layouts differ from the old output beyond the sampling approximation, because the old physics had defects:

  • repulsion was scaled by node degree — the edge-record regrouping summed a node's repulsion displacement once per incident edge record;
  • attraction acted twice per adjacent pair (the edge list carried both directions and each record pulled both endpoints); it now acts once, as the cited paper defines;
  • the canvas clamp was broken math — it mixed the axes (width²/4 − y² is a circle, not the width×height ellipse), took abs() of negative sqrt arguments, and fed the already re-shifted x into the y clamp; now a rectangular frame clamp followed by a radial projection onto the inscribed ellipse;
  • coincident nodes could stick forever (zero distance contributed zero force); they now separate along a deterministic direction derived from id order;
  • line clipping divided by zero for horizontally aligned nodes (the acknowledged TO-DO), emitting NaN coordinates — reproduced on a 100-node graph; now a unit-vector walk-back with no case analysis. Plus the literal-line x-diff=0/y-diff=0 branch set y2 from $x2 (typo).

Scope and verification

The RDF-side patterns are untouched — the contract stays Jena's RDFXML_PLAIN, now spelled out in the header note. $spring-stiffness was never used by the force model, before or now; it stays for signature compatibility. The positioning internals (ac:SVGPositioningLoop, ac:force-step, the ac:SVGPositioning tunnel) have no external dependents in Web-Client or LinkedDataHub.

Verified: identical node/line/viewBox inventory against the old output on 50/100-node graphs; no NaN; byte-identical positions across repeated runs (the hsl() type-coloring was never run-stable under SaxonJS's random-number-generator and still is not); side-by-side visual check on a small ontology.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FJSsKMVPsAKPVvpLYoK7SS

The Fruchterman-Reingold positioning was unusable beyond a few dozen nodes:
50 nodes took 8.5s, 100 nodes 506s, 250 nodes extrapolated to hours (SaxonJS
3.0.0-beta2, M-series laptop, default 30 steps). The machinery is rebuilt
around the same force model:

- positions live in ONE map keyed by node id (O(1) lookups where the
  sequence-of-maps predicate scans $seq[?node-id eq $id] made every step
  O(n^3)), threaded through xsl:iterate; adjacency and the circle seed are
  computed once, not per iteration, and the O(n^2) non-adjacent-ids lists
  are gone along with the per-edge map rebuild + regroup dance;
- hot-path map parameters are declared as="map(*)", NOT the precise
  map(xs:string, map(*)): SaxonJS validates a declared deep map type entry
  by entry on every call and parameter binding - on the tunneled positioning
  pass that alone cost 8.6s of fixed time at n=250 (the single largest
  measured share; what remains per step is the XPath interpreter's floor);
- each step buckets nodes into a spatial grid (0.7 spring lengths per cell,
  built with for-each-group - map:merge 'combine' re-copies a packed cell's
  list per node) and repels against a deterministic, spatially stratified
  sample of the 3x3 neighborhood (at most 3 per cell, 16 total) - the
  customary ~2k-cutoff grid approximation of FR;
- the per-node displacement is a single XPath expression (per-pair XSLT
  instructions dominated the profile), with sqrt-free delta * k^2 / d^2
  repulsion.

After: 50 nodes 1.7s, 100 nodes 3.1s, 250 nodes 16.8s, 500 nodes 67.5s wall
(incl. ~1.1s npx/compile startup) - about 165x at 100 nodes, and graph sizes
that simply never finished become feasible.

The physics also had genuine defects, now corrected (layouts therefore
differ from the old output beyond the sampling approximation):

- repulsion was scaled by node degree: the edge-record regrouping summed a
  node's repulsion displacement once per incident edge record;
- attraction acted twice per adjacent pair (the edge list carried both
  directions and each record pulled both endpoints); it now acts once, as
  the cited paper defines;
- the canvas clamp mixed the axes (width^2/4 - y^2 is a circle, not the
  width x height ellipse), took abs() of negative sqrt arguments, and fed
  the already re-shifted x into the y clamp; it is now a rectangular frame
  clamp followed by a radial projection onto the inscribed ellipse;
- coincident nodes contributed no force and could stick forever; they now
  separate along a deterministic direction derived from id order;
- line clipping divided by zero for horizontally aligned nodes (the
  acknowledged TO-DO), emitting NaN coordinates - reproduced in the wild on
  a 100-node synthetic graph - and is now a unit-vector walk-back that
  needs no case analysis; the literal-line x-diff=0/y-diff=0 branch set y2
  from $x2 (typo).

The RDF-side patterns are untouched: the converter's contract stays Jena's
RDFXML_PLAIN (rdf:Description subjects with rdf:type inlined as an explicit
property, no nesting), per the note at the top of the stylesheet.
$spring-stiffness was never used by the force model, before or now; it
stays for signature compatibility. Verified: identical node/line/viewBox
inventory against the old output on 50- and 100-node synthetic graphs, no
NaN, byte-identical positions across repeated runs (the hsl() type-coloring
was never run-stable under SaxonJS's random-number-generator and still is
not), and a side-by-side visual check on a small ontology. The positioning
internals (ac:SVGPositioningLoop, ac:force-step, the ac:SVGPositioning
tunnel) have no external dependents in Web-Client or LinkedDataHub.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJSsKMVPsAKPVvpLYoK7SS
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant