@d-zero/page-cluster 0.5.6 → 0.6.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.
@@ -0,0 +1,107 @@
1
+ import { jaccardSimilarity } from './jaccard-similarity.js';
2
+ import { reservoirSample } from './reservoir-sample.js';
3
+ /**
4
+ * Maximum members sampled per cluster before computing pairwise similarity.
5
+ * Bounds the cost to `O(maxSampleSize²)` per cluster regardless of how large
6
+ * the cluster actually is — 40² / 2 = 780 comparisons, cheap even across
7
+ * many clusters.
8
+ */
9
+ const DEFAULT_MAX_SAMPLE_SIZE = 40;
10
+ /**
11
+ * Below this median pairwise-similarity, a cluster is flagged `suspicious`.
12
+ *
13
+ * Chosen against the bundled `buildMirroredTemplateFixture` fixture (see
14
+ * `compute-cluster-cohesion.spec.ts`): every single-template cluster there
15
+ * has a median of `1.0` (structural tokens are identical across pages that
16
+ * differ only in text content and per-mirror stylesheet href), while every
17
+ * cluster built by mixing two *different* templates' members has a median
18
+ * at or below `0.70`. `0.75` sits with margin on both sides of that gap.
19
+ * Real crawl data can have single-template clusters with genuinely lower
20
+ * cohesion than this synthetic fixture models (legitimate content-driven
21
+ * structural variation the fixture doesn't produce) — `suspiciousMedianBelow`
22
+ * exists so a caller who has measured their own corpus can override it.
23
+ */
24
+ const DEFAULT_SUSPICIOUS_MEDIAN_BELOW = 0.75;
25
+ /**
26
+ * @param sortedAscending
27
+ */
28
+ function median(sortedAscending) {
29
+ const mid = Math.floor(sortedAscending.length / 2);
30
+ if (sortedAscending.length % 2 === 1)
31
+ return sortedAscending[mid];
32
+ return (sortedAscending[mid - 1] + sortedAscending[mid]) / 2;
33
+ }
34
+ /**
35
+ * @param sortedAscending
36
+ */
37
+ function p10(sortedAscending) {
38
+ const index = Math.floor(0.1 * (sortedAscending.length - 1));
39
+ return sortedAscending[index];
40
+ }
41
+ /**
42
+ * Reports, per cluster, how similar its members' structural token sets
43
+ * actually are to each other — not just which tokens they share (that's
44
+ * {@link ./merge-cross-block-clusters.js | computeQuorumCore}'s job), but
45
+ * whether the members hang together at all. A cluster built by merging
46
+ * unrelated templates has member pairs that mostly disagree even though a
47
+ * small frequency core still exists among them; this surfaces that
48
+ * disagreement directly, as a distribution rather than a single score, since
49
+ * a single "average similarity" would be pulled toward the middle by exactly
50
+ * the kind of partial-overlap noise this is meant to catch.
51
+ *
52
+ * Sampling uses {@link ./reservoir-sample.js | reservoirSample} seeded by
53
+ * each cluster's own key, so repeated calls on the same partition sample the
54
+ * same members and produce the same report.
55
+ *
56
+ * Deliberately returns only structured numbers, no verdict text — same
57
+ * design as {@link ./build-cluster-reason.js | ClusterReason} — so a caller
58
+ * decides what "suspicious" should mean for their own use (an interactive
59
+ * review queue vs. an automated gate might want different behavior for the
60
+ * same numbers).
61
+ * @param membersByKey Every cluster's member token sets, keyed by cluster key.
62
+ * @param options
63
+ * @example
64
+ * ```ts
65
+ * const report = computeClusterCohesion(membersByKey);
66
+ * const worstFirst = [...report].filter((r) => r.suspicious)
67
+ * .toSorted((a, b) => (a.medianPairSimilarity ?? 0) - (b.medianPairSimilarity ?? 0));
68
+ * ```
69
+ */
70
+ export function computeClusterCohesion(membersByKey, options) {
71
+ const maxSampleSize = options?.maxSampleSize ?? DEFAULT_MAX_SAMPLE_SIZE;
72
+ const suspiciousMedianBelow = options?.suspiciousMedianBelow ?? DEFAULT_SUSPICIOUS_MEDIAN_BELOW;
73
+ const results = [];
74
+ for (const [clusterKey, members] of membersByKey) {
75
+ const sample = reservoirSample(members, maxSampleSize, clusterKey);
76
+ if (sample.length < 2) {
77
+ results.push({
78
+ clusterKey,
79
+ memberCount: members.length,
80
+ sampledMemberCount: sample.length,
81
+ medianPairSimilarity: null,
82
+ p10PairSimilarity: null,
83
+ minPairSimilarity: null,
84
+ suspicious: false,
85
+ });
86
+ continue;
87
+ }
88
+ const similarities = [];
89
+ for (let a = 0; a < sample.length; a++) {
90
+ for (let b = a + 1; b < sample.length; b++) {
91
+ similarities.push(jaccardSimilarity(sample[a], sample[b]));
92
+ }
93
+ }
94
+ similarities.sort((x, y) => x - y);
95
+ const medianSimilarity = median(similarities);
96
+ results.push({
97
+ clusterKey,
98
+ memberCount: members.length,
99
+ sampledMemberCount: sample.length,
100
+ medianPairSimilarity: medianSimilarity,
101
+ p10PairSimilarity: p10(similarities),
102
+ minPairSimilarity: similarities[0],
103
+ suspicious: medianSimilarity < suspiciousMedianBelow,
104
+ });
105
+ }
106
+ return results;
107
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * A URL path segment position at which a whole site section is mirrored
3
+ * under a fixed set of alternative values — most commonly a language
4
+ * directory (`/en/`, `/zh/`, `/ko/`, `/th/`), but the same shape also arises
5
+ * from staging/production mirrors, device-variant subsites (`/sp/`), or
6
+ * versioned documentation trees. Detected by {@link detectMirrorAxis} from
7
+ * `paths` alone, with no built-in list of language codes or other
8
+ * site-specific vocabulary.
9
+ */
10
+ export type MirrorAxis = {
11
+ /** Index into a page's `paths` array where the mirrored value sits. */
12
+ readonly position: number;
13
+ /** The alternative values observed at `position` across the mirror. */
14
+ readonly values: ReadonlySet<string>;
15
+ };
16
+ /**
17
+ * Options for {@link detectMirrorAxis}.
18
+ */
19
+ export type DetectMirrorAxisOptions = {
20
+ /**
21
+ * How many leading path segments to scan for a mirror axis. Bounds the
22
+ * cost to `O(maxPosition × pageCount)`; a mirror axis deep enough to need
23
+ * a larger value is unusual (language/environment/version directories
24
+ * are conventionally near the root).
25
+ */
26
+ readonly maxPosition?: number;
27
+ /**
28
+ * Minimum number of distinct path skeletons that must share the exact
29
+ * same value set at a position before it is even considered a candidate
30
+ * axis. A single skeleton with multiple values at some position (e.g. a
31
+ * `/faq/{01,02,03}` set of sibling pages) is not a mirror — nothing about
32
+ * it repeats — so admitting `skeletonCount === 1` candidates would
33
+ * misidentify ordinary sibling pages as a site-wide axis. 3 is the
34
+ * smallest count for which "this exact value set recurs" stops being
35
+ * describable as coincidence.
36
+ */
37
+ readonly minSkeletonCount?: number;
38
+ };
39
+ /**
40
+ * Finds a mirror axis in a page corpus's URL paths, with no built-in
41
+ * knowledge of language codes or any other site-specific vocabulary — the
42
+ * axis is inferred purely from how often the exact same set of alternative
43
+ * values recurs across otherwise-identical path skeletons.
44
+ *
45
+ * For each scanned segment position, every page's path is reduced to a
46
+ * "skeleton" (that position blanked to `*`); skeletons that recur with
47
+ * `≥ 2` distinct values at that position are candidates, keyed by their
48
+ * exact value set. Candidates are ranked by how many distinct skeletons
49
+ * share that value set — the more independent path shapes that recur under
50
+ * the same alternative values, the more likely that position is a genuine
51
+ * site-wide mirror rather than a coincidence — and the top-ranked position's
52
+ * value set is accepted unless {@link autoCutThreshold}'s max-gap cut (using
53
+ * the top candidate's own count as `upperBound`, so the cut never selects a
54
+ * value the top candidate doesn't already clear) finds the next-best
55
+ * candidate too close behind to call decisively.
56
+ *
57
+ * Returns `null` when no position has any candidate clearing
58
+ * `minSkeletonCount` — including a single-language/single-mirror corpus
59
+ * (nothing recurs under alternative values at all) and a corpus whose only
60
+ * repeated-value-set position is a single skeleton's sibling pages (e.g.
61
+ * `/faq/{01,02,03}` — excluded by `minSkeletonCount`, see its own JSDoc).
62
+ * @param pagePaths Every page's URL path segments (e.g. `ExURL.paths`).
63
+ * @param options
64
+ * @example
65
+ * ```ts
66
+ * const axis = detectMirrorAxis([
67
+ * ['en', 'faq', 'index.html'], ['zh', 'faq', 'index.html'],
68
+ * ['en', 'access', 'index.html'], ['zh', 'access', 'index.html'],
69
+ * ['en', 'gallery', 'index.html'], ['zh', 'gallery', 'index.html'],
70
+ * ]);
71
+ * // { position: 0, values: Set(['en', 'zh']) }
72
+ * ```
73
+ */
74
+ export declare function detectMirrorAxis(pagePaths: readonly (readonly string[])[], options?: DetectMirrorAxisOptions): MirrorAxis | null;
Binary file
@@ -0,0 +1,94 @@
1
+ import type { MirrorAxis } from './detect-mirror-axis.js';
2
+ /**
3
+ * A page as {@link findCrossClusterDuplicates} and
4
+ * {@link ./validate-cluster-partition.js | validateClusterPartition} need
5
+ * it: already tokenized (no re-tokenization here — a caller validating a
6
+ * result from {@link ./resolve-page-cluster-keys.js | resolvePageClusterKeys}
7
+ * already has these token sets on hand) and carrying the blocking-relevant
8
+ * signals ({@link ./resolve-page-cluster-keys.js | PageClusterSignals}'s
9
+ * `paths`/`stylesheetHrefs`) needed to corroborate a match against a
10
+ * {@link MirrorAxis}.
11
+ */
12
+ export type ClusteredPage = {
13
+ readonly clusterKey: string;
14
+ readonly tokens: ReadonlySet<string>;
15
+ readonly paths: readonly string[];
16
+ readonly stylesheetHrefs: readonly string[];
17
+ };
18
+ /**
19
+ * Options for {@link findCrossClusterDuplicates}.
20
+ */
21
+ export type FindCrossClusterDuplicatesOptions = {
22
+ /**
23
+ * A `MirrorAxis` to corroborate near-duplicate (similarity `< 1`) pairs
24
+ * against. Omitting it (or passing `null`) still finds exact-duplicate
25
+ * pairs — see {@link findCrossClusterDuplicates}'s JSDoc — just without
26
+ * the near-duplicate pass, and every result's `corroboratedByMirrorAxis`
27
+ * is `false` (corroboration was never attempted, not disproven).
28
+ */
29
+ readonly mirrorAxis?: MirrorAxis | null;
30
+ /** @see DEFAULT_CORROBORATED_SIMILARITY_THRESHOLD */
31
+ readonly corroboratedSimilarityThreshold?: number;
32
+ };
33
+ /**
34
+ * One pair of clusters found to plausibly be the same template, split by
35
+ * the clustering run under review.
36
+ */
37
+ export type CrossClusterDuplicate = {
38
+ /** The two cluster keys, ordered so `clusterKeyA < clusterKeyB` (stable regardless of input order). */
39
+ readonly clusterKeyA: string;
40
+ readonly clusterKeyB: string;
41
+ /** The highest pairwise token-set Jaccard similarity found between the two clusters' members. */
42
+ readonly similarity: number;
43
+ /**
44
+ * Whether the best-similarity pair was also corroborated by the mirror
45
+ * axis (same axis-normalized path shape and axis-normalized stylesheet
46
+ * href set). Always `false` when no axis was supplied. `similarity === 1`
47
+ * pairs are reported regardless of this flag — see this function's JSDoc.
48
+ */
49
+ readonly corroboratedByMirrorAxis: boolean;
50
+ };
51
+ /**
52
+ * Finds pairs of clusters whose members are plausibly the same template,
53
+ * split apart by the clustering run under review — the signal a caller acts
54
+ * on to merge them back (see {@link ./merge-validated-clusters.js |
55
+ * mergeValidatedClusters}).
56
+ *
57
+ * Two passes, both bounded well below the full `O(pageCount²)` cross
58
+ * product:
59
+ *
60
+ * 1. **Exact match** — pages are grouped by an exact hash of their token
61
+ * set (`O(pageCount)`). Any hash group spanning more than one cluster key
62
+ * is an instant duplicate at `similarity: 1`: two pages whose structural
63
+ * tokens are byte-identical being in different clusters is a partition
64
+ * error regardless of *why* — no corroboration is required or checked
65
+ * for acceptance (an axis check still runs on the pair, if one was
66
+ * supplied, purely to populate `corroboratedByMirrorAxis` informationally).
67
+ * Pages with an empty token set are excluded — no structural evidence to
68
+ * match on.
69
+ * 2. **Axis-corroborated near match** (only when `options.mirrorAxis` is
70
+ * given) — pages are grouped by
71
+ * {@link ./normalize-path-by-mirror-axis.js | normalizePathByMirrorAxis}
72
+ * shape, which is naturally small per group (bounded by how many mirror
73
+ * values recur under that shape, not by corpus size). Within a shape
74
+ * group spanning more than one cluster key, every cross-cluster pair is
75
+ * compared directly; a pair clearing `corroboratedSimilarityThreshold`
76
+ * **and** matching under
77
+ * {@link ./normalize-href-by-mirror-axis.js | normalizeHrefByMirrorAxis}
78
+ * is accepted. Both signals are required here — same path shape alone
79
+ * can recur by coincidence (two different templates that happen to sit at
80
+ * the same depth), and same stylesheet shape alone doesn't imply the same
81
+ * DOM structure.
82
+ *
83
+ * Results are deduplicated to one entry per cluster-key pair, keeping the
84
+ * highest similarity found and OR-ing the corroboration flag across every
85
+ * qualifying page pair for that cluster pair.
86
+ * @param pages
87
+ * @param options
88
+ * @example
89
+ * ```ts
90
+ * const duplicates = findCrossClusterDuplicates(pages, { mirrorAxis: axis });
91
+ * const safeToMerge = duplicates.filter((d) => d.similarity === 1 || d.corroboratedByMirrorAxis);
92
+ * ```
93
+ */
94
+ export declare function findCrossClusterDuplicates(pages: readonly ClusteredPage[], options?: FindCrossClusterDuplicatesOptions): readonly CrossClusterDuplicate[];
@@ -0,0 +1,164 @@
1
+ import { jaccardSimilarity } from './jaccard-similarity.js';
2
+ import { normalizeHrefByMirrorAxis } from './normalize-href-by-mirror-axis.js';
3
+ import { normalizePathByMirrorAxis } from './normalize-path-by-mirror-axis.js';
4
+ /**
5
+ * Similarity required for a page pair below exact match to be accepted —
6
+ * only once axis+href corroboration also holds (see
7
+ * {@link findCrossClusterDuplicates}'s JSDoc). Matches the 80% floor already
8
+ * used throughout this package's Stage B for corroborating a secondary
9
+ * signal ({@link ./merge-cross-block-clusters.js | CROSS_BLOCK_THRESHOLD},
10
+ * `SHELL_CORROBORATION_THRESHOLD`) rather than carrying a decision alone.
11
+ */
12
+ const DEFAULT_CORROBORATED_SIMILARITY_THRESHOLD = 0.8;
13
+ /**
14
+ * @param tokens
15
+ */
16
+ function exactHashKey(tokens) {
17
+ return [...tokens].toSorted().join(' ');
18
+ }
19
+ /**
20
+ * @param a
21
+ * @param b
22
+ * @param axis
23
+ */
24
+ function hrefsMatchUnderAxis(a, b, axis) {
25
+ const normalize = (hrefs) => [...new Set(hrefs.map((h) => normalizeHrefByMirrorAxis(h, axis)))]
26
+ .toSorted()
27
+ .join(' ');
28
+ return normalize(a.stylesheetHrefs) === normalize(b.stylesheetHrefs);
29
+ }
30
+ /**
31
+ * @param best
32
+ * @param a
33
+ * @param b
34
+ * @param similarity
35
+ * @param axis
36
+ */
37
+ function recordCandidate(best, a, b, similarity, axis) {
38
+ if (a.clusterKey === b.clusterKey)
39
+ return;
40
+ const [keyA, keyB] = a.clusterKey < b.clusterKey
41
+ ? [a.clusterKey, b.clusterKey]
42
+ : [b.clusterKey, a.clusterKey];
43
+ const corroborated = axis !== null && hrefsMatchUnderAxis(a, b, axis);
44
+ let inner = best.get(keyA);
45
+ if (!inner) {
46
+ inner = new Map();
47
+ best.set(keyA, inner);
48
+ }
49
+ const existing = inner.get(keyB);
50
+ if (!existing || similarity > existing.similarity) {
51
+ inner.set(keyB, { similarity, corroboratedByMirrorAxis: corroborated });
52
+ }
53
+ else if (similarity === existing.similarity &&
54
+ corroborated &&
55
+ !existing.corroboratedByMirrorAxis) {
56
+ inner.set(keyB, { similarity, corroboratedByMirrorAxis: true });
57
+ }
58
+ }
59
+ /**
60
+ * Finds pairs of clusters whose members are plausibly the same template,
61
+ * split apart by the clustering run under review — the signal a caller acts
62
+ * on to merge them back (see {@link ./merge-validated-clusters.js |
63
+ * mergeValidatedClusters}).
64
+ *
65
+ * Two passes, both bounded well below the full `O(pageCount²)` cross
66
+ * product:
67
+ *
68
+ * 1. **Exact match** — pages are grouped by an exact hash of their token
69
+ * set (`O(pageCount)`). Any hash group spanning more than one cluster key
70
+ * is an instant duplicate at `similarity: 1`: two pages whose structural
71
+ * tokens are byte-identical being in different clusters is a partition
72
+ * error regardless of *why* — no corroboration is required or checked
73
+ * for acceptance (an axis check still runs on the pair, if one was
74
+ * supplied, purely to populate `corroboratedByMirrorAxis` informationally).
75
+ * Pages with an empty token set are excluded — no structural evidence to
76
+ * match on.
77
+ * 2. **Axis-corroborated near match** (only when `options.mirrorAxis` is
78
+ * given) — pages are grouped by
79
+ * {@link ./normalize-path-by-mirror-axis.js | normalizePathByMirrorAxis}
80
+ * shape, which is naturally small per group (bounded by how many mirror
81
+ * values recur under that shape, not by corpus size). Within a shape
82
+ * group spanning more than one cluster key, every cross-cluster pair is
83
+ * compared directly; a pair clearing `corroboratedSimilarityThreshold`
84
+ * **and** matching under
85
+ * {@link ./normalize-href-by-mirror-axis.js | normalizeHrefByMirrorAxis}
86
+ * is accepted. Both signals are required here — same path shape alone
87
+ * can recur by coincidence (two different templates that happen to sit at
88
+ * the same depth), and same stylesheet shape alone doesn't imply the same
89
+ * DOM structure.
90
+ *
91
+ * Results are deduplicated to one entry per cluster-key pair, keeping the
92
+ * highest similarity found and OR-ing the corroboration flag across every
93
+ * qualifying page pair for that cluster pair.
94
+ * @param pages
95
+ * @param options
96
+ * @example
97
+ * ```ts
98
+ * const duplicates = findCrossClusterDuplicates(pages, { mirrorAxis: axis });
99
+ * const safeToMerge = duplicates.filter((d) => d.similarity === 1 || d.corroboratedByMirrorAxis);
100
+ * ```
101
+ */
102
+ export function findCrossClusterDuplicates(pages, options) {
103
+ const axis = options?.mirrorAxis ?? null;
104
+ const threshold = options?.corroboratedSimilarityThreshold ?? DEFAULT_CORROBORATED_SIMILARITY_THRESHOLD;
105
+ const best = new Map();
106
+ // Pass 1: exact match via hash grouping.
107
+ const byExactHash = new Map();
108
+ for (const page of pages) {
109
+ if (page.tokens.size === 0)
110
+ continue;
111
+ const key = exactHashKey(page.tokens);
112
+ const group = byExactHash.get(key);
113
+ if (group)
114
+ group.push(page);
115
+ else
116
+ byExactHash.set(key, [page]);
117
+ }
118
+ for (const group of byExactHash.values()) {
119
+ if (new Set(group.map((p) => p.clusterKey)).size < 2)
120
+ continue;
121
+ for (let a = 0; a < group.length; a++) {
122
+ for (let b = a + 1; b < group.length; b++) {
123
+ recordCandidate(best, group[a], group[b], 1, axis);
124
+ }
125
+ }
126
+ }
127
+ // Pass 2: axis-corroborated near match, scoped to same axis-normalized shape.
128
+ if (axis !== null) {
129
+ const byShape = new Map();
130
+ for (const page of pages) {
131
+ const shape = normalizePathByMirrorAxis(page.paths, axis);
132
+ const group = byShape.get(shape);
133
+ if (group)
134
+ group.push(page);
135
+ else
136
+ byShape.set(shape, [page]);
137
+ }
138
+ for (const group of byShape.values()) {
139
+ if (new Set(group.map((p) => p.clusterKey)).size < 2)
140
+ continue;
141
+ for (let a = 0; a < group.length; a++) {
142
+ for (let b = a + 1; b < group.length; b++) {
143
+ const pageA = group[a];
144
+ const pageB = group[b];
145
+ if (pageA.clusterKey === pageB.clusterKey)
146
+ continue;
147
+ const similarity = jaccardSimilarity(pageA.tokens, pageB.tokens);
148
+ if (similarity < threshold)
149
+ continue;
150
+ if (!hrefsMatchUnderAxis(pageA, pageB, axis))
151
+ continue;
152
+ recordCandidate(best, pageA, pageB, similarity, axis);
153
+ }
154
+ }
155
+ }
156
+ }
157
+ const results = [];
158
+ for (const [clusterKeyA, inner] of best) {
159
+ for (const [clusterKeyB, { similarity, corroboratedByMirrorAxis }] of inner) {
160
+ results.push({ clusterKeyA, clusterKeyB, similarity, corroboratedByMirrorAxis });
161
+ }
162
+ }
163
+ return results;
164
+ }
@@ -17,6 +17,18 @@ export type CrossBlockUnit = {
17
17
  readonly key: string;
18
18
  readonly memberTokenSets: readonly ReadonlySet<string>[];
19
19
  readonly memberLandmarkInstances: readonly (readonly PerPageLandmarkInstance[])[];
20
+ /**
21
+ * Original corpus index of each member, parallel to `memberTokenSets` —
22
+ * lets a caller that pools `finalGroupsByRoot`'s output back into
23
+ * page-level records (e.g. to build a
24
+ * {@link ./find-cross-cluster-duplicates.js | ClusteredPage}) recover
25
+ * which page each merged token set came from, without a second
26
+ * corpus-wide pass. Optional because most callers (every hand-built test
27
+ * fixture in this file included) only need `mergeCrossBlockClusters` for
28
+ * its cluster-key decisions and never read this back; omitted entries are
29
+ * normalized to `-1` internally rather than left misaligned.
30
+ */
31
+ readonly memberPageIndices?: readonly number[];
20
32
  };
21
33
  /**
22
34
  * Frequency-based token core of a group of member pages: a token must be
@@ -42,6 +54,12 @@ export declare function computeQuorumCore(memberDistinctiveTokens: readonly Read
42
54
  export type FinalGroupMembers = {
43
55
  readonly tokenSets: readonly ReadonlySet<string>[];
44
56
  readonly landmarkInstances: readonly (readonly PerPageLandmarkInstance[])[];
57
+ /**
58
+ * Parallel to `tokenSets` — see {@link CrossBlockUnit}'s own
59
+ * `memberPageIndices`. An entry is `-1` for any member whose originating
60
+ * unit omitted `memberPageIndices`.
61
+ */
62
+ readonly pageIndices: readonly number[];
45
63
  };
46
64
  /**
47
65
  * `mergeCrossBlockClusters`'s result: the root-key mapping every caller