@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,222 +1,323 @@
1
+ import type { ExtractLandmarksResult } from './extract-landmarks.js';
1
2
  import type { ResolveBlockingGroupKeysOptions } from './resolve-blocking-group-keys.js';
2
3
  import type { ResolveStructuralClusterKeysOptions } from './resolve-structural-cluster-keys.js';
3
4
  import type { TokenizeOptions } from './types.js';
5
+ /**
6
+ * Reinjects each page's *local* (non-corpus-wide) landmark-instance tokens
7
+ * into its block token set for Stage A clustering, restoring exactly the
8
+ * structural signal that landmark excision removed for those pages while
9
+ * keeping global chrome removed (the whole point of `excludeLandmarks`).
10
+ *
11
+ * ## Why token-level reinjection instead of one opaque pseudo-token
12
+ *
13
+ * An earlier iteration returned a single opaque token per local signature.
14
+ * That failed on real data: adding one distinctive token to a 100+-token
15
+ * page's set produces jaccard ~0.99 between "with-local-landmark" and
16
+ * "without-local-landmark" siblings, so Stage A's 0.8-clamped auto-cut
17
+ * silently merged them anyway. Reinjecting the landmark's actual tokens
18
+ * (typically 4–20 per landmark) restores the full structural weight of
19
+ * the distinction. A real mid-sized crawl corpus's section subtree with
20
+ * a shared section-local `<nav>` now splits correctly from siblings
21
+ * without one, since the reinjected local-nav tokens push jaccard below
22
+ * the cut.
23
+ *
24
+ * ## The corpus-level auto-cut
25
+ *
26
+ * Every page's landmark instances are canonicalized to a signature (via
27
+ * {@link ./canonicalize-token-set.js | canonicalizeTokenSet}); the corpus-
28
+ * wide histogram of "how many pages carry this signature" is fed to
29
+ * {@link ./auto-cut-threshold.js | autoCutThreshold} — the same primitive
30
+ * used at every other layer of this pipeline for merge-height cutoffs. The
31
+ * clamp caps the auto-cut at 0.8 so it never picks a threshold *above* the
32
+ * conservative default. A signature at or above the cut is global chrome —
33
+ * appears on effectively every page, so its tokens carry no discriminatory
34
+ * signal and are left excised. A signature below the cut is local chrome
35
+ * for the pages that carry it, and its tokens are reinjected into those
36
+ * pages' block token sets. Same technique as the per-unit shellQuorum in
37
+ * {@link ./merge-cross-block-clusters.js | mergeCrossBlockClusters}, one
38
+ * layer up.
39
+ *
40
+ * ## The `count >= 2` gate
41
+ *
42
+ * A signature present on exactly one page is per-page variation, not
43
+ * shared local chrome — no "these pages have the same local chrome, those
44
+ * pages don't" grouping can be built from a singleton, and admitting
45
+ * singleton signatures would reinject each per-page-unique landmark into
46
+ * exactly one page's token set, causing spurious per-page cluster
47
+ * fragmentation across the corpus (confirmed against a 2-page fixture
48
+ * where two pages carry byte-different `<header>`s produced identical
49
+ * clusters as expected; without the gate, each would carry its own
50
+ * reinjected tokens and split).
51
+ * @param landmarks
52
+ * @param tokenizeOptions
53
+ */
54
+ /**
55
+ * Companion to {@link ./resolve-page-cluster-keys.js | computeLocalLandmarkTokens}
56
+ * that also returns the local-signature *set* the streaming path needs to
57
+ * reuse when tokenizing non-sample pages during Pass 1b. The in-memory path
58
+ * only cares about the per-page token sets (which pages carry which
59
+ * chrome-below-the-cut tokens); the streaming path additionally needs to
60
+ * apply the *same* "which signatures are local" verdict to pages that were
61
+ * not part of the sample the verdict was learned from.
62
+ * @param landmarks
63
+ * @param tokenizeOptions
64
+ */
65
+ export declare function computeLocalChromeArtifacts(landmarks: readonly ExtractLandmarksResult[], tokenizeOptions: TokenizeOptions | undefined): {
66
+ readonly localSignatures: ReadonlySet<string>;
67
+ readonly localTokensByPage: readonly ReadonlySet<string>[];
68
+ };
69
+ /**
70
+ * Reinjects each page's *local* (non-corpus-wide) landmark-instance tokens
71
+ * into its block token set for Stage A clustering, restoring exactly the
72
+ * structural signal that landmark excision removed for those pages while
73
+ * keeping global chrome removed (the whole point of `excludeLandmarks`).
74
+ *
75
+ * See {@link ./resolve-page-cluster-keys.js | computeLocalChromeArtifacts}
76
+ * for the underlying algorithm — this function is a thin wrapper that
77
+ * discards the local-signature set, exposed for callers that only need the
78
+ * per-page tokens (the in-memory driver's use case).
79
+ * @param landmarks
80
+ * @param tokenizeOptions
81
+ */
82
+ export declare function computeLocalLandmarkTokens(landmarks: readonly ExtractLandmarksResult[], tokenizeOptions: TokenizeOptions | undefined): ReadonlySet<string>[];
4
83
  /**
5
84
  * Per-page input to {@link ./resolve-page-cluster-keys.js | resolvePageClusterKeys}:
6
85
  * the blocking signals {@link ./resolve-blocking-group-keys.js | resolveBlockingGroupKeys}
7
- * needs, plus the page's raw HTML. Raw HTML (rather than a pre-tokenized
8
- * `Set`, as earlier versions of this type required) because
9
- * `resolvePageClusterKeys` now needs to decide *how* to tokenize each page
10
- * (see `excludeLandmarks` below) — a decision a caller handed a bare
11
- * `Set<string>` could no longer make correctly on its own.
86
+ * needs, plus the page's raw HTML.
12
87
  */
