@d-zero/page-cluster 0.2.0 → 0.3.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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +95 -41
  3. package/dist/assign-contained-clusters.d.ts +42 -0
  4. package/dist/assign-contained-clusters.js +156 -0
  5. package/dist/auto-cut-threshold.d.ts +17 -0
  6. package/dist/auto-cut-threshold.js +36 -0
  7. package/dist/canonicalize-token-set.d.ts +17 -0
  8. package/dist/canonicalize-token-set.js +19 -0
  9. package/dist/cli.d.ts +39 -0
  10. package/dist/cli.js +381 -0
  11. package/dist/collapse-anonymous-divs.d.ts +21 -0
  12. package/dist/collapse-anonymous-divs.js +42 -0
  13. package/dist/complete-linkage-dendrogram.d.ts +41 -0
  14. package/dist/complete-linkage-dendrogram.js +140 -0
  15. package/dist/derive-comparison-sets.d.ts +22 -0
  16. package/dist/derive-comparison-sets.js +33 -0
  17. package/dist/derive-path-cluster-keys.d.ts +53 -0
  18. package/dist/derive-path-cluster-keys.js +109 -0
  19. package/dist/extract-landmarks.d.ts +91 -45
  20. package/dist/extract-landmarks.js +122 -41
  21. package/dist/filter-first-party-stylesheet-hrefs.d.ts +58 -24
  22. package/dist/filter-first-party-stylesheet-hrefs.js +72 -33
  23. package/dist/find-shallowest-elements.d.ts +48 -11
  24. package/dist/find-shallowest-elements.js +41 -21
  25. package/dist/merge-cross-block-clusters.d.ts +61 -0
  26. package/dist/merge-cross-block-clusters.js +546 -0
  27. package/dist/pass0-blocking.d.ts +89 -0
  28. package/dist/pass0-blocking.js +87 -0
  29. package/dist/per-page-landmark-signatures.d.ts +48 -0
  30. package/dist/per-page-landmark-signatures.js +62 -0
  31. package/dist/reservoir-sample.d.ts +43 -0
  32. package/dist/reservoir-sample.js +98 -0
  33. package/dist/resolve-blocking-group-keys.d.ts +8 -2
  34. package/dist/resolve-blocking-group-keys.js +18 -4
  35. package/dist/resolve-landmark-variant-keys.d.ts +41 -20
  36. package/dist/resolve-landmark-variant-keys.js +69 -26
  37. package/dist/resolve-page-cluster-keys.d.ts +292 -191
  38. package/dist/resolve-page-cluster-keys.js +708 -157
  39. package/dist/resolve-structural-cluster-keys.d.ts +9 -0
  40. package/dist/resolve-structural-cluster-keys.js +14 -232
  41. package/dist/shape-token.d.ts +11 -0
  42. package/dist/shape-token.js +38 -0
  43. package/dist/stage-a-per-block.d.ts +133 -0
  44. package/dist/stage-a-per-block.js +178 -0
  45. package/dist/tokenize.d.ts +6 -0
  46. package/dist/tokenize.js +6 -0
  47. package/package.json +5 -58
  48. package/dist/html-region-utils.d.ts +0 -74
  49. package/dist/html-region-utils.js +0 -96
  50. package/dist/merge-landmark-affined-clusters.d.ts +0 -179
  51. package/dist/merge-landmark-affined-clusters.js +0 -544
