@d-zero/page-cluster 0.3.1 → 0.5.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.
@@ -1,4 +1,4 @@
1
- import type { ExtractLandmarksResult, LandmarkType } from './extract-landmarks.js';
1
+ import type { ExtractLandmarksResult, LandmarkPosition, LandmarkType } from './extract-landmarks.js';
2
2
  import type { TokenizeOptions } from './types.js';
3
3
  /**
4
4
  * Every landmark type extractLandmarks may populate, iterated in a fixed
@@ -13,11 +13,19 @@ export declare const ALL_LANDMARK_TYPES: readonly LandmarkType[];
13
13
  * {@link ./canonicalize-token-set.js | canonicalizeTokenSet}). Signatures
14
14
  * are reused across consumers so two callers see the same "same instance"
15
15
  * verdict without independently re-canonicalizing.
16
+ *
17
+ * `position` is the instance's location in the page it came from, computed
18
+ * once by {@link ./extract-landmarks.js | extractLandmarks} and carried here
19
+ * unchanged — a back-reference for callers that need to report where a
20
+ * chrome-classified instance actually sits, not just that it exists. It
21
+ * plays no part in `tokens`/`signature` computation or in the corpus-
22
+ * frequency logic that consumes this type.
16
23
  */
17
24
  export type PerPageLandmarkInstance = {
18
25
  readonly type: LandmarkType;
19
26
  readonly tokens: ReadonlySet<string>;
20
27
  readonly signature: string;
28
+ readonly position: LandmarkPosition;
21
29
  };
22
30
  /**
23
31
  * Tokenizes every landmark instance across every page once, keyed by page
@@ -44,17 +44,29 @@ export function computePerPageLandmarkInstances(landmarks, tokenizeOptions) {
44
44
  const seenSignatures = new Set();
45
45
  const out = [];
46
46
  for (const type of ALL_LANDMARK_TYPES) {
47
- for (const instanceHtml of entry[type]) {
48
- if (!instanceHtml)
47
+ for (const instance of entry[type]) {
48
+ if (!instance.html)
49
49
  continue;
50
- const tokens = new Set(tokenize(`<body>${instanceHtml}</body>`, tokenizeOptions).tokens);
50
+ const tokens = new Set(tokenize(`<body>${instance.html}</body>`, tokenizeOptions).tokens);
51
51
  if (tokens.size === 0)
52
52
  continue;
53
53
  const signature = canonicalizeTokenSet(tokens);
54
54
  if (seenSignatures.has(signature))
55
55
  continue;
56
56
  seenSignatures.add(signature);
57
- out.push({ type, tokens, signature });
57
+ out.push({
58
+ type,
59
+ tokens,
60
+ signature,
61
+ position: {
62
+ startOffset: instance.startOffset,
63
+ endOffset: instance.endOffset,
64
+ startLine: instance.startLine,
65
+ startColumn: instance.startColumn,
66
+ endLine: instance.endLine,
67
+ endColumn: instance.endColumn,
68
+ },
69
+ });
58
70
  }
59
71
  }
60
72
  return out;
@@ -1,4 +1,13 @@
1
1
  import type { PageBlockingSignals } from './resolve-blocking-group-keys.js';
2
+ /**
3
+ * Prefix distinguishing a reassigned key from the `css:`/`path:` keys
4
+ * {@link ./resolve-blocking-group-keys.js | resolveBlockingGroupKeys} itself
5
+ * produces, so the two families can never collide. Exported so
6
+ * {@link ./pass0-blocking.js | resolveBlockKeys} can recover the confined
7
+ * path key back out of a reassigned block key when building `BlockingReason`s,
8
+ * without duplicating this literal.
9
+ */
10
+ export declare const REASSIGNED_KEY_PREFIX = "orphan-merge:";
2
11
  /**
3
12
  * Rewrites the `path:`-fallback key of an "orphan" page — one with no
4
13
  * stylesheet references recorded at all — to match a same-URL-section `css:`
@@ -2,9 +2,12 @@ import { derivePathGroupKey } from './derive-path-group-key.js';
2
2
  /**
3
3
  * Prefix distinguishing a reassigned key from the `css:`/`path:` keys
4
4
  * {@link ./resolve-blocking-group-keys.js | resolveBlockingGroupKeys} itself
5
- * produces, so the two families can never collide.
5
+ * produces, so the two families can never collide. Exported so
6
+ * {@link ./pass0-blocking.js | resolveBlockKeys} can recover the confined
7
+ * path key back out of a reassigned block key when building `BlockingReason`s,
8
+ * without duplicating this literal.
6
9
  */
