hex-grid-lattice-mc-refinement

star 32

Design guide + benchmarked pitfalls for building Monte-Carlo / Simulated Annealing refinement layers on top of greedy hex-grid layouts for code visualization (packages/gui/src/store/hexLayout.ts, sandbox/hex-sandbox, future Rust ports). Use when: (1) planning to add MC/SA on top of an existing hex-grid pack, (2) deciding whether a simpler SA would match a hierarchical MC with force-directed + chain drag, (3) budgeting wall-clock for million-node cold-start, (4) debugging "blob turns Swiss-cheese" or "folders stop nucleating" during MC refinement, (5) choosing between folder cohesion and raw Σ-link-length as cost function. Contains four hard-won, quantified lessons and anti-patterns that would take a full day of experiments to rediscover.

Disentinel By Disentinel schedule Updated 4/17/2026

name: hex-grid-lattice-mc-refinement description: | Design guide + benchmarked pitfalls for building Monte-Carlo / Simulated Annealing refinement layers on top of greedy hex-grid layouts for code visualization (packages/gui/src/store/hexLayout.ts, sandbox/hex-sandbox, future Rust ports). Use when: (1) planning to add MC/SA on top of an existing hex-grid pack, (2) deciding whether a simpler SA would match a hierarchical MC with force-directed + chain drag, (3) budgeting wall-clock for million-node cold-start, (4) debugging "blob turns Swiss-cheese" or "folders stop nucleating" during MC refinement, (5) choosing between folder cohesion and raw Σ-link-length as cost function. Contains four hard-won, quantified lessons and anti-patterns that would take a full day of experiments to rediscover. author: Claude Code version: 1.0.0 date: 2026-04-15

Hex-Grid Lattice MC Refinement: Design Principles

Problem

You already have a greedy hex-grid packer (pack.js / hexLayout.ts / future Rust port) that produces reasonable file-tree layouts. You want to push closer to optimal via Monte Carlo / Simulated Annealing. Four non-obvious design decisions determine whether the refinement actually helps or wastes compute; this skill documents them with benchmark numbers from a full multi-hour experiment session so you don't rediscover them the hard way.

Context / Trigger Conditions

  • Hex-grid hierarchical layout for code visualisation (file → folder → pkg → root), hundreds to millions of nodes
  • Greedy pack already exists and is "good enough" structurally
  • Considering MC/SA refinement to reduce Σ link length further
  • Or planning a from-scratch MC cold-start because "why not"
  • Or seeing surprising symptoms (Swiss-cheese holes, folders refusing to nucleate, MC never improving on the last 20 minutes)
  • Or choosing move set: random adjacency swap vs force-directed vs chain drag vs folder drift

Lesson 1 — Hole-guard > smart proposals

Claim: For dense-pack hex lattice MC, preventing isolated-hole formation gives more improvement than adding force-directed proposal generation. Geometric invariants beat smart directions.

Benchmark (1497 grafema nodes, warm-start from greedy, 15s wall budget):

Move set Σ-link reduction over greedy
Random swaps + Metropolis (naive) 5.0%
+ hole-guard (reject hops that create interior holes) 6.9% (+1.9 p.p.)
+ force-directed top-3 flanking proposal 7.6% (+0.7 p.p.)
+ hierarchical force (intra before snap, external after) 7.5% (noise)
+ BFS bent chain drag 7.5% (noise)

Why hole-guard matters so much: Single-atom hop moves create vacancies when an atom jumps from a boundary cell into empty space. In Metropolis, hop-OUT is typically ΔE > 0 (stretches links, rarely accepted) while hop-BACK is ΔE < 0 (shortens links, almost always accepted). This breaks detailed balance — holes form at the boundary and migrate inward, turning the blob Swiss-cheese over thousands of iterations even though the energy metric keeps improving.

Fix: before allowing an atom to hop into an empty neighbour, verify the atom's old cell has ≥2 empty neighbours so it remains connected to the outer empty region after the hop. One-line check, 40% relative improvement:

const oldEmptyNeighbours = countEmptyHexNeighbours(a.coord);
if (oldEmptyNeighbours < 2) return null; // would create isolated hole

Non-obvious corollary: after adding hole-guard, force-directed proposals give only +0.7 p.p. more. If you're on a refinement budget, spend it on invariants, not smart proposals.

Lesson 2 — MC cold-start is infeasible vs structured construction

Claim: Lattice MC with local moves (swap, hop, drift, chain) cannot close the gap with a structured greedy pack for cold-start initialisation, even with hours of compute.

