@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.
- package/README.md +35 -0
- package/dist/build-mirrored-template-fixture.d.ts +61 -0
- package/dist/build-mirrored-template-fixture.js +127 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +134 -55
- package/dist/compute-cluster-cohesion.d.ts +59 -0
- package/dist/compute-cluster-cohesion.js +107 -0
- package/dist/detect-mirror-axis.d.ts +74 -0
- package/dist/detect-mirror-axis.js +0 -0
- package/dist/find-cross-cluster-duplicates.d.ts +94 -0
- package/dist/find-cross-cluster-duplicates.js +164 -0
- package/dist/merge-cross-block-clusters.d.ts +18 -0
- package/dist/merge-cross-block-clusters.js +256 -9
- package/dist/merge-validated-clusters.d.ts +27 -0
- package/dist/merge-validated-clusters.js +50 -0
- package/dist/normalize-href-by-mirror-axis.d.ts +25 -0
- package/dist/normalize-href-by-mirror-axis.js +30 -0
- package/dist/normalize-path-by-mirror-axis.d.ts +22 -0
- package/dist/normalize-path-by-mirror-axis.js +25 -0
- package/dist/resolve-page-cluster-keys.d.ts +43 -0
- package/dist/resolve-page-cluster-keys.js +99 -3
- package/dist/stage-a-per-block.js +1 -0
- package/dist/validate-cluster-partition.d.ts +70 -0
- package/dist/validate-cluster-partition.js +49 -0
- package/package.json +36 -4
|
@@ -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
|