7
- const REASSIGNED_KEY_PREFIX = 'orphan-merge:';
10
+ export const REASSIGNED_KEY_PREFIX = 'orphan-merge:';
8
11
  /**
9
12
  * Reads `values[index]`, throwing instead of returning `undefined`. Every
10
13
  * call site here indexes `pages`/`pathKeys`/`blockKeys` with a position
@@ -1,3 +1,4 @@
1
+ import type { BlockingReason } from './derive-blocking-reason.js';
1
2
  /**
2
3
  * The two blocking signals {@link ./derive-path-group-key.js | derivePathGroupKey}
3
4
  * and {@link ./derive-stylesheet-group-key.js | deriveStylesheetGroupKey} need,
@@ -36,6 +37,12 @@ export type ResolveBlockingGroupKeysOptions = {
36
37
  /** Forwarded to `splitTokensByFrequency` as-is. */
37
38
  hrefCommonThreshold?: number;
38
39
  };
40
+ /** Return shape when `includeReasons: true` is passed to `resolveBlockingGroupKeys`. */
41
+ export type BlockingGroupKeysWithReasons = {
42
+ readonly keys: string[];
43
+ /** One entry per distinct blocking key produced, keyed by that key. */
44
+ readonly reasonsByKey: ReadonlyMap<string, BlockingReason>;
45
+ };
39
46
  /**
40
47
  * Resolves, per page, which of the two independent blocking signals — the
41
48
  * exact stylesheet set or the URL path — to actually use as that page's
@@ -119,4 +126,7 @@ export type ResolveBlockingGroupKeysOptions = {
119
126
  * // common.css is loaded by all 3 pages and is filtered out as non-discriminative chrome.
120
127
  * ```
121
128
  */
129
+ export declare function resolveBlockingGroupKeys(pages: readonly PageBlockingSignals[], options: ResolveBlockingGroupKeysOptions & {
130
+ includeReasons: true;
131
+ }): BlockingGroupKeysWithReasons;
122
132
  export declare function resolveBlockingGroupKeys(pages: readonly PageBlockingSignals[], options?: ResolveBlockingGroupKeysOptions): string[];
@@ -4,89 +4,6 @@ import { derivePathGroupKey } from './derive-path-group-key.js';
4
4
  import { deriveStylesheetGroupKey } from './derive-stylesheet-group-key.js';
5
5
  import { splitTokensByFrequency } from './split-tokens-by-frequency.js';
6
6
  const DEFAULT_MIN_CSS_GROUP_SIZE = 2;