Benchmark (1497 grafema nodes, cold-start from alphabetic ring):

Budget Σ-link reduction vs greedy's 13026
Greedy (3.6s) n/a 1.00×
MC 60s 31.4% 14.0× worse
MC 180s 38.3% 12.6× worse
MC 300s 42.9% 11.7× worse
MC 900s (15 min) 54.9% 9.2× worse

Log-linear scaling: ~20 p.p. of reduction per log-unit of wall-clock. Extrapolating to match greedy's 13026 requires +40 p.p. more = 2 more log units = 100× the 900s budget = 25 hours per run. Infeasible.

Why: glassy regime — the move set is too local. Acceptance rate stays at 16-22% even after 15 min (never drops below 1.5% which would indicate equilibrium). Convergence detector never fires. "Run until frozen" SA doctrine doesn't apply.

Practical rule: Always seed MC with a structured algorithm. For hex hierarchical layouts, use the greedy pack (pack.js / future Rust port). Reserve MC as a refinement layer for the final ~7% polish on warm starts, not as a primary algorithm.

Lesson 3 — Hierarchical nucleation-aware force trades raw energy for

folder cohesion

Claim: Gating force-direction on folder nucleation state (pre-snap intra-folder, post-snap external) improves folder cohesion by ~36% at the cost of ~13% raw Σ-link energy. This is the right tradeoff for code-map visualisation.

Design:

for each atom a:
  find the deepest ancestor folder F that is NOT yet in `connected` set
  AND whose size ≤ connCheckMaxSize
  if F exists:
    # pre-snap: crystallise F
    force(a) = Σ (sibling.pos - a.pos) × weight   over same-F links
    # fallback if no intra-F links: pull to F's centroid
  else:
    # post-snap of all ancestors: place the now-coherent unit
    force(a) = Σ (other.pos - a.pos) × weight  over external-to-leaf links

Benchmark (1497 nodes, cold-start 60s):

Force type Σlinks conn folders adj invariants
Plain force (always external) 162873 273/518 186
Hierarchical (intra→external) 182678 379/518 159

Plain gives best raw energy. Hierarchical gives +38% more connected folders — the ones where a leaf actually condensed into a coherent blob. For a visualisation where folders should read as regions on a map, structure beats raw length.

Required fix — size-cap trap: folders above connCheckMaxSize never enter the connected set (BFS check too expensive). Without explicit skip in the ancestor walk, atoms below them are trapped forever in "pre-snap, pull toward huge-folder centroid" mode, producing weak scattered forces. Fix:

for (const fp of ancestors) {
  if (connected.has(fp)) continue;
  const size = folderAtoms.get(fp)?.size ?? 0;
  if (size > connCheckMaxSize) continue; // virtually nucleated
  activeFolder = fp;
  break;
}

This one-liner was missed in the first implementation and caused conn counts to plateau at 372/518. After the fix: 379/518 at 60s, 391/518 at 180s.

Lesson 4 — Bent chain drag via BFS vacancy routing

Claim: Row-shift chain drag (all atoms in a column shift by one hex) frequently breaks folder connectedness. Replacing it with BFS vacancy routing (the chain bends around obstacles) preserves invariants more often.

Design:

