@hegemonart/get-design-done 1.52.0 → 1.53.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +49 -0
- package/README.md +2 -0
- package/agents/design-context-reviewer-gate.md +102 -0
- package/agents/design-context-reviewer.md +186 -0
- package/dist/claude-code/.claude/skills/discover/SKILL.md +7 -1
- package/dist/claude-code/.claude/skills/explore/SKILL.md +3 -1
- package/package.json +1 -1
- package/scripts/lib/explore-parallel-runner/index.ts +58 -0
- package/scripts/lib/explore-parallel-runner/types.ts +58 -0
- package/scripts/lib/manifest/skills.json +2 -2
- package/scripts/lib/mappers/compute-batches.mjs +625 -0
- package/scripts/lib/mappers/graph-adjacency.mjs +129 -0
- package/scripts/lib/mappers/incremental-discover.cjs +617 -0
- package/scripts/lib/mappers/incremental-discover.d.cts +133 -0
- package/scripts/lib/mappers/neighbor-map.mjs +0 -0
- package/sdk/cli/index.js +369 -2
- package/sdk/fingerprint/classify.cjs +406 -0
- package/sdk/fingerprint/index.ts +405 -0
- package/sdk/fingerprint/store.cjs +523 -0
- package/sdk/index.ts +1 -0
- package/skills/discover/SKILL.md +7 -1
- package/skills/explore/SKILL.md +3 -1
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
// scripts/lib/mappers/compute-batches.mjs — Phase 53 (Semantic Mapper Engine), executor A.
|
|
2
|
+
//
|
|
3
|
+
// Deterministic, dependency-free community batching over a DesignContext graph
|
|
4
|
+
// (Phase 52 shape). Groups graph nodes into mapper-sized batches so the explore
|
|
5
|
+
// runner can dispatch each community to a mapper instance (the batching extends
|
|
6
|
+
// explore-parallel-runner per CONTEXT R1; THIS file owns only the pure engine).
|
|
7
|
+
//
|
|
8
|
+
// Algorithm: a self-contained two-phase Louvain modularity maximization (NO
|
|
9
|
+
// graphology, NO new dependency — CONTEXT R5/D7). The graph it optimizes is an
|
|
10
|
+
// UNDIRECTED WEIGHTED graph over file-bearing nodes:
|
|
11
|
+
// - component nodes are the primary anchors;
|
|
12
|
+
// - a non-component node (variant/layer/token/...) that is OWNED by a component
|
|
13
|
+
// (variant --extends--> component; token used by exactly one component; layer
|
|
14
|
+
// attached to one component) FOLDS into that component — it is not its own
|
|
15
|
+
// Louvain node, and it inherits the owner's community in the flat result;
|
|
16
|
+
// - structural edges (composes/extends/depends-on/consumes-context/
|
|
17
|
+
// provides-context) between two anchors contribute their edge weight;
|
|
18
|
+
// - uses-token contributes an INVERSE-FREQUENCY-DAMPED token-cohesion weight:
|
|
19
|
+
// each token shared by a pair of components adds 1/log(1+deg(token)) to that
|
|
20
|
+
// component-pair's edge weight, so a globally-used token does not collapse
|
|
21
|
+
// every component into one blob;
|
|
22
|
+
// - non-component nodes that connect only to other non-component nodes (no
|
|
23
|
+
// component owner) remain standalone anchors and cluster among themselves
|
|
24
|
+
// into "semantic groups" (token/motion/a11y batches, mergeable:false).
|
|
25
|
+
//
|
|
26
|
+
// Determinism (hard contract, CONTEXT D6): nodes are iterated in a FIXED
|
|
27
|
+
// LEXICOGRAPHIC order (the only "seed" — NO Math.random); ties on a modularity
|
|
28
|
+
// move break to the smallest community id then the smallest node id; aggregation
|
|
29
|
+
// and unfolding sort before emitting. Identical batches on win32 + Linux + macOS.
|
|
30
|
+
//
|
|
31
|
+
// Safety nets:
|
|
32
|
+
// - try/catch around the whole optimization → count-fallback (alphabetical
|
|
33
|
+
// fallbackBatchSize-file batches) on ANY throw or fewer than 2 anchors;
|
|
34
|
+
// - MAX_COMMUNITY_SIZE overflow → alphabetical sub-split (labels c7, c7-2, …);
|
|
35
|
+
// - small-batch merger pools singleton code communities into <=miscCap "misc"
|
|
36
|
+
// batches; non-code semantic groups are emitted with mergeable:false so the
|
|
37
|
+
// merger never folds them.
|
|
38
|
+
//
|
|
39
|
+
// Public API:
|
|
40
|
+
// computeBatches(graph, opts?) -> {
|
|
41
|
+
// batches: Array<{ id, members:string[], mergeable:boolean,
|
|
42
|
+
// kind:'code'|'token'|'motion'|'a11y'|'misc',
|
|
43
|
+
// source:'louvain'|'fallback'|'subsplit'|'merge' }>,
|
|
44
|
+
// modularity: number|null,
|
|
45
|
+
// method: 'louvain'|'count-fallback'
|
|
46
|
+
// }
|
|
47
|
+
// Opts: resolution=1.0, maxCommunitySize=35, miscCap=25, fallbackBatchSize=12,
|
|
48
|
+
// configCwd (override .design/config.json discovery root).
|
|
49
|
+
|
|
50
|
+
import fs from 'node:fs';
|
|
51
|
+
import path from 'node:path';
|
|
52
|
+
import { buildAdjacency, degreeIndex } from './graph-adjacency.mjs';
|
|
53
|
+
|
|
54
|
+
const STRUCTURAL_EDGE_TYPES = new Set([
|
|
55
|
+
'composes',
|
|
56
|
+
'extends',
|
|
57
|
+
'depends-on',
|
|
58
|
+
'consumes-context',
|
|
59
|
+
'provides-context',
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
// Node types that, when they DOMINATE a community, mark it a non-code semantic
|
|
63
|
+
// group (emitted mergeable:false). Mapped to the batch `kind`.
|
|
64
|
+
const NONCODE_KIND = new Map([
|
|
65
|
+
['token', 'token'],
|
|
66
|
+
['motion-fragment', 'motion'],
|
|
67
|
+
['a11y-pattern', 'a11y'],
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
const DEFAULTS = {
|
|
71
|
+
resolution: 1.0,
|
|
72
|
+
maxCommunitySize: 35,
|
|
73
|
+
miscCap: 25,
|
|
74
|
+
fallbackBatchSize: 12,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Config (mirrors blast-radius.cjs#loadConfig precedence; never throws).
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
function numberOr(...candidates) {
|
|
82
|
+
for (const v of candidates) {
|
|
83
|
+
if (typeof v === 'number' && Number.isFinite(v) && v >= 0) return v;
|
|
84
|
+
}
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Resolve options. Precedence per dimension:
|
|
90
|
+
* explicit opts > .design/config.json#louvain.{...} > DEFAULTS.
|
|
91
|
+
* resolution/maxCommunitySize live under `louvain`; miscCap/fallbackBatchSize
|
|
92
|
+
* default unless an explicit opt overrides. Absent/garbage config never throws.
|
|
93
|
+
*/
|
|
94
|
+
function resolveOpts(opts) {
|
|
95
|
+
const o = opts || {};
|
|
96
|
+
let cfg = {};
|
|
97
|
+
try {
|
|
98
|
+
const root = o.configCwd || process.cwd();
|
|
99
|
+
cfg = JSON.parse(fs.readFileSync(path.join(root, '.design', 'config.json'), 'utf8'));
|
|
100
|
+
} catch { cfg = {}; }
|
|
101
|
+
const lv = (cfg && typeof cfg === 'object' && cfg.louvain) || {};
|
|
102
|
+
return {
|
|
103
|
+
resolution: numberOr(o.resolution, lv.resolution, DEFAULTS.resolution),
|
|
104
|
+
maxCommunitySize: numberOr(o.maxCommunitySize, lv.maxCommunitySize, DEFAULTS.maxCommunitySize),
|
|
105
|
+
miscCap: numberOr(o.miscCap, lv.miscCap, DEFAULTS.miscCap),
|
|
106
|
+
fallbackBatchSize: numberOr(o.fallbackBatchSize, lv.fallbackBatchSize, DEFAULTS.fallbackBatchSize),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// Graph shaping: anchors, fold map, and the weighted undirected batch graph.
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
function nodeList(graph) {
|
|
115
|
+
return Array.isArray(graph && graph.nodes) ? graph.nodes : [];
|
|
116
|
+
}
|
|
117
|
+
function edgeList(graph) {
|
|
118
|
+
return Array.isArray(graph && graph.edges) ? graph.edges : [];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Partition graph nodes into anchors (run Louvain over these) and a fold map
|
|
123
|
+
* (folded node id -> owning anchor id). A non-component node folds into a
|
|
124
|
+
* component when it is OWNED by exactly one component:
|
|
125
|
+
* - variant/state/layer/etc. with a structural edge to a single component;
|
|
126
|
+
* - token used by exactly one component (uses-token).
|
|
127
|
+
* Everything else (every component; non-component nodes with zero or many
|
|
128
|
+
* component owners) is an anchor. Components are always anchors. Tokens with >1
|
|
129
|
+
* component owner stay anchors but contribute cohesion weight (below) and are
|
|
130
|
+
* pulled toward code communities via that weight.
|
|
131
|
+
*
|
|
132
|
+
* Deterministic: when a node has multiple candidate single owners across edge
|
|
133
|
+
* kinds we never reach here (multi-owner => anchor); single-owner is unambiguous.
|
|
134
|
+
*/
|
|
135
|
+
function shapeAnchors(graph) {
|
|
136
|
+
const nodes = nodeList(graph);
|
|
137
|
+
const byId = new Map();
|
|
138
|
+
for (const n of nodes) if (n && typeof n.id === 'string') byId.set(n.id, n);
|
|
139
|
+
|
|
140
|
+
const isComponent = (id) => byId.get(id)?.type === 'component';
|
|
141
|
+
|
|
142
|
+
// Collect, per non-component node, the set of component ids it relates to via
|
|
143
|
+
// a structural or uses-token edge (direction-agnostic — ownership is undirected).
|
|
144
|
+
const ownersOf = new Map(); // nonCompId -> Set<componentId>
|
|
145
|
+
const noteOwner = (nonComp, comp) => {
|
|
146
|
+
if (!byId.has(nonComp) || !byId.has(comp)) return;
|
|
147
|
+
if (isComponent(nonComp)) return;
|
|
148
|
+
let s = ownersOf.get(nonComp);
|
|
149
|
+
if (!s) { s = new Set(); ownersOf.set(nonComp, s); }
|
|
150
|
+
s.add(comp);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
for (const e of edgeList(graph)) {
|
|
154
|
+
if (!e || typeof e.source !== 'string' || typeof e.target !== 'string') continue;
|
|
155
|
+
const isOwning = STRUCTURAL_EDGE_TYPES.has(e.type) || e.type === 'uses-token';
|
|
156
|
+
if (!isOwning) continue;
|
|
157
|
+
const sComp = isComponent(e.source);
|
|
158
|
+
const tComp = isComponent(e.target);
|
|
159
|
+
// exactly one endpoint is a component, the other is the candidate folded node.
|
|
160
|
+
if (sComp && !tComp) noteOwner(e.target, e.source);
|
|
161
|
+
else if (tComp && !sComp) noteOwner(e.source, e.target);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const fold = new Map(); // foldedId -> ownerComponentId
|
|
165
|
+
for (const [nonComp, owners] of ownersOf) {
|
|
166
|
+
if (owners.size === 1) fold.set(nonComp, [...owners][0]);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Anchors = all components + every non-component node NOT folded.
|
|
170
|
+
const anchors = new Set();
|
|
171
|
+
for (const n of nodes) {
|
|
172
|
+
if (!n || typeof n.id !== 'string') continue;
|
|
173
|
+
if (n.type === 'component') { anchors.add(n.id); continue; }
|
|
174
|
+
if (!fold.has(n.id)) anchors.add(n.id);
|
|
175
|
+
}
|
|
176
|
+
// A fold target must itself be an anchor; if an owner somehow isn't a node,
|
|
177
|
+
// demote the folded node to an anchor (defensive, keeps the partition total).
|
|
178
|
+
for (const [folded, owner] of [...fold]) {
|
|
179
|
+
if (!anchors.has(owner)) { fold.delete(folded); anchors.add(folded); }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { byId, anchors, fold, isComponent };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Build the undirected weighted batch graph over anchors. Returns:
|
|
187
|
+
* { ids: string[] (sorted), weight: Map<id, Map<id, number>>, m: number }
|
|
188
|
+
* where `weight` is symmetric (both directions populated) and `m` is the total
|
|
189
|
+
* undirected edge weight (sum of one side). Cohesion: each token shared by a
|
|
190
|
+
* pair of component anchors adds 1/log(1+deg(token)) to that pair.
|
|
191
|
+
*/
|
|
192
|
+
function buildBatchGraph(graph, shape) {
|
|
193
|
+
const { byId, anchors, fold, isComponent } = shape;
|
|
194
|
+
const deg = degreeIndex(graph);
|
|
195
|
+
|
|
196
|
+
// Symmetric weighted adjacency among anchors only.
|
|
197
|
+
const w = new Map();
|
|
198
|
+
for (const id of anchors) w.set(id, new Map());
|
|
199
|
+
const bump = (a, b, val) => {
|
|
200
|
+
if (a === b || !w.has(a) || !w.has(b)) return;
|
|
201
|
+
const ma = w.get(a); ma.set(b, (ma.get(b) || 0) + val);
|
|
202
|
+
const mb = w.get(b); mb.set(a, (mb.get(a) || 0) + val);
|
|
203
|
+
};
|
|
204
|
+
// Resolve an endpoint to its anchor (folded node -> its owner component).
|
|
205
|
+
const anchorOf = (id) => (anchors.has(id) ? id : fold.get(id));
|
|
206
|
+
|
|
207
|
+
// 1) Structural edges between resolved anchors contribute their weight.
|
|
208
|
+
for (const e of edgeList(graph)) {
|
|
209
|
+
if (!e || typeof e.source !== 'string' || typeof e.target !== 'string') continue;
|
|
210
|
+
if (!STRUCTURAL_EDGE_TYPES.has(e.type)) continue;
|
|
211
|
+
const a = anchorOf(e.source);
|
|
212
|
+
const b = anchorOf(e.target);
|
|
213
|
+
if (a === undefined || b === undefined) continue;
|
|
214
|
+
const val = typeof e.weight === 'number' && Number.isFinite(e.weight) && e.weight >= 0 ? e.weight : 1;
|
|
215
|
+
bump(a, b, val);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 2) uses-token cohesion. For each token, find the set of component anchors
|
|
219
|
+
// that use it, then add a damped weight to every component pair sharing it.
|
|
220
|
+
// damp = 1 / log(1 + deg(token)) (deg via degreeIndex; guard log<=0 -> 1).
|
|
221
|
+
const tokenUsers = new Map(); // tokenId -> Set<componentAnchorId>
|
|
222
|
+
for (const e of edgeList(graph)) {
|
|
223
|
+
if (!e || e.type !== 'uses-token') continue;
|
|
224
|
+
// uses-token is component(source) -> token(target) in Phase 52, but tolerate
|
|
225
|
+
// either orientation: the token endpoint is the non-component one.
|
|
226
|
+
let tokenId; let compId;
|
|
227
|
+
if (byId.get(e.target)?.type === 'token') { tokenId = e.target; compId = e.source; }
|
|
228
|
+
else if (byId.get(e.source)?.type === 'token') { tokenId = e.source; compId = e.target; }
|
|
229
|
+
else continue;
|
|
230
|
+
const compAnchor = anchorOf(compId);
|
|
231
|
+
if (compAnchor === undefined || !isComponent(compAnchor)) continue;
|
|
232
|
+
let s = tokenUsers.get(tokenId);
|
|
233
|
+
if (!s) { s = new Set(); tokenUsers.set(tokenId, s); }
|
|
234
|
+
s.add(compAnchor);
|
|
235
|
+
}
|
|
236
|
+
for (const [tokenId, usersSet] of tokenUsers) {
|
|
237
|
+
if (usersSet.size < 2) continue; // a token used by <2 components ties nothing
|
|
238
|
+
const dlog = Math.log(1 + (deg.get(tokenId) || 0));
|
|
239
|
+
const damp = dlog > 0 ? 1 / dlog : 1;
|
|
240
|
+
const users = [...usersSet].sort(); // deterministic pair order
|
|
241
|
+
for (let i = 0; i < users.length; i++) {
|
|
242
|
+
for (let j = i + 1; j < users.length; j++) {
|
|
243
|
+
bump(users[i], users[j], damp);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Total undirected edge weight m = half the sum of all directed entries.
|
|
249
|
+
let two = 0;
|
|
250
|
+
for (const m of w.values()) for (const v of m.values()) two += v;
|
|
251
|
+
const m = two / 2;
|
|
252
|
+
|
|
253
|
+
const ids = [...anchors].sort();
|
|
254
|
+
return { ids, weight: w, m };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// Two-phase Louvain.
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* One level of local modularity optimization over a weighted graph given as
|
|
263
|
+
* nodes: string[] (already sorted lexicographically — the determinism seed),
|
|
264
|
+
* adj: Map<id, Map<id, number>> (symmetric),
|
|
265
|
+
* selfLoop: Map<id, number> (intra-node weight from aggregation; 0 at level 0),
|
|
266
|
+
* m: total undirected edge weight,
|
|
267
|
+
* gamma: resolution.
|
|
268
|
+
* Returns Map<id, communityId> (community id is a representative node id string).
|
|
269
|
+
*
|
|
270
|
+
* ΔQ(i->c) = k_{i,in}/m − gamma * (Σ_tot[c] * k_i) / (2 m^2)
|
|
271
|
+
* Move each node to the neighbor community of max positive gain; tie-break to the
|
|
272
|
+
* smallest community id then smallest node id (handled by fixed iteration + the
|
|
273
|
+
* comparison below). Stops when a pass improves total gain < 1e-7 or 20 passes.
|
|
274
|
+
*/
|
|
275
|
+
function louvainLevel(nodes, adj, selfLoop, m, gamma) {
|
|
276
|
+
const community = new Map(); // node -> communityId
|
|
277
|
+
const k = new Map(); // node -> weighted degree (incident, incl self*2)
|
|
278
|
+
const sigmaTot = new Map(); // communityId -> Σ degree of members
|
|
279
|
+
|
|
280
|
+
for (const n of nodes) {
|
|
281
|
+
community.set(n, n);
|
|
282
|
+
const self = selfLoop.get(n) || 0;
|
|
283
|
+
let deg = self * 2; // a self-loop contributes twice to weighted degree
|
|
284
|
+
for (const v of (adj.get(n) || new Map()).values()) deg += v;
|
|
285
|
+
k.set(n, deg);
|
|
286
|
+
sigmaTot.set(n, deg);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (m <= 0) return community; // no edges: every node its own community
|
|
290
|
+
|
|
291
|
+
const twoM2 = 2 * m * m;
|
|
292
|
+
|
|
293
|
+
for (let pass = 0; pass < 20; pass++) {
|
|
294
|
+
let passGain = 0;
|
|
295
|
+
for (const i of nodes) { // FIXED lexicographic order — the determinism seed
|
|
296
|
+
const ci = community.get(i);
|
|
297
|
+
const ki = k.get(i);
|
|
298
|
+
|
|
299
|
+
// Sum of weights from i into each candidate community (excluding self-loop).
|
|
300
|
+
const wTo = new Map();
|
|
301
|
+
for (const [nb, wgt] of adj.get(i) || new Map()) {
|
|
302
|
+
if (nb === i) continue;
|
|
303
|
+
const cnb = community.get(nb);
|
|
304
|
+
wTo.set(cnb, (wTo.get(cnb) || 0) + wgt);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Remove i from its current community before evaluating moves.
|
|
308
|
+
sigmaTot.set(ci, sigmaTot.get(ci) - ki);
|
|
309
|
+
|
|
310
|
+
// Gain of placing i into community c: k_{i,in}/m − gamma*(Σ_tot[c]*k_i)/(2m²).
|
|
311
|
+
// The shared 1/m and damped term make the absolute baseline (staying in ci,
|
|
312
|
+
// now emptied of i) gain wTo(ci)/m − gamma*sigmaTot[ci]*ki/2m². We pick the
|
|
313
|
+
// max; ties break to smallest community id then smallest node id.
|
|
314
|
+
let bestC = ci;
|
|
315
|
+
let bestGain = (wTo.get(ci) || 0) / m - (gamma * sigmaTot.get(ci) * ki) / twoM2;
|
|
316
|
+
// Ensure the "stay" option is always considered with ci as the tie anchor.
|
|
317
|
+
for (const [c, kin] of [...wTo].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))) {
|
|
318
|
+
const gain = kin / m - (gamma * sigmaTot.get(c) * ki) / twoM2;
|
|
319
|
+
if (gain > bestGain + 1e-12 || (Math.abs(gain - bestGain) <= 1e-12 && c < bestC)) {
|
|
320
|
+
bestGain = gain; bestC = c;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Place i into bestC.
|
|
325
|
+
sigmaTot.set(bestC, sigmaTot.get(bestC) + ki);
|
|
326
|
+
if (bestC !== ci) {
|
|
327
|
+
community.set(i, bestC);
|
|
328
|
+
const stayGain = (wTo.get(ci) || 0) / m - (gamma * (sigmaTot.get(ci)) * ki) / twoM2;
|
|
329
|
+
passGain += Math.max(0, bestGain - stayGain);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
if (passGain < 1e-7) break;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return community;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Compute global modularity Q of a partition over the weighted graph. */
|
|
339
|
+
function modularityOf(nodes, adj, selfLoop, m, community) {
|
|
340
|
+
if (m <= 0) return null;
|
|
341
|
+
// Q = Σ_c [ Σ_in[c]/(2m) − (Σ_tot[c]/(2m))² ]
|
|
342
|
+
const sigmaIn = new Map(); // community -> internal weight (2*intra incl self-loops counted once*2)
|
|
343
|
+
const sigmaTot = new Map();
|
|
344
|
+
const deg = new Map();
|
|
345
|
+
for (const n of nodes) {
|
|
346
|
+
const self = selfLoop.get(n) || 0;
|
|
347
|
+
let d = self * 2;
|
|
348
|
+
for (const v of (adj.get(n) || new Map()).values()) d += v;
|
|
349
|
+
deg.set(n, d);
|
|
350
|
+
const c = community.get(n);
|
|
351
|
+
sigmaTot.set(c, (sigmaTot.get(c) || 0) + d);
|
|
352
|
+
// self-loop is fully internal: contributes 2*self to Σ_in.
|
|
353
|
+
sigmaIn.set(c, (sigmaIn.get(c) || 0) + 2 * self);
|
|
354
|
+
}
|
|
355
|
+
for (const n of nodes) {
|
|
356
|
+
const c = community.get(n);
|
|
357
|
+
for (const [nb, wgt] of adj.get(n) || new Map()) {
|
|
358
|
+
if (nb === n) continue;
|
|
359
|
+
if (community.get(nb) === c) sigmaIn.set(c, (sigmaIn.get(c) || 0) + wgt);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
let q = 0;
|
|
363
|
+
const twoM = 2 * m;
|
|
364
|
+
for (const c of new Set(community.values())) {
|
|
365
|
+
const sin = sigmaIn.get(c) || 0;
|
|
366
|
+
const stot = sigmaTot.get(c) || 0;
|
|
367
|
+
q += sin / twoM - (stot / twoM) * (stot / twoM);
|
|
368
|
+
}
|
|
369
|
+
return q;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Full two-phase Louvain. Returns { labels: Map<anchorId, communityLabel>,
|
|
374
|
+
* modularity:number|null }. Iterates: local optimize -> aggregate communities
|
|
375
|
+
* into super-nodes (summing inter/intra weights) -> repeat until the partition
|
|
376
|
+
* stops changing (or no gain). Community labels are the lexicographically
|
|
377
|
+
* smallest original anchor id in each community (stable, OS-independent).
|
|
378
|
+
*/
|
|
379
|
+
function runLouvain(batchGraph, gamma) {
|
|
380
|
+
const { ids, weight, m } = batchGraph;
|
|
381
|
+
if (m <= 0 || ids.length < 2) {
|
|
382
|
+
// Degenerate: each anchor alone. Caller's <2 check handles the throw path;
|
|
383
|
+
// here (no edges) we still return singletons labeled by themselves.
|
|
384
|
+
const labels = new Map(ids.map((id) => [id, id]));
|
|
385
|
+
return { labels, modularity: m <= 0 ? null : 0 };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// membership: original anchor id -> current super-node id (starts as itself).
|
|
389
|
+
let nodes = ids.slice();
|
|
390
|
+
let adj = weight;
|
|
391
|
+
let selfLoop = new Map(nodes.map((n) => [n, 0]));
|
|
392
|
+
// map from current super-node id -> set of original anchor ids it contains.
|
|
393
|
+
let contains = new Map(nodes.map((n) => [n, new Set([n])]));
|
|
394
|
+
|
|
395
|
+
for (let iter = 0; iter < 50; iter++) {
|
|
396
|
+
const community = louvainLevel(nodes, adj, selfLoop, m, gamma);
|
|
397
|
+
|
|
398
|
+
// Did anything merge? If every node is its own community, we're stable.
|
|
399
|
+
const distinct = new Set(community.values());
|
|
400
|
+
if (distinct.size === nodes.length) break;
|
|
401
|
+
|
|
402
|
+
// Aggregate: new super-node id = lexicographically smallest member id.
|
|
403
|
+
const repOf = new Map(); // oldCommunityId -> representative (smallest member)
|
|
404
|
+
for (const n of nodes) {
|
|
405
|
+
const c = community.get(n);
|
|
406
|
+
const cur = repOf.get(c);
|
|
407
|
+
if (cur === undefined || n < cur) repOf.set(c, n);
|
|
408
|
+
}
|
|
409
|
+
const newContains = new Map();
|
|
410
|
+
for (const n of nodes) {
|
|
411
|
+
const rep = repOf.get(community.get(n));
|
|
412
|
+
let set = newContains.get(rep);
|
|
413
|
+
if (!set) { set = new Set(); newContains.set(rep, set); }
|
|
414
|
+
for (const orig of contains.get(n)) set.add(orig);
|
|
415
|
+
}
|
|
416
|
+
const newNodes = [...newContains.keys()].sort();
|
|
417
|
+
const newAdj = new Map(newNodes.map((n) => [n, new Map()]));
|
|
418
|
+
const newSelf = new Map(newNodes.map((n) => [n, 0]));
|
|
419
|
+
for (const n of nodes) {
|
|
420
|
+
const rn = repOf.get(community.get(n));
|
|
421
|
+
// self-loop carries forward.
|
|
422
|
+
newSelf.set(rn, (newSelf.get(rn) || 0) + (selfLoop.get(n) || 0));
|
|
423
|
+
for (const [nb, wgt] of adj.get(n) || new Map()) {
|
|
424
|
+
const rnb = repOf.get(community.get(nb));
|
|
425
|
+
if (rn === rnb) {
|
|
426
|
+
if (nb === n) continue; // self handled above
|
|
427
|
+
// intra-community edge becomes part of the super-node self-loop (each
|
|
428
|
+
// undirected edge appears twice across n/nb; add half here).
|
|
429
|
+
newSelf.set(rn, (newSelf.get(rn) || 0) + wgt / 2);
|
|
430
|
+
} else {
|
|
431
|
+
const ma = newAdj.get(rn);
|
|
432
|
+
ma.set(rnb, (ma.get(rnb) || 0) + wgt);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
nodes = newNodes;
|
|
438
|
+
adj = newAdj;
|
|
439
|
+
selfLoop = newSelf;
|
|
440
|
+
contains = newContains;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Unfold: every original anchor id -> the super-node id (representative) it
|
|
444
|
+
// ended in. `contains` maps current super-node -> original ids.
|
|
445
|
+
const labels = new Map();
|
|
446
|
+
for (const [rep, origSet] of contains) {
|
|
447
|
+
for (const orig of origSet) labels.set(orig, rep);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Final modularity is measured on the ORIGINAL graph with the final partition.
|
|
451
|
+
const origCommunity = new Map(ids.map((id) => [id, labels.get(id)]));
|
|
452
|
+
const modularity = modularityOf(ids, weight, new Map(ids.map((id) => [id, 0])), m, origCommunity);
|
|
453
|
+
|
|
454
|
+
return { labels, modularity };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ---------------------------------------------------------------------------
|
|
458
|
+
// Community -> batches (kind classification, sub-split, merge).
|
|
459
|
+
// ---------------------------------------------------------------------------
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Expand each Louvain community into full membership (anchors + their folded
|
|
463
|
+
* children), then classify, sub-split oversize, and merge small code singletons.
|
|
464
|
+
* Returns the final batches array.
|
|
465
|
+
*/
|
|
466
|
+
function communitiesToBatches(labels, shape, opts) {
|
|
467
|
+
const { byId, fold } = shape;
|
|
468
|
+
const { maxCommunitySize, miscCap } = opts;
|
|
469
|
+
|
|
470
|
+
// community label -> member node ids (anchors + folded children).
|
|
471
|
+
const members = new Map();
|
|
472
|
+
const push = (label, id) => {
|
|
473
|
+
let arr = members.get(label);
|
|
474
|
+
if (!arr) { arr = []; members.set(label, arr); }
|
|
475
|
+
arr.push(id);
|
|
476
|
+
};
|
|
477
|
+
for (const [anchor, label] of labels) push(label, anchor);
|
|
478
|
+
for (const [folded, owner] of fold) {
|
|
479
|
+
const label = labels.get(owner);
|
|
480
|
+
if (label !== undefined) push(label, folded);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Classify a community by node-type majority. Non-code (token/motion/a11y
|
|
484
|
+
// dominant) -> that kind + mergeable:false. Code (component-bearing) -> 'code'.
|
|
485
|
+
const classify = (ids) => {
|
|
486
|
+
const counts = new Map();
|
|
487
|
+
for (const id of ids) {
|
|
488
|
+
const t = byId.get(id)?.type || 'unknown';
|
|
489
|
+
counts.set(t, (counts.get(t) || 0) + 1);
|
|
490
|
+
}
|
|
491
|
+
const componentCount = counts.get('component') || 0;
|
|
492
|
+
if (componentCount > 0) return { kind: 'code', mergeable: true };
|
|
493
|
+
// No components: pick the dominant non-code type (deterministic tie -> the
|
|
494
|
+
// NONCODE_KIND order, then alpha).
|
|
495
|
+
let bestType = null; let bestN = -1;
|
|
496
|
+
for (const [t, n] of [...counts].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))) {
|
|
497
|
+
if (n > bestN) { bestN = n; bestType = t; }
|
|
498
|
+
}
|
|
499
|
+
const kind = NONCODE_KIND.get(bestType) || 'misc';
|
|
500
|
+
// token/motion/a11y semantic groups are non-mergeable; pure 'misc' (e.g. a
|
|
501
|
+
// lone variant/layer/screen cluster) stays mergeable so the merger can pool it.
|
|
502
|
+
const mergeable = !NONCODE_KIND.has(bestType);
|
|
503
|
+
return { kind, mergeable };
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
// Stable community order: by smallest member id.
|
|
507
|
+
const orderedLabels = [...members.keys()].sort();
|
|
508
|
+
let counter = 0;
|
|
509
|
+
const nextId = () => `batch-${String(++counter).padStart(2, '0')}`;
|
|
510
|
+
|
|
511
|
+
const out = [];
|
|
512
|
+
for (const label of orderedLabels) {
|
|
513
|
+
const ids = members.get(label).slice().sort();
|
|
514
|
+
const { kind, mergeable } = classify(ids);
|
|
515
|
+
|
|
516
|
+
if (ids.length <= maxCommunitySize) {
|
|
517
|
+
out.push({ id: nextId(), members: ids, mergeable, kind, source: 'louvain' });
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
// Oversize -> alphabetical sub-split into <=maxCommunitySize chunks.
|
|
521
|
+
const baseId = nextId();
|
|
522
|
+
for (let i = 0, part = 0; i < ids.length; i += maxCommunitySize, part++) {
|
|
523
|
+
const chunk = ids.slice(i, i + maxCommunitySize);
|
|
524
|
+
const id = part === 0 ? baseId : `${baseId}-${part + 1}`;
|
|
525
|
+
out.push({ id, members: chunk, mergeable, kind, source: 'subsplit' });
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
return mergeSmall(out, miscCap, nextId);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Pool small MERGEABLE code/misc batches (here: singletons and tiny leftovers)
|
|
534
|
+
* into <=miscCap "misc" batches. Non-mergeable semantic groups pass through
|
|
535
|
+
* untouched. Deterministic: merge candidates are sorted by their first member id
|
|
536
|
+
* and packed greedily into misc bins of size <=miscCap.
|
|
537
|
+
*/
|
|
538
|
+
function mergeSmall(batches, miscCap, nextId) {
|
|
539
|
+
const SMALL = 2; // a "small" mergeable batch is a singleton (1 member).
|
|
540
|
+
const keep = [];
|
|
541
|
+
const poolable = [];
|
|
542
|
+
for (const b of batches) {
|
|
543
|
+
if (b.mergeable && b.members.length < SMALL) poolable.push(b);
|
|
544
|
+
else keep.push(b);
|
|
545
|
+
}
|
|
546
|
+
if (poolable.length < 2) return batches; // nothing worth pooling
|
|
547
|
+
|
|
548
|
+
poolable.sort((a, b) => (a.members[0] < b.members[0] ? -1 : a.members[0] > b.members[0] ? 1 : 0));
|
|
549
|
+
const merged = [];
|
|
550
|
+
let bin = [];
|
|
551
|
+
const flush = () => {
|
|
552
|
+
if (!bin.length) return;
|
|
553
|
+
// `nextId` continues the counter past the last assigned batch id so merge
|
|
554
|
+
// bins get fresh, non-colliding ids; existing ids (incl. subsplit -N
|
|
555
|
+
// suffixes like batch-07-2) are PRESERVED — never renumbered.
|
|
556
|
+
merged.push({ id: nextId(), members: bin.slice().sort(), mergeable: true, kind: 'misc', source: 'merge' });
|
|
557
|
+
bin = [];
|
|
558
|
+
};
|
|
559
|
+
for (const b of poolable) {
|
|
560
|
+
if (bin.length + b.members.length > miscCap) flush();
|
|
561
|
+
bin.push(...b.members);
|
|
562
|
+
}
|
|
563
|
+
flush();
|
|
564
|
+
|
|
565
|
+
// Stable output order by first member id; ids are left untouched so the
|
|
566
|
+
// documented label contract (subsplit base + -N suffix) survives merging.
|
|
567
|
+
const all = keep.concat(merged);
|
|
568
|
+
all.sort((a, b) => (a.members[0] < b.members[0] ? -1 : a.members[0] > b.members[0] ? 1 : 0));
|
|
569
|
+
return all;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// ---------------------------------------------------------------------------
|
|
573
|
+
// count-fallback (alphabetical fixed-size batches).
|
|
574
|
+
// ---------------------------------------------------------------------------
|
|
575
|
+
|
|
576
|
+
/** Alphabetical fallback over ALL node ids in fixed-size chunks. */
|
|
577
|
+
function countFallback(graph, size) {
|
|
578
|
+
const ids = nodeList(graph)
|
|
579
|
+
.filter((n) => n && typeof n.id === 'string')
|
|
580
|
+
.map((n) => n.id)
|
|
581
|
+
.sort();
|
|
582
|
+
const batches = [];
|
|
583
|
+
let n = 0;
|
|
584
|
+
for (let i = 0; i < ids.length; i += size) {
|
|
585
|
+
batches.push({
|
|
586
|
+
id: `batch-${String(++n).padStart(2, '0')}`,
|
|
587
|
+
members: ids.slice(i, i + size),
|
|
588
|
+
mergeable: true,
|
|
589
|
+
kind: 'misc',
|
|
590
|
+
source: 'fallback',
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
return { batches, modularity: null, method: 'count-fallback' };
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// ---------------------------------------------------------------------------
|
|
597
|
+
// Public entry.
|
|
598
|
+
// ---------------------------------------------------------------------------
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Group a DesignContext graph into mapper batches via deterministic two-phase
|
|
602
|
+
* Louvain, with safety nets. See file header for the full contract.
|
|
603
|
+
*
|
|
604
|
+
* @param {object} graph Phase-52 graph ({ nodes, edges }).
|
|
605
|
+
* @param {object} [opts] resolution, maxCommunitySize, miscCap, fallbackBatchSize, configCwd.
|
|
606
|
+
* @returns {{batches:Array, modularity:number|null, method:'louvain'|'count-fallback'}}
|
|
607
|
+
*/
|
|
608
|
+
export function computeBatches(graph, opts) {
|
|
609
|
+
const resolved = resolveOpts(opts);
|
|
610
|
+
try {
|
|
611
|
+
const shape = shapeAnchors(graph);
|
|
612
|
+
// <2 anchors => count-fallback (the contract's small-input guard).
|
|
613
|
+
if (shape.anchors.size < 2) return countFallback(graph, resolved.fallbackBatchSize);
|
|
614
|
+
|
|
615
|
+
const batchGraph = buildBatchGraph(graph, shape);
|
|
616
|
+
const { labels, modularity } = runLouvain(batchGraph, resolved.resolution);
|
|
617
|
+
const batches = communitiesToBatches(labels, shape, resolved);
|
|
618
|
+
return { batches, modularity, method: 'louvain' };
|
|
619
|
+
} catch {
|
|
620
|
+
// ANY throw -> count-fallback (never crash the dispatcher).
|
|
621
|
+
return countFallback(graph, resolved.fallbackBatchSize);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
export default { computeBatches };
|