7
- /**
8
- * Resolves, per page, which of the two independent blocking signals — the
9
- * exact stylesheet set or the URL path — to actually use as that page's
10
- * grouping key. Returns one key per page, in the same order as `pages`.
11
- *
12
- * Literature on entity-resolution blocking (Michelson & Knoblock's DNF
13
- * scheme, canopy clustering, ensemble blocking) combines independent
14
- * blocking predicates with OR to generate *candidate pairs* for a later
15
- * similarity/classification pass. This function instead commits each page to
16
- * exactly one final key: `resolve-page-cluster-keys.js`'s
17
- * `resolvePageClusterKeys` *does* run a later refinement step
18
- * (`resolveStructuralClusterKeys`) on top of whichever key a page lands on,
19
- * but only within that one key's candidate pool — it has no way to pull in
20
- * a page that this function routed to a different key. So this function's
21
- * per-page choice is still effectively final for blocking purposes: a page
22
- * assigned to the wrong key here never re-enters the correct key's pool
23
- * downstream. A true OR-merge (letting a page carry both the stylesheet and
24
- * path candidates, deferring to the refinement step to reconcile overlapping
25
- * results across them) would close that gap, but is deliberately deferred —
26
- * it needs the same literature-plus-real-data validation cycle this
27
- * package's linkage-criterion and NN-chain choices already went through, not
28
- * a change bundled in alongside unrelated fixes. Until then, a
29
- * priority-with-fallback decision — try the strong signal, fall back to the
30
- * weak one — is the applicable pattern here, not OR-merge: a union of
31
- * equivalence relations can only ever coarsen a partition, never split it,
32
- * but the whole point of preferring the stylesheet signal is that it *splits*
33
- * pages a URL-path-only grouping would otherwise lump together (confirmed
34
- * against real crawl data: a single page embedded under an otherwise-uniform
35
- * URL section, but loading a completely different stylesheet set, is exactly
36
- * the case a path-only key misses and a stylesheet key catches).
37
- *
38
- * Before comparing stylesheet sets, this reuses
39
- * {@link ./compute-document-frequency.js | computeDocumentFrequency} and
40
- * {@link ./split-tokens-by-frequency.js | splitTokensByFrequency} — originally
41
- * built to separate a page's site-wide chrome from its page-specific HTML
42
- * structure — to strip stylesheet hrefs that recur across most of `pages`
43
- * (e.g. a shared reset/font stylesheet) before hashing. Without this, two
44
- * pages from otherwise-unrelated sections that happen to load only that one
45
- * shared stylesheet would satisfy `minCssGroupSize` and be wrongly treated as
46
- * the same template family: the problem there isn't too few pages sharing
47
- * the key (raising `minCssGroupSize` doesn't fix it), it's that the key
48
- * itself carries no discriminative information. A page whose stylesheet set
49
- * is empty, or becomes empty after this filtering, always falls back to the
50
- * path key — loading no distinctive stylesheet is an absence of evidence,
51
- * not evidence of a shared template, so it must never itself become a
52
- * matching signal.
53
- *
54
- * Document frequency is computed only over pages that load at least one
55
- * stylesheet: including stylesheet-less pages in the denominator would dilute
56
- * every href's frequency ratio (e.g. a stylesheet loaded by 100% of the pages
57
- * that load *any* stylesheet would read as a low, "distinctive" frequency if
58
- * most pages in the batch load none), letting a genuinely non-discriminative,
59
- * site-wide stylesheet slip through the common-href filter.
60
- *
61
- * Like `computeDocumentFrequency` itself, this expects `pages` to be a
62
- * roughly homogeneous batch (one site, or one section of a large
63
- * multi-template site) — see that function's JSDoc for why a federation of
64
- * independently-templated sub-sections defeats frequency-based filtering.
65
- * Splitting a heterogeneous crawl into sections before calling this function
66
- * is the caller's responsibility.
67
- *
68
- * This filtering needs enough stylesheet-bearing pages to tell "loaded by
69
- * every page that has any stylesheet" apart from "coincidentally the only
70
- * stylesheet two pages happen to load": with only two stylesheet-bearing
71
- * pages in the whole batch and nothing else to contrast against, any
72
- * stylesheet they share reads as 100% common and gets filtered out,
73
- * producing a path-key fallback even when the two pages are a genuine
74
- * template match. A third, differently-styled page (as in the example below)
75
- * is what gives the shared stylesheet a frequency below the common-href
76
- * cutoff.
77
- * @param pages
78
- * @param options
79
- * @example
80
- * ```ts
81
- * resolveBlockingGroupKeys([
82
- * { paths: ['dept-a', 'news', '1'], stylesheetHrefs: ['https://example.com/a.css', 'https://example.com/common.css'] },
83
- * { paths: ['dept-a', 'news', '2'], stylesheetHrefs: ['https://example.com/a.css', 'https://example.com/common.css'] },
84
- * { paths: ['dept-b', 'about'], stylesheetHrefs: ['https://example.com/common.css'] },
85
- * ]);
86
- * // ['css:<hash of a.css>', 'css:<hash of a.css>', 'path:dept-b']
87
- * // common.css is loaded by all 3 pages and is filtered out as non-discriminative chrome.
88
- * ```
89
- */
90
7
  export function resolveBlockingGroupKeys(pages, options) {
91
8
  const pathDepthOption = options?.pathDepth;
92
9
  const minCssGroupSize = options?.minCssGroupSize ?? DEFAULT_MIN_CSS_GROUP_SIZE;
@@ -121,14 +38,27 @@ export function resolveBlockingGroupKeys(pages, options) {
121
38
  cssKeyCounts.set(cssKey, (cssKeyCounts.get(cssKey) ?? 0) + 1);
122
39
  }
123
40
  }
