@sigloch/se-engine 1.0.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.
@@ -0,0 +1,383 @@
1
+ /** Locale-independent id order — the canonical walk order of every primitive here. */
2
+ const byIdCanonical = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
3
+ /**
4
+ * Build adjacency maps from a graph. Traces referencing unknown nodes are
5
+ * dropped. `nodeWeight` (e.g. LOC per node, see layer.ts `weightNodes`) sets
6
+ * node masses; edge weight = mean of endpoint masses.
7
+ *
8
+ * ORDER-INVARIANT (CR-SM-240): `nodes` and every neighbour set come back in
9
+ * canonical id order, so the SAME graph in a different element/trace order
10
+ * yields a bit-identical Adjacency — and therefore bit-identical metrics.
11
+ */
12
+ export function buildAdjacency(graph, nodeWeight) {
13
+ // CR-SM-240: CANONICAL order, not the caller's element order. Every primitive
14
+ // in this file walks `nodes` and the neighbour sets, so the caller's ordering
15
+ // used to leak into (a) the community ids and the CNM tie-break — moving
16
+ // `modifiability` and `coherence` by ~1e-2 on a real graph — and (b) the float
17
+ // summation order of degrees/betweenness, worth ~1e-15 in `scalability`.
18
+ // Sorting HERE fixes both at the single place where the order enters, instead
19
+ // of at each of the five consumers.
20
+ //
21
+ // Plain `<`/`>` on purpose, NOT `localeCompare`: the comparison must not depend
22
+ // on the runtime's locale — an invariant that holds only on the developer's
23
+ // machine is not an invariant.
24
+ const nodes = graph.elements.map((e) => e.id).sort(byIdCanonical);
25
+ const known = new Set(nodes);
26
+ const undirected = new Map();
27
+ const out = new Map();
28
+ const inn = new Map();
29
+ const w = new Map();
30
+ const mass = new Map(nodes.map((id) => [id, nodeWeight?.get(id) ?? 1]));
31
+ for (const id of nodes) {
32
+ undirected.set(id, new Set());
33
+ out.set(id, new Set());
34
+ inn.set(id, new Set());
35
+ w.set(id, new Map());
36
+ }
37
+ for (const t of graph.traces) {
38
+ if (!known.has(t.source) || !known.has(t.target) || t.source === t.target)
39
+ continue;
40
+ out.get(t.source).add(t.target);
41
+ inn.get(t.target).add(t.source);
42
+ undirected.get(t.source).add(t.target);
43
+ undirected.get(t.target).add(t.source);
44
+ const ew = (mass.get(t.source) + mass.get(t.target)) / 2;
45
+ w.get(t.source).set(t.target, ew);
46
+ w.get(t.target).set(t.source, ew);
47
+ }
48
+ // The sets were filled in TRACE order; re-lay them in canonical id order so the
49
+ // neighbour walks (BFS in betweenness, the CNM aggregate build) are invariant too.
50
+ // Sorting only `nodes` would leave the second half of the leak open.
51
+ for (const id of nodes) {
52
+ undirected.set(id, new Set([...undirected.get(id)].sort(byIdCanonical)));
53
+ out.set(id, new Set([...out.get(id)].sort(byIdCanonical)));
54
+ inn.set(id, new Set([...inn.get(id)].sort(byIdCanonical)));
55
+ }
56
+ return { nodes, undirected, out, in: inn, w, mass };
57
+ }
58
+ /**
59
+ * Betweenness centrality (Brandes' algorithm, unweighted undirected), normalized
60
+ * to [0,1] by the number of ordered node pairs (n-1)(n-2). Returns 0 for all
61
+ * nodes when n < 3. Higher = more of the shortest paths route through the node
62
+ * (structural bottleneck).
63
+ *
64
+ * CR-SM-228: index-based typed arrays instead of a fresh Map<string,X> per BFS
65
+ * source. The algorithm and its output are unchanged (bit-identical to the
66
+ * prior Map-based version) -- only the per-source scratch state changes from
67
+ * "allocate 4 new Maps" (O(V) allocation repeated V times = O(V^2) overhead
68
+ * on top of Brandes' own O(V*E), dominant on sparse graphs) to "reset 4
69
+ * preallocated typed arrays via .fill()" (same O(V) work, without the
70
+ * allocation/hashing cost). Measured A/B (1910-node graph, 3915 edges,
71
+ * cloned from graphcode's own SSOT):
72
+ * OLD per-source Map allocation : ~610 ms
73
+ * NEW indexed typed arrays : ~93 ms (6.6x faster, max diff 0)
74
+ * Root cause + measurement: sigloch-modules/docs/cr/open/CR-SM-228-*.md.
75
+ */
76
+ export function betweenness(adj) {
77
+ const { nodes, undirected } = adj;
78
+ const n = nodes.length;
79
+ const cb = new Map(nodes.map((id) => [id, 0]));
80
+ if (n < 3)
81
+ return cb;
82
+ const indexOf = new Map(nodes.map((id, i) => [id, i]));
83
+ // Neighbour lists as index arrays, built once (not per BFS source).
84
+ const neighbours = nodes.map((id) => Int32Array.from(undirected.get(id), (w) => indexOf.get(w)));
85
+ const dist = new Int32Array(n);
86
+ const sigma = new Float64Array(n);
87
+ const delta = new Float64Array(n);
88
+ const queue = new Int32Array(n);
89
+ const stack = new Int32Array(n);
90
+ const cbArr = new Float64Array(n);
91
+ // Predecessor lists, reused across sources (length reset to 0, not reallocated).
92
+ const pred = Array.from({ length: n }, () => []);
93
+ for (let s = 0; s < n; s++) {
94
+ dist.fill(-1);
95
+ sigma.fill(0);
96
+ delta.fill(0);
97
+ for (let i = 0; i < n; i++)
98
+ pred[i].length = 0;
99
+ dist[s] = 0;
100
+ sigma[s] = 1;
101
+ let qHead = 0;
102
+ let qTail = 0;
103
+ let sTop = 0;
104
+ queue[qTail++] = s;
105
+ while (qHead < qTail) {
106
+ const v = queue[qHead++];
107
+ stack[sTop++] = v;
108
+ const dv = dist[v];
109
+ for (const w2 of neighbours[v]) {
110
+ if (dist[w2] < 0) {
111
+ dist[w2] = dv + 1;
112
+ queue[qTail++] = w2;
113
+ }
114
+ if (dist[w2] === dv + 1) {
115
+ sigma[w2] += sigma[v];
116
+ pred[w2].push(v);
117
+ }
118
+ }
119
+ }
120
+ while (sTop > 0) {
121
+ const w2 = stack[--sTop];
122
+ for (const v of pred[w2]) {
123
+ delta[v] += (sigma[v] / sigma[w2]) * (1 + delta[w2]);
124
+ }
125
+ if (w2 !== s)
126
+ cbArr[w2] += delta[w2];
127
+ }
128
+ }
129
+ // Each shortest path counted twice on an undirected graph → divide by 2,
130
+ // then normalize by the number of ordered pairs (n-1)(n-2).
131
+ const norm = (n - 1) * (n - 2);
132
+ nodes.forEach((id, i) => cb.set(id, cbArr[i] / norm));
133
+ return cb;
134
+ }
135
+ /** Max betweenness over all nodes (the worst bottleneck), in [0,1]. */
136
+ export function maxBetweenness(adj) {
137
+ let max = 0;
138
+ for (const v of betweenness(adj).values())
139
+ if (v > max)
140
+ max = v;
141
+ return max;
142
+ }
143
+ /** Weighted degree (strength) per node and 2m (sum of strengths). */
144
+ function degrees(adj) {
145
+ const deg = new Map();
146
+ let twoM = 0;
147
+ for (const id of adj.nodes) {
148
+ let s = 0;
149
+ for (const ww of adj.w.get(id).values())
150
+ s += ww;
151
+ deg.set(id, s);
152
+ twoM += s;
153
+ }
154
+ return { deg, twoM };
155
+ }
156
+ /**
157
+ * Newman modularity Q for a given community assignment, over weighted edges.
158
+ * Q = (1/2m) Σ_ij [A_ij − k_i k_j / 2m] δ(c_i,c_j) ∈ [-0.5, 1]. 0 when edgeless.
159
+ * Unweighted graphs (all edge weights 1) reproduce the classic unweighted Q.
160
+ */
161
+ export function modularityOf(adj, community) {
162
+ const { nodes, undirected } = adj;
163
+ const { deg, twoM } = degrees(adj);
164
+ if (twoM === 0)
165
+ return 0;
166
+ let intra = 0; // Σ A_ij over same-community ordered pairs (= 2 × internal edge weight)
167
+ for (const v of nodes) {
168
+ for (const w2 of undirected.get(v)) {
169
+ if (community.get(v) === community.get(w2))
170
+ intra += adj.w.get(v).get(w2);
171
+ }
172
+ }
173
+ const sumDegByComm = new Map();
174
+ for (const v of nodes) {
175
+ const c = community.get(v);
176
+ sumDegByComm.set(c, (sumDegByComm.get(c) ?? 0) + deg.get(v));
177
+ }
178
+ let degTerm = 0;
179
+ for (const s of sumDegByComm.values())
180
+ degTerm += (s / twoM) * (s / twoM);
181
+ return intra / twoM - degTerm;
182
+ }
183
+ /** Newman modularity Q of the graph under greedy CNM communities. */
184
+ export function modularityQ(adj) {
185
+ return modularityOf(adj, detectCommunities(adj));
186
+ }
187
+ /**
188
+ * Greedy agglomerative community detection (Clauset–Newman–Moore, weighted):
189
+ * start each node in its own community, repeatedly merge the edge-linked pair
190
+ * that most increases Q, until no merge helps. Returns node→community-id.
191
+ * Edgeless graph → singletons.
192
+ *
193
+ * DETERMINISM (CR-SM-240) — the claim used to be "ties broken by community-id
194
+ * order", which was only half true: the ids are `buildAdjacency`'s node indices,
195
+ * and those were the CALLER's element order. A tie then went to whoever came
196
+ * first in the input, so a permuted graph produced a different partition (and
197
+ * with it different `modifiability`/`coherence`). The ids are canonical since
198
+ * CR-SM-240, so the sentence now holds: `alive` iterates ascending (built 0..n-1,
199
+ * deletions preserve order), each `e[i]` row was filled in canonical node order,
200
+ * and the strict `dq > best` therefore keeps the FIRST maximum in ascending
201
+ * (i, k) order. The invariant lives in `buildAdjacency`; do not re-derive it here.
202
+ */
203
+ export function detectCommunities(adj) {
204
+ const { nodes, undirected } = adj;
205
+ const community = new Map(nodes.map((id, i) => [id, i]));
206
+ const { deg, twoM } = degrees(adj);
207
+ if (twoM === 0)
208
+ return community;
209
+ // Incremental CNM state (CR-231): each merge selects the pair with maximum
210
+ // modularity gain ΔQ = 2·(e_ij − a_i·a_j), computed in O(1) from maintained
211
+ // aggregates instead of re-scoring the whole partition.
212
+ // a[c] = fraction of edge-endpoint weight in community c (Σ strength / 2m)
213
+ // e[c][k] = fraction of edge weight between communities c and k
214
+ const a = new Map();
215
+ const members = new Map();
216
+ const e = new Map();
217
+ nodes.forEach((id, i) => {
218
+ a.set(i, deg.get(id) / twoM);
219
+ members.set(i, [id]);
220
+ e.set(i, new Map());
221
+ });
222
+ for (const v of nodes) {
223
+ const cv = community.get(v);
224
+ for (const w2 of undirected.get(v)) {
225
+ const cw = community.get(w2);
226
+ if (cv === cw)
227
+ continue;
228
+ const row = e.get(cv);
229
+ row.set(cw, (row.get(cw) ?? 0) + adj.w.get(v).get(w2) / twoM); // each direction adds w/2m → edge total w/m
230
+ }
231
+ }
232
+ const alive = new Set(members.keys());
233
+ for (;;) {
234
+ // Best merge = max ΔQ over adjacent community pairs (ascending id order → deterministic).
235
+ let best = 1e-12;
236
+ let bi = -1;
237
+ let bj = -1;
238
+ for (const i of alive) {
239
+ const ai = a.get(i);
240
+ for (const [k, eik] of e.get(i)) {
241
+ if (k <= i)
242
+ continue; // unordered pair once
243
+ const dq = 2 * (eik - ai * a.get(k));
244
+ if (dq > best) {
245
+ best = dq;
246
+ bi = i;
247
+ bj = k;
248
+ }
249
+ }
250
+ }
251
+ if (bi < 0)
252
+ break;
253
+ // Merge bj into bi (keep the lower id).
254
+ const ei = e.get(bi);
255
+ const ej = e.get(bj);
256
+ a.set(bi, a.get(bi) + a.get(bj));
257
+ for (const [k, ejk] of ej) {
258
+ if (k === bi)
259
+ continue;
260
+ ei.set(k, (ei.get(k) ?? 0) + ejk);
261
+ const ek = e.get(k);
262
+ ek.set(bi, (ek.get(bi) ?? 0) + ejk);
263
+ ek.delete(bj);
264
+ }
265
+ ei.delete(bj);
266
+ e.delete(bj);
267
+ for (const id of members.get(bj))
268
+ community.set(id, bi);
269
+ members.get(bi).push(...members.get(bj));
270
+ members.delete(bj);
271
+ a.delete(bj);
272
+ alive.delete(bj);
273
+ }
274
+ return community;
275
+ }
276
+ /** Weakly-connected components (undirected reachability), as node-id groups. */
277
+ export function components(adj) {
278
+ const { nodes, undirected } = adj;
279
+ const seen = new Set();
280
+ const groups = [];
281
+ for (const start of nodes) {
282
+ if (seen.has(start))
283
+ continue;
284
+ const group = [];
285
+ const stack = [start];
286
+ seen.add(start);
287
+ while (stack.length) {
288
+ const v = stack.pop();
289
+ group.push(v);
290
+ for (const w2 of undirected.get(v))
291
+ if (!seen.has(w2)) {
292
+ seen.add(w2);
293
+ stack.push(w2);
294
+ }
295
+ }
296
+ groups.push(group);
297
+ }
298
+ return groups;
299
+ }
300
+ /** Weakly-connected component sizes (node counts). */
301
+ export function componentSizes(adj) {
302
+ return components(adj).map((g) => g.length);
303
+ }
304
+ /**
305
+ * Cyclomatic redundancy density = (m − n + c) / n, where m = undirected edges,
306
+ * n = nodes, c = components. Counts independent cycles per node — the redundant
307
+ * paths that survive a single edge/node failure. 0 for a forest. Unweighted.
308
+ */
309
+ export function redundancyDensity(adj) {
310
+ const { nodes, undirected } = adj;
311
+ const n = nodes.length;
312
+ if (n === 0)
313
+ return 0;
314
+ let m = 0;
315
+ for (const nb of undirected.values())
316
+ m += nb.size;
317
+ m /= 2;
318
+ const c = components(adj).length;
319
+ return (m - n + c) / n;
320
+ }
321
+ /**
322
+ * Coupling/cohesion: the fraction of edge WEIGHT that stays INSIDE a community
323
+ * of the given partition (vs crossing between communities). ∈ [0,1]; higher =
324
+ * more cohesive / less coupled. Community-based, so non-trivial on the
325
+ * near-bipartite, layered SE ontology graph (CR-229). 0 when edgeless.
326
+ */
327
+ export function intraEdgeFraction(adj, community) {
328
+ const { nodes, undirected } = adj;
329
+ let internal = 0;
330
+ let total = 0;
331
+ for (const v of nodes) {
332
+ for (const w2 of undirected.get(v)) {
333
+ // Each undirected edge is counted twice (v→w and w→v); the ratio is unaffected.
334
+ const ww = adj.w.get(v).get(w2);
335
+ total += ww;
336
+ if (community.get(v) === community.get(w2))
337
+ internal += ww;
338
+ }
339
+ }
340
+ return total === 0 ? 0 : internal / total;
341
+ }
342
+ /**
343
+ * Mean shortest directed path length from sources (in-degree 0) to sinks
344
+ * (out-degree 0), over reachable source→sink pairs only. Returns
345
+ * { meanLength, reachableFraction }. When no source→sink pair is reachable,
346
+ * meanLength is 0 and reachableFraction is 0. Unweighted.
347
+ */
348
+ export function sourceSinkPaths(adj) {
349
+ const { nodes, out, in: inn } = adj;
350
+ const sources = nodes.filter((id) => inn.get(id).size === 0 && out.get(id).size > 0);
351
+ const sinks = new Set(nodes.filter((id) => out.get(id).size === 0 && inn.get(id).size > 0));
352
+ if (sources.length === 0 || sinks.size === 0)
353
+ return { meanLength: 0, reachableFraction: 0 };
354
+ let total = 0;
355
+ let lengthSum = 0;
356
+ let reached = 0;
357
+ for (const s of sources) {
358
+ // BFS shortest directed path lengths from s.
359
+ const dist = new Map([[s, 0]]);
360
+ const queue = [s];
361
+ while (queue.length) {
362
+ const v = queue.shift();
363
+ for (const w2 of out.get(v)) {
364
+ if (!dist.has(w2)) {
365
+ dist.set(w2, dist.get(v) + 1);
366
+ queue.push(w2);
367
+ }
368
+ }
369
+ }
370
+ for (const sink of sinks) {
371
+ total += 1;
372
+ const d = dist.get(sink);
373
+ if (d !== undefined && d > 0) {
374
+ lengthSum += d;
375
+ reached += 1;
376
+ }
377
+ }
378
+ }
379
+ return {
380
+ meanLength: reached === 0 ? 0 : lengthSum / reached,
381
+ reachableFraction: total === 0 ? 0 : reached / total,
382
+ };
383
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@sigloch/se-engine",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "files": [
8
+ "dist",
9
+ "LICENSE"
10
+ ],
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "default": "./dist/index.js"
15
+ }
16
+ },
17
+ "scripts": {
18
+ "build": "rm -rf dist && tsc",
19
+ "test": "vitest run",
20
+ "prepublishOnly": "npm run build && npm run test"
21
+ },
22
+ "dependencies": {
23
+ "zod": "^4.3.6"
24
+ },
25
+ "license": "MIT",
26
+ "description": "SE analysis over a governed graph — metric vector, layer projection, rule apply/suggest, readiness computation",
27
+ "author": "sigloch-consulting",
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/andreassigloch/sigloch-modules.git",
31
+ "directory": "packages/se-engine"
32
+ },
33
+ "homepage": "https://github.com/andreassigloch/sigloch-modules#readme",
34
+ "bugs": {
35
+ "url": "https://github.com/andreassigloch/sigloch-modules/issues"
36
+ },
37
+ "engines": {
38
+ "node": ">=22"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "peerDependencies": {
44
+ "@sigloch/contracts": ">=5 <6"
45
+ },
46
+ "devDependencies": {
47
+ "@sigloch/contracts": "^5.0.1"
48
+ }
49
+ }