@d-zero/page-cluster 0.5.6 → 0.5.7

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.
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Applies a set of confirmed cluster-pair merges to a `clusterKey` array,
3
+ * via union-find over the distinct cluster keys. Every `duplicates` entry is
4
+ * merged unconditionally — deciding *which* {@link CrossClusterDuplicate}s
5
+ * are trustworthy enough to act on (e.g. `similarity === 1` or
6
+ * `corroboratedByMirrorAxis`) is the caller's job, same separation of
7
+ * detection from action as
8
+ * {@link ./find-cross-cluster-duplicates.js | findCrossClusterDuplicates}
9
+ * itself.
10
+ *
11
+ * The surviving key for a merged group is its alphabetically smallest
12
+ * member — arbitrary but deterministic, so repeated calls on the same input
13
+ * produce the same output (mirrors the "lower index wins" rule
14
+ * {@link ./merge-cross-block-clusters.js | mergeCrossBlockClusters}'s own
15
+ * union-find already uses).
16
+ * @param clusterKeys Every page's current cluster key, in input order.
17
+ * @param duplicates Cluster-pair merges to apply.
18
+ * @example
19
+ * ```ts
20
+ * const merged = mergeValidatedClusters(
21
+ * clusterKeys,
22
+ * duplicates.filter((d) => d.similarity === 1 || d.corroboratedByMirrorAxis),
23
+ * );
24
+ * ```
25
+ */
26
+ export function mergeValidatedClusters(clusterKeys, duplicates) {
27
+ const parent = new Map();
28
+ const find = (key) => {
29
+ let root = key;
30
+ while (parent.has(root))
31
+ root = parent.get(root);
32
+ // Path compression for cheap repeated lookups within this call.
33
+ let cur = key;
34
+ while (cur !== root) {
35
+ const next = parent.get(cur);
36
+ parent.set(cur, root);
37
+ cur = next;
38
+ }
39
+ return root;
40
+ };
41
+ for (const { clusterKeyA, clusterKeyB } of duplicates) {
42
+ const rootA = find(clusterKeyA);
43
+ const rootB = find(clusterKeyB);
44
+ if (rootA === rootB)
45
+ continue;
46
+ const [smaller, larger] = rootA < rootB ? [rootA, rootB] : [rootB, rootA];
47
+ parent.set(larger, smaller);
48
+ }
49
+ return clusterKeys.map((key) => (parent.has(key) ? find(key) : key));
50
+ }
@@ -0,0 +1,25 @@
1
+ import type { MirrorAxis } from './detect-mirror-axis.js';
2
+ /**
3
+ * Reduces a URL (typically a stylesheet href) to a shape that is stable
4
+ * across a detected {@link MirrorAxis}, by replacing every `/<value>/`
5
+ * occurrence — for any of the axis's known values — with a fixed
6
+ * placeholder segment. Unlike {@link ./normalize-path-by-mirror-axis.js |
7
+ * normalizePathByMirrorAxis}, this does not anchor on `axis.position`: an
8
+ * href's own path structure (e.g. `/assets/en/style.css`) does not
9
+ * necessarily line up with the page's path depth (e.g. `/en/section/`), so
10
+ * matching is done by substring rather than by segment index. This is what
11
+ * lets a per-mirror stylesheet — the same template's CSS duplicated once per
12
+ * language directory instead of shared from one file — normalize to the
13
+ * same shape as its sibling mirrors, corroborating that two pages under
14
+ * different blocking keys are the same template mirrored rather than two
15
+ * different templates.
16
+ * @param href A URL string (typically a stylesheet href).
17
+ * @param axis A `MirrorAxis` from {@link ./detect-mirror-axis.js | detectMirrorAxis}.
18
+ * @example
19
+ * ```ts
20
+ * const axis = { position: 0, values: new Set(['en', 'zh']) };
21
+ * normalizeHrefByMirrorAxis('https://example.test/en/faq/page.css', axis);
22
+ * // 'https://example.test/{axis}/faq/page.css'
23
+ * ```
24
+ */
25
+ export declare function normalizeHrefByMirrorAxis(href: string, axis: MirrorAxis): string;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Reduces a URL (typically a stylesheet href) to a shape that is stable
3
+ * across a detected {@link MirrorAxis}, by replacing every `/<value>/`
4
+ * occurrence — for any of the axis's known values — with a fixed
5
+ * placeholder segment. Unlike {@link ./normalize-path-by-mirror-axis.js |
6
+ * normalizePathByMirrorAxis}, this does not anchor on `axis.position`: an
7
+ * href's own path structure (e.g. `/assets/en/style.css`) does not
8
+ * necessarily line up with the page's path depth (e.g. `/en/section/`), so
9
+ * matching is done by substring rather than by segment index. This is what
10
+ * lets a per-mirror stylesheet — the same template's CSS duplicated once per
11
+ * language directory instead of shared from one file — normalize to the
12
+ * same shape as its sibling mirrors, corroborating that two pages under
13
+ * different blocking keys are the same template mirrored rather than two
14
+ * different templates.
15
+ * @param href A URL string (typically a stylesheet href).
16
+ * @param axis A `MirrorAxis` from {@link ./detect-mirror-axis.js | detectMirrorAxis}.
17
+ * @example
18
+ * ```ts
19
+ * const axis = { position: 0, values: new Set(['en', 'zh']) };
20
+ * normalizeHrefByMirrorAxis('https://example.test/en/faq/page.css', axis);
21
+ * // 'https://example.test/{axis}/faq/page.css'
22
+ * ```
23
+ */
24
+ export function normalizeHrefByMirrorAxis(href, axis) {
25
+ let normalized = href;
26
+ for (const value of axis.values) {
27
+ normalized = normalized.split(`/${value}/`).join('/{axis}/');
28
+ }
29
+ return normalized;
30
+ }
@@ -0,0 +1,22 @@
1
+ import type { MirrorAxis } from './detect-mirror-axis.js';
2
+ /**
3
+ * Reduces a page's URL path to a shape that is stable across a detected
4
+ * {@link MirrorAxis} by replacing the segment at `axis.position` with a
5
+ * fixed placeholder whenever it is one of the axis's known values. Two pages
6
+ * that are mirrors of the same content under different axis values (e.g. the
7
+ * `en` and `zh` copies of the same page) normalize to the same shape; a page
8
+ * whose segment at that position is *not* one of the axis's values (so it
9
+ * isn't part of the mirror at all) is left untouched, since collapsing it
10
+ * too would falsely equate unrelated pages that merely share a path depth.
11
+ * @param paths A page's URL path segments (e.g. `ExURL.paths`).
12
+ * @param axis A `MirrorAxis` from {@link ./detect-mirror-axis.js | detectMirrorAxis}.
13
+ * @example
14
+ * ```ts
15
+ * const axis = { position: 0, values: new Set(['en', 'zh']) };
16
+ * normalizePathByMirrorAxis(['en', 'faq', 'index.html'], axis);
17
+ * // '{axis}/faq/index.html'
18
+ * normalizePathByMirrorAxis(['zh', 'faq', 'index.html'], axis);
19
+ * // '{axis}/faq/index.html' — same shape as the `en` page above
20
+ * ```
21
+ */
22
+ export declare function normalizePathByMirrorAxis(paths: readonly string[], axis: MirrorAxis): string;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Reduces a page's URL path to a shape that is stable across a detected
3
+ * {@link MirrorAxis} by replacing the segment at `axis.position` with a
4
+ * fixed placeholder whenever it is one of the axis's known values. Two pages
5
+ * that are mirrors of the same content under different axis values (e.g. the
6
+ * `en` and `zh` copies of the same page) normalize to the same shape; a page
7
+ * whose segment at that position is *not* one of the axis's values (so it
8
+ * isn't part of the mirror at all) is left untouched, since collapsing it
9
+ * too would falsely equate unrelated pages that merely share a path depth.
10
+ * @param paths A page's URL path segments (e.g. `ExURL.paths`).
11
+ * @param axis A `MirrorAxis` from {@link ./detect-mirror-axis.js | detectMirrorAxis}.
12
+ * @example
13
+ * ```ts
14
+ * const axis = { position: 0, values: new Set(['en', 'zh']) };
15
+ * normalizePathByMirrorAxis(['en', 'faq', 'index.html'], axis);
16
+ * // '{axis}/faq/index.html'
17
+ * normalizePathByMirrorAxis(['zh', 'faq', 'index.html'], axis);
18
+ * // '{axis}/faq/index.html' — same shape as the `en` page above
19
+ * ```
20
+ */
21
+ export function normalizePathByMirrorAxis(paths, axis) {
22
+ return paths
23
+ .map((segment, i) => i === axis.position && axis.values.has(segment) ? '{axis}' : segment)
24
+ .join('/');
25
+ }
@@ -4,6 +4,7 @@ import type { PerPageLandmarkInstance } from './per-page-landmark-signatures.js'
4
4
  import type { ResolveBlockingGroupKeysOptions } from './resolve-blocking-group-keys.js';
