@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
package/dist/cli.js ADDED
@@ -0,0 +1,381 @@
1
+ #!/usr/bin/env node
2
+ // Wire the `page-cluster` executable to the library's factory-based
3
+ // `resolvePageClusterKeys`. Reads JSONL from stdin (one page per line),
4
+ // writes JSONL to stdout (one cluster assignment per line, in input order),
5
+ // and streams progress to stderr via `@d-zero/dealer`'s `Lanes` — in-place
6
+ // animated header on a TTY, appended `[page-cluster] …` lines otherwise.
7
+ import process from 'node:process';
8
+ import { Lanes } from '@d-zero/dealer';
9
+ import { resolvePageClusterKeys } from './resolve-page-cluster-keys.js';
10
+ const HELP_TEXT = `Usage:
11
+ page-cluster [--content-block-attribute <name>] < pages.jsonl > clusters.jsonl
12
+
13
+ Input (JSONL, one page per line):
14
+ {
15
+ "id": "any stable identifier",
16
+ "html": "<html>...</html>",
17
+ "paths": ["news", "1"], // optional
18
+ "stylesheetHrefs": ["/a.css"], // optional
19
+ "host": "example.com" // optional
20
+ }
21
+
22
+ Output (JSONL, one line per input page, in input order):
23
+ { "id": "...", "clusterKey": "..." }
24
+
25
+ Options:
26
+ --content-block-attribute <name> CMS-provided attribute marking freeform
27
+ content blocks that should be stripped
28
+ before comparison (e.g. \`data-bgb\`).
29
+ --help Print this help and exit.
30
+ --version Print the package version and exit.
31
+
32
+ Progress:
33
+ Emitted to stderr while the run is in progress. On an interactive
34
+ terminal, an in-place animated header shows the current phase and
35
+ elapsed time. When stderr is not a TTY (redirected to a file, piped, or
36
+ under CI), each phase transition is appended as a \`[page-cluster] …\`
37
+ line so \`grep\` / \`awk\` stay trivial. Silence progress with
38
+ \`2>/dev/null\`; the JSONL output on stdout is unaffected either way.
39
+ `;
40
+ /**
41
+ * Log id used for the single-lane `lanes.update()` call under verbose mode.
42
+ * Lanes was originally designed for one line per parallel worker; here we
43
+ * only ever have one narrative line, so a fixed id is enough.
44
+ */
45
+ const LANE_ID = 0;
46
+ /**
47
+ * Prefix rendered ahead of every verbose progress line. Set on `lanes.header`
48
+ * at CLI init so that `lanes.update(LANE_ID, …)` under Lanes' verbose mode
49
+ * emits `[page-cluster] <line>` rather than the `undefined <line>` string
50
+ * it would produce with an unset header (see `dealer/lanes.ts:104`).
51
+ */
52
+ const VERBOSE_HEADER = '[page-cluster]';
53
+ /**
54
+ * Parses `process.argv`-style arguments (already sliced past `node script`)
55
+ * into a `CliArgs`. Deliberately tolerant of an unknown flag so the caller
56
+ * can decide the error message shape, and so tests can assert on the
57
+ * unrecognized flag name directly.
58
+ * @param argv
59
+ */
60
+ export function parseArgs(argv) {
61
+ const out = {};
62
+ for (let i = 0; i < argv.length; i++) {
63
+ const arg = argv[i];
64
+ switch (arg) {
65
+ case '--help':
66
+ case '-h': {
67
+ out.help = true;
68
+ break;
69
+ }
70
+ case '--version':
71
+ case '-v': {
72
+ out.version = true;
73
+ break;
74
+ }
75
+ case '--content-block-attribute': {
76
+ const next = argv[i + 1];
77
+ if (next === undefined) {
78
+ out.unknownFlag = `${arg} requires a value`;
79
+ return out;
80
+ }
81
+ out.contentBlockAttribute = next;
82
+ i++;
83
+ break;
84
+ }
85
+ default: {
86
+ out.unknownFlag = arg;
87
+ return out;
88
+ }
89
+ }
90
+ }
91
+ return out;
92
+ }
93
+ /**
94
+ * Streams `stdin` and yields per-line JSON-parsed page objects (plus the
95
+ * original line's `id`, preserved for the output row). Chunk-boundary
96
+ * splitting is done by hand rather than via `readline` because certain
97
+ * multi-KB UTF-8 lines have been observed to trip `readline`'s parser on
98
+ * this codebase — see `.page-cluster/scale-spike.mjs`'s note for the
99
+ * concrete case.
100
+ * @param input
101
+ */
102
+ async function* readJsonlPages(input) {
103
+ input.setEncoding?.('utf8');
104
+ let leftover = '';
105
+ let lineNo = 0;
106
+ for await (const chunk of input) {
107
+ const text = leftover + chunk;
108
+ const parts = text.split('\n');
109
+ leftover = parts.pop() ?? '';
110
+ for (const line of parts) {
111
+ lineNo++;
112
+ if (line.length === 0)
113
+ continue;
114
+ let entry;
115
+ try {
116
+ entry = JSON.parse(line);
117
+ }
118
+ catch (error) {
119
+ throw new Error(`failed to parse JSONL line ${lineNo}: ${error.message}`);
120
+ }
121
+ if (typeof entry.html !== 'string') {
122
+ throw new TypeError(`JSONL line ${lineNo} is missing a string \`html\` field`);
123
+ }
124
+ yield {
125
+ id: entry.id,
126
+ page: {
127
+ html: entry.html,
128
+ paths: entry.paths ?? [],
129
+ stylesheetHrefs: entry.stylesheetHrefs ?? [],
130
+ host: entry.host,
131
+ },
132
+ };
133
+ }
134
+ }
135
+ if (leftover.length > 0) {
136
+ lineNo++;
137
+ let entry;
138
+ try {
139
+ entry = JSON.parse(leftover);
140
+ }
141
+ catch (error) {
142
+ throw new Error(`failed to parse final JSONL line ${lineNo}: ${error.message}`);
143
+ }
144
+ if (typeof entry.html !== 'string') {
145
+ throw new TypeError(`JSONL line ${lineNo} is missing a string \`html\` field`);
146
+ }
147
+ yield {
148
+ id: entry.id,
149
+ page: {
150
+ html: entry.html,
151
+ paths: entry.paths ?? [],
152
+ stylesheetHrefs: entry.stylesheetHrefs ?? [],
153
+ host: entry.host,
154
+ },
155
+ };
156
+ }
157
+ }
158
+ /**
159
+ * Renders the current lane message via TTY header or verbose-appended line
160
+ * depending on the `useTty` mode. Wraps both `lanes.header()` and
161
+ * `lanes.update(LANE_ID, …)` because — as documented in `dealer/lanes.ts` —
162
+ * `header()` under verbose is a state-only setter that produces no output,
163
+ * so verbose runs must go through `update()` to actually emit a line.
164
+ * @param lanes
165
+ * @param useTty
166
+ * @param line
167
+ */
168
+ function renderProgress(lanes, useTty, line) {
169
+ if (useTty) {
170
+ lanes.header(line.tty);
171
+ }
172
+ else {
173
+ lanes.update(LANE_ID, line.verbose);
174
+ }
175
+ }
176
+ /**
177
+ * Human-facing wording for the "reading input pages" moment (before stdin
178
+ * is fully consumed). Verbose lines omit the `[page-cluster]` prefix — it
179
+ * is applied once via `VERBOSE_HEADER` when Lanes constructs each verbose
180
+ * line.
181
+ */
182
+ const READING_INPUT = {
183
+ tty: '%earth% page-cluster — reading input...',
184
+ verbose: 'reading input pages...',
185
+ };
186
+ /**
187
+ * Progress line emitted after stdin is fully consumed, before the async
188
+ * factory-based `resolvePageClusterKeys` starts producing events.
189
+ * @param pageCount
190
+ */
191
+ function readingDoneLine(pageCount) {
192
+ return {
193
+ tty: `%earth% page-cluster — read ${pageCount} pages, clustering...`,
194
+ verbose: `read ${pageCount} pages, clustering...`,
195
+ };
196
+ }
197
+ /**
198
+ * Final summary line rendered right before `lanes.close()`.
199
+ * @param pageCount
200
+ * @param clusterCount
201
+ * @param elapsedSec
202
+ */
203
+ function doneLine(pageCount, clusterCount, elapsedSec) {
204
+ const body = `${pageCount} pages in ${clusterCount} clusters (elapsed ${elapsedSec}s)`;
205
+ return {
206
+ tty: `page-cluster — done: ${body}`,
207
+ verbose: `done — ${body}`,
208
+ };
209
+ }
210
+ /**
211
+ * Fatal-error line rendered via Lanes rather than a direct
212
+ * `stderr.write` — the interactive Display's `close()` runs a
213
+ * CURSOR_UP + ERASE_DOWN repaint that would wipe any raw stderr write
214
+ * emitted before it, silently swallowing the diagnostic in TTY mode.
215
+ * Sending the error through Lanes puts it into the final repainted
216
+ * frame, so it survives `close()`.
217
+ * @param message
218
+ */
219
+ function errorLine(message) {
220
+ return {
221
+ tty: `page-cluster: ${message}`,
222
+ verbose: `error: ${message}`,
223
+ };
224
+ }
225
+ /**
226
+ * Maps a library `ProgressEvent` to a human-facing `ProgressLine`. The
227
+ * verbose arm keeps the historical `pass0:` / `pass1:` / `pass1b:` /
228
+ * `stage-b:` phase tokens so callers who grep stderr by phase name (a
229
+ * pattern the earlier `formatProgress` documented) stay compatible; TTY
230
+ * lines use natural-language wording since interactive users read the
231
+ * header, not grep.
232
+ * @param event
233
+ * @param elapsedSec
234
+ */
235
+ function formatProgressLine(event, elapsedSec) {
236
+ switch (event.phase) {
237
+ case 'pass0-signals': {
238
+ return {
239
+ tty: `%earth% page-cluster — reading signals: ${event.pagesSeen} pages (elapsed ${elapsedSec}s)`,
240
+ verbose: `pass0: ${event.pagesSeen} pages read`,
241
+ };
242
+ }
243
+ case 'pass1-block-complete': {
244
+ return {
245
+ tty: `%earth% page-cluster — clustering ${event.blocksProcessed}/${event.totalBlocks} blocks (elapsed ${elapsedSec}s)`,
246
+ verbose: `pass1: clustered block ${event.blocksProcessed}/${event.totalBlocks}`,
247
+ };
248
+ }
249
+ case 'pass1b-assign': {
250
+ return {
251
+ tty: `%earth% page-cluster — assigning pages: ${event.pagesAssigned}/${event.pagesToAssign} (elapsed ${elapsedSec}s)`,
252
+ verbose: `pass1b: ${event.pagesAssigned}/${event.pagesToAssign} pages assigned`,
253
+ };
254
+ }
255
+ case 'stage-b-start': {
256
+ return {
257
+ tty: `%earth% page-cluster — merging ${event.unitCount} units (elapsed ${elapsedSec}s)`,
258
+ verbose: `stage-b: merging ${event.unitCount} units`,
259
+ };
260
+ }
261
+ }
262
+ }
263
+ /**
264
+ * Test-friendly entry point: takes the run's stdin/stdout/stderr streams
265
+ * and the parsed CLI flags rather than reading them out of the process
266
+ * globals. `runCli` returns the exit code, allowing the caller (either the
267
+ * top-level `main` here or a spec test) to decide how to signal it.
268
+ * @param options
269
+ * @param options.stdin
270
+ * @param options.stdout
271
+ * @param options.stderr
272
+ * @param options.argv
273
+ * @param options.version
274
+ */
275
+ export async function runCli(options) {
276
+ const args = parseArgs(options.argv);
277
+ if (args.help) {
278
+ options.stdout.write(HELP_TEXT);
279
+ return 0;
280
+ }
281
+ if (args.version) {
282
+ options.stdout.write(`${options.version}\n`);
283
+ return 0;
284
+ }
285
+ if (args.unknownFlag !== undefined) {
286
+ options.stderr.write(`page-cluster: unrecognized argument ${JSON.stringify(args.unknownFlag)}\n`);
287
+ options.stderr.write(HELP_TEXT);
288
+ return 2;
289
+ }
290
+ // TTY detection: `NodeJS.WritableStream` doesn't expose `isTTY`, but the
291
+ // concrete `WriteStream` (and the in-memory `Writable` collectors tests
292
+ // pass in) does — reading it defensively lets both real usage and
293
+ // unit-test doubles work without a separate `--no-progress` flag.
294
+ const useTty = options.stderr.isTTY === true;
295
+ const lanes = new Lanes({ stream: options.stderr, verbose: !useTty });
296
+ // Verbose Lanes prepends `#header` to every `update()` line. Without
297
+ // this seed call the header would be undefined and each progress line
298
+ // would begin with the literal string `undefined ` — bug caught by
299
+ // code-review high effort. TTY mode overwrites the header per event
300
+ // so the seed value is only surfaced verbatim under verbose.
301
+ if (!useTty) {
302
+ lanes.header(VERBOSE_HEADER);
303
+ }
304
+ const startTime = Date.now();
305
+ const elapsed = () => Math.max(0, Math.round((Date.now() - startTime) / 1000));
306
+ // Every early return past this point must run through the finally block
307
+ // so `lanes.close()` releases the display's setTimeout timer — without
308
+ // it a `return 1` on a stdin parse error would leave the process
309
+ // hanging on the timer's next tick.
310
+ try {
311
+ renderProgress(lanes, useTty, READING_INPUT);
312
+ // Load every JSONL line into memory once so the ids array stays
313
+ // parallel to the pages array — the streaming driver reads its
314
+ // factory twice, and stdin is a one-shot pipe.
315
+ const ids = [];
316
+ const pages = [];
317
+ try {
318
+ for await (const { id, page } of readJsonlPages(options.stdin)) {
319
+ ids.push(id);
320
+ pages.push(page);
321
+ }
322
+ }
323
+ catch (error) {
324
+ renderProgress(lanes, useTty, errorLine(error.message));
325
+ return 1;
326
+ }
327
+ renderProgress(lanes, useTty, readingDoneLine(pages.length));
328
+ const resolveOptions = {
329
+ contentBlockAttribute: args.contentBlockAttribute,
330
+ onProgress: (event) => {
331
+ renderProgress(lanes, useTty, formatProgressLine(event, elapsed()));
332
+ },
333
+ };
334
+ let keys;
335
+ try {
336
+ keys = await resolvePageClusterKeys(() => pages, resolveOptions);
337
+ }
338
+ catch (error) {
339
+ renderProgress(lanes, useTty, errorLine(error.message));
340
+ return 1;
341
+ }
342
+ const clusterCount = new Set(keys).size;
343
+ renderProgress(lanes, useTty, doneLine(pages.length, clusterCount, elapsed()));
344
+ for (const [index, key] of keys.entries()) {
345
+ options.stdout.write(`${JSON.stringify({ id: ids[index] ?? index, clusterKey: key })}\n`);
346
+ }
347
+ return 0;
348
+ }
349
+ finally {
350
+ lanes.close();
351
+ }
352
+ }
353
+ /**
354
+ * Reads the package version out of `package.json` at runtime. Kept as a
355
+ * separate helper so `runCli` can be exercised in tests without needing a
356
+ * real `package.json` on disk.
357
+ */
358
+ async function readPackageVersion() {
359
+ try {
360
+ const url = new URL('../package.json', import.meta.url);
361
+ const { readFile } = await import('node:fs/promises');
362
+ const raw = await readFile(url, 'utf8');
363
+ const parsed = JSON.parse(raw);
364
+ return parsed.version ?? '0.0.0';
365
+ }
366
+ catch {
367
+ return '0.0.0';
368
+ }
369
+ }
370
+ // Only run when invoked as the actual entry point.
371
+ if (import.meta.url === `file://${process.argv[1]}`) {
372
+ const version = await readPackageVersion();
373
+ const exitCode = await runCli({
374
+ stdin: process.stdin,
375
+ stdout: process.stdout,
376
+ stderr: process.stderr,
377
+ argv: process.argv.slice(2),
378
+ version,
379
+ });
380
+ process.exit(exitCode);
381
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Removes bare foldable-tag segments (e.g. plain `div`, `span`) from the
3
+ * interior of a path token, leaving the first and last segments untouched.
4
+ * Used before containment-assignment union construction so that two clusters
5
+ * whose paths differ only in intermediate anonymous wrapper `<div>`s are not
6
+ * spuriously separated.
7
+ *
8
+ * Intermediate bare `div`/`span` elements are anonymous wrappers with no
9
+ * class, role, or type — they carry no structural meaning beyond "something
10
+ * was nested here", and their presence varies freely across CMS themes and
11
+ * minor template revisions. Stripping them from the middle of a path keeps
12
+ * the union comparison focused on the meaningful skeleton (semantic tags,
13
+ * class-bearing wrappers, bracketed roles).
14
+ *
15
+ * First and last segments are always preserved: the first segment anchors the
16
+ * path in the document tree (`body`, `main`, …) and the last segment is the
17
+ * leaf element being tokenized — both carry meaning even when they are bare
18
+ * foldable tags.
19
+ * @param token A full path token, e.g. `body>main>div>section.c-x>div>.card`.
20
+ */
21
+ export declare function collapseAnonymousDivs(token: string): string;
@@ -0,0 +1,42 @@
1
+ import { FOLDABLE_TAGS } from './foldable-tags.js';
2
+ /**
3
+ * Only `FOLDABLE_TAGS` qualify — those are the tags `build-segment.ts` omits
4
+ * the name from when they have classes (producing `.c-x` instead of `div.c-x`),
5
+ * so a bare `div` or `span` with NO class is a structural-only wrapper.
6
+ * Non-foldable tags (`section`, `article`, …) always appear with their tag name
7
+ * and carry semantic meaning even when classless, so they are kept.
8
+ * @param segment
9
+ */
10
+ function isAnonymousSegment(segment) {
11
+ return FOLDABLE_TAGS.has(segment);
12
+ }
13
+ /**
14
+ * Removes bare foldable-tag segments (e.g. plain `div`, `span`) from the
15
+ * interior of a path token, leaving the first and last segments untouched.
16
+ * Used before containment-assignment union construction so that two clusters
17
+ * whose paths differ only in intermediate anonymous wrapper `<div>`s are not
18
+ * spuriously separated.
19
+ *
20
+ * Intermediate bare `div`/`span` elements are anonymous wrappers with no
21
+ * class, role, or type — they carry no structural meaning beyond "something
22
+ * was nested here", and their presence varies freely across CMS themes and
23
+ * minor template revisions. Stripping them from the middle of a path keeps
24
+ * the union comparison focused on the meaningful skeleton (semantic tags,
25
+ * class-bearing wrappers, bracketed roles).
26
+ *
27
+ * First and last segments are always preserved: the first segment anchors the
28
+ * path in the document tree (`body`, `main`, …) and the last segment is the
29
+ * leaf element being tokenized — both carry meaning even when they are bare
30
+ * foldable tags.
31
+ * @param token A full path token, e.g. `body>main>div>section.c-x>div>.card`.
32
+ */
33
+ export function collapseAnonymousDivs(token) {
34
+ const segments = token.split('>');
35
+ if (segments.length <= 2) {
36
+ return token;
37
+ }
38
+ const first = segments[0] ?? '';
39
+ const last = segments.at(-1) ?? '';
40
+ const middle = segments.slice(1, -1).filter((seg) => !isAnonymousSegment(seg));
41
+ return [first, ...middle, last].join('>');
42
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * One merge event in the complete-linkage dendrogram. `survivor` is the
3
+ * lower index (kept active), `dead` the higher (deactivated), `height`
4
+ * is the Jaccard similarity at which the merge occurred. All `n - 1`
5
+ * merges for `n` input sets are recorded, including those below any
6
+ * particular threshold — the caller decides which heights are meaningful
7
+ * via `labelsAtThreshold`.
8
+ */
9
+ export type DendrogramMerge = {
10
+ readonly survivor: number;
11
+ readonly dead: number;
12
+ readonly height: number;
13
+ };
14
+ /**
15
+ * Computes the complete-linkage dendrogram for `tokenSets` via the NN-chain
16
+ * algorithm (Murtagh, F., 1983) and returns all `n - 1` merge events in
17
+ * discovery order. This is the same O(n²) NN-chain implementation as
18
+ * `resolve-structural-cluster-keys.ts` — extracted here so that callers can
19
+ * record the full height sequence and apply an arbitrary threshold cut via
20
+ * `labelsAtThreshold`, rather than committing to a fixed threshold upfront.
21
+ *
22
+ * Recording all merges (not just threshold-clearing ones) is essential for
23
+ * `autoCutThreshold`: to find the largest gap in the height sequence, every
24
+ * height must be available regardless of where the eventual cut lands.
25
+ * @param tokenSets
26
+ */
27
+ export declare function completeLinkageDendrogram(tokenSets: readonly ReadonlySet<string>[]): DendrogramMerge[];
28
+ /**
29
+ * Reconstructs cluster labels for all `size` original items at a given
30
+ * similarity threshold, using the merge list from `completeLinkageDendrogram`.
31
+ *
32
+ * Lance-Williams monotonicity guarantees that only merges at `>= threshold`
33
+ * need to be applied to reconstruct the correct threshold cut, regardless of
34
+ * the order in which NN-chain discovered them. This is the same invariant
35
+ * documented in `resolve-structural-cluster-keys.ts`'s JSDoc for
36
+ * `clusterByCompleteLinkage`.
37
+ * @param size Total number of items (= length of the original `tokenSets`).
38
+ * @param merges All merge events from `completeLinkageDendrogram`.
39
+ * @param threshold Similarity threshold; merges below this are ignored.
40
+ */
41
+ export declare function labelsAtThreshold(size: number, merges: readonly DendrogramMerge[], threshold: number): number[];
@@ -0,0 +1,140 @@
1
+ import { jaccardSimilarity } from './jaccard-similarity.js';
2
+ /**
3
+ * `jaccardSimilarity()` returns a floating-point division that can land a
4
+ * hair below the caller's threshold even when mathematically equal. Same
5
+ * epsilon as `resolve-structural-cluster-keys.ts`.
6
+ */
7
+ const BOUNDARY_EPSILON = 1e-9;
8
+ /**
9
+ * `noUncheckedIndexedAccess` makes indexed reads return `T | undefined`.
10
+ * Call sites here index positions this function itself generated (NN-chain
11
+ * internal arrays), so the throw branch is unreachable in practice — it exists
12
+ * solely to satisfy the compiler without a non-null assertion everywhere.
13
+ * @param values
14
+ * @param index
15
+ */
16
+ function requireIndex(values, index) {
17
+ const value = values[index];
18
+ if (value === undefined) {
19
+ throw new Error('completeLinkageDendrogram: index out of bounds');
20
+ }
21
+ return value;
22
+ }
23
+ /**
24
+ * Path-compressed union-find root lookup. `labelsAtThreshold` rebuilds the
25
+ * cluster label for each item by following its parent chain to the root;
26
+ * path compression keeps repeated lookups O(α) instead of O(n).
27
+ * @param parent
28
+ * @param index
29
+ */
30
+ function find(parent, index) {
31
+ let root = index;
32
+ while (requireIndex(parent, root) !== root) {
33
+ root = requireIndex(parent, root);
34
+ }
35
+ let current = index;
36
+ while (current !== root) {
37
+ const next = requireIndex(parent, current);
38
+ parent[current] = root;
39
+ current = next;
40
+ }
41
+ return root;
42
+ }
43
+ /**
44
+ * Computes the complete-linkage dendrogram for `tokenSets` via the NN-chain
45
+ * algorithm (Murtagh, F., 1983) and returns all `n - 1` merge events in
46
+ * discovery order. This is the same O(n²) NN-chain implementation as
47
+ * `resolve-structural-cluster-keys.ts` — extracted here so that callers can
48
+ * record the full height sequence and apply an arbitrary threshold cut via
49
+ * `labelsAtThreshold`, rather than committing to a fixed threshold upfront.
50
+ *
51
+ * Recording all merges (not just threshold-clearing ones) is essential for
52
+ * `autoCutThreshold`: to find the largest gap in the height sequence, every
53
+ * height must be available regardless of where the eventual cut lands.
54
+ * @param tokenSets
55
+ */
56
+ export function completeLinkageDendrogram(tokenSets) {
57
+ const size = tokenSets.length;
58
+ if (size <= 1)
59
+ return [];
60
+ const similarity = new Float64Array(size * size);
61
+ for (let i = 0; i < size; i++) {
62
+ for (let j = i + 1; j < size; j++) {
63
+ const score = jaccardSimilarity(requireIndex(tokenSets, i), requireIndex(tokenSets, j));
64
+ similarity[i * size + j] = score;
65
+ similarity[j * size + i] = score;
66
+ }
67
+ }
68
+ const active = new Uint8Array(size).fill(1);
69
+ const chain = [];
70
+ const merges = [];
71
+ const findFreshStart = () => {
72
+ for (let index = 0; index < size; index++) {
73
+ if (requireIndex(active, index) === 1)
74
+ return index;
75
+ }
76
+ throw new Error('completeLinkageDendrogram: no active cluster left to resume from');
77
+ };
78
+ let activeCount = size;
79
+ while (activeCount > 1) {
80
+ if (chain.length === 0)
81
+ chain.push(findFreshStart());
82
+ const top = requireIndex(chain, chain.length - 1);
83
+ let best = -1;
84
+ let bestScore = Number.NEGATIVE_INFINITY;
85
+ for (let candidate = 0; candidate < size; candidate++) {
86
+ if (candidate !== top && requireIndex(active, candidate) === 1) {
87
+ const score = requireIndex(similarity, top * size + candidate);
88
+ if (score > bestScore) {
89
+ bestScore = score;
90
+ best = candidate;
91
+ }
92
+ }
93
+ }
94
+ const secondFromTop = chain.length >= 2 ? chain.at(-2) : undefined;
95
+ if (best === secondFromTop) {
96
+ chain.pop();
97
+ chain.pop();
98
+ const survivor = Math.min(top, best);
99
+ const dead = Math.max(top, best);
100
+ for (let candidate = 0; candidate < size; candidate++) {
101
+ if (candidate !== top &&
102
+ candidate !== best &&
103
+ requireIndex(active, candidate) === 1) {
104
+ const merged = Math.min(requireIndex(similarity, top * size + candidate), requireIndex(similarity, best * size + candidate));
105
+ similarity[survivor * size + candidate] = merged;
106
+ similarity[candidate * size + survivor] = merged;
107
+ }
108
+ }
109
+ active[dead] = 0;
110
+ merges.push({ survivor, dead, height: bestScore });
111
+ activeCount--;
112
+ }
113
+ else {
114
+ chain.push(best);
115
+ }
116
+ }
117
+ return merges;
118
+ }
119
+ /**
120
+ * Reconstructs cluster labels for all `size` original items at a given
121
+ * similarity threshold, using the merge list from `completeLinkageDendrogram`.
122
+ *
123
+ * Lance-Williams monotonicity guarantees that only merges at `>= threshold`
124
+ * need to be applied to reconstruct the correct threshold cut, regardless of
125
+ * the order in which NN-chain discovered them. This is the same invariant
126
+ * documented in `resolve-structural-cluster-keys.ts`'s JSDoc for
127
+ * `clusterByCompleteLinkage`.
128
+ * @param size Total number of items (= length of the original `tokenSets`).
129
+ * @param merges All merge events from `completeLinkageDendrogram`.
130
+ * @param threshold Similarity threshold; merges below this are ignored.
131
+ */
132
+ export function labelsAtThreshold(size, merges, threshold) {
133
+ const parent = Int32Array.from({ length: size }, (_, i) => i);
134
+ for (const { survivor, dead, height } of merges) {
135
+ if (height >= threshold - BOUNDARY_EPSILON) {
136
+ parent[find(parent, dead)] = find(parent, survivor);
137
+ }
138
+ }
139
+ return Array.from({ length: size }, (_, i) => find(parent, i));
140
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Below this many pages, `computeDocumentFrequency`/`splitTokensByFrequency`
3
+ * (the default 90% cutoff) degenerate rather than usefully separate chrome
4
+ * from content — see `deriveComparisonSets` for the failure mode. Derived
5
+ * from `splitTokensByFrequency`'s own cutoff: a token missing from exactly
6
+ * one page out of `n` still counts as chrome only if
7
+ * `(n - 1) / n >= 0.9`, i.e. `n >= 10`. Below that, the caller should fall
8
+ * back to comparing token sets directly, unfiltered.
9
+ */
10
+ export declare const MIN_PAGE_COUNT_FOR_FREQUENCY_SPLIT = 10;
11
+ /**
12
+ * Narrows each page's token set to its page-specific content before
13
+ * clustering, so pages that only share site-wide chrome (header/nav/footer)
14
+ * don't read as more similar than they structurally are. Skipped below
15
+ * `MIN_PAGE_COUNT_FOR_FREQUENCY_SPLIT` pages — see that constant's JSDoc.
16
+ *
17
+ * A page whose entire token set narrows away falls back to its raw tokens
18
+ * rather than the empty result, for the same reason documented in
19
+ * `resolve-structural-cluster-keys.ts`.
20
+ * @param tokenSets
21
+ */
22
+ export declare function deriveComparisonSets(tokenSets: readonly ReadonlySet<string>[]): readonly ReadonlySet<string>[];
@@ -0,0 +1,33 @@
1
+ import { computeDocumentFrequency } from './compute-document-frequency.js';
2
+ import { splitTokensByFrequency } from './split-tokens-by-frequency.js';
3
+ /**
4
+ * Below this many pages, `computeDocumentFrequency`/`splitTokensByFrequency`
5
+ * (the default 90% cutoff) degenerate rather than usefully separate chrome
6
+ * from content — see `deriveComparisonSets` for the failure mode. Derived
7
+ * from `splitTokensByFrequency`'s own cutoff: a token missing from exactly
8
+ * one page out of `n` still counts as chrome only if
9
+ * `(n - 1) / n >= 0.9`, i.e. `n >= 10`. Below that, the caller should fall
10
+ * back to comparing token sets directly, unfiltered.
11
+ */
12
+ export const MIN_PAGE_COUNT_FOR_FREQUENCY_SPLIT = 10;
13
+ /**
14
+ * Narrows each page's token set to its page-specific content before
15
+ * clustering, so pages that only share site-wide chrome (header/nav/footer)
16
+ * don't read as more similar than they structurally are. Skipped below
17
+ * `MIN_PAGE_COUNT_FOR_FREQUENCY_SPLIT` pages — see that constant's JSDoc.
18
+ *
19
+ * A page whose entire token set narrows away falls back to its raw tokens
20
+ * rather than the empty result, for the same reason documented in
21
+ * `resolve-structural-cluster-keys.ts`.
22
+ * @param tokenSets
23
+ */
24
+ export function deriveComparisonSets(tokenSets) {
25
+ if (tokenSets.length < MIN_PAGE_COUNT_FOR_FREQUENCY_SPLIT) {
26
+ return tokenSets;
27
+ }
28
+ const corpusFrequency = computeDocumentFrequency(tokenSets);
29
+ return tokenSets.map((tokens) => {
30
+ const { contentTokens } = splitTokensByFrequency(tokens, corpusFrequency);
31
+ return contentTokens.size === 0 && tokens.size > 0 ? tokens : contentTokens;
32
+ });
33
+ }