@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,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
- { tokenSets: u.memberTokenSets, landmarkInstances: u.memberLandmarkInstances },
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] and
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
- if (fineMerges.length > 0) {
371
- applyMerges(fineMerges);
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
- if (l2Merges.length === 0)
459
- break; // fully converged
460
- applyMerges(l2Merges);
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
- { tokenSets: g.tokenSets, landmarkInstances: g.landmarkInstances },
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[];