124
- return pages.map((page, index) => {
41
+ const reasonsByKey = new Map();
42
+ const keys = pages.map((page, index) => {
125
43
  const cssKey = cssKeys[index];
126
44
  if (cssKey !== undefined && (cssKeyCounts.get(cssKey) ?? 0) >= minCssGroupSize) {
127
- return `css:${cssKey}`;
45
+ const key = `css:${cssKey}`;
46
+ if (!reasonsByKey.has(key)) {
47
+ reasonsByKey.set(key, {
48
+ kind: 'css',
49
+ distinctiveStylesheetHrefs: [...(distinctiveHrefs[index] ?? [])].toSorted(),
50
+ });
51
+ }
52
+ return key;
128
53
  }
129
54
  const pathKey = perPagePathKeys === null
130
55
  ? derivePathGroupKey(page.paths, pathDepthOption)
131
56
  : (perPagePathKeys[index] ?? '');
132
- return `path:${pathKey}`;
57
+ const key = `path:${pathKey}`;
58
+ if (!reasonsByKey.has(key)) {
59
+ reasonsByKey.set(key, { kind: 'path', pathKey });
60
+ }
61
+ return key;
133
62
  });
63
+ return options?.includeReasons ? { keys, reasonsByKey } : keys;
134
64
  }
@@ -1,4 +1,6 @@
1
+ import type { ClusterReason } from './build-cluster-reason.js';
1
2
  import type { ExtractLandmarksResult } from './extract-landmarks.js';
3
+ import type { PerPageLandmarkInstance } from './per-page-landmark-signatures.js';
2
4
  import type { ResolveBlockingGroupKeysOptions } from './resolve-blocking-group-keys.js';
3
5
  import type { ResolveStructuralClusterKeysOptions } from './resolve-structural-cluster-keys.js';
4
6
  import type { TokenizeOptions } from './types.js';
@@ -65,6 +67,7 @@ import type { TokenizeOptions } from './types.js';
65
67
  export declare function computeLocalChromeArtifacts(landmarks: readonly ExtractLandmarksResult[], tokenizeOptions: TokenizeOptions | undefined): {
66
68
  readonly localSignatures: ReadonlySet<string>;
67
69
  readonly localTokensByPage: readonly ReadonlySet<string>[];
70
+ readonly perPageInstances: readonly (readonly PerPageLandmarkInstance[])[];
68
71
  };
69
72
  /**
70
73
  * Reinjects each page's *local* (non-corpus-wide) landmark-instance tokens
@@ -162,9 +165,42 @@ export type ResolvePageClusterKeysOptions = TokenizeOptions & ResolveBlockingGro
162
165
  * sync helper to running a per-block async loop that emits
163
166
  * `pass1-block-complete` and `stage-b-start`. Omitting `onProgress`
164
167
  * keeps the small-corpus branch on the pre-refactor sync path with
165
- * zero yield overhead.
168
+ * zero yield overhead. Ignored when `onClusterReason` is set — that
169
+ * option always routes the small-corpus branch through the sync
170
+ * helper (see `onClusterReason`'s own JSDoc), so no progress events
171
+ * fire in that combination.
166
172
  */
167
173
  onProgress?: (event: ProgressEvent) => void;
174
+ /**
175
+ * Optional observability hook invoked once per **final cluster** (not
176
+ * per page) with that cluster's {@link ClusterReason} — the blocking
177
+ * signal that grouped it, its DOM-structural token core, its
178
+ * per-landmark-type commonality, and the sibling cluster keys it was
179
+ * split from within the same Pass-0 block. Unlike `onProgress`, this
180
+ * fires on every path — small-corpus and streaming alike — because a
181
+ * `ClusterReason` is sized by cluster count, not page count, so it
182
+ * carries no streaming-path memory risk the way a per-page report
183
+ * would.
184
+ *
185
+ * Building the reasons re-uses Stage A/B's own intermediate state (the
186
+ * quorum core, the per-unit landmark instances, the blocking
187
+ * evidence) — it does not re-tokenize pages or re-run corpus-wide
188
+ * discovery. Omitting `onClusterReason` skips that bookkeeping
189
+ * entirely, so existing callers pay nothing for this option.
190
+ *
191
+ * On the async factory-based `resolvePageClusterKeys`, setting this
192
+ * forces the small-corpus branch through the sync
193
+ * `resolvePageClusterKeysInMemory` helper regardless of `onProgress`
194
+ * — see `onProgress`'s own JSDoc.
195
+ * @example
196
+ * ```ts
197
+ * const reasons = new Map<string, ClusterReason>();
198
+ * const keys = await resolvePageClusterKeys(pages, {
199
+ * onClusterReason: (key, reason) => reasons.set(key, reason),
200
+ * });
201
+ * ```
202
+ */
203
+ onClusterReason?: (clusterKey: string, reason: ClusterReason) => void;
168
204
  };
169
205
  /**
170
206
  * Corpus size at or below which the async factory-based
@@ -1,4 +1,5 @@
1
1
  import { autoCutThreshold } from './auto-cut-threshold.js';
2
+ import { buildClusterReason } from './build-cluster-reason.js';
2
3
  import { capContentDepth } from './cap-content-depth.js';
3
4
  import { detectContentDepthCap, validateDetectContentDepthCapOptions, } from './detect-content-depth-cap.js';
4
5
  import { extractLandmarks } from './extract-landmarks.js';
@@ -162,8 +163,9 @@ function assignPageToNearestCluster(html, assignment, excludeLandmarks, contentB
162
163
  */