13
88
  export type PageClusterSignals = {
14
89
  paths: readonly string[];
15
90
  stylesheetHrefs: readonly string[];
16
91
  html: string;
92
+ /**
93
+ * This page's own URL host (hostname, optionally `:port` — same shape as
94
+ * `new URL(pageUrl).host`), forwarded to
95
+ * {@link ./filter-first-party-stylesheet-hrefs.js | filterFirstPartyStylesheetHrefs}
96
+ * so it can judge that page's `stylesheetHrefs` by direct comparison
97
+ * instead of inferring a batch-wide dominant host.
98
+ */
99
+ host?: string;
100
+ };
101
+ /**
102
+ * Progress event emitted by the async factory-based
103
+ * `resolvePageClusterKeys` when an `onProgress` callback is provided. Meant
104
+ * as a lightweight, opt-in observability hook for callers who need visible
105
+ * progress on multi-minute jobs — the CLI wires this straight to stderr.
106
+ *
107
+ * The event is a discriminated union on `phase`:
108
+ * - `pass0-signals`: streaming path only. Reading blocking signals (paths /
109
+ * stylesheetHrefs / host) from the factory. Fires every ~1,000 pages
110
+ * during Pass 0.
111
+ * - `pass1-block-complete`: one block's Stage A finished. Fires on **both**
112
+ * the small-corpus path (`≤ CORPUS_INLINE_THRESHOLD`, once per block in
113
+ * `indicesByBlockKey` iteration order) and the streaming path (once per
114
+ * block as its reservoir fills). The event carries the block's key and a
115
+ * running count of how many blocks have completed so far.
116
+ * - `pass1b-assign`: streaming path only. Streaming assignment of
117
+ * non-sample pages is in progress. Fires every ~1,000 pages of Pass 1b.
118
+ * Conceptually absent on the small-corpus path — every page is a "sample"
119
+ * there.
120
+ * - `stage-b-start`: cross-block merge has begun. Fires once per call on
121
+ * both paths.
122
+ *
123
+ * Stage B does not currently emit per-round events — a future extension
124
+ * that passes a callback down into `mergeCrossBlockClusters` can add them
125
+ * without breaking the existing shape.
126
+ *
127
+ * The **sync** `resolvePageClusterKeysInMemory` never emits any progress
128
+ * (it has no way to yield to a caller mid-block anyway). Only the async
129
+ * factory-based entry point participates in `onProgress`.
130
+ */
131
+ export type ProgressEvent = {
132
+ readonly phase: 'pass0-signals';
133
+ readonly pagesSeen: number;
134
+ } | {
135
+ readonly phase: 'pass1-block-complete';
136
+ readonly blockKey: string;
137
+ readonly blocksProcessed: number;
138
+ readonly totalBlocks: number;
139
+ } | {
140
+ readonly phase: 'pass1b-assign';
141
+ readonly pagesAssigned: number;
142
+ readonly pagesToAssign: number;
143
+ } | {
144
+ readonly phase: 'stage-b-start';
145
+ readonly unitCount: number;
17
146
  };