5
5
  import type { ResolveStructuralClusterKeysOptions } from './resolve-structural-cluster-keys.js';
6
6
  import type { TokenizeOptions } from './types.js';
7
+ import type { ClusterPartitionReport } from './validate-cluster-partition.js';
7
8
  /**
8
9
  * Reinjects each page's *local* (non-corpus-wide) landmark-instance tokens
9
10
  * into its block token set for Stage A clustering, restoring exactly the
@@ -201,6 +202,48 @@ export type ResolvePageClusterKeysOptions = TokenizeOptions & ResolveBlockingGro
201
202
  * ```
202
203
  */
203
204
  onClusterReason?: (clusterKey: string, reason: ClusterReason) => void;
205
+ /**
206
+ * Optional observability hook invoked once per run with a
207
+ * {@link ClusterPartitionReport} — evidence that Stage A/B's finished
208
+ * partition may need correcting, from
209
+ * {@link ./validate-cluster-partition.js | validateClusterPartition}.
210
+ *
211
+ * Setting this option does two things, both gated on its presence the
212
+ * same way `onClusterReason` gates its own bookkeeping (existing
213
+ * callers that omit it pay nothing and see no behavior change):
214
+ *
215
+ * 1. Computes the report from Stage B's finished grouping (no
216
+ * re-tokenization — reuses the token sets Stage A/B already hold).
217
+ * 2. Applies a fixed, built-in auto-merge policy to the *actual*
218
+ * returned cluster keys before this callback (and
219
+ * `onClusterReason`) ever sees them: every
220
+ * `report.crossClusterDuplicates` entry with `similarity === 1` or
221
+ * `corroboratedByMirrorAxis: true` is merged via
222
+ * {@link ./merge-validated-clusters.js | mergeValidatedClusters}.
223
+ * This is the one part of this library where an option changes the
224
+ * returned `clusterKey`s themselves, not just side-channel
225
+ * metadata — a caller who wants a *different* merge policy (or
226
+ * none at all) should omit this option and call
227
+ * `validateClusterPartition`/`findCrossClusterDuplicates`/
228
+ * `mergeValidatedClusters` directly on this function's own output.
229
+ *
230
+ * On the streaming path (`pageCount > CORPUS_INLINE_THRESHOLD`), the
231
+ * report is built only from pages Stage A/B actually retained token
232
+ * sets for — reservoir-sampled block representatives, not pages
233
+ * assigned in Pass 1b — the same sampling trade-off Stage B itself
234
+ * already makes for corpora too large to hold in full. Any merge the
235
+ * sampled evidence justifies is still applied to every page sharing
236
+ * the merged keys, sampled or not.
237
+ * @example
238
+ * ```ts
239
+ * const keys = await resolvePageClusterKeys(pages, {
240
+ * onPartitionReport: (report) => {
241
+ * for (const c of report.cohesion) if (c.suspicious) console.warn(c);
242
+ * },
243
+ * });
244
+ * ```
245
+ */
246
+ onPartitionReport?: (report: ClusterPartitionReport) => void;
204
247
  };