@@ -0,0 +1,546 @@
1
+ import { assignContainedClusters } from './assign-contained-clusters.js';
2
+ import { autoCutThreshold } from './auto-cut-threshold.js';
3
+ import { collapseAnonymousDivs } from './collapse-anonymous-divs.js';
4
+ import { completeLinkageDendrogram, labelsAtThreshold, } from './complete-linkage-dendrogram.js';
5
+ import { computeDocumentFrequency } from './compute-document-frequency.js';
6
+ import { jaccardSimilarity } from './jaccard-similarity.js';
7
+ import { reservoirSample } from './reservoir-sample.js';
8
+ import { shapeToken } from './shape-token.js';
9
+ import { splitTokensByFrequency } from './split-tokens-by-frequency.js';
10
+ /**
11
+ * Fixed complete-linkage threshold for the cross-block fine stage.
12
+ *
13
+ * Not auto-cut: cross-block units are few (typically 10–100 for a whole
14
+ * site), so the merge-height distribution is too sparse for max-gap detection
15
+ * to produce a reliable cut. Confirmed on real crawl data: without this fixed
16
+ * floor, auto-cut selected 0.045 on an 18-unit corpus, causing spurious
17
+ * micro-merges.
18
+ */
19
+ const CROSS_BLOCK_THRESHOLD = 0.8;
20
+ /**
21
+ * Quorum fraction: a token must be present in at least this fraction of a
22
+ * unit's member pages to enter the unit's core.
23
+ *
24
+ * Strict intersection degenerates: a unit of 89 articles sharing only one
25
+ * common distinctive token produces jaccard 1.0 with everything — confirmed
26
+ * on real crawl data. Full union is shell-dominated: 298 pages collapsed into
27
+ * 4 clusters — also confirmed. 80% quorum avoids both failure modes.
28
+ *
29
+ * Also reused as the fallback clamp for {@link ./auto-cut-threshold.js |
30
+ * autoCutThreshold} when running on the per-landmark-instance frequency
31
+ * distribution in {@link ./merge-cross-block-clusters.js | shellQuorum}. The
32
+ * clamp only ever *loosens* the cut relative to this floor (never tightens),
33
+ * and only fires in the degenerate cases the JSDoc there describes.
34
+ */
35
+ const QUORUM_FRACTION = 0.8;
36
+ /**
37
+ * Shape-based Jaccard threshold for "same skeleton, different class names".
38
+ * Class-name Jaccard for reports/projects/news list pages: 0.000; shape
39
+ * Jaccard: 1.000 on real crawl data.
40
+ */
41
+ const SHAPE_JACCARD_THRESHOLD = 0.9;
42
+ /**
43
+ * Minimum member-page count for a unit to participate in shape-Jaccard
44
+ * comparison. Single-page units are excluded because their quorum core
45
+ * equals their raw token set with no frequency filtering — any two 1-page
46
+ * units with the same tag skeleton but completely different content will
47
+ * shape-merge spuriously. Multi-page units produce quorum cores that
48
+ * reflect a shared template rather than individual page noise, so shape
49
+ * comparison there is meaningful.
50
+ */
51
+ const SHAPE_MIN_PAGES = 2;
52
+ /**
53
+ * L2-stage shell corroboration threshold. Prevents cross-microsite false
54
+ * merges: a microsite with a different shell (header/nav/footer) from the
55
+ * main site would otherwise merge via L2 alone. Confirmed on real crawl
56
+ * data: two false merges blocked, correct merges (same shell) unaffected.
57
+ */
58
+ const SHELL_CORROBORATION_THRESHOLD = 0.8;
59
+ /**
60
+ * Maximum cross-block merge rounds. Real crawl data converged in ≤ 7 rounds
61
+ * on the two validation corpora (302 pages / 8,936 pages).
62
+ */
63
+ const MAX_ROUNDS = 10;
64
+ /**
65
+ * Segments carrying no structural information at L2 resolution.
66
+ * Tokens whose only non-`main` content segments are all generic are excluded
67
+ * from L2 signatures as uninformative.
68
+ */
69
+ const GENERIC_SEGMENTS = new Set([
70
+ 'div',
71
+ 'span',
72
+ '*',
73
+ 'script',
74
+ 'noscript',
75
+ 'style',
76
+ 'iframe',
77
+ 'a',
78
+ 'br',
79
+ 'img',
80
+ 'picture',
81
+ 'source',
82
+ ]);
83
+ // ---------------------------------------------------------------------------
84
+ // Internal helpers
85
+ // ---------------------------------------------------------------------------
86
+ /**
87
+ *
88
+ * @param memberDistinctiveTokens
89
+ */
90
+ function quorumCore(memberDistinctiveTokens) {
91
+ const n = memberDistinctiveTokens.length;
92
+ if (n === 0)
93
+ return new Set();
94
+ const minCount = Math.ceil(QUORUM_FRACTION * n);
95
+ const tokenCount = new Map();
96
+ for (const tokens of memberDistinctiveTokens) {
97
+ for (const token of tokens) {
98
+ tokenCount.set(token, (tokenCount.get(token) ?? 0) + 1);
99
+ }
100
+ }
101
+ const core = new Set();
102
+ for (const [token, count] of tokenCount) {
103
+ if (count >= minCount)
104
+ core.add(token);
105
+ }
106
+ if (core.size > 0)
107
+ return core;
108
+ // Fallback: union of all distinctive tokens (happens for very small units)
109
+ const union = new Set();
110
+ for (const tokens of memberDistinctiveTokens) {
111
+ for (const t of tokens)
112
+ union.add(t);
113
+ }
114
+ return union;
115
+ }
116
+ /**
117
+ *
118
+ * @param core
119
+ */
120
+ function shapedCoreSet(core) {
121
+ const shaped = new Set();
122
+ for (const token of core)
123
+ shaped.add(shapeToken(token));
124
+ return shaped;
125
+ }
126
+ /**
127
+ *
128
+ * @param core
129
+ */
130
+ function l2Signature(core) {
131
+ const counts = new Map();
132
+ for (const token of core) {
133
+ const shaped = shapeToken(token);
134
+ const segments = shaped.split('>');
135
+ const mainIdx = segments.findIndex((s) => s === 'main' || s.startsWith('main[') || s.startsWith('main.'));
136
+ if (mainIdx === -1)
137
+ continue;
138
+ // Take main + up to 2 levels after it
139
+ const truncated = segments.slice(mainIdx, mainIdx + 3);
140
+ const contentSegments = truncated.slice(1);
141
+ // Skip if all content segments are generic (or none exist)
142
+ if (contentSegments.every((s) => GENERIC_SEGMENTS.has(s))) {
143
+ continue;
144
+ }
145
+ const key = truncated.join('>');
146
+ counts.set(key, (counts.get(key) ?? 0) + 1);
147
+ }
148
+ return counts.size > 0 ? counts : null;
149
+ }
150
+ /**
151
+ *
152
+ * @param xSig
153
+ * @param ySig
154
+ */
155
+ function l2Contained(xSig, ySig) {
156
+ for (const [key, xCount] of xSig) {
157
+ if (xCount > (ySig.get(key) ?? 0))
158
+ return false;
159
+ }
160
+ return true;
161
+ }
162
+ /**
163
+ * Discovers a unit's shell tokens by auto-cutting the per-*token* page-
164
+ * frequency histogram of every landmark instance's tokens. This is the same
165
+ * max-gap primitive used for Stage A merge-height cutoffs, applied
166
+ * recursively at the landmark-token layer.
167
+ *
168
+ * ## Why per-token and not per-signature
169
+ *
170
+ * An earlier iteration ran the histogram at the level of full landmark-
171
+ * instance signatures (canonicalized token sets). That failed on a real,
172
+ * common pattern: a shared site chrome whose markup carries a per-page
173
+ * distinguishing element (a breadcrumb, a page-title element with a page-
174
+ * specific class, a "current" state). All pages have most of the same
175
+ * tokens, but every page's full signature is distinct because tokens embed
176
+ * class names. Per-signature counting saw 5 signatures at freq 0.2 each,
177
+ * autoCutThreshold on the flat distribution returned the clamp, and the
178
+ * shell collapsed to empty even though every page shared the core header
179
+ * skeleton. Per-token counting handles the same case correctly — the shared
180
+ * skeleton tokens each hit freq 1.0.
181
+ *
182
+ * ## The histogram
183
+ *
184
+ * For every member page, all its landmark instances are tokenized and
185
+ * unioned into a single per-page token set (order-agnostic, deduped: a
186
+ * token appearing in two of the page's landmarks still counts once for
187
+ * that page). The corpus histogram is then "how many pages contain each
188
+ * token". Tokens that appear on nearly every page are the unit's chrome;
189
+ * tokens that appear on only a handful are page-specific content that
190
+ * happens to be tagged as a landmark.
191
+ *
192
+ * ## Why auto-cut instead of a hard-coded quorum
193
+ *
194
+ * A fixed 80% quorum (this file's earlier implementation) baked one
195
+ * threshold in for every unit. Real corpora don't obey a universal cutoff:
196
+ * a section-local landmark token that appears on 60% of a unit's pages is
197
+ * the section's chrome under any reasonable reading, but 80% quorum
198
+ * discards it. Auto-cut looks at the *shape* of the frequency distribution
199
+ * and picks the widest gap between adjacent frequencies — if the
200
+ * distribution is `{1.00, 1.00, 0.65, 0.03, 0.02}`, the gap between 0.65
201
+ * and 0.03 (0.62) dwarfs everything else and the cut lands mid-gap around
202
+ * 0.34, correctly grouping the 0.65 tokens with the site-wide 1.00 ones as
203
+ * "chrome for this unit". If instead the distribution is flat, the clamp
204
+ * to {@link QUORUM_FRACTION} keeps the threshold from being tighter than
205
+ * the fallback default.
206
+ *
207
+ * ## Fallbacks
208
+ *
209
+ * A single distinct token (`heights.length < 2`) or a perfectly flat
210
+ * distribution (`maxGap === 0`) returns the {@link QUORUM_FRACTION} clamp
211
+ * verbatim — exactly the same 80%-quorum behavior as before. So degenerate
212
+ * cases degrade to the old contract; only richer distributions get the
213
+ * auto-cut benefit.
214
+ *
215
+ * A page with no landmarks contributes an empty set, deliberately, so the
216
+ * shell-corroboration jaccard between two landmark-less pages is 0 rather
217
+ * than 1 (which it would be if we handed back a `<body></body>`-derived
218
+ * `{body}` fallback set to both sides).
219
+ * @param perPageInstances
220
+ */
221
+ function shellQuorum(perPageInstances) {
222
+ const pageCount = perPageInstances.length;
223
+ if (pageCount === 0)
224
+ return new Set();
225
+ // Union all instance token sets per page (dedupe within page: a token
226
+ // present on both header and footer of the same page still counts once
227
+ // for that page's contribution).
228
+ const tokenPageCount = new Map();
229
+ for (const instances of perPageInstances) {
230
+ const perPageUnion = new Set();
231
+ for (const inst of instances) {
232
+ for (const token of inst.tokens)
233
+ perPageUnion.add(token);
234
+ }
235
+ for (const token of perPageUnion) {
236
+ tokenPageCount.set(token, (tokenPageCount.get(token) ?? 0) + 1);
237
+ }
238
+ }
239
+ if (tokenPageCount.size === 0)
240
+ return new Set();
241
+ const frequencies = [];
242
+ for (const count of tokenPageCount.values()) {
243
+ frequencies.push(count / pageCount);
244
+ }
245
+ const cut = autoCutThreshold(frequencies, QUORUM_FRACTION);
246
+ const shell = new Set();
247
+ for (const [token, count] of tokenPageCount) {
248
+ if (count / pageCount >= cut)
249
+ shell.add(token);
250
+ }
251
+ return shell;
252
+ }
253
+ // ---------------------------------------------------------------------------
254
+ // Main function
255
+ // ---------------------------------------------------------------------------
256
+ /**
257
+ * Merges cross-block clusters (Stage B) via recursive quorum-core comparison.
258
+ *
259
+ * Returns a `Map` from each input unit's `key` to its final root key. Units
260
+ * not absorbed into any other unit map to themselves.
261
+ *
262
+ * Three merge mechanisms run per round, in order:
263
+ * 1. **Fine stage** — complete-linkage at `CROSS_BLOCK_THRESHOLD` on quorum
264
+ * cores, then containment assignment (0.9), then shape-Jaccard (0.9) for
265
+ * class-name-only differences.
266
+ * 2. **L2 stage** (only when fine found nothing) — multiset containment on
267
+ * `main`-anchored 2-level shape signatures, with shell corroboration
268
+ * (header+nav+footer quorum Jaccard ≥ 0.8) required.
269
+ *
270
+ * Rounds continue until neither stage finds anything, or `MAX_ROUNDS` is hit.
271
+ * Each round re-derives quorum cores from pooled members of merged units.
272
+ *
273
+ * Why quorum cores instead of strict intersection or full union:
274
+ * strict intersection degenerated on real crawl data (89 articles → 1 shared
275
+ * distinctive token → jaccard 1.0 false merges). Full union was shell-dominated
276
+ * (298-page avalanche into 4 clusters). Both failure modes are documented in
277
+ * `@d-zero/page-cluster` source JSDoc; quorum 80% + page-frequency shell
278
+ * removal was validated on two real crawl corpora.
279
+ * @param units Post-Stage-A clusters.
280
+ * @param options Forwarded `similarityThreshold` (defaults to 0.8).
281
+ * @param options.similarityThreshold
282
+ * @param options.capMembers
283
+ */
284
+ export function mergeCrossBlockClusters(units, options) {
285
+ if (units.length <= 1) {
286
+ return new Map(units.map((u) => [u.key, u.key]));
287
+ }
288
+ const threshold = options?.similarityThreshold ?? CROSS_BLOCK_THRESHOLD;
289
+ const capMembers = options?.capMembers;
290
+ const groups = new Map();
291
+ for (const unit of units) {
292
+ groups.set(unit.key, {
293
+ tokenSets: [...unit.memberTokenSets],
294
+ landmarkInstances: [...unit.memberLandmarkInstances],
295
+ });
296
+ }
297
+ // Maps every original key to its current root (updated on each merge)
298
+ const keyToRoot = new Map(units.map((u) => [u.key, u.key]));
299
+ /**
300
+ * Applies a list of [absorbed, root] merges to `groups` and `keyToRoot`.
301
+ * All absorbed groups' members are folded into their respective roots.
302
+ * @param merges
303
+ */
304
+ function applyMerges(merges) {
305
+ for (const [absorbed, root] of merges) {
306
+ const absorbedG = groups.get(absorbed);
307
+ const rootG = groups.get(root);
308
+ if (!absorbedG || !rootG)
309
+ continue;
310
+ const mergedTokenSets = [...rootG.tokenSets, ...absorbedG.tokenSets];
311
+ const mergedLandmarkInstances = [
312
+ ...rootG.landmarkInstances,
313
+ ...absorbedG.landmarkInstances,
314
+ ];
315
+ // Only down-sample when the caller explicitly opts in (streaming
316
+ // path). Same-index sampling keeps memberTokenSets[i] and
317
+ // landmarkInstances[i] parallel.
318
+ if (capMembers !== undefined && mergedTokenSets.length > capMembers) {
319
+ const indices = mergedTokenSets.map((_, i) => i);
320
+ const kept = reservoirSample(indices, capMembers, root);
321
+ rootG.tokenSets = kept.map((i) => mergedTokenSets[i]);
322
+ rootG.landmarkInstances = kept.map((i) => mergedLandmarkInstances[i]);
323
+ }
324
+ else {
325
+ rootG.tokenSets = mergedTokenSets;
326
+ rootG.landmarkInstances = mergedLandmarkInstances;
327
+ }
328
+ groups.delete(absorbed);
329
+ for (const [origKey, cur] of keyToRoot) {
330
+ if (cur === absorbed)
331
+ keyToRoot.set(origKey, root);
332
+ }
333
+ keyToRoot.set(absorbed, root);
334
+ }
335
+ }
336
+ for (let round = 0; round < MAX_ROUNDS; round++) {
337
+ const groupKeys = [...groups.keys()];
338
+ const n = groupKeys.length;
339
+ if (n <= 1)
340
+ break;
341
+ // ---------------------------------------------------------------
342
+ // Compute corpus distinctive tokens (page-frequency shell removal)
343
+ // ---------------------------------------------------------------
344
+ const allPageTokenSets = groupKeys.flatMap((k) => groups.get(k).tokenSets);
345
+ const corpusFrequency = computeDocumentFrequency(allPageTokenSets);
346
+ const groupDistinctive = new Map();
347
+ for (const key of groupKeys) {
348
+ const g = groups.get(key);
349
+ const dist = [];
350
+ for (const tokens of g.tokenSets) {
351
+ const { contentTokens } = splitTokensByFrequency(tokens, corpusFrequency);
352
+ dist.push(contentTokens.size > 0 ? contentTokens : tokens);
353
+ }
354
+ groupDistinctive.set(key, dist);
355
+ }
356
+ // Quorum core per group
357
+ const cores = new Map();
358
+ for (const key of groupKeys) {
359
+ cores.set(key, quorumCore(groupDistinctive.get(key) ?? []));
360
+ }
361
+ // ---------------------------------------------------------------
362
+ // Fine stage: union-find over group indices
363
+ // ---------------------------------------------------------------
364
+ const parent = Array.from({ length: n }, (_, i) => i);
365
+ const ufFind = (x) => {
366
+ let r = x;
367
+ while (parent[r] !== r)
368
+ r = parent[r];
369
+ let c = x;
370
+ while (c !== r) {
371
+ const next = parent[c];
372
+ parent[c] = r;
373
+ c = next;
374
+ }
375
+ return r;
376
+ };
377
+ const ufUnion = (a, b) => {
378
+ const ra = ufFind(a);
379
+ const rb = ufFind(b);
380
+ if (ra !== rb)
381
+ parent[rb] = ra; // lower index wins
382
+ };
383
+ const coreSets = groupKeys.map((k) => cores.get(k) ?? new Set());
384
+ // Step 1: CL merges
385
+ const dendrogram = completeLinkageDendrogram(coreSets);
386
+ const clLabels = labelsAtThreshold(n, dendrogram, threshold);
387
+ for (let i = 0; i < n; i++) {
388
+ const r = clLabels[i];
389
+ if (r !== undefined && r !== i)
390
+ ufUnion(r, i);
391
+ }
392
+ // Step 2: Containment on the current union-find clusters
393
+ // Build union token set per UF cluster
394
+ const clusterUnion = new Map();
395
+ const clusterPageCount = new Map();
396
+ for (let i = 0; i < n; i++) {
397
+ const r = ufFind(i);
398
+ let u = clusterUnion.get(r);
399
+ if (!u) {
400
+ u = new Set();
401
+ clusterUnion.set(r, u);
402
+ }
403
+ for (const t of coreSets[i] ?? [])
404
+ u.add(collapseAnonymousDivs(t));
405
+ clusterPageCount.set(r, (clusterPageCount.get(r) ?? 0) +
406
+ (groups.get(groupKeys[i] ?? '')?.tokenSets.length ?? 0));
407
+ }
408
+ const contEntries = [...clusterUnion.entries()].map(([id, tokens]) => ({
409
+ id,
410
+ tokens: tokens,
411
+ pageCount: clusterPageCount.get(id) ?? 0,
412
+ }));
413
+ const contResult = assignContainedClusters(contEntries);
414
+ // Apply containment assignment: fromId (UF root index) → toId (UF root index)
415
+ for (const [fromId, toId] of contResult) {
416
+ if (fromId === toId)
417
+ continue;
418
+ // fromId/toId are group indices (the UF roots when contEntries was built)
419
+ ufUnion(toId, fromId);
420
+ }
421
+ // Step 3: Shape-Jaccard (multi-page units only — see SHAPE_MIN_PAGES)
422
+ const shapedCores = groupKeys.map((k) => shapedCoreSet(cores.get(k) ?? new Set()));
423
+ const groupPageCounts = groupKeys.map((k) => groups.get(k)?.tokenSets.length ?? 0);
424
+ for (let i = 0; i < n; i++) {
425
+ for (let j = i + 1; j < n; j++) {
426
+ if (ufFind(i) === ufFind(j))
427
+ continue;
428
+ if ((groupPageCounts[i] ?? 0) < SHAPE_MIN_PAGES ||
429
+ (groupPageCounts[j] ?? 0) < SHAPE_MIN_PAGES) {
430
+ continue;
431
+ }
432
+ const si = shapedCores[i] ?? new Set();
433
+ const sj = shapedCores[j] ?? new Set();
434
+ if (jaccardSimilarity(si, sj) >= SHAPE_JACCARD_THRESHOLD) {
435
+ ufUnion(ufFind(i), ufFind(j));
436
+ }
437
+ }
438
+ }
439
+ // Collect fine-stage merges: groups that share a UF root
440
+ const rootToFirstKey = new Map(); // UF root → first group key (alphabetically first)
441
+ const fineMerges = [];
442
+ for (let i = 0; i < n; i++) {
443
+ const r = ufFind(i);
444
+ const gk = groupKeys[i] ?? '';
445
+ const rootKey = rootToFirstKey.get(r);
446
+ if (rootKey === undefined) {
447
+ rootToFirstKey.set(r, gk);
448
+ }
449
+ else {
450
+ fineMerges.push([gk, rootKey]);
451
+ }
452
+ }
453
+ if (fineMerges.length > 0) {
454
+ applyMerges(fineMerges);
455
+ continue; // next round
456
+ }
457
+ // ---------------------------------------------------------------
458
+ // L2 stage: multiset containment + shell corroboration
459
+ // ---------------------------------------------------------------
460
+ const l2Keys = [...groups.keys()];
461
+ const l2n = l2Keys.length;
462
+ if (l2n <= 1)
463
+ break;
464
+ // Lazily compute L2 sigs and shell quorums
465
+ const l2SigCache = new Map();
466
+ const shellCache = new Map();
467
+ const getL2Sig = (key) => {
468
+ if (!l2SigCache.has(key)) {
469
+ l2SigCache.set(key, l2Signature(cores.get(key) ?? new Set()));
470
+ }
471
+ return l2SigCache.get(key) ?? null;
472
+ };
473
+ const getShell = (key) => {
474
+ if (!shellCache.has(key)) {
475
+ shellCache.set(key, shellQuorum(groups.get(key)?.landmarkInstances ?? []));
476
+ }
477
+ return shellCache.get(key) ?? new Set();
478
+ };
479
+ // Collect valid L2 containment pairs and apply via union-find
480
+ // Direction: x is contained in y → x is absorbed by y
481
+ // Multiple pairs can apply in one round if they form consistent groups
482
+ const l2Parent = Array.from({ length: l2n }, (_, i) => i);
483
+ const l2Find = (x) => {
484
+ let r = x;
485
+ while (l2Parent[r] !== r)
486
+ r = l2Parent[r];
487
+ let c = x;
488
+ while (c !== r) {
489
+ const next = l2Parent[c];
490
+ l2Parent[c] = r;
491
+ c = next;
492
+ }
493
+ return r;
494
+ };
495
+ const l2Union = (a, b) => {
496
+ const ra = l2Find(a);
497
+ const rb = l2Find(b);
498
+ if (ra !== rb)
499
+ l2Parent[rb] = ra;
500
+ };
501
+ for (let xi = 0; xi < l2n; xi++) {
502
+ const xKey = l2Keys[xi] ?? '';
503
+ const xSig = getL2Sig(xKey);
504
+ if (!xSig)
505
+ continue;
506
+ for (let yi = 0; yi < l2n; yi++) {
507
+ if (xi === yi || l2Find(xi) === l2Find(yi))
508
+ continue;
509
+ const yKey = l2Keys[yi] ?? '';
510
+ const ySig = getL2Sig(yKey);
511
+ if (!ySig)
512
+ continue;
513
+ if (!l2Contained(xSig, ySig))
514
+ continue;
515
+ // Shell corroboration
516
+ const xShell = getShell(xKey);
517
+ const yShell = getShell(yKey);
518
+ if (xShell.size === 0 ||
519
+ yShell.size === 0 ||
520
+ jaccardSimilarity(xShell, yShell) < SHELL_CORROBORATION_THRESHOLD) {
521
+ continue;
522
+ }
523
+ // x absorbed by y: l2Parent[xi] = yi after l2Union
524
+ l2Union(yi, xi);
525
+ break; // xSig is stale once merged; let the next round re-evaluate
526
+ }
527
+ }
528
+ const l2RootToFirst = new Map();
529
+ const l2Merges = [];
530
+ for (let i = 0; i < l2n; i++) {
531
+ const r = l2Find(i);
532
+ const gk = l2Keys[i] ?? '';
533
+ const rootKey = l2RootToFirst.get(r);
534
+ if (rootKey === undefined) {
535
+ l2RootToFirst.set(r, gk);
536
+ }
537
+ else {
538
+ l2Merges.push([gk, rootKey]);
539
+ }
540
+ }
541
+ if (l2Merges.length === 0)
542
+ break; // fully converged
543
+ applyMerges(l2Merges);
544
+ }
545
+ return keyToRoot;
546
+ }
@@ -0,0 +1,89 @@
1
+ import type { ResolveBlockingGroupKeysOptions } from './resolve-blocking-group-keys.js';
2
+ /**
3
+ * The corpus-wide, HTML-free inputs needed by
4
+ * {@link ./pass0-blocking.js | resolveBlockKeys}: the blocking signals plus
5
+ * the page's own URL host. The full `PageClusterSignals` type carries `html`
6
+ * on top of these, but Pass 0 deliberately does not read HTML — the whole
7
+ * point of extracting the blocking step is that it can be run before any
8
+ * per-page HTML is held in memory.
9
+ */
10
+ export type Pass0PageSignals = {
11
+ readonly paths: readonly string[];
12
+ readonly stylesheetHrefs: readonly string[];
13
+ readonly host?: string;
14
+ };
15
+ /**
16
+ * @see resolveBlockKeys
17
+ */
18
+ export type ResolveBlockKeysOptions = ResolveBlockingGroupKeysOptions & {
19
+ /**
20
+ * Apply {@link ./reassign-orphan-block-keys.js | reassignOrphanBlockKeys}
21
+ * to the blocking keys, so a page with no recorded stylesheets can rejoin
22
+ * a same-URL-section `css:` block instead of being stranded on its weaker
23
+ * `path:` fallback. Defaults to `true`.
24
+ */
25
+ readonly reassignOrphans?: boolean;
26
+ /**
27
+ * Apply {@link ./filter-first-party-stylesheet-hrefs.js |
28
+ * filterFirstPartyStylesheetHrefs} to `pages` before computing blocking
29
+ * keys, so a page's incidental third-party embeds do not contaminate the
30
+ * blocking signal. Defaults to `true`.
31
+ */
32
+ readonly restrictStylesheetsToFirstParty?: boolean;
33
+ };
34
+ /**
35
+ * Splits `resolvePageClusterKeys` into a size-flat first pass so the driver
36
+ * can decide per-block memory strategy before loading any page HTML. Runs the
37
+ * three corpus-wide, HTML-free stages of blocking in the same order the
38
+ * in-memory driver already uses — first-party stylesheet filtering, blocking-
39
+ * key derivation, orphan reassignment — and returns one final block key per
40
+ * input page in input order.
41
+ *
42
+ * ## Why extract this from resolvePageClusterKeys?
43
+ *
44
+ * The in-memory driver holds every page's `html`, `remainderHtml`,
45
+ * `landmarks[]`, and pre-Stage-A prepared HTML at once. At 176k pages × ~57
46
+ * KB average, that alone breaks a 17 GB RAM machine well before Stage A
47
+ * starts (measured: OS SIGKILL at ~15,000 pages, before the resolve phase
48
+ * even began). All three blocking stages, in contrast, depend only on
49
+ * `paths` / `stylesheetHrefs` / `host` — a few hundred bytes per page. Running
50
+ * them first, HTML-free, lets the downstream per-block clustering hold HTML
51
+ * for only one block's pages at a time.
52
+ *
53
+ * ## Preserves in-memory driver semantics exactly
54
+ *
55
+ * The output of this function is byte-identical to the block-key portion of
56
+ * the current `resolvePageClusterKeys` for the same input, because it reuses
57
+ * the same three underlying functions in the same order with the same
58
+ * defaults. That guarantee is load-bearing: the size-threshold-gated Pass 1
59
+ * that follows this function runs the current in-memory implementation
60
+ * unchanged for small blocks, and any drift in block-key computation between
61
+ * Pass 0 and Pass 1 would silently misroute pages between the two paths.
62
+ * @param pages
63
+ * @param options
64
+ * @example
65
+ * ```ts
66
+ * const blockKeys = resolveBlockKeys([
67
+ * { paths: ['news', '1'], stylesheetHrefs: ['/a.css'], host: 'example.com' },
68
+ * { paths: ['news', '2'], stylesheetHrefs: ['/a.css'], host: 'example.com' },
69
+ * { paths: ['about'], stylesheetHrefs: [], host: 'example.com' },
70
+ * ]);
71
+ * // ['css:<hash>', 'css:<hash>', 'path:about']
72
+ * ```
73
+ */
74
+ export declare function resolveBlockKeys(pages: readonly Pass0PageSignals[], options?: ResolveBlockKeysOptions): string[];
75
+ /**
76
+ * Groups pages by their block key while preserving each block's members in
77
+ * input order. Returned as a `Map` so the caller can iterate blocks in
78
+ * insertion order (first-seen block first) — matching the order
79
+ * `resolvePageClusterKeys`'s own per-block loop already uses so cluster IDs
80
+ * assigned per block stay deterministic across in-memory and streaming
81
+ * paths.
82
+ * @param blockKeys
83
+ * @example
84
+ * ```ts
85
+ * const indices = groupIndicesByBlockKey(['a', 'b', 'a', 'c', 'a']);
86
+ * // Map { 'a' => [0, 2, 4], 'b' => [1], 'c' => [3] }
87
+ * ```
88
+ */
89
+ export declare function groupIndicesByBlockKey(blockKeys: readonly string[]): Map<string, number[]>;