"MST" clustering draws again: a true spanning tree in under a second instead of a 12-second freeze
Choosing MST on the research map built and sorted every pair of pins (~2.2 million edges for the 2,107-pin roster), ~12 s on the main thread, and then did it all again for a log line. The page froze, and check:clustering-modes read the map before any line existed: "MST drew no cluster lines". - MSTClusteringUtil runs Prim over the implicit complete graph: O(n²) time, O(n) memory, no edge list. Starting a new tree when the nearest outside point is beyond maxConnectionDistance gives the same spanning forest as before. - useMapClustering no longer recomputes every tree for its stats log line. - check:mst-clustering (headless, in gates:post): same edge count and total length as a reference all-pairs Kruskal on 2-400 pins, capped and uncapped; 2,107 pins in 872 ms here (budget 3 s). On the previous code it FAILs at 11,206 ms.
This commit is contained in:
parent
1dfd63b631
commit
924a185aaa
4 changed files with 88 additions and 32 deletions
49
internal/check-mst-clustering.mjs
Normal file
49
internal/check-mst-clustering.mjs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-mst-clustering — the "MST" clustering mode draws a true minimum spanning tree, fast enough to
|
||||
* run on the main thread. Headless.
|
||||
*
|
||||
* WHY (2026-09-26): MST built and sorted every pair of pins (~2.2 million edges for the 2,107-pin roster),
|
||||
* ~12 s on the main thread, twice over with a stats pass. Choosing "MST" froze the page, and the browser
|
||||
* gate check:clustering-modes read the map before any line existed ("MST drew no cluster lines").
|
||||
* MSTClusteringUtil now runs Prim in O(n²).
|
||||
*
|
||||
* ASSERTS (1) on sizes 2-400, capped and uncapped, the util's tree has the SAME edge count and total
|
||||
* length as a reference all-pairs Kruskal written here; (2) 2,107 pins in under 3 s.
|
||||
* EXIT 0 pass · 1 fail
|
||||
*/
|
||||
import MST from '../library/utils/MSTClusteringUtil.js'
|
||||
import { euclideanDistance } from '../library/utils/ClusterHelpers.js'
|
||||
|
||||
const kruskal = (pts, cap) => {
|
||||
const e = []
|
||||
for (let i = 0; i < pts.length; i++) for (let j = i + 1; j < pts.length; j++) {
|
||||
const d = euclideanDistance(pts[i].latitude, pts[i].longitude, pts[j].latitude, pts[j].longitude)
|
||||
if (d <= cap) e.push([d, i, j])
|
||||
}
|
||||
e.sort((a, b) => a[0] - b[0])
|
||||
const p = pts.map((_, i) => i), f = (x) => (p[x] === x ? x : (p[x] = f(p[x])))
|
||||
let n = 0, len = 0
|
||||
for (const [d, i, j] of e) { const a = f(i), b = f(j); if (a !== b) { p[a] = b; n++; len += d } }
|
||||
return { n, len }
|
||||
}
|
||||
const pts = (n, k = 1) => Array.from({ length: n }, (_, i) => ({ latitude: Math.sin(i * 1.7 * k) * 60, longitude: Math.cos(i * 1.3 * k) * 170 }))
|
||||
const quiet = console.warn; console.warn = () => {}
|
||||
let bad = 0
|
||||
for (const [n, cap] of [[2, Infinity], [3, Infinity], [50, Infinity], [400, Infinity], [400, 12], [400, 4]]) {
|
||||
const P = pts(n), ref = kruskal(P, cap)
|
||||
const L = new MST({ maxConnectionDistance: cap }).generateMSTLines(P)
|
||||
const len = L.reduce((a, f) => a + f.properties.distance, 0)
|
||||
const ok = L.length === ref.n && Math.abs(len - ref.len) < 1e-6
|
||||
if (!ok) bad++
|
||||
console.log(` ${ok ? 'ok ' : 'FAIL'} ${String(n).padStart(4)} pins, cap ${cap}: ${L.length} edges, length ${len.toFixed(4)} (reference ${ref.n}, ${ref.len.toFixed(4)})`)
|
||||
}
|
||||
console.warn = quiet
|
||||
const t = Date.now()
|
||||
const L = new MST({}).generateAllMSTLines({ A: pts(1500, 1.1), B: pts(607, 0.9) })
|
||||
const ms = Date.now() - t
|
||||
const fast = ms < 3000 && L.length === 2105
|
||||
if (!fast) bad++
|
||||
console.log(` ${fast ? 'ok ' : 'FAIL'} 2,107 pins in two groups: ${L.length} lines in ${ms} ms (budget 3000 ms)`)
|
||||
if (bad) { console.log(`MST-CLUSTERING: FAIL — ${bad} case(s).`); process.exit(1) }
|
||||
console.log('MST-CLUSTERING: PASS — a true minimum spanning tree/forest, and fast enough for the main thread.')
|
||||
|
|
@ -511,7 +511,8 @@ export function useMapClustering(options = {}) {
|
|||
|
||||
if (algorithm === 'mst') {
|
||||
lines = mstUtil.generateAllMSTLines(groups, colorFunc);
|
||||
stats = mstUtil.getMSTStats(groups);
|
||||
// Not getMSTStats(): it recomputes every tree just for this log line (it doubled the work).
|
||||
stats = { totalGroups: Object.keys(groups).length };
|
||||
} else {
|
||||
lines = networkUtil.generateAllNetworkLines(groups, colorFunc);
|
||||
stats = networkUtil.getClusteringStats(groups);
|
||||
|
|
|
|||
|
|
@ -137,39 +137,43 @@ export class MSTClusteringUtil {
|
|||
|
||||
if (validPoints.length < 2) return [];
|
||||
|
||||
// Step 1: Build all candidate edges
|
||||
const edges = [];
|
||||
for (let i = 0; i < validPoints.length; i++) {
|
||||
for (let j = i + 1; j < validPoints.length; j++) {
|
||||
const distance = this._calculateDistance(validPoints[i], validPoints[j]);
|
||||
if (distance <= maxConnectionDistance) {
|
||||
edges.push({
|
||||
from: i,
|
||||
to: j,
|
||||
distance,
|
||||
fromPoint: validPoints[i],
|
||||
toPoint: validPoints[j]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Sort edges by distance ascending
|
||||
edges.sort((a, b) => a.distance - b.distance);
|
||||
|
||||
// Step 3: Kruskal via Union-Find
|
||||
const uf = this._createUnionFind(validPoints.length);
|
||||
// ★ PRIM, O(n²) time and O(n) memory — NOT all-pairs Kruskal (2026-09-26). Kruskal built and
|
||||
// sorted every pair: ~2.2 million edge objects for the 2,107-pin roster, ~12 s ON THE MAIN THREAD
|
||||
// (twice, with the stats pass), so choosing "MST" froze the page and a browser gate read the map
|
||||
// before any line existed ("MST drew no cluster lines"). Prim over the implicit complete graph
|
||||
// gives a minimum spanning tree of the same total length with no edge list; starting a new tree
|
||||
// whenever the nearest outside point is beyond maxConnectionDistance gives the same spanning
|
||||
// FOREST Kruskal did. Ties may pick a different, equally short edge.
|
||||
const n = validPoints.length;
|
||||
const lat = validPoints.map(p => parseFloat(p.latitude));
|
||||
const lng = validPoints.map(p => parseFloat(p.longitude));
|
||||
const inTree = new Uint8Array(n);
|
||||
const best = new Float64Array(n).fill(Infinity);
|
||||
const via = new Int32Array(n).fill(-1);
|
||||
const mstEdges = [];
|
||||
let edgesAdded = 0;
|
||||
const targetEdges = validPoints.length - 1;
|
||||
|
||||
for (const edge of edges) {
|
||||
if (edgesAdded >= targetEdges) break;
|
||||
if (uf.union(edge.from, edge.to)) {
|
||||
mstEdges.push(edge);
|
||||
edgesAdded++;
|
||||
let remaining = n;
|
||||
while (remaining > 0) {
|
||||
// Next point: the closest one to the growing tree, or a new root if none is within reach.
|
||||
let u = -1, ud = Infinity;
|
||||
for (let i = 0; i < n; i++) if (!inTree[i] && best[i] < ud) { ud = best[i]; u = i; }
|
||||
if (u === -1 || ud > maxConnectionDistance) {
|
||||
u = -1;
|
||||
for (let i = 0; i < n; i++) if (!inTree[i]) { u = i; break; }
|
||||
via[u] = -1;
|
||||
}
|
||||
inTree[u] = 1; remaining--;
|
||||
if (via[u] >= 0) {
|
||||
const f = via[u];
|
||||
mstEdges.push({ from: f, to: u, distance: best[u], fromPoint: validPoints[f], toPoint: validPoints[u] });
|
||||
}
|
||||
for (let v = 0; v < n; v++) {
|
||||
if (inTree[v]) continue;
|
||||
const d = euclideanDistance(lat[u], lng[u], lat[v], lng[v]);
|
||||
if (d < best[v]) { best[v] = d; via[v] = u; }
|
||||
}
|
||||
}
|
||||
const edgesAdded = mstEdges.length;
|
||||
const targetEdges = n - 1;
|
||||
|
||||
// Step 4: Convert to GeoJSON
|
||||
const lineFeatures = mstEdges.map(edge => ({
|
||||
|
|
|
|||
|
|
@ -115,6 +115,7 @@
|
|||
"check:mfe-two-tab": "node internal/check-mfe-two-tab.mjs",
|
||||
"check:mjs-parse": "node internal/check-mjs-parses.mjs",
|
||||
"check:mobile": "bun run cap:sync && bun run cap:verify",
|
||||
"check:mst-clustering": "node internal/check-mst-clustering.mjs",
|
||||
"check:nav-reachable": "node internal/check-nav-reachable-mobile.mjs",
|
||||
"check:nested-token": "node internal/check-nested-token-delivery.mjs",
|
||||
"check:no-abs-paths": "node internal/check-no-absolute-paths.mjs",
|
||||
|
|
@ -189,7 +190,7 @@
|
|||
"dev": "bun x vite",
|
||||
"fix:sri": "node internal/fix-sri.mjs",
|
||||
"gallery": "node internal/generate-gallery.mjs",
|
||||
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion",
|
||||
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering",
|
||||
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts",
|
||||
"library-index": "node internal/generate-library-index.mjs",
|
||||
"packet-fixture": "node internal/generate-packet-fixture.mjs",
|
||||
|
|
@ -301,6 +302,7 @@
|
|||
"check:map-unmount-mid-init": "end-to-end, deterministic: holds mapbox-gl.js, opens a map plugin in the studio, switches away (destroying the pane) mid-initialise, then releases the script. Fails on any page error. REQUIRES --url. Exit 2 if the race was not staged.",
|
||||
"check:map-unmount-race": "browser-free, sub-second: runs the shipped ensureEngineCSS with a null element (what an unmounted pane hands it) against a copy with its guard stripped; the guarded arm must resolve and the stripped arm must throw \"reading getRootNode\". By gospel-plugin, adopted by studio.",
|
||||
"check:mobile": "deploy-2 CONTRACT check: needs NO JDK/SDK/device. cap sync is a file copy + plugin wiring, so it runs anywhere; cap:verify then asserts assets/public/index.html exists. Guards the SOFT FAILURE capacitor.config.json warns about: a wrong webDir yields an EMPTY assets/public and a BLANK WHITE SCREEN at launch, not an error.",
|
||||
"check:mst-clustering": "the MST clustering mode draws a true minimum spanning tree/forest (same edge count and length as a reference Kruskal) and 2,107 pins take under 3 s on the main thread. Headless.",
|
||||
"check:nav-reachable": "a cap-5 shell must have ONE on-screen control that opens its tab rail at phone width (needs `bun run serve` on 4173; browser gate, not in build:unlocked)",
|
||||
"check:no-abs-paths": "no /home/… or /Users/… in any TRACKED file (a clone must work on any machine)",
|
||||
"check:no-codename": "the development-era INTERNAL CODE NAME may not ship in any TRACKED file. Same hand-off family as check:no-abs-paths: meaningless to the Doxa team who inherit this, and invisible to every other gate (none read for vocabulary). Deliberately does NOT fail on the same letters appearing as the unix USERNAME inside a /home/… path — that is a portability defect check:no-abs-paths already reports better, and two gates disagreeing about one string teaches everyone to ignore both.",
|
||||
|
|
|
|||
Loading…
Reference in a new issue