18
147
  /**
19
148
  * @see resolvePageClusterKeys
20
149
  */
21
150
  export type ResolvePageClusterKeysOptions = TokenizeOptions & ResolveBlockingGroupKeysOptions & ResolveStructuralClusterKeysOptions & {
22
- /**
23
- * Tokenize each page's `<header>`/`<footer>`/`<nav>`/`<aside>`-excised
24
- * remainder ({@link ./extract-landmarks.js | extractLandmarks}'s
25
- * `remainderHtml`) instead of its raw HTML, so shared site chrome never
26
- * reaches the structural-similarity comparison. Defaults to `true`.
27
- * Set to `false` to fall back to tokenizing the untouched page (the
28
- * pre-landmark-extraction behavior) — this is a large behavioral
29
- * change not yet validated across many sites beyond the two real
30
- * corpora checked so far, so the escape hatch is kept available.
31
- *
32
- * Leaving this `true` means every page's HTML is parsed twice (once by
33
- * `extractLandmarks`, once by `tokenize` on its `remainderHtml`)
34
- * instead of once. Measured on a real crawl corpus (8,936 pages),
35
- * this is still net faster overall than the single-parse `false`
36
- * path (17,557ms vs 25,997ms end-to-end): `remainderHtml` is
37
- * substantially shorter than the original page once landmarks are
38
- * excised, and the resulting smaller `tokenize` pass costs less than
39
- * the extra `extractLandmarks` pass adds.
40
- */
41
151
  excludeLandmarks?: boolean;
42
- /**
43
- * Apply {@link ./reassign-orphan-block-keys.js | reassignOrphanBlockKeys}
44
- * to the blocking keys before clustering, so a page with no recorded
45
- * stylesheets ("orphan" — often a crawl-completeness gap, not evidence
46
- * the page is actually template-less) can rejoin a same-URL-section
47
- * `css:` block instead of being stranded on its weaker `path:` fallback.
48
- * Defaults to `true`. Set to `false` to fall back to the raw
49
- * {@link ./resolve-blocking-group-keys.js | resolveBlockingGroupKeys}
50
- * output — kept available both because this is not yet broadly-
51
- * validated beyond the two real crawls checked so far, and because it
52
- * has a known trade-off documented on
53
- * {@link ./reassign-orphan-block-keys.js | reassignOrphanBlockKeys}
54
- * itself: pooling pages for comparison can change unrelated pages'
55
- * cluster outcomes too, not just the orphan's.
56
- */
57
152
  reassignOrphans?: boolean;
58
- /**
59
- * Apply {@link ./remove-content-blocks.js | removeContentBlocks} to each
60
- * page's landmark-excised remainder before tokenizing, so a freeform
61
- * block-editor content area's page-to-page variation (which specific
62
- * mix of blocks an author used) never reaches the structural-similarity
63
- * comparison. No default — unlike `excludeLandmarks`/`reassignOrphans`,
64
- * this needs the caller's own block-editor attribute name (see
65
- * `removeContentBlocks`'s `blockAttribute` option), which this package
66
- * cannot guess. Omit to skip this step entirely.
67
- */
68
153
  contentBlockAttribute?: string;
69
- /**
70
- * Apply {@link ./filter-first-party-stylesheet-hrefs.js |
71
- * filterFirstPartyStylesheetHrefs} to `pages` before computing blocking
72
- * keys, so a page's incidental third-party embeds (e.g. a video
73
- * player's own stylesheet, extra web-font requests pulled in by a
74
- * widget) never get mistaken for a template-identifying signal by
75
- * {@link ./resolve-blocking-group-keys.js | resolveBlockingGroupKeys}.
76
- * Defaults to `true`. Set to `false` to block on every page's full,
77
- * unfiltered `stylesheetHrefs` — kept available for the same reason
78
- * `excludeLandmarks`'s escape hatch is: a real but not yet broadly-
79
- * validated behavioral change (confirmed so far on one real crawl).
80
- *
81
- * Inherits `filterFirstPartyStylesheetHrefs`'s "roughly homogeneous
82
- * batch" precondition (see that function's own JSDoc): `pages` should
83
- * be one site or one section, the same expectation
84
- * `resolveBlockingGroupKeys` already places on its own input.
85
- */
86
154
  restrictStylesheetsToFirstParty?: boolean;
87
155
  /**
88
- * Apply {@link ./detect-content-depth-cap.js | detectContentDepthCap}
89
- * separately *within each block* (after `excludeLandmarks`/
90
- * `contentBlockAttribute`, after blocking, before that block's own
91
- * tokenizing) to find how many levels of nesting inside
92
- * `<main>`/`role="main"` to keep, then
93
- * {@link ./cap-content-depth.js | capContentDepth} each of that
94
- * block's pages at that depth a `contentBlockAttribute`-style fix
95
- * for freeform-content noise that needs no site-specific attribute
96
- * name, since `<main>` is an HTML5/ARIA standard. Defaults to
97
- * `false`: unlike `contentBlockAttribute` (which does nothing unless
98
- * a matching attribute is actually present), this discards real
99
- * content whenever a page has a `<main>`/`role="main"` at all, so
100
- * it's opt-in until validated on more than the two real corpora
101
- * checked so far.
102
- *
103
- * Per-block rather than once across the whole corpus: different
104
- * blocks (different templates/sections) can have genuinely different
105
- * "skeleton depths." Confirmed on real crawl data: an 814-page block
106
- * whose own knee sits at depth 2 stayed at 189 clusters (barely moved
107
- * from 224 uncapped) when capped at depth 3 — the knee derived from
108
- * the *whole* 8,936-page corpus, dominated by two much larger blocks
109
- * whose own knee is 3. Re-deriving the knee for that block alone
110
- * brings it down to 46. A block too small for its knee-detection
111
- * sweep to find a reliable jump just falls through to
112
- * `detectContentDepthCap`'s own no-knee fallback (the largest
113
- * candidate depth, effectively "don't cap") — the same safe default
114
- * it already has for any input, now reached per-block instead of
115
- * corpus-wide. Skipped entirely (no cap) for a block of exactly 1
116
- * page — nothing to compare it against, so a knee sweep there could
117
- * only ever confirm what's already true.
118
- *
119
- * Trade-off of going per-block: a corpus-wide sweep's cluster-count
120
- * ratios are diluted by thousands of ordinary pages, so one
121
- * incidental outlier (e.g. a single page with an extra wrapper `div`
122
- * from a stray widget) barely moves them. A small block's sweep has
123
- * no such dilution — a similar outlier among only a handful of pages
124
- * can itself clear `minKneeRatio` and produce a too-shallow cap for
125
- * that block. Not yet observed on either real corpus checked so far
126
- * (both corpora's small blocks happened to be uniform enough that
127
- * this didn't come up), so no size-based guard is added speculatively;
128
- * revisit if real data surfaces it.
129
- *
130
- * Composes with `contentBlockAttribute` rather than replacing it: both
131
- * can be set at once — `removeContentBlocks` runs first, then
132
- * `capContentDepth` on what's left — for a site whose CMS marks *some*
133
- * blocks with a known attribute but still has other, unmarked
134
- * freeform depth the attribute alone doesn't catch.
135
- *
136
- * Confirmed on real crawl data this can outperform
137
- * `contentBlockAttribute` on its own, not just stand in for it when the
138
- * attribute is unknown: on a 302-page corpus, `autoCapMainDepth` alone
139
- * produced 20 final clusters versus 27 for
140
- * `contentBlockAttribute: 'data-bgb'` together with
141
- * `restrictStylesheetsToFirstParty` — the site's known CMS attribute
142
- * doesn't mark every source of freeform depth, but the `<main>`
143
- * boundary catches all of it uniformly. See `detectContentDepthCap`'s
144
- * JSDoc for the real cost/accuracy numbers this per-block sweep
145
- * measures on the same two corpora.
146
- */
147
- autoCapMainDepth?: boolean;
148
- /**
149
- * Re-key two or more otherwise-distinct clusters onto one shared key
150
- * when every landmark type present on their pages is both identical
151
- * and rare corpus-wide — see
152
- * {@link ./merge-landmark-affined-clusters.js | mergeLandmarkAffinedClusters}'s
153
- * JSDoc for the exact rule, the withdrawn earlier prototype this
154
- * reimplements, and why "rare" (not merely "identical") is required.
155
- * Defaults to `false`.
156
- *
157
- * Kept `false` by default: unlike `autoCapMainDepth`/
158
- * `restrictStylesheetsToFirstParty`, this has not been run against
159
- * real crawl data at all as of this change — only synthetic-fixture
160
- * unit/regression tests. See `mergeLandmarkAffinedClusters`'s JSDoc
161
- * for its cost profile before enabling this on a large corpus.
156
+ * Optional observability hook invoked at every progress event
157
+ * documented on {@link ./resolve-page-cluster-keys.js | ProgressEvent}.
158
+ * Fires only on the async factory-based `resolvePageClusterKeys`;
159
+ * the sync `resolvePageClusterKeysInMemory` never emits events.
160
+ * On the async path, passing this option promotes the small-corpus
161
+ * branch (`≤ CORPUS_INLINE_THRESHOLD`) from delegating to the
162
+ * sync helper to running a per-block async loop that emits
163
+ * `pass1-block-complete` and `stage-b-start`. Omitting `onProgress`
164
+ * keeps the small-corpus branch on the pre-refactor sync path with
165
+ * zero yield overhead.
162
166
  */
163
- mergeRareLandmarkClusters?: boolean;
164
- /** Forwarded to {@link ./merge-landmark-affined-clusters.js | mergeLandmarkAffinedClusters} as-is. */
165
- landmarkRarityThreshold?: number;
166
- /** Forwarded to {@link ./merge-landmark-affined-clusters.js | mergeLandmarkAffinedClusters} as-is. */
167
- landmarkGateSimilarityThreshold?: number;
167
+ onProgress?: (event: ProgressEvent) => void;
168
168
  };