163
164
  export function computeLocalChromeArtifacts(landmarks, tokenizeOptions) {
164
165
  const pageCount = landmarks.length;
165
- if (pageCount === 0)
166
- return { localSignatures: new Set(), localTokensByPage: [] };
166
+ if (pageCount === 0) {
167
+ return { localSignatures: new Set(), localTokensByPage: [], perPageInstances: [] };
168
+ }
167
169
  const perPageInstances = computePerPageLandmarkInstances(landmarks, tokenizeOptions);
168
170
  // Corpus-wide histogram: signature → { count, tokens }. tokens is the
169
171
  // token set of any one occurrence of the signature (all occurrences are
@@ -184,6 +186,7 @@ export function computeLocalChromeArtifacts(landmarks, tokenizeOptions) {
184
186
  return {
185
187
  localSignatures: new Set(),
186
188
  localTokensByPage: landmarks.map(() => new Set()),
189
+ perPageInstances,
187
190
  };
188
191
  }
189
192
  const frequencies = [];
@@ -202,6 +205,7 @@ export function computeLocalChromeArtifacts(landmarks, tokenizeOptions) {
202
205
  return {
203
206
  localSignatures: new Set(),
204
207
  localTokensByPage: landmarks.map(() => new Set()),
208
+ perPageInstances,
205
209
  };
206
210
  }
207
211
  const localTokensByPage = perPageInstances.map((instances) => {
@@ -214,7 +218,7 @@ export function computeLocalChromeArtifacts(landmarks, tokenizeOptions) {
214
218
  }
215
219
  return out;
216
220
  });
217
- return { localSignatures, localTokensByPage };
221
+ return { localSignatures, localTokensByPage, perPageInstances };
218
222
  }
