@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.
- package/LICENSE +21 -0
- package/README.md +95 -41
- package/dist/assign-contained-clusters.d.ts +42 -0
- package/dist/assign-contained-clusters.js +156 -0
- package/dist/auto-cut-threshold.d.ts +17 -0
- package/dist/auto-cut-threshold.js +36 -0
- package/dist/canonicalize-token-set.d.ts +17 -0
- package/dist/canonicalize-token-set.js +19 -0
- package/dist/cli.d.ts +39 -0
- package/dist/cli.js +381 -0
- package/dist/collapse-anonymous-divs.d.ts +21 -0
- package/dist/collapse-anonymous-divs.js +42 -0
- package/dist/complete-linkage-dendrogram.d.ts +41 -0
- package/dist/complete-linkage-dendrogram.js +140 -0
- package/dist/derive-comparison-sets.d.ts +22 -0
- package/dist/derive-comparison-sets.js +33 -0
- package/dist/derive-path-cluster-keys.d.ts +53 -0
- package/dist/derive-path-cluster-keys.js +109 -0
- package/dist/extract-landmarks.d.ts +91 -45
- package/dist/extract-landmarks.js +122 -41
- package/dist/filter-first-party-stylesheet-hrefs.d.ts +58 -24
- package/dist/filter-first-party-stylesheet-hrefs.js +72 -33
- package/dist/find-shallowest-elements.d.ts +48 -11
- package/dist/find-shallowest-elements.js +41 -21
- package/dist/merge-cross-block-clusters.d.ts +61 -0
- package/dist/merge-cross-block-clusters.js +546 -0
- package/dist/pass0-blocking.d.ts +89 -0
- package/dist/pass0-blocking.js +87 -0
- package/dist/per-page-landmark-signatures.d.ts +48 -0
- package/dist/per-page-landmark-signatures.js +62 -0
- package/dist/reservoir-sample.d.ts +43 -0
- package/dist/reservoir-sample.js +98 -0
- package/dist/resolve-blocking-group-keys.d.ts +8 -2
- package/dist/resolve-blocking-group-keys.js +18 -4
- package/dist/resolve-landmark-variant-keys.d.ts +41 -20
- package/dist/resolve-landmark-variant-keys.js +69 -26
- package/dist/resolve-page-cluster-keys.d.ts +292 -191
- package/dist/resolve-page-cluster-keys.js +708 -157
- package/dist/resolve-structural-cluster-keys.d.ts +9 -0
- package/dist/resolve-structural-cluster-keys.js +14 -232
- package/dist/shape-token.d.ts +11 -0
- package/dist/shape-token.js +38 -0
- package/dist/stage-a-per-block.d.ts +133 -0
- package/dist/stage-a-per-block.js +178 -0
- package/dist/tokenize.d.ts +6 -0
- package/dist/tokenize.js +6 -0
- package/package.json +5 -58
- package/dist/html-region-utils.d.ts +0 -74
- package/dist/html-region-utils.js +0 -96
- package/dist/merge-landmark-affined-clusters.d.ts +0 -179
- 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.
|
|
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
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
* `
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
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
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
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
|
|
323
|
+
export declare function resolvePageClusterKeysFromArray(pages: readonly PageClusterSignals[], options?: ResolvePageClusterKeysOptions): Promise<string[]>;
|