169
169
  /**
170
- * Connects the two stages this package otherwise leaves for the caller to
171
- * wire together: {@link ./resolve-blocking-group-keys.js | resolveBlockingGroupKeys}
172
- * (coarse blocking by URL path or stylesheet set) and
173
- * {@link ./resolve-structural-cluster-keys.js | resolveStructuralClusterKeys}
174
- * (exact structural clustering *within* one block). Returns one final key
175
- * per page, in the same order as `pages`, unique across the whole input —
176
- * not just within a block.
177
- *
178
- * `resolveStructuralClusterKeys` numbers its clusters `cluster:0`,
179
- * `cluster:1`, ... independently every time it's called, so two different
180
- * blocks' `cluster:0` are unrelated but identically named. Composing the
181
- * block key and the per-block cluster label via `JSON.stringify` (rather
182
- * than plain string concatenation, e.g. a `::` separator) rules out
183
- * collisions regardless of what either half happens to contain, without
184
- * depending on `resolveStructuralClusterKeys`'s label format never changing.
185
- *
186
- * `excludeLandmarks` and `similarityThreshold` interact: removing shared
187
- * chrome makes every remaining comparison stricter (there's no more
188
- * chrome-driven baseline similarity propping scores up), so a threshold
189
- * tuned against raw, chrome-included tokens can become too strict once
190
- * landmarks are excluded. Confirmed on real crawl data: a 4-page block where
191
- * 3 same-template pages merged at the default `similarityThreshold` (0.8)
192
- * using raw tokens split one of the 3 into its own singleton once landmarks
193
- * were excluded, and re-merged correctly at `similarityThreshold: 0.6` — re-
194
- * tune per site after switching this on, the same as `similarityThreshold`
195
- * itself already needs.
196
- *
197
- * `reassignOrphans` only ever pools a `path:`-fallback orphan alongside a
198
- * same-section `css:` block for `resolveStructuralClusterKeys` to compare —
199
- * it never forces a merge itself. An orphan that turns out not to match
200
- * anything in that pool (confirmed on real crawl data) correctly surfaces as
201
- * its own singleton, the same as it would have without this option.
202
- *
203
- * `restrictStylesheetsToFirstParty` runs before `reassignOrphans`: a page
204
- * whose only stylesheet reference was third-party becomes an orphan (no
205
- * first-party stylesheet left) *because of* the filtering, and is then
206
- * itself eligible for orphan reassignment — this is intentional, not an
207
- * ordering accident, since the underlying reason both options exist is the
208
- * same (a page's blocking key should reflect its template, not incidental
209
- * third-party embeds or missing crawl data).
170
+ * Corpus size at or below which the async factory-based
171
+ * `resolvePageClusterKeys` reads the entire input into an array and delegates
172
+ * to {@link ./resolve-page-cluster-keys.js | resolvePageClusterKeysInMemory}
173
+ * unchanged — preserving corpus-wide semantics (chrome discovery, Stage B
174
+ * across all pages) exactly.
175
+ *
176
+ * Above this threshold, the streaming path takes over: block dispatch during
177
+ * a second factory read, per-block chrome discovery (semantic drift from
178
+ * corpus-wide, unavoidable when the whole corpus does not fit in memory),
179
+ * and Stage B fed with the incrementally-accumulated cross-block units.
180
+ *
181
+ * Chosen from Phase 0 spike measurements: a ~9,000-page real crawl (biggest
182
+ * block ~3,900) completed in ~108s / 1.25 GB heap on the in-memory path.
183
+ * Doubling that headroom to 20,000 keeps every corpus previously validated
184
+ * (302, 1,416, 8,936, 89 pages) on the exact code path they were validated
185
+ * against, so their gate values (9 / 21 / 63 / 3 clusters respectively)
186
+ * remain byte-reproducible. A ~176,000-page real crawl OOM'd on the in-memory
187
+ * path well below this threshold worth of pages ever being materialized, so
188
+ * anything above 20,000 is routed to streaming.
189
+ */
190
+ export declare const CORPUS_INLINE_THRESHOLD = 20000;
191
+ /**
192
+ * Reservoir-sample size per block on the streaming path. Blocks larger than
193
+ * this have Stage A run on a random sample of `BLOCK_SAMPLE_SIZE` pages,
194
+ * with the remaining non-sample pages assigned via Jaccard similarity to
195
+ * the sample-derived clusters during Pass 1b. Blocks at or below this size
196
+ * still work — the sampling degenerates to "keep every input page unchanged"
197
+ * per the `reservoirSample` contract, so small blocks behave identically to
198
+ * the in-memory path.
199
+ *
200
+ * Chosen to bound accumulated Stage-B state: units × sample_size × per-
201
+ * member memory 200 units × 100 members × 25 KB ≈ 500 MB, well within an
202
+ * 8 GB Node heap even on macOS where jetsam (the kernel OOM killer) reacts
203
+ * to RSS pressure before V8's own heap limit trips.
204
+ *
205
+ * ## Semantic differences from the in-memory path
206
+ *
207
+ * - **Chrome discovery is sample-based per block.** Landmark signatures that
208
+ * are rare in the sample get treated as global chrome; only signatures
209
+ * that show up on 2 sample members and below the sample-derived
210
+ * auto-cut are reinjected. Full-block chrome discovery would see rare
211
+ * signatures too — the sample-based decision approximates it.
212
+ * - **Non-sample pages are assigned by max-Jaccard against sample member
213
+ * token sets.** A page whose closest sample member is genuinely dissimilar
214
+ * still gets slotted into the least-bad cluster; this is a pragmatic
215
+ * trade for a bounded assignment cost (no unbounded "outlier" cluster
216
+ * growth).
217
+ * - **Stage B sees the sample-based `CrossBlockUnit`s only.** Non-sample
218
+ * pages carry the final key that Stage B produces for their assigned
219
+ * sample cluster, without contributing to Stage B's own DF / quorum-core
220
+ * / shell-quorum computations.
221
+ *
222
+ * Preserves the in-memory path unchanged for corpora at or below
223
+ * {@link CORPUS_INLINE_THRESHOLD} — sampling is streaming-mode only.
224
+ */
225
+ export declare const BLOCK_SAMPLE_SIZE = 100;
226
+ /**
227
+ * Preserves the previous synchronous, array-in / array-out API of
228
+ * `resolvePageClusterKeys` under a new name so the factory-based async
229
+ * export can take the primary name while callers that already had a
230
+ * materialized page array (spec tests, the in-repo dogfood harness,
231
+ * downstream code that hasn't switched to streaming yet) retain the
232
+ * exact same behavior.
233
+ *
234
+ * Semantics: identical to the pre-refactor `resolvePageClusterKeys`.
235
+ * Corpus-wide chrome discovery, Stage B across every page, no memory
236
+ * bound — meant to be called on inputs already known to fit in memory.
237
+ * The async factory-based export delegates here whenever
238
+ * `pages.length ≤ CORPUS_INLINE_THRESHOLD`, guaranteeing existing corpora
239
+ * hit exactly this code path.
240
+ * @param pages
241
+ * @param options
242
+ */
243
+ export declare function resolvePageClusterKeysInMemory(pages: readonly PageClusterSignals[], options?: ResolvePageClusterKeysOptions): string[];
244
+ /**
245
+ * Factory function returning an iterator over pages. Called once per streaming
246
+ * pass — the driver may invoke it multiple times to re-read the same corpus
247
+ * (once HTML-free for blocking, once again per block for HTML processing).
248
+ * Callers with a materialized array can wrap it as
249
+ * `() => pagesArray[Symbol.iterator]()`, or use
250
+ * {@link ./resolve-page-cluster-keys.js | resolvePageClusterKeysFromArray}.
251
+ *
252
+ * ## Why a factory rather than an `AsyncIterable`
253
+ *
254
+ * An `AsyncIterable` returned once cannot be traversed a second time (the
255
+ * iterator is spent after the first `for await`). The streaming driver must
256
+ * read the corpus at least twice — once with HTML dropped to compute
257
+ * blocking keys, and once again per block to process HTML. A factory
258
+ * function lets the caller build a fresh iterator each pass (typically by
259
+ * re-opening a JSONL file or re-issuing an archive query), so per-corpus
260
+ * memory stays proportional to the largest single block rather than to the
261
+ * full corpus.
262
+ */
263
+ export type PageFactory = () => Iterable<PageClusterSignals> | AsyncIterable<PageClusterSignals>;
264
+ /**
265
+ * Streaming, memory-bounded version of `resolvePageClusterKeysInMemory`.
266
+ *
267
+ * ## Behavior gate
268
+ *
269
+ * - `pageCount ≤ CORPUS_INLINE_THRESHOLD` — reads the whole factory into an
270
+ * array, delegates to `resolvePageClusterKeysInMemory`. Same corpus-wide
271
+ * chrome discovery, same Stage B across every page. All previously
272
+ * validated corpora (302 / 1,416 / 8,936 / 89 pages) hit this path.
273
+ * - `pageCount > CORPUS_INLINE_THRESHOLD` — streaming path: reads the
274
+ * factory twice (once for blocking signals, once for HTML processing),
275
+ * dispatches HTML per block, runs Stage A per block, accumulates
276
+ * cross-block units, then runs Stage B across all accumulated units. Peak
277
+ * memory ≈ largest single block, not the whole corpus.
278
+ *
279
+ * ## Semantic differences in streaming mode
280
+ *
281
+ * - **Chrome discovery is per-block, not corpus-wide.** In the in-memory
282
+ * path, {@link ./resolve-page-cluster-keys.js | computeLocalLandmarkTokens}
283
+ * runs on all pages at once. In streaming mode the entire corpus cannot
284
+ * be held at once, so chrome discovery runs per block. A landmark
285
+ * signature that is rare corpus-wide but common within one block will
286
+ * be treated as global chrome in streaming mode, whereas the in-memory
287
+ * mode would treat it as local. This trade-off is why the threshold
288
+ * above is set generously — every real corpus historically validated
289
+ * here stays on the in-memory path.
290
+ * @param pages
291
+ * @param options
292
+ * @example
293
+ * ```ts
294
+ * // JSONL file source — factory can be re-invoked to re-open the file.
295
+ * import { createReadStream } from 'node:fs';
296
+ * import readline from 'node:readline';
297
+ *
298
+ * const keys = await resolvePageClusterKeys(() => {
299
+ * const lines = readline.createInterface({ input: createReadStream('pages.jsonl') });
300
+ * return (async function* () {
301
+ * for await (const line of lines) yield JSON.parse(line);
302
+ * })();
303
+ * });
304
+ * ```
305
+ */
306
+ export declare function resolvePageClusterKeys(pages: PageFactory, options?: ResolvePageClusterKeysOptions): Promise<string[]>;
307
+ /**
308
+ * Convenience wrapper that runs {@link ./resolve-page-cluster-keys.js | resolvePageClusterKeys}
309
+ * on a materialized array. Preserves the pre-refactor sync API for callers
310
+ * that already have all pages in memory, while flowing through the same
311
+ * async driver so behavior stays consistent across the two entry points.
210
312
  * @param pages
211
313
  * @param options
212
314
  * @example
213
315
  * ```ts
214
- * resolvePageClusterKeys([
215
- * { paths: ['news', '1'], stylesheetHrefs: [], html: '<body><article>one</article></body>' },
216
- * { paths: ['news', '2'], stylesheetHrefs: [], html: '<body><article>two</article></body>' },
217
- * { paths: ['about'], stylesheetHrefs: [], html: '<body><section>about</section></body>' },
316
+ * const keys = await resolvePageClusterKeysFromArray([
317
+ * { paths: ['news', '1'], stylesheetHrefs: [], html: '<body><article>one</article></body>' },
318
+ * { paths: ['news', '2'], stylesheetHrefs: [], html: '<body><article>two</article></body>' },
319
+ * { paths: ['about'], stylesheetHrefs: [], html: '<body><section>about</section></body>' },
218
320
  * ]);
219
- * // pages 0 and 1 (same block, same structure) share a key; page 2 (different block) gets its own
220
321
  * ```
221
322
  */
222
- export declare function resolvePageClusterKeys(pages: readonly PageClusterSignals[], options?: ResolvePageClusterKeysOptions): string[];
323
+ export declare function resolvePageClusterKeysFromArray(pages: readonly PageClusterSignals[], options?: ResolvePageClusterKeysOptions): Promise<string[]>;