219
223
  /**
220
224
  * Reinjects each page's *local* (non-corpus-wide) landmark-instance tokens
@@ -232,6 +236,63 @@ export function computeLocalChromeArtifacts(landmarks, tokenizeOptions) {
232
236
  export function computeLocalLandmarkTokens(landmarks, tokenizeOptions) {
233
237
  return [...computeLocalChromeArtifacts(landmarks, tokenizeOptions).localTokensByPage];
234
238
  }
239
+ /**
240
+ * Builds and emits one {@link ClusterReason} per final cluster via
241
+ * `onClusterReason`, from data Stage A/B already computed for clustering
242
+ * itself: `crossBlockUnits` (Stage A's pre-merge units, each carrying its
243
+ * originating block key inside `JSON.parse(unit.key)[0]`), Stage B's
244
+ * `rootByKey`/`finalGroupsByRoot`, the per-block-key `BlockingReason`s Pass 0
245
+ * derived, and the per-block sibling-unit-key lists the driver accumulated
246
+ * alongside its Stage A loop. No re-tokenization and no extra corpus pass —
247
+ * this only re-groups references the driver already held.
248
+ * @param crossBlockUnits
249
+ * @param rootByKey
250
+ * @param finalGroupsByRoot
251
+ * @param reasonsByBlockKey
252
+ * @param siblingUnitKeysByBlock
253
+ * @param onClusterReason
254
+ */
255
+ function emitClusterReasons(crossBlockUnits, rootByKey, finalGroupsByRoot, reasonsByBlockKey, siblingUnitKeysByBlock, onClusterReason) {
256
+ const unitKeysByRoot = new Map();
257
+ for (const unit of crossBlockUnits) {
258
+ const root = rootByKey.get(unit.key) ?? unit.key;
259
+ const list = unitKeysByRoot.get(root);
260
+ if (list) {
261
+ list.push(unit.key);
262
+ }
263
+ else {
264
+ unitKeysByRoot.set(root, [unit.key]);
265
+ }
266
+ }
267
+ for (const [rootKey, unitKeys] of unitKeysByRoot) {
268
+ const finalGroup = finalGroupsByRoot.get(rootKey);
269
+ if (!finalGroup)
270
+ continue;
271
+ const seenBlockKeys = new Set();
272
+ const blocking = [];
273
+ const siblingRoots = new Set();
274
+ for (const unitKey of unitKeys) {
275
+ const blockKey = JSON.parse(unitKey)[0];
276
+ if (!seenBlockKeys.has(blockKey)) {
277
+ seenBlockKeys.add(blockKey);
278
+ const reason = reasonsByBlockKey.get(blockKey);
279
+ if (reason)
280
+ blocking.push({ blockKey, reason });
281
+ }
282
+ for (const siblingUnitKey of siblingUnitKeysByBlock.get(blockKey) ?? []) {
283
+ const siblingRoot = rootByKey.get(siblingUnitKey) ?? siblingUnitKey;
284
+ if (siblingRoot !== rootKey)
285
+ siblingRoots.add(siblingRoot);
286
+ }
287
+ }
288
+ onClusterReason(rootKey, buildClusterReason({
289
+ tokenSets: finalGroup.tokenSets,
290
+ landmarkInstances: finalGroup.landmarkInstances,
291
+ blocking,
292
+ siblingClusterKeys: [...siblingRoots].toSorted(),
293
+ }));
294
+ }
295
+ }
235
296
  /**
236
297
  * Corpus size at or below which the async factory-based
237
298
  * `resolvePageClusterKeys` reads the entire input into an array and delegates
@@ -313,11 +374,9 @@ export function resolvePageClusterKeysInMemory(pages, options) {
313
374
  throw new RangeError(`resolvePageClusterKeys: similarityThreshold must be between 0 and 1, got ${similarityThreshold}`);
314
375
  }
315
376
  // Always computed: landmark fields are needed by Stage B's shell
316
- // corroboration regardless of `excludeLandmarks`, and `remainderHtml` is
317
- // needed whenever `excludeLandmarks` is true.
377
+ // corroboration regardless of `excludeLandmarks`.
318
378
  const landmarks = pages.map((page) => extractLandmarks(page.html));
319
- // Corpus-level chrome discovery
320
- const localLandmarkTokensByPage = computeLocalLandmarkTokens(landmarks, options);
379
+ const { localTokensByPage: localLandmarkTokensByPage } = computeLocalChromeArtifacts(landmarks, options);
321
380
  const contentBlockAttribute = options?.contentBlockAttribute;
322
381
  const preparedHtml = pages.map((page, index) => {
323
382
  const landmarksExcised = excludeLandmarks
@@ -332,7 +391,20 @@ export function resolvePageClusterKeysInMemory(pages, options) {
332
391
  const blockingPages = restrictStylesheetsToFirstParty
333
392
  ? filterFirstPartyStylesheetHrefs(pages)
334
393
  : pages;
335
- const blockKeys = resolveBlockKeys(blockingPages, options);
394
+ // Reasons (blocking evidence) are only worth deriving when a caller
395
+ // actually asked for `onClusterReason` — see that option's own JSDoc for
396
+ // why this is the only place ClusterReason bookkeeping is opt-in.
397
+ const onClusterReason = options?.onClusterReason;
398
+ let blockKeys;
399
+ let reasonsByBlockKey;
400
+ if (onClusterReason) {
401
+ const result = resolveBlockKeys(blockingPages, { ...options, includeReasons: true });
402
+ blockKeys = result.blockKeys;
403
+ reasonsByBlockKey = result.reasonsByBlockKey;
404
+ }
405
+ else {
406
+ blockKeys = resolveBlockKeys(blockingPages, options);
407
+ }
336
408
  const indicesByBlockKey = groupIndicesByBlockKey(blockKeys);
337
409
  // Validated here, eagerly, because it's otherwise only reached from
338
410
  // inside the per-block loop below — which never runs at all for an empty
@@ -341,6 +413,9 @@ export function resolvePageClusterKeysInMemory(pages, options) {
341
413
  validateDetectContentDepthCapOptions(options);
342
414
  const finalKeys = Array.from({ length: pages.length });
343
415
  const crossBlockUnits = [];
416
+ const siblingUnitKeysByBlock = onClusterReason
417
+ ? new Map()
418
+ : undefined;
344
419
  for (const [blockKey, indices] of indicesByBlockKey) {
345
420
  const result = stageAPerBlock({
346
421
  blockKey,
@@ -353,16 +428,20 @@ export function resolvePageClusterKeysInMemory(pages, options) {
353
428
  finalKeys[pageIndex] = key;
354
429
  }
355
430
  crossBlockUnits.push(...result.crossBlockUnits);
431
+ siblingUnitKeysByBlock?.set(blockKey, result.crossBlockUnits.map((u) => u.key));
356
432
  }
357
433
  // Stage B: cross-block merge — always runs regardless of options
358
- const stageBResult = mergeCrossBlockClusters(crossBlockUnits, options);
434
+ const { rootByKey, finalGroupsByRoot } = mergeCrossBlockClusters(crossBlockUnits, options);
359
435
  for (let i = 0; i < finalKeys.length; i++) {
360
436
  const currentKey = finalKeys[i];
361
- const rootKey = stageBResult.get(currentKey);
437
+ const rootKey = rootByKey.get(currentKey);
362
438
  if (rootKey !== undefined && rootKey !== currentKey) {
363
439
  finalKeys[i] = rootKey;
364
440
  }
365
441
  }
442
+ if (onClusterReason && reasonsByBlockKey && siblingUnitKeysByBlock) {
443
+ emitClusterReasons(crossBlockUnits, rootByKey, finalGroupsByRoot, reasonsByBlockKey, siblingUnitKeysByBlock, onClusterReason);
444
+ }
366
445
  return finalKeys;
367
446
  }
368
447
  /**
@@ -443,10 +522,10 @@ async function resolveSmallCorpusWithProgress(pages, onProgress, options) {
443
522
  await new Promise((resolve) => setImmediate(resolve));
444
523
  }
445
524
  onProgress({ phase: 'stage-b-start', unitCount: crossBlockUnits.length });
446
- const stageBResult = mergeCrossBlockClusters(crossBlockUnits, options);
525
+ const { rootByKey } = mergeCrossBlockClusters(crossBlockUnits, options);
447
526
  for (let i = 0; i < finalKeys.length; i++) {
448
527
  const currentKey = finalKeys[i];
449
- const rootKey = stageBResult.get(currentKey);
528
+ const rootKey = rootByKey.get(currentKey);
450
529
  if (rootKey !== undefined && rootKey !== currentKey) {
451
530
  finalKeys[i] = rootKey;
452
531
  }
@@ -530,8 +609,12 @@ export async function resolvePageClusterKeys(pages, options) {
530
609
  // into per-block progress, so delegate to the untouched sync path —
531
610
  // keeping behavior byte-for-byte identical (and yield-overhead-free)
532
611
  // to how library-only consumers experienced this before the CLI
533
- // progress work landed.
534
- if (onProgress === undefined) {
612
+ // progress work landed. `onClusterReason` always routes here too (see
613
+ // its own JSDoc): `resolveSmallCorpusWithProgress` has no
614
+ // cluster-reason support, and duplicating that logic into the
615
+ // progress-emitting path for a reporting feature that has nothing to
616
+ // do with progress observability isn't worth the added surface.
617
+ if (onProgress === undefined || options?.onClusterReason) {
535
618
  return resolvePageClusterKeysInMemory(fullPages, options);
536
619
  }
537
620
  return resolveSmallCorpusWithProgress(fullPages, onProgress, options);
@@ -547,11 +630,30 @@ export async function resolvePageClusterKeys(pages, options) {
547
630
  const blockingPagesForKeys = restrictStylesheetsToFirstParty
548
631
  ? filterFirstPartyStylesheetHrefs(blockingSignals)
549
632
  : blockingSignals;
550
- const blockKeys = resolveBlockKeys(blockingPagesForKeys, options);
633
+ // Reasons (blocking evidence) cost nothing beyond bookkeeping — a Map
634
+ // keyed by distinct block key, not by page — but are only derived when a
635
+ // caller actually asked for `onClusterReason`.
636
+ const onClusterReason = options?.onClusterReason;
637
+ let blockKeys;
638
+ let reasonsByBlockKey;
639
+ if (onClusterReason) {
640
+ const result = resolveBlockKeys(blockingPagesForKeys, {
641
+ ...options,
642
+ includeReasons: true,
643
+ });
644
+ blockKeys = result.blockKeys;
645
+ reasonsByBlockKey = result.reasonsByBlockKey;
646
+ }
647
+ else {
648
+ blockKeys = resolveBlockKeys(blockingPagesForKeys, options);
649
+ }
551
650
  const indicesByBlockKey = groupIndicesByBlockKey(blockKeys);
552
651
  const finalKeys = Array.from({ length: blockingSignals.length });
553
652
  const crossBlockUnits = [];
554
653
  const contentBlockAttribute = options?.contentBlockAttribute;
654
+ const siblingUnitKeysByBlock = onClusterReason
655
+ ? new Map()
656
+ : undefined;
555
657
  /** Non-sample page indices that need Pass 1b Jaccard-based assignment. */