tryChainDrag(atom a, direction d):
  target = a.coord + d
  if target empty: use a plain hop instead
  if a.coord has <2 empty neighbours: abort (hole-guard)

  BFS from `target` through OCCUPIED cells, looking for the nearest empty
  cell. Each step uses any of 6 hex neighbours (chain bends freely).
  Max depth CHAIN_BFS_MAX_DEPTH=6.

  When empty cell found, reconstruct path: [target, c1, c2, ..., empty]
  Each cell path[i] moves to path[i+1]:
    - occupant of path[0] → path[1]
    - occupant of path[1] → path[2]
    - ...
    - occupant of path[k-1] → empty
    Finally `a` moves from a.coord to path[0] (now freed).

  Per-atom step direction can differ (that's the bending).
  ΔE: sum over affected atoms' external links; inter-chain links use
  new positions (both endpoints moved).

Why bending helps: a rigid row-shift only succeeds if the entire row can translate without breaking a registered connected(F) invariant. For a typical mid-nucleation cold-start, at least one atom on the row is usually a cut vertex of some folder — rigid shift fails. A bent chain can route around that atom, pushing only atoms whose folder is safe to re-arrange.

Not implemented (future): proactive constraint-aware BFS that weights candidates by "is this atom a cut vertex of an active invariant folder". Current implementation is reactive — it applies the chain, then rejects on invariant-check failure. Proactive would cost O(folder BFS) per step and wasn't worth it for our benchmarks, but may matter for much larger projects.

Consolidated verdict

For a refinement layer on top of greedy on ~1500-node projects:

  • Minimum viable MC = plain random swaps + Metropolis + hole-guard gets 6.9% over greedy in 15s. ~100 lines of code.
  • Maximum effort MC (force-directed + hierarchical + bent chain + nucleation invariants) gets 7.5% in 15s. ~600 lines of code.
  • Net value of the fancy machinery: +0.6 p.p. (noise-level).
  • Only reason to keep the complexity: folder cohesion guarantees (hierarchical nucleation) and map-like visual structure. If raw Σ-links is the only metric, delete it and use plain SA.

For million-node targets: MC is not the answer at all. Port the greedy pack to Rust (see REG-1102). MC refinement gives ≤7% that you can spend a week of engineering on; greedy port gives 100× throughput for 1-2 weeks of engineering. Cold-start MC gives an extrapolated 25h wall-clock for a result that greedy produces in seconds.

Example

// The minimum-viable refinement loop that captures 90% of the value:
async function refine(map, wallMs = 15000) {
  const rng = Math.random;
  const atoms = [...map.nodes.values()];
  const linksByAtom = indexLinksByAtom(map.links);
  const occupancy = buildOccupancy(map);

  // Temperature auto-calibrate from 300 random adjacency-swap ΔE samples
  const avgDelta = sampleSwapDeltas(300, atoms, linksByAtom, rng);
  const T0 = Math.max(1, avgDelta * 2);
  const T1 = Math.max(0.05, avgDelta * 0.01);

  const tStart = performance.now();
  let energy = computeTotalEnergy(map.links);

  while (performance.now() - tStart < wallMs) {
    const t = (performance.now() - tStart) / wallMs;
    const T = T0 * (T1 / T0) ** t;

    // Propose: 80% adjacency swap, 15% long-distance swap, 5% hole-safe hop
    const roll = rng();
    const move = roll < 0.80 ? adjSwap(atoms, rng, occupancy, map)
               : roll < 0.95 ? randomSwap(atoms, rng)
               : holeSafeHop(atoms, rng, occupancy);          // ← guards against Lesson 1
    if (!move) continue;

    const dE = computeDeltaE(move, linksByAtom);
    if (dE > 0 && rng() >= Math.exp(-dE / T)) continue;

    applyMove(move, occupancy);
    energy += dE;
  }
}

For the project-specific holeSafeHop implementation, look in sandbox/hex-sandbox/src/pack-mc.js (commit 6dcefd09) — it's a 20-line helper with the ≥2-empty-neighbours guard.

Notes

  • All benchmarks used a 1497-node grafema codebase with 4351 real polyglot import edges (scan-links.mjs). Synthetic data with folder-biased random links gives artificially optimistic numbers.
  • The connected.has(fp) check relies on a size-cap (connCheckMaxSize=200) — any folder above that never enters the invariant set; code walking ancestors must skip them explicitly (Lesson 3 size-cap trap).
  • 0 reheats across every cold-start benchmark confirms the glassy regime: acceptance stays above the 1.5% reheat threshold even at 15 min. Don't budget based on "time to convergence" — budget on wall-clock.
  • Chain drag is expensive per-call (BFS + multi-atom apply/undo). Cap its proposal frequency to 5% or less of the move mix, otherwise iteration throughput drops below the point where simpler moves give better ΔE/second.
  • For incremental (warm-start with small changes) use case, MC is ideal: seeded from last layout, low-temperature polish. This is the ONE use case where MC beats everything including fresh greedy.

References

  • Session artifact: sandbox/hex-sandbox/src/pack-mc.js at commit 6dcefd09
  • Related skills:
    • .claude/skills/hex-grid-sa-o1-connectivity/ — O(1) connectivity check for SA migrate moves (orthogonal speedup)
    • .claude/skills/hex-grid-sequential-bfs-layout/ — the greedy algorithm this MC refines (pack.js)
  • Linear: REG-1102 (Rust port of greedy for million-node target), REG-1100 (layout/view split), REG-1101 (npm package)
  • Physics literature: Wolff 1989 (cluster moves), Binder & Heermann "Monte Carlo Simulation in Statistical Physics" (glassy regime)
Install via CLI
npx skills add https://github.com/Disentinel/grafema --skill hex-grid-lattice-mc-refinement
Repository Details
star Stars 32
call_split Forks 3
navigation Branch main
article Path SKILL.md
More from Creator