205
248
  /**
206
249
  * Corpus size at or below which the async factory-based
@@ -6,11 +6,13 @@ import { extractLandmarks } from './extract-landmarks.js';
6
6
  import { filterFirstPartyStylesheetHrefs } from './filter-first-party-stylesheet-hrefs.js';
7
7
  import { jaccardSimilarity } from './jaccard-similarity.js';
8
8
  import { mergeCrossBlockClusters } from './merge-cross-block-clusters.js';
9
+ import { mergeValidatedClusters } from './merge-validated-clusters.js';
9
10
  import { groupIndicesByBlockKey, resolveBlockKeys } from './pass0-blocking.js';
10
11
  import { computePerPageLandmarkInstances } from './per-page-landmark-signatures.js';
11
12
  import { removeContentBlocks } from './remove-content-blocks.js';
12
13
  import { stageAPerBlock } from './stage-a-per-block.js';
13
14
  import { tokenize } from './tokenize.js';
15
+ import { validateClusterPartition } from './validate-cluster-partition.js';
14
16
  /**
15
17
  * FNV-1a 32-bit hash of a string, used to seed the per-block PRNG so
16
18
  * reservoir sampling on the streaming path is deterministic for a given
@@ -257,6 +259,92 @@ function resolveBlockKeysForClustering(blockingPages, options, needReasons) {
257
259
  const result = resolveBlockKeys(blockingPages, { ...options, includeReasons: true });
258
260
  return { blockKeys: result.blockKeys, reasonsByBlockKey: result.reasonsByBlockKey };
259
261
  }
262
+ /**
263
+ * Runs {@link ./validate-cluster-partition.js | validateClusterPartition}
264
+ * over Stage B's finished grouping and applies the built-in auto-merge
265
+ * policy (see `onPartitionReport`'s own JSDoc) directly to `finalKeys`,
266
+ * mutating it in place — the same in-place style the driver's own
267
+ * `rootByKey` rewrite loop already uses just before calling this.
268
+ *
269
+ * No-ops (returning `rootByKey`/`finalGroupsByRoot` unchanged) when
270
+ * `onPartitionReport` is `undefined` — the single gate every driver defers
271
+ * to, mirroring `emitClusterReasons`'s own gate for `onClusterReason`.
272
+ *
273
+ * When a merge is applied, `rootByKey` and `finalGroupsByRoot` are folded
274
+ * according to the same rename so `emitClusterReasons` (called with this
275
+ * function's return value, not the pre-merge originals) builds each
276
+ * survivor's `ClusterReason` from its *actual* post-merge membership —
277
+ * without this, a merged-away key's members would report `finalKeys[i]`
278
+ * pointing at the survivor while its own stale `ClusterReason.memberCount`
279
+ * still reflected only its pre-merge share.
280
+ * @param finalKeys Every page's current final cluster key, in input order.
281
+ * Mutated in place when a merge is applied.
282
+ * @param rootByKey Stage B's own result (see
283
+ * {@link ./merge-cross-block-clusters.js | MergeCrossBlockClustersResult}).
284
+ * @param finalGroupsByRoot Stage B's own result.
285
+ * @param getPageSignals Given an original page index, returns that page's
286
+ * `paths`/`stylesheetHrefs` — `undefined` for an index the caller has no
287
+ * record of (defensive; should not occur for indices Stage A/B actually
288
+ * retained).
289
+ * @param onPartitionReport
290
+ */
291
+ function validateAndMergePartition(finalKeys, rootByKey, finalGroupsByRoot, getPageSignals, onPartitionReport) {
292
+ if (!onPartitionReport)
293
+ return { rootByKey, finalGroupsByRoot };
294
+ const clusteredPages = [];
295
+ for (const [finalKey, group] of finalGroupsByRoot) {
296
+ for (const [i, tokens] of group.tokenSets.entries()) {
297
+ const pageIndex = group.pageIndices[i] ?? -1;
298
+ if (pageIndex === -1)
299
+ continue;
300
+ const signals = getPageSignals(pageIndex);
301
+ if (!signals)
302
+ continue;
303
+ clusteredPages.push({
304
+ clusterKey: finalKey,
305
+ tokens,
306
+ paths: signals.paths,
307
+ stylesheetHrefs: signals.stylesheetHrefs,
308
+ });
309
+ }
310
+ }
311
+ const report = validateClusterPartition(clusteredPages);
312
+ onPartitionReport(report);
313
+ // `findCrossClusterDuplicates` only ever returns entries that already
314
+ // satisfy this condition (its exact-match pass sets `similarity: 1`
315
+ // unconditionally; its near-match pass requires `corroboratedByMirrorAxis`
316
+ // to accept a pair at all) — this filter is a defensive invariant check,
317
+ // not something that currently narrows the result, kept so this call site
318
+ // keeps working correctly if that policy ever loosens.
319
+ const safeToMerge = report.crossClusterDuplicates.filter((d) => d.similarity === 1 || d.corroboratedByMirrorAxis);
320
+ if (safeToMerge.length === 0)
321
+ return { rootByKey, finalGroupsByRoot };
322
+ const mergedKeys = mergeValidatedClusters(finalKeys, safeToMerge);
323
+ const renameMap = new Map();
324
+ for (const [i, mergedKey] of mergedKeys.entries()) {
325
+ if (finalKeys[i] !== mergedKey)
326
+ renameMap.set(finalKeys[i], mergedKey);
327
+ finalKeys[i] = mergedKey;
328
+ }
329
+ const mergedFinalGroupsByRoot = new Map();
330
+ for (const [key, group] of finalGroupsByRoot) {
331
+ const target = renameMap.get(key) ?? key;
332
+ const existing = mergedFinalGroupsByRoot.get(target);
333
+ mergedFinalGroupsByRoot.set(target, {
334
+ tokenSets: [...(existing?.tokenSets ?? []), ...group.tokenSets],
335
+ landmarkInstances: [
336
+ ...(existing?.landmarkInstances ?? []),
337
+ ...group.landmarkInstances,
338
+ ],
339
+ pageIndices: [...(existing?.pageIndices ?? []), ...group.pageIndices],
340
+ });
341
+ }
342
+ const mergedRootByKey = new Map();
343
+ for (const [unitKey, stageBRoot] of rootByKey) {
344
+ mergedRootByKey.set(unitKey, renameMap.get(stageBRoot) ?? stageBRoot);
345
+ }
346
+ return { rootByKey: mergedRootByKey, finalGroupsByRoot: mergedFinalGroupsByRoot };
347
+ }
260
348
  /**
261
349
  * Builds and emits one {@link ClusterReason} per final cluster via
262
350
  * `onClusterReason`, from data Stage A/B already computed for clustering
@@ -459,7 +547,8 @@ export function resolvePageClusterKeysInMemory(pages, options) {
459
547
  finalKeys[i] = rootKey;
460
548
  }
461
549
  }
462
- emitClusterReasons(crossBlockUnits, rootByKey, finalGroupsByRoot, reasonsByBlockKey, siblingUnitKeysByBlock, onClusterReason);
550
+ const validated = validateAndMergePartition(finalKeys, rootByKey, finalGroupsByRoot, (i) => pages[i], options?.onPartitionReport);
551
+ emitClusterReasons(crossBlockUnits, validated.rootByKey, validated.finalGroupsByRoot, reasonsByBlockKey, siblingUnitKeysByBlock, onClusterReason);
463
552
  return finalKeys;
464
553
  }
465
554
  /**
@@ -557,7 +646,8 @@ async function resolveSmallCorpusWithProgress(pages, onProgress, options) {
557
646
  finalKeys[i] = rootKey;
558
647
  }
559
648
  }
560
- emitClusterReasons(crossBlockUnits, rootByKey, finalGroupsByRoot, reasonsByBlockKey, siblingUnitKeysByBlock, onClusterReason);
649
+ const validated = validateAndMergePartition(finalKeys, rootByKey, finalGroupsByRoot, (i) => pages[i], options?.onPartitionReport);
650
+ emitClusterReasons(crossBlockUnits, validated.rootByKey, validated.finalGroupsByRoot, reasonsByBlockKey, siblingUnitKeysByBlock, onClusterReason);
561
651
  return finalKeys;
562
652
  }
563
653
  /**
@@ -844,7 +934,13 @@ export async function resolvePageClusterKeys(pages, options) {
844
934
  finalKeys[i] = rootKey;
845
935
  }
846
936
  }
847
- emitClusterReasons(crossBlockUnits, rootByKey, finalGroupsByRoot, reasonsByBlockKey, siblingUnitKeysByBlock, onClusterReason);
937
+ // `blockingSignals` (built during Pass 0, held for the whole call) has an
938
+ // entry for every page in the corpus, sampled or not — only the pages
939
+ // `finalGroupsByRoot` actually kept a token set for (reservoir-sampled
940
+ // block representatives) end up in the report, per `onPartitionReport`'s
941
+ // own JSDoc.
942
+ const validated = validateAndMergePartition(finalKeys, rootByKey, finalGroupsByRoot, (i) => blockingSignals[i], options?.onPartitionReport);
943
+ emitClusterReasons(crossBlockUnits, validated.rootByKey, validated.finalGroupsByRoot, reasonsByBlockKey, siblingUnitKeysByBlock, onClusterReason);
848
944
  return finalKeys;
849
945
  }
850
946
  /**
@@ -172,6 +172,7 @@ export function stageAPerBlock(input, options) {
172
172
  key: unitKey,
173
173
  memberTokenSets: sampledPositions.map((pos) => blockTokenSets[pos]),
174
174
  memberLandmarkInstances: sampledPositions.map((pos) => memberLandmarkInstancesByPage[pos]),
175
+ memberPageIndices: sampledPositions.map((pos) => memberIndices[pos]),
175
176
  });
176
177
  }
177
178
  return { pageKeys, crossBlockUnits };
@@ -0,0 +1,70 @@
1
+ import type { ClusterCohesion, ClusterCohesionOptions } from './compute-cluster-cohesion.js';
2
+ import type { DetectMirrorAxisOptions, MirrorAxis } from './detect-mirror-axis.js';
3
+ import type { ClusteredPage, CrossClusterDuplicate } from './find-cross-cluster-duplicates.js';
4
+ /**
5
+ * Options for {@link validateClusterPartition}, forwarded to the three
6
+ * checks it composes.
7
+ */
8
+ export type ValidateClusterPartitionOptions = {
9
+ readonly mirrorAxis?: DetectMirrorAxisOptions;
10
+ readonly cohesion?: ClusterCohesionOptions;
11
+ /**
12
+ * Only `corroboratedSimilarityThreshold` — `mirrorAxis` itself is always
13
+ * the axis this function just detected, not independently settable here.
14
+ */
15
+ readonly crossClusterDuplicates?: {
16
+ readonly corroboratedSimilarityThreshold?: number;
17
+ };
18
+ };
19
+ /**
20
+ * Structured evidence that a clustering run's partition may need
21
+ * correcting: which pages are plausibly the same template despite ending up
22
+ * in different clusters, and which clusters plausibly mix unrelated
23
+ * templates together. Carries no verdict on whether to act — same design as
24
+ * {@link ./build-cluster-reason.js | ClusterReason} — a caller decides what
25
+ * to do with `crossClusterDuplicates` (e.g. via
26
+ * {@link ./merge-validated-clusters.js | mergeValidatedClusters}) and with
27
+ * `cohesion`'s `suspicious` flags.
28
+ */
29
+ export type ClusterPartitionReport = {
30
+ /**
31
+ * The mirror axis detected across every page's `paths`, or `null` if
32
+ * none was found — see
33
+ * {@link ./detect-mirror-axis.js | detectMirrorAxis}.
34
+ */
35
+ readonly mirrorAxis: MirrorAxis | null;
36
+ /** One entry per distinct cluster key present in `pages`. */
37
+ readonly cohesion: readonly ClusterCohesion[];
38
+ /** See {@link ./find-cross-cluster-duplicates.js | findCrossClusterDuplicates}. */
39
+ readonly crossClusterDuplicates: readonly CrossClusterDuplicate[];
40
+ };
41
+ /**
42
+ * Validates a finished clustering partition by checking it against itself,
43
+ * rather than trying to get the clustering right the first time: whether
44
+ * pages that ended up in different clusters are nonetheless structurally
45
+ * identical or near-identical (a likely over-split, see
46
+ * {@link ./find-cross-cluster-duplicates.js | findCrossClusterDuplicates}),
47
+ * and whether any single cluster's members actually agree with each other
48
+ * (a likely over-merge, see
49
+ * {@link ./compute-cluster-cohesion.js | computeClusterCohesion}). Neither
50
+ * check depends on *how* the partition was produced — this is deliberately
51
+ * decoupled from `resolvePageClusterKeys`'s own Stage A/B internals so it
52
+ * can validate a partition regardless of its origin, including a stored one
53
+ * loaded back from an archive.
54
+ *
55
+ * Takes already-tokenized pages rather than raw HTML — a caller with a
56
+ * `resolvePageClusterKeys` result already has token sets on hand (or can
57
+ * derive them via {@link ./tokenize.js | tokenize}), and re-tokenizing here
58
+ * would cost a second full corpus pass for no benefit.
59
+ * @param pages
60
+ * @param options
61
+ * @example
62
+ * ```ts
63
+ * const report = validateClusterPartition(pages);
64
+ * const safeToMerge = report.crossClusterDuplicates.filter(
65
+ * (d) => d.similarity === 1 || d.corroboratedByMirrorAxis,
66
+ * );
67
+ * const mergedKeys = mergeValidatedClusters(clusterKeys, safeToMerge);
68
+ * ```
69
+ */
70
+ export declare function validateClusterPartition(pages: readonly ClusteredPage[], options?: ValidateClusterPartitionOptions): ClusterPartitionReport;
@@ -0,0 +1,49 @@
1
+ import { computeClusterCohesion } from './compute-cluster-cohesion.js';
2
+ import { detectMirrorAxis } from './detect-mirror-axis.js';
3
+ import { findCrossClusterDuplicates } from './find-cross-cluster-duplicates.js';
4
+ /**
5
+ * Validates a finished clustering partition by checking it against itself,
6
+ * rather than trying to get the clustering right the first time: whether
7
+ * pages that ended up in different clusters are nonetheless structurally
8
+ * identical or near-identical (a likely over-split, see
9
+ * {@link ./find-cross-cluster-duplicates.js | findCrossClusterDuplicates}),
10
+ * and whether any single cluster's members actually agree with each other
11
+ * (a likely over-merge, see
12
+ * {@link ./compute-cluster-cohesion.js | computeClusterCohesion}). Neither
13
+ * check depends on *how* the partition was produced — this is deliberately
14
+ * decoupled from `resolvePageClusterKeys`'s own Stage A/B internals so it
15
+ * can validate a partition regardless of its origin, including a stored one
16
+ * loaded back from an archive.
17
+ *
18
+ * Takes already-tokenized pages rather than raw HTML — a caller with a
19
+ * `resolvePageClusterKeys` result already has token sets on hand (or can
20
+ * derive them via {@link ./tokenize.js | tokenize}), and re-tokenizing here
21
+ * would cost a second full corpus pass for no benefit.
22
+ * @param pages
23
+ * @param options
24
+ * @example
25
+ * ```ts
26
+ * const report = validateClusterPartition(pages);
27
+ * const safeToMerge = report.crossClusterDuplicates.filter(
28
+ * (d) => d.similarity === 1 || d.corroboratedByMirrorAxis,
29
+ * );
30
+ * const mergedKeys = mergeValidatedClusters(clusterKeys, safeToMerge);
31
+ * ```
32
+ */
33
+ export function validateClusterPartition(pages, options) {
34
+ const mirrorAxis = detectMirrorAxis(pages.map((p) => p.paths), options?.mirrorAxis);
35
+ const membersByKey = new Map();
36
+ for (const page of pages) {
37
+ const members = membersByKey.get(page.clusterKey);
38
+ if (members)
39
+ members.push(page.tokens);
40
+ else
41
+ membersByKey.set(page.clusterKey, [page.tokens]);
42
+ }
43
+ const cohesion = computeClusterCohesion(membersByKey, options?.cohesion);
44
+ const crossClusterDuplicates = findCrossClusterDuplicates(pages, {
45
+ mirrorAxis,
46
+ corroboratedSimilarityThreshold: options?.crossClusterDuplicates?.corroboratedSimilarityThreshold,
47
+ });
48
+ return { mirrorAxis, cohesion, crossClusterDuplicates };
49
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d-zero/page-cluster",
3
- "version": "0.5.6",
3
+ "version": "0.5.7",
4
4
  "description": "Clusters crawled HTML pages by DOM-structure similarity — assigns the same key to pages sharing a template, ignoring text content. CLI-first, with library APIs.",
5
5
  "author": "D-ZERO",
6
6
  "license": "MIT",
@@ -36,6 +36,34 @@
36
36
  "./build-cluster-reason": {
37
37
  "import": "./dist/build-cluster-reason.js",
38
38
  "types": "./dist/build-cluster-reason.d.ts"
39
+ },
40
+ "./detect-mirror-axis": {
41
+ "import": "./dist/detect-mirror-axis.js",
42
+ "types": "./dist/detect-mirror-axis.d.ts"
43
+ },
44
+ "./normalize-path-by-mirror-axis": {
45
+ "import": "./dist/normalize-path-by-mirror-axis.js",
46
+ "types": "./dist/normalize-path-by-mirror-axis.d.ts"
47
+ },
48
+ "./normalize-href-by-mirror-axis": {
49
+ "import": "./dist/normalize-href-by-mirror-axis.js",
50
+ "types": "./dist/normalize-href-by-mirror-axis.d.ts"
51
+ },
52
+ "./compute-cluster-cohesion": {
53
+ "import": "./dist/compute-cluster-cohesion.js",
54
+ "types": "./dist/compute-cluster-cohesion.d.ts"
55
+ },
56
+ "./find-cross-cluster-duplicates": {
57
+ "import": "./dist/find-cross-cluster-duplicates.js",
58
+ "types": "./dist/find-cross-cluster-duplicates.d.ts"
59
+ },
60
+ "./validate-cluster-partition": {
61
+ "import": "./dist/validate-cluster-partition.js",
62
+ "types": "./dist/validate-cluster-partition.d.ts"
63
+ },
64
+ "./merge-validated-clusters": {
65
+ "import": "./dist/merge-validated-clusters.js",
66
+ "types": "./dist/merge-validated-clusters.d.ts"
39
67
  }
40
68
  },
41
69
  "bin": "dist/cli.js",
@@ -57,5 +85,5 @@
57
85
  "url": "https://github.com/d-zero-dev/tools.git",
58
86
  "directory": "packages/@d-zero/page-cluster"
59
87
  },
60
- "gitHead": "b06568f6812c4f560b4d8c3a1d280cd42ec264a5"
88
+ "gitHead": "1321d85f63db05896135599046924221f4f714c5"
61
89
  }