@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
@@ -1,124 +1,327 @@
1
+ import { autoCutThreshold } from './auto-cut-threshold.js';
1
2
  import { capContentDepth } from './cap-content-depth.js';
2
3
  import { detectContentDepthCap, validateDetectContentDepthCapOptions, } from './detect-content-depth-cap.js';
3
4
  import { extractLandmarks } from './extract-landmarks.js';
4
5
  import { filterFirstPartyStylesheetHrefs } from './filter-first-party-stylesheet-hrefs.js';
5
- import { mergeLandmarkAffinedClusters, validateMergeLandmarkAffinedClustersOptions, } from './merge-landmark-affined-clusters.js';
6
- import { reassignOrphanBlockKeys } from './reassign-orphan-block-keys.js';
6
+ import { jaccardSimilarity } from './jaccard-similarity.js';
7
+ import { mergeCrossBlockClusters } from './merge-cross-block-clusters.js';
8
+ import { groupIndicesByBlockKey, resolveBlockKeys } from './pass0-blocking.js';
9
+ import { computePerPageLandmarkInstances } from './per-page-landmark-signatures.js';
7
10
  import { removeContentBlocks } from './remove-content-blocks.js';
8
- import { resolveBlockingGroupKeys } from './resolve-blocking-group-keys.js';
9
- import { resolveStructuralClusterKeys } from './resolve-structural-cluster-keys.js';
11
+ import { stageAPerBlock } from './stage-a-per-block.js';
10
12
  import { tokenize } from './tokenize.js';
11
13
  /**
12
- * Reads `values[index]`, throwing instead of returning `undefined`. Every
13
- * call site here indexes with a position this function generated itself
14
- * (an entry from `Map#entries()`/`Array#entries()` over an array it just
15
- * built), so the thrown branch is unreachable in practice; it exists to
16
- * satisfy `noUncheckedIndexedAccess` without a non-null assertion. Not
17
- * imported from `resolve-structural-cluster-keys.ts`'s own copy: that file's
18
- * `export`s are its intended public API surface, and this ~7-line generic
19
- * helper isn't worth carving an exception into that boundary for (same
20
- * rationale as `readDpValue` in `array-edit-distance.ts` being its own
21
- * independent copy rather than a shared import).
22
- * @param values
23
- * @param index
14
+ * FNV-1a 32-bit hash of a string, used to seed the per-block PRNG so
15
+ * reservoir sampling on the streaming path is deterministic for a given
16
+ * corpus (same input order same sampled indices same cluster keys).
17
+ * @param input
24
18
  */
