@d-zero/page-cluster 0.5.5 → 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.
- 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 +71 -1
- 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 +32 -4
|
@@ -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
|
|
@@ -118,6 +118,154 @@ export function computeQuorumCore(memberDistinctiveTokens) {
|
|
|
118
118
|
}
|
|
119
119
|
return union;
|
|
120
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* Minimum ratio a proposed merge's post-merge quorum-core size must retain,
|
|
123
|
+
* relative to the strongest lineage anchor on either side (see
|
|
124
|
+
* `anchorByRoot` in {@link filterMergesByCohesion}), or the merge is
|
|
125
|
+
* discarded.
|
|
126
|
+
*
|
|
127
|
+
* Chosen against `merge-cross-block-clusters.spec.ts`'s own fixtures, which
|
|
128
|
+
* bound both edges this value has to sit between:
|
|
129
|
+
* - "a hub unit does not absorb several mutually-unrelated units via
|
|
130
|
+
* containment" needs a ratio *above* ~`0.3` to reject each absorption (the
|
|
131
|
+
* hub's own 10-token core collapses to the 3 tokens the absorbed unit
|
|
132
|
+
* happens to also carry).
|
|
133
|
+
* - the bundled `buildMirroredTemplateFixture` fixture (see
|
|
134
|
+
* `resolve-page-cluster-keys.spec.ts`) needs a ratio *at or below* ~`0.8`
|
|
135
|
+
* to keep merging every one of its genuine per-mirror units (the same
|
|
136
|
+
* template, once per axis value) — above that, some legitimate mirrors
|
|
137
|
+
* stop merging because a handful of per-page module drops (this fixture's
|
|
138
|
+
* stand-in for optional-section variation) dip just far enough below a
|
|
139
|
+
* stricter bar.
|
|
140
|
+
*
|
|
141
|
+
* `0.7` sits with margin inside `(0.3, 0.8]` rather than against either
|
|
142
|
+
* edge. This is a per-step ratio, not an absolute floor — see
|
|
143
|
+
* `anchorByRoot`'s own JSDoc for why an anchor was needed at all, and for
|
|
144
|
+
* the residual limitation neither the ratio nor the anchor fixes: many
|
|
145
|
+
* originally-small-core units chained together one step at a time can each
|
|
146
|
+
* individually clear this ratio against the previous step's *already-small*
|
|
147
|
+
* anchor, so a long enough chain of naturally low-information pages can
|
|
148
|
+
* still end up merged even though no single step looks anomalous. Longer
|
|
149
|
+
* chains are exactly what {@link ./build-cluster-reason.js | ClusterReason}'s
|
|
150
|
+
* `blocking` array length and
|
|
151
|
+
* {@link ./compute-cluster-cohesion.js | computeClusterCohesion}'s
|
|
152
|
+
* `suspicious` flag are for — this guard reduces how often that happens and
|
|
153
|
+
* how far it goes, it does not claim to make it impossible.
|
|
154
|
+
*/
|
|
155
|
+
const MIN_COHESION_RATIO = 0.7;
|
|
156
|
+
/**
|
|
157
|
+
* `computeQuorumCore` without its full-union fallback for empty cores. The
|
|
158
|
+
* fallback exists so a final `ClusterReason.structuralCoreTokens` is never
|
|
159
|
+
* empty for a genuinely tiny unit — but it makes core *size* useless as a
|
|
160
|
+
* cohesion signal: a merge that destroys every token's 80% quorum would
|
|
161
|
+
* silently read as "core size is now the size of the union", i.e. bigger,
|
|
162
|
+
* not smaller. {@link filterMergesByCohesion} needs "zero tokens survive
|
|
163
|
+
* quorum" to mean zero.
|
|
164
|
+
* @param memberDistinctiveTokens
|
|
165
|
+
*/
|
|
166
|
+
function strictQuorumCoreSize(memberDistinctiveTokens) {
|
|
167
|
+
const n = memberDistinctiveTokens.length;
|
|
168
|
+
if (n === 0)
|
|
169
|
+
return 0;
|
|
170
|
+
const minCount = Math.ceil(QUORUM_FRACTION * n);
|
|
171
|
+
const tokenCount = new Map();
|
|
172
|
+
for (const tokens of memberDistinctiveTokens) {
|
|
173
|
+
for (const token of tokens) {
|
|
174
|
+
tokenCount.set(token, (tokenCount.get(token) ?? 0) + 1);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
let coreSize = 0;
|
|
178
|
+
for (const count of tokenCount.values()) {
|
|
179
|
+
if (count >= minCount)
|
|
180
|
+
coreSize++;
|
|
181
|
+
}
|
|
182
|
+
return coreSize;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Filters a round's proposed `[absorbed, root]` merges, rejecting any merge
|
|
186
|
+
* whose post-merge quorum core would collapse relative to the best core any
|
|
187
|
+
* single original unit now pooled into either side ever had — the guard
|
|
188
|
+
* against Stage B's fine/L2 stages successively absorbing unrelated units
|
|
189
|
+
* into a "catch-all" whose core shrinks toward shell-only tokens with every
|
|
190
|
+
* additional merge (each individual merge can look locally justified — the
|
|
191
|
+
* pair's *pre-merge* cores still overlap enough to clear
|
|
192
|
+
* `CROSS_BLOCK_THRESHOLD`/containment/L2 — while the *post-merge* core keeps
|
|
193
|
+
* shrinking, which none of those pre-merge checks observe).
|
|
194
|
+
*
|
|
195
|
+
* ## Why an anchor, not just the immediately preceding step
|
|
196
|
+
*
|
|
197
|
+
* An earlier version compared each merge only against the pool as it stood
|
|
198
|
+
* after the *previous* accepted merge for that root. That still lets a long
|
|
199
|
+
* chain erode a core to nothing, one acceptable-looking step at a time: if
|
|
200
|
+
* each step's ratio is checked only against the *result of the previous
|
|
201
|
+
* step*, and each step dilutes the pool a little, the reference the ratio is
|
|
202
|
+
* measured against keeps shrinking right along with the pool being measured
|
|
203
|
+
* — nothing ever compares the current state back to where the lineage
|
|
204
|
+
* started, so a chain of many individually-small erosions can compound into
|
|
205
|
+
* a total collapse no single step's check would have allowed on its own.
|
|
206
|
+
* `anchorByRoot` fixes this: every original unit's *own*, pre-any-merge core
|
|
207
|
+
* size (`anchorCoreSizeByKey`, computed once before the round loop) is
|
|
208
|
+
* carried forward — via `Math.max`, never re-derived from the current pool —
|
|
209
|
+
* as units merge into a root, so every later merge attempt is still measured
|
|
210
|
+
* against the strongest evidence its lineage ever had, not against
|
|
211
|
+
* whatever the lineage has been diluted to by the time of the attempt.
|
|
212
|
+
*
|
|
213
|
+
* Merges proposed for the same root are applied incrementally, in the order
|
|
214
|
+
* given, checking each one against the pool as it stood *after* the
|
|
215
|
+
* previously accepted merges for that root, so a chain of merges within a
|
|
216
|
+
* single call cannot each pass by being compared to a `pooled` state that
|
|
217
|
+
* never reflects the merges already accepted earlier in the same call.
|
|
218
|
+
*
|
|
219
|
+
* Two things intentionally do not gate rejection alone:
|
|
220
|
+
* - `strictQuorumCoreSize` is used instead of `computeQuorumCore`'s size —
|
|
221
|
+
* see that function's own JSDoc for why the fallback would invert the
|
|
222
|
+
* signal for exactly the merges this guard exists to catch.
|
|
223
|
+
* - A merge whose post-merge core size is `0` is always rejected, even when
|
|
224
|
+
* the anchor was also `0` (which would make the ratio check
|
|
225
|
+
* `0 >= ratio * 0` vacuously pass) — otherwise a unit that already lost
|
|
226
|
+
* its own core would become a sink that absorbs anything with no further
|
|
227
|
+
* resistance.
|
|
228
|
+
* @param proposedMerges `[absorbedKey, rootKey]` pairs, as produced by the
|
|
229
|
+
* fine or L2 stage's own union-find pass.
|
|
230
|
+
* @param groupDistinctive This round's per-group distinctive token sets,
|
|
231
|
+
* keyed by group key. Callers pass the class-name-stripped
|
|
232
|
+
* `groupDistinctiveShaped` projection (see
|
|
233
|
+
* {@link mergeCrossBlockClusters}'s own body) rather than raw
|
|
234
|
+
* `groupDistinctive` — the fine stage's shape-Jaccard merges pair units
|
|
235
|
+
* with disjoint raw tokens by construction, and a cohesion check against
|
|
236
|
+
* raw tokens would reject every one of those merges outright.
|
|
237
|
+
* @param anchorByRoot Every current root's strongest lineage core size (see
|
|
238
|
+
* above). Mutated in place: an accepted merge's root inherits
|
|
239
|
+
* `Math.max(root's anchor, absorbed's anchor)`.
|
|
240
|
+
*/
|
|
241
|
+
function filterMergesByCohesion(proposedMerges, groupDistinctive, anchorByRoot) {
|
|
242
|
+
const byRoot = new Map();
|
|
243
|
+
for (const [absorbed, root] of proposedMerges) {
|
|
244
|
+
const list = byRoot.get(root);
|
|
245
|
+
if (list)
|
|
246
|
+
list.push(absorbed);
|
|
247
|
+
else
|
|
248
|
+
byRoot.set(root, [absorbed]);
|
|
249
|
+
}
|
|
250
|
+
const accepted = [];
|
|
251
|
+
for (const [root, absorbedKeys] of byRoot) {
|
|
252
|
+
let pooled = [...(groupDistinctive.get(root) ?? [])];
|
|
253
|
+
for (const absorbed of absorbedKeys) {
|
|
254
|
+
const absorbedTokens = groupDistinctive.get(absorbed) ?? [];
|
|
255
|
+
const candidatePool = [...pooled, ...absorbedTokens];
|
|
256
|
+
const candidateCoreSize = strictQuorumCoreSize(candidatePool);
|
|
257
|
+
const referenceCoreSize = Math.max(anchorByRoot.get(root) ?? strictQuorumCoreSize(pooled), anchorByRoot.get(absorbed) ?? strictQuorumCoreSize(absorbedTokens));
|
|
258
|
+
if (candidateCoreSize > 0 &&
|
|
259
|
+
candidateCoreSize >= MIN_COHESION_RATIO * referenceCoreSize) {
|
|
260
|
+
pooled = candidatePool;
|
|
261
|
+
anchorByRoot.set(root, referenceCoreSize);
|
|
262
|
+
anchorByRoot.delete(absorbed);
|
|
263
|
+
accepted.push([absorbed, root]);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return accepted;
|
|
268
|
+
}
|
|
121
269
|
/**
|
|
122
270
|
*
|
|
123
271
|
* @param core
|
|
@@ -164,6 +312,62 @@ function l2Contained(xSig, ySig) {
|
|
|
164
312
|
}
|
|
165
313
|
return true;
|
|
166
314
|
}
|
|
315
|
+
/**
|
|
316
|
+
* Canonical id for an `l2Signature`'s *shape* — its key set, ignoring the
|
|
317
|
+
* per-key counts — so {@link hasDiscriminatingL2Signatures} can tell whether
|
|
318
|
+
* two units reduced to the same vocabulary of `main`-anchored shapes,
|
|
319
|
+
* independent of how many pages contributed to each count.
|
|
320
|
+
* @param signature
|
|
321
|
+
*/
|
|
322
|
+
function l2SignatureShapeId(signature) {
|
|
323
|
+
return [...signature.keys()].toSorted().join('');
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Minimum number of units an L2-degeneracy check requires before it will
|
|
327
|
+
* reject the whole comparison — below this, "every unit shares one shape"
|
|
328
|
+
* is unremarkable (there is nothing to discriminate between yet), not
|
|
329
|
+
* evidence the signature itself lacks resolving power.
|
|
330
|
+
*/
|
|
331
|
+
const MIN_L2_PARTICIPANTS_FOR_DEGENERACY_CHECK = 3;
|
|
332
|
+
/**
|
|
333
|
+
* Whether this round's L2 signatures carry any discriminating power at all,
|
|
334
|
+
* checked once per round *before* running the `O(l2n²)` containment
|
|
335
|
+
* comparison rather than discovering it empirically pair by pair.
|
|
336
|
+
*
|
|
337
|
+
* `l2Signature` truncates to `main` plus up to 2 shape-stripped levels (see
|
|
338
|
+
* its own JSDoc); a corpus where the actual template content sits under a
|
|
339
|
+
* shared `main > article > <wrapper>` chain collapses every unit's
|
|
340
|
+
* signature to the exact same handful of keys (`main>article>*`, in the
|
|
341
|
+
* bundled `buildMirroredTemplateFixture` fixture's own case — see
|
|
342
|
+
* `merge-cross-block-clusters.spec.ts`), at which point `l2Contained`'s
|
|
343
|
+
* multiset containment degenerates into a plain count comparison with no
|
|
344
|
+
* structural meaning left. Rather than let that degenerate comparison run
|
|
345
|
+
* (and rely solely on {@link filterMergesByCohesion} to catch whatever it
|
|
346
|
+
* proposes), this is checked up front: if every participating unit reduces
|
|
347
|
+
* to the *same* shape, the signature has already lost all resolving power
|
|
348
|
+
* for this round, and comparing pairs is wasted work.
|
|
349
|
+
*
|
|
350
|
+
* Only total collapse (all participants share one shape) is detected —
|
|
351
|
+
* partial collapse (e.g. 8 units reducing to 2 shapes that don't line up
|
|
352
|
+
* with their true 8 templates) is not, and still relies on
|
|
353
|
+
* {@link filterMergesByCohesion} downstream.
|
|
354
|
+
* @param l2Keys
|
|
355
|
+
* @param getL2Sig
|
|
356
|
+
*/
|
|
357
|
+
function hasDiscriminatingL2Signatures(l2Keys, getL2Sig) {
|
|
358
|
+
const shapeIds = new Set();
|
|
359
|
+
let participantCount = 0;
|
|
360
|
+
for (const key of l2Keys) {
|
|
361
|
+
const sig = getL2Sig(key);
|
|
362
|
+
if (!sig)
|
|
363
|
+
continue;
|
|
364
|
+
participantCount++;
|
|
365
|
+
shapeIds.add(l2SignatureShapeId(sig));
|
|
366
|
+
if (shapeIds.size > 1)
|
|
367
|
+
return true;
|
|
368
|
+
}
|
|
369
|
+
return participantCount < MIN_L2_PARTICIPANTS_FOR_DEGENERACY_CHECK;
|
|
370
|
+
}
|
|
167
371
|
/**
|
|
168
372
|
* Merges cross-block clusters (Stage B) via recursive quorum-core comparison.
|
|
169
373
|
*
|
|
@@ -198,7 +402,11 @@ export function mergeCrossBlockClusters(units, options) {
|
|
|
198
402
|
rootByKey: new Map(units.map((u) => [u.key, u.key])),
|
|
199
403
|
finalGroupsByRoot: new Map(units.map((u) => [
|
|
200
404
|
u.key,
|
|
201
|
-
{
|
|
405
|
+
{
|
|
406
|
+
tokenSets: u.memberTokenSets,
|
|
407
|
+
landmarkInstances: u.memberLandmarkInstances,
|
|
408
|
+
pageIndices: u.memberPageIndices ?? u.memberTokenSets.map(() => -1),
|
|
409
|
+
},
|
|
202
410
|
])),
|
|
203
411
|
};
|
|
204
412
|
}
|
|
@@ -209,10 +417,26 @@ export function mergeCrossBlockClusters(units, options) {
|
|
|
209
417
|
groups.set(unit.key, {
|
|
210
418
|
tokenSets: [...unit.memberTokenSets],
|
|
211
419
|
landmarkInstances: [...unit.memberLandmarkInstances],
|
|
420
|
+
pageIndices: [...(unit.memberPageIndices ?? unit.memberTokenSets.map(() => -1))],
|
|
212
421
|
});
|
|
213
422
|
}
|
|
214
423
|
// Maps every original key to its current root (updated on each merge)
|
|
215
424
|
const keyToRoot = new Map(units.map((u) => [u.key, u.key]));
|
|
425
|
+
// Each unit's own pre-any-merge core size, carried forward by
|
|
426
|
+
// `filterMergesByCohesion` as units merge — see that function's own
|
|
427
|
+
// JSDoc for why an anchor is needed at all. Computed the same way round
|
|
428
|
+
// 1's own `groupDistinctiveShaped` would (document frequency over the
|
|
429
|
+
// full initial unit set, then class-name-stripped), so a solo unit's
|
|
430
|
+
// anchor matches what the very first round would already compute for it.
|
|
431
|
+
const initialFrequency = computeDocumentFrequency(units.flatMap((u) => u.memberTokenSets));
|
|
432
|
+
const anchorByRoot = new Map(units.map((u) => {
|
|
433
|
+
const distinctiveShaped = u.memberTokenSets.map((tokens) => {
|
|
434
|
+
const { contentTokens } = splitTokensByFrequency(tokens, initialFrequency);
|
|
435
|
+
const distinctive = contentTokens.size > 0 ? contentTokens : tokens;
|
|
436
|
+
return new Set([...distinctive].map((t) => shapeToken(t)));
|
|
437
|
+
});
|
|
438
|
+
return [u.key, strictQuorumCoreSize(distinctiveShaped)];
|
|
439
|
+
}));
|
|
216
440
|
/**
|
|
217
441
|
* Applies a list of [absorbed, root] merges to `groups` and `keyToRoot`.
|
|
218
442
|
* All absorbed groups' members are folded into their respective roots.
|
|
@@ -229,18 +453,21 @@ export function mergeCrossBlockClusters(units, options) {
|
|
|
229
453
|
...rootG.landmarkInstances,
|
|
230
454
|
...absorbedG.landmarkInstances,
|
|
231
455
|
];
|
|
456
|
+
const mergedPageIndices = [...rootG.pageIndices, ...absorbedG.pageIndices];
|
|
232
457
|
// Only down-sample when the caller explicitly opts in (streaming
|
|
233
|
-
// path). Same-index sampling keeps memberTokenSets[i]
|
|
234
|
-
// landmarkInstances[i] parallel.
|
|
458
|
+
// path). Same-index sampling keeps memberTokenSets[i],
|
|
459
|
+
// landmarkInstances[i], and pageIndices[i] parallel.
|
|
235
460
|
if (capMembers !== undefined && mergedTokenSets.length > capMembers) {
|
|
236
461
|
const indices = mergedTokenSets.map((_, i) => i);
|
|
237
462
|
const kept = reservoirSample(indices, capMembers, root);
|
|
238
463
|
rootG.tokenSets = kept.map((i) => mergedTokenSets[i]);
|
|
239
464
|
rootG.landmarkInstances = kept.map((i) => mergedLandmarkInstances[i]);
|
|
465
|
+
rootG.pageIndices = kept.map((i) => mergedPageIndices[i]);
|
|
240
466
|
}
|
|
241
467
|
else {
|
|
242
468
|
rootG.tokenSets = mergedTokenSets;
|
|
243
469
|
rootG.landmarkInstances = mergedLandmarkInstances;
|
|
470
|
+
rootG.pageIndices = mergedPageIndices;
|
|
244
471
|
}
|
|
245
472
|
groups.delete(absorbed);
|
|
246
473
|
for (const [origKey, cur] of keyToRoot) {
|
|
@@ -270,6 +497,18 @@ export function mergeCrossBlockClusters(units, options) {
|
|
|
270
497
|
}
|
|
271
498
|
groupDistinctive.set(key, dist);
|
|
272
499
|
}
|
|
500
|
+
// Class-name-stripped projection of `groupDistinctive`, for
|
|
501
|
+
// {@link filterMergesByCohesion} only: the fine stage's own
|
|
502
|
+
// shape-Jaccard step (below) merges units whose *raw* tokens are
|
|
503
|
+
// disjoint by construction (same skeleton, different BEM class
|
|
504
|
+
// names — see `SHAPE_JACCARD_THRESHOLD`'s JSDoc), so a cohesion check
|
|
505
|
+
// against raw tokens would reject every shape-Jaccard merge outright.
|
|
506
|
+
// Shaping first lets the guard see that 'section.c-reports' and
|
|
507
|
+
// 'section.c-projects' both contribute to a shared 'section' token.
|
|
508
|
+
const groupDistinctiveShaped = new Map();
|
|
509
|
+
for (const [key, dist] of groupDistinctive) {
|
|
510
|
+
groupDistinctiveShaped.set(key, dist.map((tokens) => new Set([...tokens].map((t) => shapeToken(t)))));
|
|
511
|
+
}
|
|
273
512
|
// Quorum core per group
|
|
274
513
|
const cores = new Map();
|
|
275
514
|
for (const key of groupKeys) {
|
|
@@ -367,8 +606,9 @@ export function mergeCrossBlockClusters(units, options) {
|
|
|
367
606
|
fineMerges.push([gk, rootKey]);
|
|
368
607
|
}
|
|
369
608
|
}
|
|
370
|
-
|
|
371
|
-
|
|
609
|
+
const acceptedFineMerges = filterMergesByCohesion(fineMerges, groupDistinctiveShaped, anchorByRoot);
|
|
610
|
+
if (acceptedFineMerges.length > 0) {
|
|
611
|
+
applyMerges(acceptedFineMerges);
|
|
372
612
|
continue; // next round
|
|
373
613
|
}
|
|
374
614
|
// ---------------------------------------------------------------
|
|
@@ -393,6 +633,8 @@ export function mergeCrossBlockClusters(units, options) {
|
|
|
393
633
|
}
|
|
394
634
|
return shellCache.get(key) ?? new Set();
|
|
395
635
|
};
|
|
636
|
+
if (!hasDiscriminatingL2Signatures(l2Keys, getL2Sig))
|
|
637
|
+
break;
|
|
396
638
|
// Collect valid L2 containment pairs and apply via union-find
|
|
397
639
|
// Direction: x is contained in y → x is absorbed by y
|
|
398
640
|
// Multiple pairs can apply in one round if they form consistent groups
|
|
@@ -455,13 +697,18 @@ export function mergeCrossBlockClusters(units, options) {
|
|
|
455
697
|
l2Merges.push([gk, rootKey]);
|
|
456
698
|
}
|
|
457
699
|
}
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
700
|
+
const acceptedL2Merges = filterMergesByCohesion(l2Merges, groupDistinctiveShaped, anchorByRoot);
|
|
701
|
+
if (acceptedL2Merges.length === 0)
|
|
702
|
+
break; // fully converged (or every proposal was rejected)
|
|
703
|
+
applyMerges(acceptedL2Merges);
|
|
461
704
|
}
|
|
462
705
|
const finalGroupsByRoot = new Map([...groups.entries()].map(([root, g]) => [
|
|
463
706
|
root,
|
|
464
|
-
{
|
|
707
|
+
{
|
|
708
|
+
tokenSets: g.tokenSets,
|
|
709
|
+
landmarkInstances: g.landmarkInstances,
|
|
710
|
+
pageIndices: g.pageIndices,
|
|
711
|
+
},
|
|
465
712
|
]));
|
|
466
713
|
return { rootByKey: keyToRoot, finalGroupsByRoot };
|
|
467
714
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { CrossClusterDuplicate } from './find-cross-cluster-duplicates.js';
|
|
2
|
+
/**
|
|
3
|
+
* Applies a set of confirmed cluster-pair merges to a `clusterKey` array,
|
|
4
|
+
* via union-find over the distinct cluster keys. Every `duplicates` entry is
|
|
5
|
+
* merged unconditionally — deciding *which* {@link CrossClusterDuplicate}s
|
|
6
|
+
* are trustworthy enough to act on (e.g. `similarity === 1` or
|
|
7
|
+
* `corroboratedByMirrorAxis`) is the caller's job, same separation of
|
|
8
|
+
* detection from action as
|
|
9
|
+
* {@link ./find-cross-cluster-duplicates.js | findCrossClusterDuplicates}
|
|
10
|
+
* itself.
|
|
11
|
+
*
|
|
12
|
+
* The surviving key for a merged group is its alphabetically smallest
|
|
13
|
+
* member — arbitrary but deterministic, so repeated calls on the same input
|
|
14
|
+
* produce the same output (mirrors the "lower index wins" rule
|
|
15
|
+
* {@link ./merge-cross-block-clusters.js | mergeCrossBlockClusters}'s own
|
|
16
|
+
* union-find already uses).
|
|
17
|
+
* @param clusterKeys Every page's current cluster key, in input order.
|
|
18
|
+
* @param duplicates Cluster-pair merges to apply.
|
|
19
|
+
* @example
|
|
20
|
+
* ```ts
|
|
21
|
+
* const merged = mergeValidatedClusters(
|
|
22
|
+
* clusterKeys,
|
|
23
|
+
* duplicates.filter((d) => d.similarity === 1 || d.corroboratedByMirrorAxis),
|
|
24
|
+
* );
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export declare function mergeValidatedClusters(clusterKeys: readonly string[], duplicates: readonly CrossClusterDuplicate[]): string[];
|