Skip to content

Tune the layout defaults against the benchmark corpus - #382

Merged
matt-edmondson merged 3 commits into
mainfrom
claude/layout-tuning
Sep 9, 2026
Merged

Tune the layout defaults against the benchmark corpus#382
matt-edmondson merged 3 commits into
mainfrom
claude/layout-tuning

Conversation

@matt-edmondson

@matt-edmondson matt-edmondson commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Uses the harness from #380 to tune LayoutSettings.Defaults one setting at a time. Six of the fifteen moved; the other nine were offered the same ranges and declined them.

setting was is
RepulsionStrength 600,000 900,000
MinRepulsionDistance 50 5
LinkSpringStrength 0.5 0.1
RestLinkLength 225 50
DirectionalBias 0.5 4
LinkFlatteningStrength 0.5 3

Evidence

Corpus score 3.25 → 1.19, measured at 48 starting arrangements per point. Validated on three families of arrangements the values were never chosen on:

holdout family defaults tuned
1 3.256 1.217
2 3.234 1.260
3 3.171 1.281

Against a measurement deviation of 0.032. A confirmation pass re-swept all fifteen settings at the tuned point: each either held its value or offered a gain inside the noise, so this is a genuine local minimum rather than a stop part-way down. DirectionalBias was re-checked past the top of its original list (8 and 16 both score worse) so it is an interior optimum, not a truncation.

What it fixes

The defect the corpus was built to expose. Per-graph, at 12 starts:

graph edge angle overlap settled readable
Chain 53.7° → 0.1° 0 → 0 5/12 → 12/12 5/12 → 12/12
MixedSizes 50.0° → 2.1° 0 → 0 4/12 → 12/12 1/12 → 12/12
TwoClasses 43.2° → 20.4° 16.9 → 0.0 2/12 → 7/12 8/12 → 12/12
Counter 25.9° → 14.8° 0 → 0 4/12 → 6/12 12/12 → 12/12
FanIn 31.9° → 10.5° 0 → 0 12/12 → 12/12 12/12 → 12/12

Pulling every body towards one centre folds a long chain into a coil. Gravity is not what fixed it — weakening GravityStrength did straighten the chain, but at the cost of the one thing gravity is for, and the descent left it at 50 untouched. LinkFlatteningStrength at six times its old value simply outcompetes the coil: a force pulling each edge towards horizontal beats one pulling every body towards a point, and neither has to be turned off for that to be true.

What it costs

Links drawn across an unrelated body get more common as a graph flattens — monotonically with the setting, from 0.10 normalised at flattening 0 to 0.40 at 12 — roughly doubling on TwoClasses, whose cross-class calls are the long edges that have to cross whatever is parked between them.

The score's minimum is a shallow basin it cannot actually resolve:

flattening total hidden angle unsettled
2 1.211 0.20 0.10 0.33
3 1.194 0.24 0.09 0.26
4 1.220 0.27 0.08 0.26

2 and 3 differ by half a standard deviation, so something other than the score has to choose: 3 settles more reliably, 2 hides fewer links. Going with 3 because it is what the objective picked and its weights are written down and arguable — it is a one-number change if the trade should go the other way.

Two things found on the way

MinRepulsionDistance has no effect below ≈13. MaxForce caps the total force at 5,000 while the law's own cap is 36,000, so close-range repulsion is a constant 5,000 and the inverse-square only reappears past 13 units of clear space. Sweeping 0, 2, 5, 10 measures the same layout four times. What the setting really controls is whether that floor is hard or soft — at the old 50 the law's cap was 360, well under MaxForce, so repulsion went soft at close range and let bodies crowd.

Zero was never a valid value for it — it is itself the clamp keeping the law finite at contact, so it yielded infinity and then NaN positions. Fixed in the first commit here, with a regression test verified to fail on the unfixed code.

Tests

73/73 in ForceDirectedLayout.Tests, 98/98 in ImGui.NodeEditor.Tests unchanged.

Three tests broke on this tuning without anything being wrong with them, and were fixed by naming the inputs they depend on rather than by moving thresholds:

  • LinkFlattening_..._SplaysApartHorizontally — the pair now levels completely, so the drop goes to zero and a bezier bound of zero is met by any arrangement at all. Its fixture pins the flattening strength and rest length it was written for.
  • TwistedLinks_AreNotHeldApartOnTheAxisTheySwapAlong — flattening decides that geometry outright, so held and free agree to six figures. With flattening and bias off they differ by 193 vertically and 426 horizontally, which is the effect the test is named for; the fixture now isolates it.
  • Repulsion_WithNoClearSpace_PushesAtTheFloor — now asserts the real contract, the smaller of the law's cap and MaxForce.