25
- function requireIndex(values, index) {
26
- const value = values[index];
27
- if (value === undefined) {
28
- throw new Error('resolvePageClusterKeys: index out of bounds');
19
+ function fnv1a32(input) {
20
+ let hash = 0x81_1c_9d_c5;
21
+ for (let i = 0; i < input.length; i++) {
22
+ hash ^= input.codePointAt(i) ?? 0;
23
+ hash = Math.imul(hash, 0x01_00_01_93);
29
24
  }
30
- return value;
25
+ return hash >>> 0;
31
26
  }
32
27
  /**
33
- * Narrows `landmarks` from `readonly ExtractLandmarksResult[] | undefined` to
34
- * defined, throwing instead of asserting non-null. Always defined in
35
- * practice at every call site below: both call sites are only reached when
36
- * `excludeLandmarks || mergeRareLandmarkClusters` is true, exactly the
37
- * condition under which `landmarks` is populated. Exists only to satisfy the
38
- * type checker without a non-null assertion (same rationale as
39
- * `requireIndex` above).
28
+ * Mulberry32 small, well-known 32-bit PRNG. Kept independent per block
29
+ * (each block seeds from its own block key via {@link ./resolve-page-cluster-keys.js | fnv1a32})
30
+ * so different blocks sample independently.
31
+ * @param seed
32
+ */
33
+ function makeSeededPrng(seed) {
34
+ let state = (typeof seed === 'string' ? fnv1a32(seed) : seed) >>> 0;
35
+ return () => {
36
+ state = (state + 0x6d_2b_79_f5) >>> 0;
37
+ let t = state;
38
+ t = Math.imul(t ^ (t >>> 15), t | 1);
39
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
40
+ return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296;
41
+ };
42
+ }
43
+ /**
44
+ * Tokenizes a non-sample page using the block's learned parameters
45
+ * (`maxMainDepth` and local-signature reinjection set) and returns the
46
+ * key of the sample-derived cluster whose members it most closely matches
47
+ * by Jaccard similarity. Ties break in first-seen order (JS `Map`
48
+ * iteration). Falls back to a block-scoped singleton key when the block
49
+ * has no clusters at all (edge case: an empty sample, which shouldn't
50
+ * happen for a non-empty block but is defended against here).
51
+ * @param html
52
+ * @param assignment
53
+ * @param assignment.maxMainDepth
54
+ * @param assignment.localSignatures
55
+ * @param assignment.clustersByUnitKey
56
+ * @param excludeLandmarks
57
+ * @param contentBlockAttribute
58
+ * @param tokenizeOptions
59
+ * @param blockKey
60
+ */
61
+ function assignPageToNearestCluster(html, assignment, excludeLandmarks, contentBlockAttribute, tokenizeOptions, blockKey) {
62
+ const landmarkResult = extractLandmarks(html);
63
+ const landmarksExcised = excludeLandmarks ? landmarkResult.remainderHtml : html;
64
+ let prepared = contentBlockAttribute === undefined
65
+ ? landmarksExcised
66
+ : removeContentBlocks(landmarksExcised, { blockAttribute: contentBlockAttribute })
67
+ .remainderHtml;
68
+ if (assignment.maxMainDepth !== undefined) {
69
+ prepared = capContentDepth(prepared, {
70
+ landmark: 'main',
71
+ maxDepth: assignment.maxMainDepth,
72
+ }).remainderHtml;
73
+ }
74
+ const pageTokens = new Set(tokenize(prepared, tokenizeOptions).tokens);
75
+ // Reinject tokens for landmark instances whose signature matches the
76
+ // block's learned local-signature set (same rule the sample-side Stage
77
+ // A applied via computeLocalChromeArtifacts).
78
+ if (assignment.localSignatures.size > 0) {
79
+ const instances = computePerPageLandmarkInstances([landmarkResult], tokenizeOptions)[0];
80
+ for (const inst of instances) {
81
+ if (!assignment.localSignatures.has(inst.signature))
82
+ continue;
83
+ for (const t of inst.tokens)
84
+ pageTokens.add(t);
85
+ }
86
+ }
87
+ let bestKey;
88
+ let bestScore = -1;
89
+ for (const [unitKey, memberTokenSets] of assignment.clustersByUnitKey) {
90
+ let clusterBest = 0;
91
+ for (const memberTokens of memberTokenSets) {
92
+ const score = jaccardSimilarity(pageTokens, memberTokens);
93
+ if (score > clusterBest)
94
+ clusterBest = score;
95
+ }
96
+ if (clusterBest > bestScore) {
97
+ bestScore = clusterBest;
98
+ bestKey = unitKey;
99
+ }
100
+ }
101
+ return bestKey ?? JSON.stringify([blockKey, 'cluster:unassigned']);
102
+ }
103
+ /**
104
+ * Reinjects each page's *local* (non-corpus-wide) landmark-instance tokens
105
+ * into its block token set for Stage A clustering, restoring exactly the
106
+ * structural signal that landmark excision removed for those pages while
107
+ * keeping global chrome removed (the whole point of `excludeLandmarks`).
108
+ *
109
+ * ## Why token-level reinjection instead of one opaque pseudo-token
110
+ *
111
+ * An earlier iteration returned a single opaque token per local signature.
112
+ * That failed on real data: adding one distinctive token to a 100+-token
113
+ * page's set produces jaccard ~0.99 between "with-local-landmark" and
114
+ * "without-local-landmark" siblings, so Stage A's 0.8-clamped auto-cut
115
+ * silently merged them anyway. Reinjecting the landmark's actual tokens
116
+ * (typically 4–20 per landmark) restores the full structural weight of
117
+ * the distinction. A real mid-sized crawl corpus's section subtree with
118
+ * a shared section-local `<nav>` now splits correctly from siblings
119
+ * without one, since the reinjected local-nav tokens push jaccard below
120
+ * the cut.
121
+ *
122
+ * ## The corpus-level auto-cut
123
+ *
124
+ * Every page's landmark instances are canonicalized to a signature (via
125
+ * {@link ./canonicalize-token-set.js | canonicalizeTokenSet}); the corpus-
126
+ * wide histogram of "how many pages carry this signature" is fed to
127
+ * {@link ./auto-cut-threshold.js | autoCutThreshold} — the same primitive
128
+ * used at every other layer of this pipeline for merge-height cutoffs. The
129
+ * clamp caps the auto-cut at 0.8 so it never picks a threshold *above* the
130
+ * conservative default. A signature at or above the cut is global chrome —
131
+ * appears on effectively every page, so its tokens carry no discriminatory
132
+ * signal and are left excised. A signature below the cut is local chrome
133
+ * for the pages that carry it, and its tokens are reinjected into those
134
+ * pages' block token sets. Same technique as the per-unit shellQuorum in
135
+ * {@link ./merge-cross-block-clusters.js | mergeCrossBlockClusters}, one
136
+ * layer up.
137
+ *
138
+ * ## The `count >= 2` gate
139
+ *
140
+ * A signature present on exactly one page is per-page variation, not
141
+ * shared local chrome — no "these pages have the same local chrome, those
142
+ * pages don't" grouping can be built from a singleton, and admitting
143
+ * singleton signatures would reinject each per-page-unique landmark into
144
+ * exactly one page's token set, causing spurious per-page cluster
145
+ * fragmentation across the corpus (confirmed against a 2-page fixture
146
+ * where two pages carry byte-different `<header>`s produced identical
147
+ * clusters as expected; without the gate, each would carry its own
148
+ * reinjected tokens and split).
149
+ * @param landmarks
150
+ * @param tokenizeOptions
151
+ */
152
+ /**
153
+ * Companion to {@link ./resolve-page-cluster-keys.js | computeLocalLandmarkTokens}
154
+ * that also returns the local-signature *set* the streaming path needs to
155
+ * reuse when tokenizing non-sample pages during Pass 1b. The in-memory path
156
+ * only cares about the per-page token sets (which pages carry which
157
+ * chrome-below-the-cut tokens); the streaming path additionally needs to
158
+ * apply the *same* "which signatures are local" verdict to pages that were
159
+ * not part of the sample the verdict was learned from.
40
160
  * @param landmarks
161
+ * @param tokenizeOptions
41
162
  */
42
- function requireLandmarks(landmarks) {
43
- if (landmarks === undefined) {
44
- throw new Error('resolvePageClusterKeys: landmarks were not computed');
163
+ export function computeLocalChromeArtifacts(landmarks, tokenizeOptions) {
164
+ const pageCount = landmarks.length;
165
+ if (pageCount === 0)
166
+ return { localSignatures: new Set(), localTokensByPage: [] };
167
+ const perPageInstances = computePerPageLandmarkInstances(landmarks, tokenizeOptions);
168
+ // Corpus-wide histogram: signature → { count, tokens }. tokens is the
169
+ // token set of any one occurrence of the signature (all occurrences are
170
+ // equal by construction).
171
+ const corpusHistogram = new Map();
172
+ for (const instances of perPageInstances) {
173
+ for (const inst of instances) {
174
+ const entry = corpusHistogram.get(inst.signature);
175
+ if (entry) {
176
+ entry.count++;
177
+ }
178
+ else {
179
+ corpusHistogram.set(inst.signature, { count: 1, tokens: inst.tokens });
180
+ }
181
+ }
182
+ }
183
+ if (corpusHistogram.size === 0) {
184
+ return {
185
+ localSignatures: new Set(),
186
+ localTokensByPage: landmarks.map(() => new Set()),
187
+ };
188
+ }
189
+ const frequencies = [];
190
+ for (const entry of corpusHistogram.values()) {
191
+ frequencies.push(entry.count / pageCount);
192
+ }
193
+ const cut = autoCutThreshold(frequencies, 0.8);
194
+ // Signatures whose tokens we'll reinject: below cut, non-singleton.
195
+ const localSignatures = new Set();
196
+ for (const [sig, entry] of corpusHistogram) {
197
+ if (entry.count >= 2 && entry.count / pageCount < cut) {
198
+ localSignatures.add(sig);
199
+ }
200
+ }
201
+ if (localSignatures.size === 0) {
202
+ return {
203
+ localSignatures: new Set(),
204
+ localTokensByPage: landmarks.map(() => new Set()),
205
+ };
45
206
  }
46
- return landmarks;
207
+ const localTokensByPage = perPageInstances.map((instances) => {
208
+ const out = new Set();
209
+ for (const inst of instances) {
210
+ if (!localSignatures.has(inst.signature))
211
+ continue;
212
+ for (const token of inst.tokens)
213
+ out.add(token);
214
+ }
215
+ return out;
216
+ });
217
+ return { localSignatures, localTokensByPage };
218
+ }
219
+ /**
220
+ * Reinjects each page's *local* (non-corpus-wide) landmark-instance tokens
221
+ * into its block token set for Stage A clustering, restoring exactly the
222
+ * structural signal that landmark excision removed for those pages while
223
+ * keeping global chrome removed (the whole point of `excludeLandmarks`).
224
+ *
225
+ * See {@link ./resolve-page-cluster-keys.js | computeLocalChromeArtifacts}
226
+ * for the underlying algorithm — this function is a thin wrapper that
227
+ * discards the local-signature set, exposed for callers that only need the
228
+ * per-page tokens (the in-memory driver's use case).
229
+ * @param landmarks
230
+ * @param tokenizeOptions
231
+ */
232
+ export function computeLocalLandmarkTokens(landmarks, tokenizeOptions) {
233
+ return [...computeLocalChromeArtifacts(landmarks, tokenizeOptions).localTokensByPage];
47
234
  }
48
235
  /**
49
- * Connects the two stages this package otherwise leaves for the caller to
50
- * wire together: {@link ./resolve-blocking-group-keys.js | resolveBlockingGroupKeys}
51
- * (coarse blocking by URL path or stylesheet set) and
52
- * {@link ./resolve-structural-cluster-keys.js | resolveStructuralClusterKeys}
53
- * (exact structural clustering *within* one block). Returns one final key
54
- * per page, in the same order as `pages`, unique across the whole input —
55
- * not just within a block.
56
- *
57
- * `resolveStructuralClusterKeys` numbers its clusters `cluster:0`,
58
- * `cluster:1`, ... independently every time it's called, so two different
59
- * blocks' `cluster:0` are unrelated but identically named. Composing the
60
- * block key and the per-block cluster label via `JSON.stringify` (rather
61
- * than plain string concatenation, e.g. a `::` separator) rules out
62
- * collisions regardless of what either half happens to contain, without
63
- * depending on `resolveStructuralClusterKeys`'s label format never changing.
64
- *
65
- * `excludeLandmarks` and `similarityThreshold` interact: removing shared
66
- * chrome makes every remaining comparison stricter (there's no more
67
- * chrome-driven baseline similarity propping scores up), so a threshold
68
- * tuned against raw, chrome-included tokens can become too strict once
69
- * landmarks are excluded. Confirmed on real crawl data: a 4-page block where
70
- * 3 same-template pages merged at the default `similarityThreshold` (0.8)
71
- * using raw tokens split one of the 3 into its own singleton once landmarks
72
- * were excluded, and re-merged correctly at `similarityThreshold: 0.6` — re-
73
- * tune per site after switching this on, the same as `similarityThreshold`
74
- * itself already needs.
75
- *
76
- * `reassignOrphans` only ever pools a `path:`-fallback orphan alongside a
77
- * same-section `css:` block for `resolveStructuralClusterKeys` to compare —
78
- * it never forces a merge itself. An orphan that turns out not to match
79
- * anything in that pool (confirmed on real crawl data) correctly surfaces as
80
- * its own singleton, the same as it would have without this option.
81
- *
82
- * `restrictStylesheetsToFirstParty` runs before `reassignOrphans`: a page
83
- * whose only stylesheet reference was third-party becomes an orphan (no
84
- * first-party stylesheet left) *because of* the filtering, and is then
85
- * itself eligible for orphan reassignment — this is intentional, not an
86
- * ordering accident, since the underlying reason both options exist is the
87
- * same (a page's blocking key should reflect its template, not incidental
88
- * third-party embeds or missing crawl data).
236
+ * Corpus size at or below which the async factory-based
237
+ * `resolvePageClusterKeys` reads the entire input into an array and delegates
238
+ * to {@link ./resolve-page-cluster-keys.js | resolvePageClusterKeysInMemory}
239
+ * unchanged — preserving corpus-wide semantics (chrome discovery, Stage B
240
+ * across all pages) exactly.
241
+ *
242
+ * Above this threshold, the streaming path takes over: block dispatch during
243
+ * a second factory read, per-block chrome discovery (semantic drift from
244
+ * corpus-wide, unavoidable when the whole corpus does not fit in memory),
245
+ * and Stage B fed with the incrementally-accumulated cross-block units.
246
+ *
247
+ * Chosen from Phase 0 spike measurements: a ~9,000-page real crawl (biggest
248
+ * block ~3,900) completed in ~108s / 1.25 GB heap on the in-memory path.
249
+ * Doubling that headroom to 20,000 keeps every corpus previously validated
250
+ * (302, 1,416, 8,936, 89 pages) on the exact code path they were validated
251
+ * against, so their gate values (9 / 21 / 63 / 3 clusters respectively)
252
+ * remain byte-reproducible. A ~176,000-page real crawl OOM'd on the in-memory
253
+ * path well below this threshold worth of pages ever being materialized, so
254
+ * anything above 20,000 is routed to streaming.
255
+ */
256
+ export const CORPUS_INLINE_THRESHOLD = 20_000;
257
+ /**
258
+ * Reservoir-sample size per block on the streaming path. Blocks larger than
259
+ * this have Stage A run on a random sample of `BLOCK_SAMPLE_SIZE` pages,
260
+ * with the remaining non-sample pages assigned via Jaccard similarity to
261
+ * the sample-derived clusters during Pass 1b. Blocks at or below this size
262
+ * still work — the sampling degenerates to "keep every input page unchanged"
263
+ * per the `reservoirSample` contract, so small blocks behave identically to
264
+ * the in-memory path.
265
+ *
266
+ * Chosen to bound accumulated Stage-B state: units × sample_size × per-
267
+ * member memory 200 units × 100 members × 25 KB ≈ 500 MB, well within an
268
+ * 8 GB Node heap even on macOS where jetsam (the kernel OOM killer) reacts
269
+ * to RSS pressure before V8's own heap limit trips.
270
+ *
271
+ * ## Semantic differences from the in-memory path
272
+ *
273
+ * - **Chrome discovery is sample-based per block.** Landmark signatures that
274
+ * are rare in the sample get treated as global chrome; only signatures
275
+ * that show up on 2 sample members and below the sample-derived
276
+ * auto-cut are reinjected. Full-block chrome discovery would see rare
277
+ * signatures too — the sample-based decision approximates it.
278
+ * - **Non-sample pages are assigned by max-Jaccard against sample member
279
+ * token sets.** A page whose closest sample member is genuinely dissimilar
280
+ * still gets slotted into the least-bad cluster; this is a pragmatic
281
+ * trade for a bounded assignment cost (no unbounded "outlier" cluster
282
+ * growth).
283
+ * - **Stage B sees the sample-based `CrossBlockUnit`s only.** Non-sample
284
+ * pages carry the final key that Stage B produces for their assigned
285
+ * sample cluster, without contributing to Stage B's own DF / quorum-core
286
+ * / shell-quorum computations.
287
+ *
288
+ * Preserves the in-memory path unchanged for corpora at or below
289
+ * {@link CORPUS_INLINE_THRESHOLD} — sampling is streaming-mode only.
290
+ */
291
+ export const BLOCK_SAMPLE_SIZE = 100;
292
+ /**
293
+ * Preserves the previous synchronous, array-in / array-out API of
294
+ * `resolvePageClusterKeys` under a new name so the factory-based async
295
+ * export can take the primary name while callers that already had a
296
+ * materialized page array (spec tests, the in-repo dogfood harness,
297
+ * downstream code that hasn't switched to streaming yet) retain the
298
+ * exact same behavior.
299
+ *
300
+ * Semantics: identical to the pre-refactor `resolvePageClusterKeys`.
301
+ * Corpus-wide chrome discovery, Stage B across every page, no memory
302
+ * bound — meant to be called on inputs already known to fit in memory.
303
+ * The async factory-based export delegates here whenever
304
+ * `pages.length ≤ CORPUS_INLINE_THRESHOLD`, guaranteeing existing corpora
305
+ * hit exactly this code path.
89
306
  * @param pages
90
307
  * @param options
91
- * @example
92
- * ```ts
93
- * resolvePageClusterKeys([
94
- * { paths: ['news', '1'], stylesheetHrefs: [], html: '<body><article>one</article></body>' },
95
- * { paths: ['news', '2'], stylesheetHrefs: [], html: '<body><article>two</article></body>' },
96
- * { paths: ['about'], stylesheetHrefs: [], html: '<body><section>about</section></body>' },
97
- * ]);
98
- * // pages 0 and 1 (same block, same structure) share a key; page 2 (different block) gets its own
99
- * ```
100
308
  */
101
- export function resolvePageClusterKeys(pages, options) {
309
+ export function resolvePageClusterKeysInMemory(pages, options) {
102
310
  const excludeLandmarks = options?.excludeLandmarks ?? true;
103
- const mergeRareLandmarkClusters = options?.mergeRareLandmarkClusters ?? false;
104
- if (mergeRareLandmarkClusters) {
105
- // Eager, same rationale as autoCapMainDepth's own eager validation
106
- // below: this option's own validation is otherwise only reached from
107
- // mergeLandmarkAffinedClusters's call at the very end of this
108
- // function, which never runs at all for an empty `pages`.
109
- validateMergeLandmarkAffinedClustersOptions(options);
110
- }
111
- // extractLandmarks parses the whole page once; excludeLandmarks needs
112
- // remainderHtml and mergeRareLandmarkClusters needs the four landmark
113
- // fields, so this computes it once up front whenever either is needed,
114
- // rather than each option branch parsing independently.
115
- const landmarks = excludeLandmarks || mergeRareLandmarkClusters
116
- ? pages.map((page) => extractLandmarks(page.html))
117
- : undefined;
311
+ const similarityThreshold = options?.similarityThreshold ?? 0.8;
312
+ if (!(similarityThreshold >= 0 && similarityThreshold <= 1)) {
313
+ throw new RangeError(`resolvePageClusterKeys: similarityThreshold must be between 0 and 1, got ${similarityThreshold}`);
314
+ }
315
+ // 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.
318
+ const landmarks = pages.map((page) => extractLandmarks(page.html));
319
+ // Corpus-level chrome discovery
320
+ const localLandmarkTokensByPage = computeLocalLandmarkTokens(landmarks, options);
118
321
  const contentBlockAttribute = options?.contentBlockAttribute;
119
322
  const preparedHtml = pages.map((page, index) => {
120
323
  const landmarksExcised = excludeLandmarks
121
- ? requireIndex(requireLandmarks(landmarks), index).remainderHtml
324
+ ? landmarks[index].remainderHtml
122
325
  : page.html;
123
326
  return contentBlockAttribute === undefined
124
327
  ? landmarksExcised
@@ -129,70 +332,418 @@ export function resolvePageClusterKeys(pages, options) {
129
332
  const blockingPages = restrictStylesheetsToFirstParty
130
333
  ? filterFirstPartyStylesheetHrefs(pages)
131
334
  : pages;
132
- const reassignOrphans = options?.reassignOrphans ?? true;
133
- const rawBlockKeys = resolveBlockingGroupKeys(blockingPages, options);
134
- const blockKeys = reassignOrphans
135
- ? reassignOrphanBlockKeys(blockingPages, rawBlockKeys, options?.pathDepth)
136
- : rawBlockKeys;
137
- const indicesByBlockKey = new Map();
138
- for (const [index, blockKey] of blockKeys.entries()) {
139
- const indices = indicesByBlockKey.get(blockKey);
140
- if (indices) {
141
- indices.push(index);
335
+ const blockKeys = resolveBlockKeys(blockingPages, options);
336
+ const indicesByBlockKey = groupIndicesByBlockKey(blockKeys);
337
+ // Validated here, eagerly, because it's otherwise only reached from
338
+ // inside the per-block loop below — which never runs at all for an empty
339
+ // `pages` (no blocks), silently skipping a bad option instead of failing
340
+ // fast the way a direct `detectContentDepthCap` call always does.
341
+ validateDetectContentDepthCapOptions(options);
342
+ const finalKeys = Array.from({ length: pages.length });
343
+ const crossBlockUnits = [];
344
+ for (const [blockKey, indices] of indicesByBlockKey) {
345
+ const result = stageAPerBlock({
346
+ blockKey,
347
+ memberIndices: indices,
348
+ preparedHtml: indices.map((i) => preparedHtml[i]),
349
+ landmarks: indices.map((i) => landmarks[i]),
350
+ localLandmarkTokensByPage: indices.map((i) => localLandmarkTokensByPage[i]),
351
+ }, options);
352
+ for (const [pageIndex, key] of result.pageKeys) {
353
+ finalKeys[pageIndex] = key;
142
354
  }
143
- else {
144
- indicesByBlockKey.set(blockKey, [index]);
355
+ crossBlockUnits.push(...result.crossBlockUnits);
356
+ }
357
+ // Stage B: cross-block merge — always runs regardless of options
358
+ const stageBResult = mergeCrossBlockClusters(crossBlockUnits, options);
359
+ for (let i = 0; i < finalKeys.length; i++) {
360
+ const currentKey = finalKeys[i];
361
+ const rootKey = stageBResult.get(currentKey);
362
+ if (rootKey !== undefined && rootKey !== currentKey) {
363
+ finalKeys[i] = rootKey;
145
364
  }
146
365
  }
147
- const autoCapMainDepth = options?.autoCapMainDepth ?? false;
148
- if (autoCapMainDepth) {
149
- // Validated here, eagerly, because it's otherwise only reached from
150
- // inside the per-block loop below which never runs at all for an
151
- // empty `pages` (no blocks), silently skipping a bad option instead
152
- // of failing fast the way a direct `detectContentDepthCap` call
153
- // always does.
154
- validateDetectContentDepthCapOptions(options);
366
+ return finalKeys;
367
+ }
368
+ /**
369
+ * Async twin of {@link ./resolve-page-cluster-keys.js | resolvePageClusterKeysInMemory}
370
+ * that emits `pass1-block-complete` (per block) and `stage-b-start`
371
+ * `ProgressEvent`s and yields control back to the event loop between
372
+ * blocks with `setImmediate`, so the async factory-based
373
+ * `resolvePageClusterKeys` can expose live progress on small corpora
374
+ * (`≤ CORPUS_INLINE_THRESHOLD`) without blocking the caller's UI thread.
375
+ *
376
+ * Semantic equivalence with `resolvePageClusterKeysInMemory` is preserved
377
+ * exactly: same corpus-wide chrome discovery, same per-block Stage A, same
378
+ * un-capped Stage B across the entire crossBlockUnits array. `finalKeys`
379
+ * returned here must be byte-for-byte identical to what the sync path
380
+ * would have produced for the same `pages` input — spec-enforced by
381
+ * `resolve-page-cluster-keys-streaming.spec.ts`.
382
+ *
383
+ * The sync `resolvePageClusterKeysInMemory` is deliberately left in place
384
+ * as its own implementation rather than being folded into a shared helper.
385
+ * The intentional duplication guarantees that library callers who pass no
386
+ * `onProgress` incur zero behavioral difference from the pre-refactor code
387
+ * (see the `onProgress === undefined` short-circuit in
388
+ * {@link ./resolve-page-cluster-keys.js | resolvePageClusterKeys}).
389
+ * @param pages
390
+ * @param onProgress
391
+ * @param options
392
+ */
393
+ async function resolveSmallCorpusWithProgress(pages, onProgress, options) {
394
+ const excludeLandmarks = options?.excludeLandmarks ?? true;
395
+ const similarityThreshold = options?.similarityThreshold ?? 0.8;
396
+ if (!(similarityThreshold >= 0 && similarityThreshold <= 1)) {
397
+ throw new RangeError(`resolvePageClusterKeys: similarityThreshold must be between 0 and 1, got ${similarityThreshold}`);
155
398
  }
399
+ const landmarks = pages.map((page) => extractLandmarks(page.html));
400
+ const localLandmarkTokensByPage = computeLocalLandmarkTokens(landmarks, options);
401
+ const contentBlockAttribute = options?.contentBlockAttribute;
402
+ const preparedHtml = pages.map((page, index) => {
403
+ const landmarksExcised = excludeLandmarks
404
+ ? landmarks[index].remainderHtml
405
+ : page.html;
406
+ return contentBlockAttribute === undefined
407
+ ? landmarksExcised
408
+ : removeContentBlocks(landmarksExcised, { blockAttribute: contentBlockAttribute })
409
+ .remainderHtml;
410
+ });
411
+ const restrictStylesheetsToFirstParty = options?.restrictStylesheetsToFirstParty ?? true;
412
+ const blockingPages = restrictStylesheetsToFirstParty
413
+ ? filterFirstPartyStylesheetHrefs(pages)
414
+ : pages;
415
+ const blockKeys = resolveBlockKeys(blockingPages, options);
416
+ const indicesByBlockKey = groupIndicesByBlockKey(blockKeys);
417
+ validateDetectContentDepthCapOptions(options);
156
418
  const finalKeys = Array.from({ length: pages.length });
419
+ const crossBlockUnits = [];
420
+ const totalBlocks = indicesByBlockKey.size;
421
+ let blocksProcessed = 0;
157
422
  for (const [blockKey, indices] of indicesByBlockKey) {
158
- const blockPreparedHtml = indices.map((index) => requireIndex(preparedHtml, index));
159
- // A block of 1 can never produce more than one cluster regardless of
160
- // how it's tokenized — nothing to compare it against — so detecting a
161
- // knee and capping for it would only spend a full multi-depth sweep
162
- // (see detectContentDepthCap's own cost notes) to arrive back at the
163
- // same single-cluster result. Skipped rather than swept.
164
- const maxMainDepth = autoCapMainDepth && blockPreparedHtml.length > 1
165
- ? detectContentDepthCap(blockPreparedHtml, options)
166
- : undefined;
167
- const blockTokenSets = blockPreparedHtml.map((html) => {
168
- const capped = maxMainDepth === undefined
169
- ? html
170
- : capContentDepth(html, { landmark: 'main', maxDepth: maxMainDepth })
171
- .remainderHtml;
172
- return new Set(tokenize(capped, options).tokens);
423
+ const result = stageAPerBlock({
424
+ blockKey,
425
+ memberIndices: indices,
426
+ preparedHtml: indices.map((i) => preparedHtml[i]),
427
+ landmarks: indices.map((i) => landmarks[i]),
428
+ localLandmarkTokensByPage: indices.map((i) => localLandmarkTokensByPage[i]),
429
+ }, options);
430
+ for (const [pageIndex, key] of result.pageKeys) {
431
+ finalKeys[pageIndex] = key;
432
+ }
433
+ crossBlockUnits.push(...result.crossBlockUnits);
434
+ blocksProcessed++;
435
+ onProgress({
436
+ phase: 'pass1-block-complete',
437
+ blockKey,
438
+ blocksProcessed,
439
+ totalBlocks,
173
440
  });
174
- const localLabels = resolveStructuralClusterKeys(blockTokenSets, options);
175
- for (const [position, index] of indices.entries()) {
176
- finalKeys[index] = JSON.stringify([blockKey, requireIndex(localLabels, position)]);
177
- }
178
- }
179
- if (!mergeRareLandmarkClusters) {
180
- return finalKeys;
181
- }
182
- // Deliberately not a reuse of blockTokenSets (this function's own
183
- // primary-clustering token sets): those follow excludeLandmarks (raw
184
- // HTML, landmarks included, when false) and resolveStructuralClusterKeys
185
- // narrows them further via its internal deriveComparisonSets once a
186
- // block reaches 10+ pages. mergeLandmarkAffinedClusters's secondary
187
- // content-similarity gate is meant to be independent corroboration
188
- // alongside the landmark match already used to select these pages — if
189
- // it reused landmark-inclusive tokens, a bulky shared rare header's own
190
- // tokens could inflate two otherwise-unrelated pages' similarity past
191
- // the gate, and if it reused the frequency-narrowed set, the gate would
192
- // silently test different content than its own JSDoc describes. Always
193
- // tokenizing each page's landmark-excised remainderHtml here keeps the
194
- // gate's evidence independent of both.
195
- const resolvedLandmarks = requireLandmarks(landmarks);
196
- const mergeGateContentTokenSets = resolvedLandmarks.map((entry) => new Set(tokenize(entry.remainderHtml, options).tokens));
197
- return mergeLandmarkAffinedClusters(finalKeys, resolvedLandmarks, mergeGateContentTokenSets, options);
441
+ // Yield to the event loop so Lanes' setTimeout frame can paint the
442
+ // updated header before the next block starts.
443
+ await new Promise((resolve) => setImmediate(resolve));
444
+ }
445
+ onProgress({ phase: 'stage-b-start', unitCount: crossBlockUnits.length });
446
+ const stageBResult = mergeCrossBlockClusters(crossBlockUnits, options);
447
+ for (let i = 0; i < finalKeys.length; i++) {
448
+ const currentKey = finalKeys[i];
449
+ const rootKey = stageBResult.get(currentKey);
450
+ if (rootKey !== undefined && rootKey !== currentKey) {
451
+ finalKeys[i] = rootKey;
452
+ }
453
+ }
454
+ return finalKeys;
455
+ }
456
+ /**
457
+ * Streaming, memory-bounded version of `resolvePageClusterKeysInMemory`.
458
+ *
459
+ * ## Behavior gate
460
+ *
461
+ * - `pageCount CORPUS_INLINE_THRESHOLD` — reads the whole factory into an
462
+ * array, delegates to `resolvePageClusterKeysInMemory`. Same corpus-wide
463
+ * chrome discovery, same Stage B across every page. All previously
464
+ * validated corpora (302 / 1,416 / 8,936 / 89 pages) hit this path.
465
+ * - `pageCount > CORPUS_INLINE_THRESHOLD` — streaming path: reads the
466
+ * factory twice (once for blocking signals, once for HTML processing),
467
+ * dispatches HTML per block, runs Stage A per block, accumulates
468
+ * cross-block units, then runs Stage B across all accumulated units. Peak
469
+ * memory ≈ largest single block, not the whole corpus.
470
+ *
471
+ * ## Semantic differences in streaming mode
472
+ *
473
+ * - **Chrome discovery is per-block, not corpus-wide.** In the in-memory
474
+ * path, {@link ./resolve-page-cluster-keys.js | computeLocalLandmarkTokens}
475
+ * runs on all pages at once. In streaming mode the entire corpus cannot
476
+ * be held at once, so chrome discovery runs per block. A landmark
477
+ * signature that is rare corpus-wide but common within one block will
478
+ * be treated as global chrome in streaming mode, whereas the in-memory
479
+ * mode would treat it as local. This trade-off is why the threshold
480
+ * above is set generously — every real corpus historically validated
481
+ * here stays on the in-memory path.
482
+ * @param pages
483
+ * @param options
484
+ * @example
485
+ * ```ts
486
+ * // JSONL file source — factory can be re-invoked to re-open the file.
487
+ * import { createReadStream } from 'node:fs';
488
+ * import readline from 'node:readline';
489
+ *
490
+ * const keys = await resolvePageClusterKeys(() => {
491
+ * const lines = readline.createInterface({ input: createReadStream('pages.jsonl') });
492
+ * return (async function* () {
493
+ * for await (const line of lines) yield JSON.parse(line);
494
+ * })();
495
+ * });
496
+ * ```
497
+ */
498
+ export async function resolvePageClusterKeys(pages, options) {
499
+ const onProgress = options?.onProgress;
500
+ // Pass 0: HTML-free — collect blocking signals (paths, stylesheetHrefs,
501
+ // host) into an array. This is the only per-page state we keep across
502
+ // the whole corpus in streaming mode.
503
+ const blockingSignals = [];
504
+ for await (const page of pages()) {
505
+ if (onProgress && blockingSignals.length > 0 && blockingSignals.length % 1000 === 0) {
506
+ onProgress({ phase: 'pass0-signals', pagesSeen: blockingSignals.length });
507
+ }
508
+ blockingSignals.push({
509
+ paths: page.paths,
510
+ stylesheetHrefs: page.stylesheetHrefs,
511
+ host: page.host,
512
+ });
513
+ }
514
+ if (blockingSignals.length === 0)
515
+ return [];
516
+ // Small corpus: read the whole factory again with HTML this time, then
517
+ // delegate to the in-memory path. Preserves every corpus-wide semantic
518
+ // (chrome, Stage B) for corpora within the threshold.
519
+ if (blockingSignals.length <= CORPUS_INLINE_THRESHOLD) {
520
+ const fullPages = [];
521
+ for await (const page of pages()) {
522
+ fullPages.push({
523
+ paths: page.paths,
524
+ stylesheetHrefs: page.stylesheetHrefs,
525
+ html: page.html,
526
+ host: page.host,
527
+ });
528
+ }
529
+ // Without an onProgress callback the caller does not need visibility
530
+ // into per-block progress, so delegate to the untouched sync path —
531
+ // keeping behavior byte-for-byte identical (and yield-overhead-free)
532
+ // to how library-only consumers experienced this before the CLI
533
+ // progress work landed.
534
+ if (onProgress === undefined) {
535
+ return resolvePageClusterKeysInMemory(fullPages, options);
536
+ }
537
+ return resolveSmallCorpusWithProgress(fullPages, onProgress, options);
538
+ }
539
+ // Large corpus: streaming path.
540
+ const excludeLandmarks = options?.excludeLandmarks ?? true;
541
+ const similarityThreshold = options?.similarityThreshold ?? 0.8;
542
+ if (!(similarityThreshold >= 0 && similarityThreshold <= 1)) {
543
+ throw new RangeError(`resolvePageClusterKeys: similarityThreshold must be between 0 and 1, got ${similarityThreshold}`);
544
+ }
545
+ validateDetectContentDepthCapOptions(options);
546
+ const restrictStylesheetsToFirstParty = options?.restrictStylesheetsToFirstParty ?? true;
547
+ const blockingPagesForKeys = restrictStylesheetsToFirstParty
548
+ ? filterFirstPartyStylesheetHrefs(blockingSignals)
549
+ : blockingSignals;
550
+ const blockKeys = resolveBlockKeys(blockingPagesForKeys, options);
551
+ const indicesByBlockKey = groupIndicesByBlockKey(blockKeys);
552
+ const finalKeys = Array.from({ length: blockingSignals.length });
553
+ const crossBlockUnits = [];
554
+ const contentBlockAttribute = options?.contentBlockAttribute;
555
+ /** Non-sample page indices that need Pass 1b Jaccard-based assignment. */
556
+ const pendingAssignmentBlockKeyByIndex = new Map();
557
+ /** Block-level artifacts saved after Stage A runs on the sample. */
558
+ const blockAssignments = new Map();
559
+ const buckets = new Map();
560
+ for (const [blockKey, indices] of indicesByBlockKey) {
561
+ buckets.set(blockKey, {
562
+ blockKey,
563
+ targetSize: indices.length,
564
+ reservoirIndices: [],
565
+ reservoirPreparedHtml: [],
566
+ reservoirLandmarks: [],
567
+ seenCount: 0,
568
+ prng: makeSeededPrng(blockKey),
569
+ });
570
+ }
571
+ /**
572
+ * Runs Stage A on the block's reservoir sample, records sample-member
573
+ * cluster keys into `finalKeys`, saves per-cluster member token sets so
574
+ * Pass 1b can Jaccard-assign non-sample pages, and appends the produced
575
+ * `CrossBlockUnit`s to the Stage-B input.
576
+ * @param bucket
577
+ */
578
+ function flushBlock(bucket) {
579
+ const { localSignatures, localTokensByPage } = computeLocalChromeArtifacts(bucket.reservoirLandmarks, options);
580
+ const result = stageAPerBlock({
581
+ blockKey: bucket.blockKey,
582
+ memberIndices: bucket.reservoirIndices,
583
+ preparedHtml: bucket.reservoirPreparedHtml,
584
+ landmarks: bucket.reservoirLandmarks,
585
+ localLandmarkTokensByPage: localTokensByPage,
586
+ },
587
+ // No capMembers — the reservoir already bounds `sampleSize`.
588
+ options);
589
+ for (const [idx, key] of result.pageKeys) {
590
+ finalKeys[idx] = key;
591
+ }
592
+ crossBlockUnits.push(...result.crossBlockUnits);
593
+ if (bucket.seenCount > bucket.reservoirIndices.length) {
594
+ // Save assignment artifacts for Pass 1b.
595
+ const maxMainDepth = bucket.reservoirPreparedHtml.length > 1
596
+ ? detectContentDepthCap(bucket.reservoirPreparedHtml, options)
597
+ : undefined;
598
+ const clustersByUnitKey = new Map();
599
+ for (const unit of result.crossBlockUnits) {
600
+ clustersByUnitKey.set(unit.key, [...unit.memberTokenSets]);
601
+ }
602
+ blockAssignments.set(bucket.blockKey, {
603
+ maxMainDepth,
604
+ localSignatures,
605
+ clustersByUnitKey,
606
+ });
607
+ }
608
+ // Encourage V8 to reclaim the reservoir's transient allocations.
609
+ const maybeGc = globalThis.gc;
610
+ if (maybeGc !== undefined)
611
+ maybeGc();
612
+ }
613
+ // Pass 1: stream HTML pages, reservoir-sample each block, run Stage A
614
+ // on the sample the moment the block is fully seen.
615
+ let pageIndex = 0;
616
+ for await (const page of pages()) {
617
+ const blockKey = blockKeys[pageIndex];
618
+ const bucket = buckets.get(blockKey);
619
+ if (bucket === undefined) {
620
+ throw new Error(`resolvePageClusterKeys: block "${blockKey}" is missing from the bucket registry`);
621
+ }
622
+ // Reservoir sampling (Algorithm R): keep the first BLOCK_SAMPLE_SIZE
623
+ // pages, then for each subsequent one replace a random reservoir slot
624
+ // with decreasing probability.
625
+ if (bucket.reservoirIndices.length < BLOCK_SAMPLE_SIZE) {
626
+ const landmarkResult = extractLandmarks(page.html);
627
+ const landmarksExcised = excludeLandmarks
628
+ ? landmarkResult.remainderHtml
629
+ : page.html;
630
+ const prepared = contentBlockAttribute === undefined
631
+ ? landmarksExcised
632
+ : removeContentBlocks(landmarksExcised, {
633
+ blockAttribute: contentBlockAttribute,
634
+ }).remainderHtml;
635
+ const strippedLandmark = { ...landmarkResult, remainderHtml: '' };
636
+ bucket.reservoirIndices.push(pageIndex);
637
+ bucket.reservoirPreparedHtml.push(prepared);
638
+ bucket.reservoirLandmarks.push(strippedLandmark);
639
+ }
640
+ else {
641
+ const j = Math.floor(bucket.prng() * (bucket.seenCount + 1));
642
+ if (j < BLOCK_SAMPLE_SIZE) {
643
+ const landmarkResult = extractLandmarks(page.html);
644
+ const landmarksExcised = excludeLandmarks
645
+ ? landmarkResult.remainderHtml
646
+ : page.html;
647
+ const prepared = contentBlockAttribute === undefined
648
+ ? landmarksExcised
649
+ : removeContentBlocks(landmarksExcised, {
650
+ blockAttribute: contentBlockAttribute,
651
+ }).remainderHtml;
652
+ const strippedLandmark = { ...landmarkResult, remainderHtml: '' };
653
+ const evicted = bucket.reservoirIndices[j];
654
+ pendingAssignmentBlockKeyByIndex.set(evicted, bucket.blockKey);
655
+ bucket.reservoirIndices[j] = pageIndex;
656
+ bucket.reservoirPreparedHtml[j] = prepared;
657
+ bucket.reservoirLandmarks[j] = strippedLandmark;
658
+ }
659
+ else {
660
+ pendingAssignmentBlockKeyByIndex.set(pageIndex, bucket.blockKey);
661
+ }
662
+ }
663
+ bucket.seenCount++;
664
+ if (bucket.seenCount === bucket.targetSize) {
665
+ flushBlock(bucket);
666
+ buckets.delete(bucket.blockKey);
667
+ if (onProgress) {
668
+ onProgress({
669
+ phase: 'pass1-block-complete',
670
+ blockKey: bucket.blockKey,
671
+ blocksProcessed: indicesByBlockKey.size - buckets.size,
672
+ totalBlocks: indicesByBlockKey.size,
673
+ });
674
+ }
675
+ }
676
+ pageIndex++;
677
+ }
678
+ if (pageIndex !== blockingSignals.length) {
679
+ throw new Error(`resolvePageClusterKeys: streaming input yielded ${pageIndex} pages but Pass 0 saw ${blockingSignals.length} — factory must produce the same pages in the same order on repeated invocations`);
680
+ }
681
+ if (buckets.size > 0) {
682
+ throw new Error(`resolvePageClusterKeys: ${buckets.size} block(s) never reached their target size — this should not happen if the factory produced identical pages across the two passes`);
683
+ }
684
+ // Pass 1b: for every non-sample page (evicted from a block's reservoir
685
+ // or never selected), re-stream its HTML and Jaccard-assign it to the
686
+ // nearest sample-derived cluster in its block. Nothing is added to
687
+ // crossBlockUnits here — the assignment writes directly into finalKeys.
688
+ if (pendingAssignmentBlockKeyByIndex.size > 0) {
689
+ const totalToAssign = pendingAssignmentBlockKeyByIndex.size;
690
+ let assignedCount = 0;
691
+ let assignPageIndex = 0;
692
+ for await (const page of pages()) {
693
+ const targetBlockKey = pendingAssignmentBlockKeyByIndex.get(assignPageIndex);
694
+ if (targetBlockKey !== undefined) {
695
+ const assignment = blockAssignments.get(targetBlockKey);
696
+ if (assignment !== undefined) {
697
+ finalKeys[assignPageIndex] = assignPageToNearestCluster(page.html, assignment, excludeLandmarks, contentBlockAttribute, options, targetBlockKey);
698
+ }
699
+ assignedCount++;
700
+ if (onProgress && assignedCount % 1000 === 0) {
701
+ onProgress({
702
+ phase: 'pass1b-assign',
703
+ pagesAssigned: assignedCount,
704
+ pagesToAssign: totalToAssign,
705
+ });
706
+ }
707
+ }
708
+ assignPageIndex++;
709
+ }
710
+ }
711
+ // Stage B: cross-block merge over the accumulated (sample-based) units.
712
+ // Each unit already has at most BLOCK_SAMPLE_SIZE members from the
713
+ // reservoir sampling above, so no additional cap is needed here — the
714
+ // per-merge cost stays bounded across rounds.
715
+ if (onProgress) {
716
+ onProgress({ phase: 'stage-b-start', unitCount: crossBlockUnits.length });
717
+ }
718
+ const stageBResult = mergeCrossBlockClusters(crossBlockUnits, {
719
+ ...options,
720
+ capMembers: BLOCK_SAMPLE_SIZE,
721
+ });
722
+ for (let i = 0; i < finalKeys.length; i++) {
723
+ const currentKey = finalKeys[i];
724
+ const rootKey = stageBResult.get(currentKey);
725
+ if (rootKey !== undefined && rootKey !== currentKey) {
726
+ finalKeys[i] = rootKey;
727
+ }
728
+ }
729
+ return finalKeys;
730
+ }
731
+ /**
732
+ * Convenience wrapper that runs {@link ./resolve-page-cluster-keys.js | resolvePageClusterKeys}
733
+ * on a materialized array. Preserves the pre-refactor sync API for callers
734
+ * that already have all pages in memory, while flowing through the same
735
+ * async driver so behavior stays consistent across the two entry points.
736
+ * @param pages
737
+ * @param options
738
+ * @example
739
+ * ```ts
740
+ * const keys = await resolvePageClusterKeysFromArray([
741
+ * { paths: ['news', '1'], stylesheetHrefs: [], html: '<body><article>one</article></body>' },
742
+ * { paths: ['news', '2'], stylesheetHrefs: [], html: '<body><article>two</article></body>' },
743
+ * { paths: ['about'], stylesheetHrefs: [], html: '<body><section>about</section></body>' },
744
+ * ]);
745
+ * ```
746
+ */
747
+ export function resolvePageClusterKeysFromArray(pages, options) {
748
+ return resolvePageClusterKeys(() => pages, options);
198
749
  }