556
658
  const pendingAssignmentBlockKeyByIndex = new Map();
557
659
  /** Block-level artifacts saved after Stage A runs on the sample. */
@@ -590,6 +692,7 @@ export async function resolvePageClusterKeys(pages, options) {
590
692
  finalKeys[idx] = key;
591
693
  }
592
694
  crossBlockUnits.push(...result.crossBlockUnits);
695
+ siblingUnitKeysByBlock?.set(bucket.blockKey, result.crossBlockUnits.map((u) => u.key));
593
696
  if (bucket.seenCount > bucket.reservoirIndices.length) {
594
697
  // Save assignment artifacts for Pass 1b.
595
698
  const maxMainDepth = bucket.reservoirPreparedHtml.length > 1
@@ -715,17 +818,20 @@ export async function resolvePageClusterKeys(pages, options) {
715
818
  if (onProgress) {
716
819
  onProgress({ phase: 'stage-b-start', unitCount: crossBlockUnits.length });
717
820
  }
718
- const stageBResult = mergeCrossBlockClusters(crossBlockUnits, {
821
+ const { rootByKey, finalGroupsByRoot } = mergeCrossBlockClusters(crossBlockUnits, {
719
822
  ...options,
720
823
  capMembers: BLOCK_SAMPLE_SIZE,
721
824
  });
722
825
  for (let i = 0; i < finalKeys.length; i++) {
723
826
  const currentKey = finalKeys[i];
724
- const rootKey = stageBResult.get(currentKey);
827
+ const rootKey = rootByKey.get(currentKey);
725
828
  if (rootKey !== undefined && rootKey !== currentKey) {
726
829
  finalKeys[i] = rootKey;
727
830
  }
728
831
  }
832
+ if (onClusterReason && reasonsByBlockKey && siblingUnitKeysByBlock) {
833
+ emitClusterReasons(crossBlockUnits, rootByKey, finalGroupsByRoot, reasonsByBlockKey, siblingUnitKeysByBlock, onClusterReason);
834
+ }
729
835
  return finalKeys;
730
836
  }
731
837
  /**
@@ -0,0 +1,70 @@
1
+ import type { PerPageLandmarkInstance } from './per-page-landmark-signatures.js';
2
+ /**
3
+ * Discovers a unit's shell tokens by auto-cutting the per-*token* page-
4
+ * frequency histogram of every landmark instance's tokens. This is the same
5
+ * max-gap primitive used for Stage A merge-height cutoffs, applied
6
+ * recursively at the landmark-token layer.
7
+ *
8
+ * ## Why per-token and not per-signature
9
+ *
10
+ * An earlier iteration ran the histogram at the level of full landmark-
11
+ * instance signatures (canonicalized token sets). That failed on a real,
12
+ * common pattern: a shared site chrome whose markup carries a per-page
13
+ * distinguishing element (a breadcrumb, a page-title element with a page-
14
+ * specific class, a "current" state). All pages have most of the same
15
+ * tokens, but every page's full signature is distinct because tokens embed
16
+ * class names. Per-signature counting saw 5 signatures at freq 0.2 each,
17
+ * autoCutThreshold on the flat distribution returned the clamp, and the
18
+ * shell collapsed to empty even though every page shared the core header
19
+ * skeleton. Per-token counting handles the same case correctly — the shared
20
+ * skeleton tokens each hit freq 1.0.
21
+ *
22
+ * ## The histogram
23
+ *
24
+ * For every member page, all its landmark instances are tokenized and
25
+ * unioned into a single per-page token set (order-agnostic, deduped: a
26
+ * token appearing in two of the page's landmarks still counts once for
27
+ * that page). The corpus histogram is then "how many pages contain each
28
+ * token". Tokens that appear on nearly every page are the unit's chrome;
29
+ * tokens that appear on only a handful are page-specific content that
30
+ * happens to be tagged as a landmark.
31
+ *
32
+ * ## Why auto-cut instead of a hard-coded quorum
33
+ *
34
+ * A fixed 80% quorum (this file's earlier implementation) baked one
35
+ * threshold in for every unit. Real corpora don't obey a universal cutoff:
36
+ * a section-local landmark token that appears on 60% of a unit's pages is
37
+ * the section's chrome under any reasonable reading, but 80% quorum
38
+ * discards it. Auto-cut looks at the *shape* of the frequency distribution
39
+ * and picks the widest gap between adjacent frequencies — if the
40
+ * distribution is `{1.00, 1.00, 0.65, 0.03, 0.02}`, the gap between 0.65
41
+ * and 0.03 (0.62) dwarfs everything else and the cut lands mid-gap around
42
+ * 0.34, correctly grouping the 0.65 tokens with the site-wide 1.00 ones as
43
+ * "chrome for this unit". If instead the distribution is flat, the clamp
44
+ * to {@link SHELL_QUORUM_FALLBACK_FRACTION} keeps the threshold from being
45
+ * tighter than the fallback default.
46
+ *
47
+ * ## Fallbacks
48
+ *
49
+ * A single distinct token (`heights.length < 2`) or a perfectly flat
50
+ * distribution (`maxGap === 0`) returns the
51
+ * {@link SHELL_QUORUM_FALLBACK_FRACTION} clamp verbatim — exactly the same
52
+ * 80%-quorum behavior as before. So degenerate cases degrade to the old
53
+ * contract; only richer distributions get the auto-cut benefit.
54
+ *
55
+ * A page with no landmarks contributes an empty set, deliberately, so the
56
+ * shell-corroboration jaccard between two landmark-less pages is 0 rather
57
+ * than 1 (which it would be if we handed back a `<body></body>`-derived
58
+ * `{body}` fallback set to both sides).
59
+ *
60
+ * ## Reuse
61
+ *
62
+ * Originally private to {@link ./merge-cross-block-clusters.js | mergeCrossBlockClusters}'s
63
+ * Stage B L2 corroboration; exported from its own module so
64
+ * {@link ./build-cluster-reason.js | buildClusterReason} can run it once per
65
+ * final cluster, per landmark type, to classify individual landmark
66
+ * instances as chrome (see {@link ./is-chrome-landmark-instance.js |
67
+ * isChromeLandmarkInstance}).
68
+ * @param perPageInstances
69
+ */
70
+ export declare function shellQuorum(perPageInstances: readonly (readonly PerPageLandmarkInstance[])[]): ReadonlySet<string>;