RDFXML2SVG: fix the force layout's asymptotics and physics - #92
Open
namedgraph wants to merge 1 commit into
Open
RDFXML2SVG: fix the force layout's asymptotics and physics#92namedgraph wants to merge 1 commit into
namedgraph wants to merge 1 commit into
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The Fruchterman–Reingold positioning in
RDFXML2SVG.xslwas 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):
What changed:
$seq[?node-id eq $id]made every step O(n³)); adjacency and the circle seed are computed once, and the O(n²)non-adjacent-idslists are gone along with the per-edge map rebuild + regroup dance;as="map(*)", not the precisemap(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);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 3×3 neighborhood (≤3 per cell, ≤16 total) — the customary ~2k-cutoff grid approximation of FR;Δ·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:
width²/4 − y²is a circle, not the width×height ellipse), tookabs()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;NaNcoordinates — reproduced on a 100-node graph; now a unit-vector walk-back with no case analysis. Plus the literal-linex-diff=0/y-diff=0branch sety2from$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-stiffnesswas never used by the force model, before or now; it stays for signature compatibility. The positioning internals (ac:SVGPositioningLoop,ac:force-step, theac:SVGPositioningtunnel) 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 (thehsl()type-coloring was never run-stable under SaxonJS'srandom-number-generatorand still is not); side-by-side visual check on a small ontology.🤖 Generated with Claude Code
https://claude.ai/code/session_01FJSsKMVPsAKPVvpLYoK7SS