Corpus gate thresholds tightened to match (Chain 60° → 10°, MixedSizes 58° → 15°, Counter 40° → 25°), with entries added for the two graphs that had none.

🤖 Generated with Claude Code

https://claude.ai/code/session_018b2bn5CKSzpjRV4nxv3c3y

That setting is itself the clamp that keeps the inverse-square law finite
where two bodies touch, so setting it to zero removes the only thing
standing between the force and a division by zero: touching boxes have
exactly no clear space between them, the magnitude comes back infinite,
and the integrator carries that into positions that are NaN from then on.

The cost is not a bad layout, it is a layout that stops being numbers.
It surfaced from a parameter sweep whose candidate list happened to
include zero, where every metric taken afterwards read NaN rather than
reading badly — a result that cannot even be recognised as a failure.

Floored at a value small enough to change nothing for any usable
setting, so zero now means "as small as this can safely be".

Also extends DirectionalBias's tuning candidates past 4, since a descent
that settles on the largest value it was offered has found the edge of
the list rather than the edge of the useful range.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018b2bn5CKSzpjRV4nxv3c3y
Six of the fifteen settings moved; the other nine were offered the same
ranges and declined them. Measured by coordinate descent over the corpus,
scored by LayoutScore, at forty-eight starting arrangements per
measurement:

    RepulsionStrength      600,000 -> 900,000
    MinRepulsionDistance        50 -> 5
    LinkSpringStrength         0.5 -> 0.1
    RestLinkLength             225 -> 50
    DirectionalBias            0.5 -> 4
    LinkFlatteningStrength     0.5 -> 3

The corpus score went from 3.25 to 1.19, and held at 1.22-1.28 on three
families of starting arrangements the values were never chosen on,
against a measurement deviation of 0.03. A confirmation pass re-swept
every setting at the tuned point: each either held its value or offered a
gain inside the noise, so this is a genuine local minimum and not a stop
part-way down.

This fixes the defect the corpus was built to expose. A twelve-node chain
settled at 53 degrees off horizontal with two starts in six reading left
to right, because pulling every body towards one centre folds a long
chain into a coil; it now settles at 0.1 degrees, twelve in twelve, and
MixedSizes goes from 50 degrees and 1/12 to 2.1 and 12/12. Gravity is not
what fixed it and was left untouched at 50: a force pulling each edge
towards horizontal simply outcompetes one pulling every body at a point.

The cost is real and is recorded rather than hidden. Links drawn across
an unrelated body get more common as a graph flattens, monotonically with
the setting, roughly doubling on the two-class graph whose cross-class
calls are the long edges. The score's minimum is a shallow basin - 2 and
3 land within half a standard deviation - so that choice is a judgement
call, not a measurement.

Three tests in ForceLayoutTests broke without anything being wrong with
them, and were fixed by naming the inputs they depend on rather than by
moving their thresholds: the flattening splay stopped splaying because
the pair now levels completely and a bound of zero is met by anything,
and the untwist's overlap-pass exemption became unobservable because
flattening decides that geometry outright (held and free agree to six
figures on the defaults; with flattening off they differ by 193
vertically and 426 horizontally). The repulsion floor test now asserts
the real contract, which is the smaller of the law's cap and MaxForce -
under these defaults the latter, so MinRepulsionDistance has no effect at
all below about 13.

Corpus gate thresholds tightened to match, with entries added for the two
graphs that had none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018b2bn5CKSzpjRV4nxv3c3y
@matt-edmondson matt-edmondson changed the title Keep repulsion finite when MinRepulsionDistance is zero Tune the layout defaults against the benchmark corpus Sep 9, 2026
MSTEST0037, on an assertion this branch had already rewritten. The
argument order was checked rather than assumed: a backwards conversion
still passes on data that satisfies both readings, so it was verified by
moving the bound to a value it cannot meet and confirming the failure
names 206 against it, which is the value in the second position.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018b2bn5CKSzpjRV4nxv3c3y
@sonarqubecloud

sonarqubecloud Bot commented Sep 9, 2026

Copy link
Copy Markdown

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.

2 participants