@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
|
@@ -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[];
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Applies a set of confirmed cluster-pair merges to a `clusterKey` array,
|
|
3
|
+
* via union-find over the distinct cluster keys. Every `duplicates` entry is
|
|
4
|
+
* merged unconditionally — deciding *which* {@link CrossClusterDuplicate}s
|
|
5
|
+
* are trustworthy enough to act on (e.g. `similarity === 1` or
|
|
6
|
+
* `corroboratedByMirrorAxis`) is the caller's job, same separation of
|
|
7
|
+
* detection from action as
|
|
8
|
+
* {@link ./find-cross-cluster-duplicates.js | findCrossClusterDuplicates}
|
|
9
|
+
* itself.
|
|
10
|
+
*
|
|
11
|
+
* The surviving key for a merged group is its alphabetically smallest
|
|
12
|
+
* member — arbitrary but deterministic, so repeated calls on the same input
|
|
13
|
+
* produce the same output (mirrors the "lower index wins" rule
|
|
14
|
+
* {@link ./merge-cross-block-clusters.js | mergeCrossBlockClusters}'s own
|
|
15
|
+
* union-find already uses).
|
|
16
|
+
* @param clusterKeys Every page's current cluster key, in input order.
|
|
17
|
+
* @param duplicates Cluster-pair merges to apply.
|
|
18
|
+
* @example
|
|
19
|
+
* ```ts
|
|
20
|
+
* const merged = mergeValidatedClusters(
|
|
21
|
+
* clusterKeys,
|
|
22
|
+
* duplicates.filter((d) => d.similarity === 1 || d.corroboratedByMirrorAxis),
|
|
23
|
+
* );
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export function mergeValidatedClusters(clusterKeys, duplicates) {
|
|
27
|
+
const parent = new Map();
|
|
28
|
+
const find = (key) => {
|
|
29
|
+
let root = key;
|
|
30
|
+
while (parent.has(root))
|
|
31
|
+
root = parent.get(root);
|
|
32
|
+
// Path compression for cheap repeated lookups within this call.
|
|
33
|
+
let cur = key;
|
|
34
|
+
while (cur !== root) {
|
|
35
|
+
const next = parent.get(cur);
|
|
36
|
+
parent.set(cur, root);
|
|
37
|
+
cur = next;
|
|
38
|
+
}
|
|
39
|
+
return root;
|
|
40
|
+
};
|
|
41
|
+
for (const { clusterKeyA, clusterKeyB } of duplicates) {
|
|
42
|
+
const rootA = find(clusterKeyA);
|
|
43
|
+
const rootB = find(clusterKeyB);
|
|
44
|
+
if (rootA === rootB)
|
|
45
|
+
continue;
|
|
46
|
+
const [smaller, larger] = rootA < rootB ? [rootA, rootB] : [rootB, rootA];
|
|
47
|
+
parent.set(larger, smaller);
|
|
48
|
+
}
|
|
49
|
+
return clusterKeys.map((key) => (parent.has(key) ? find(key) : key));
|
|
50
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { MirrorAxis } from './detect-mirror-axis.js';
|
|
2
|
+
/**
|
|
3
|
+
* Reduces a URL (typically a stylesheet href) to a shape that is stable
|
|
4
|
+
* across a detected {@link MirrorAxis}, by replacing every `/<value>/`
|
|
5
|
+
* occurrence — for any of the axis's known values — with a fixed
|
|
6
|
+
* placeholder segment. Unlike {@link ./normalize-path-by-mirror-axis.js |
|
|
7
|
+
* normalizePathByMirrorAxis}, this does not anchor on `axis.position`: an
|
|
8
|
+
* href's own path structure (e.g. `/assets/en/style.css`) does not
|
|
9
|
+
* necessarily line up with the page's path depth (e.g. `/en/section/`), so
|
|
10
|
+
* matching is done by substring rather than by segment index. This is what
|
|
11
|
+
* lets a per-mirror stylesheet — the same template's CSS duplicated once per
|
|
12
|
+
* language directory instead of shared from one file — normalize to the
|
|
13
|
+
* same shape as its sibling mirrors, corroborating that two pages under
|
|
14
|
+
* different blocking keys are the same template mirrored rather than two
|
|
15
|
+
* different templates.
|
|
16
|
+
* @param href A URL string (typically a stylesheet href).
|
|
17
|
+
* @param axis A `MirrorAxis` from {@link ./detect-mirror-axis.js | detectMirrorAxis}.
|
|
18
|
+
* @example
|
|
19
|
+
* ```ts
|
|
20
|
+
* const axis = { position: 0, values: new Set(['en', 'zh']) };
|
|
21
|
+
* normalizeHrefByMirrorAxis('https://example.test/en/faq/page.css', axis);
|
|
22
|
+
* // 'https://example.test/{axis}/faq/page.css'
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export declare function normalizeHrefByMirrorAxis(href: string, axis: MirrorAxis): string;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reduces a URL (typically a stylesheet href) to a shape that is stable
|
|
3
|
+
* across a detected {@link MirrorAxis}, by replacing every `/<value>/`
|
|
4
|
+
* occurrence — for any of the axis's known values — with a fixed
|
|
5
|
+
* placeholder segment. Unlike {@link ./normalize-path-by-mirror-axis.js |
|
|
6
|
+
* normalizePathByMirrorAxis}, this does not anchor on `axis.position`: an
|
|
7
|
+
* href's own path structure (e.g. `/assets/en/style.css`) does not
|
|
8
|
+
* necessarily line up with the page's path depth (e.g. `/en/section/`), so
|
|
9
|
+
* matching is done by substring rather than by segment index. This is what
|
|
10
|
+
* lets a per-mirror stylesheet — the same template's CSS duplicated once per
|
|
11
|
+
* language directory instead of shared from one file — normalize to the
|
|
12
|
+
* same shape as its sibling mirrors, corroborating that two pages under
|
|
13
|
+
* different blocking keys are the same template mirrored rather than two
|
|
14
|
+
* different templates.
|
|
15
|
+
* @param href A URL string (typically a stylesheet href).
|
|
16
|
+
* @param axis A `MirrorAxis` from {@link ./detect-mirror-axis.js | detectMirrorAxis}.
|
|
17
|
+
* @example
|
|
18
|
+
* ```ts
|
|
19
|
+
* const axis = { position: 0, values: new Set(['en', 'zh']) };
|
|
20
|
+
* normalizeHrefByMirrorAxis('https://example.test/en/faq/page.css', axis);
|
|
21
|
+
* // 'https://example.test/{axis}/faq/page.css'
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export function normalizeHrefByMirrorAxis(href, axis) {
|
|
25
|
+
let normalized = href;
|
|
26
|
+
for (const value of axis.values) {
|
|
27
|
+
normalized = normalized.split(`/${value}/`).join('/{axis}/');
|
|
28
|
+
}
|
|
29
|
+
return normalized;
|
|
30
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { MirrorAxis } from './detect-mirror-axis.js';
|
|
2
|
+
/**
|
|
3
|
+
* Reduces a page's URL path to a shape that is stable across a detected
|
|
4
|
+
* {@link MirrorAxis} by replacing the segment at `axis.position` with a
|
|
5
|
+
* fixed placeholder whenever it is one of the axis's known values. Two pages
|
|
6
|
+
* that are mirrors of the same content under different axis values (e.g. the
|
|
7
|
+
* `en` and `zh` copies of the same page) normalize to the same shape; a page
|
|
8
|
+
* whose segment at that position is *not* one of the axis's values (so it
|
|
9
|
+
* isn't part of the mirror at all) is left untouched, since collapsing it
|
|
10
|
+
* too would falsely equate unrelated pages that merely share a path depth.
|
|
11
|
+
* @param paths A page's URL path segments (e.g. `ExURL.paths`).
|
|
12
|
+
* @param axis A `MirrorAxis` from {@link ./detect-mirror-axis.js | detectMirrorAxis}.
|
|
13
|
+
* @example
|
|
14
|
+
* ```ts
|
|
15
|
+
* const axis = { position: 0, values: new Set(['en', 'zh']) };
|
|
16
|
+
* normalizePathByMirrorAxis(['en', 'faq', 'index.html'], axis);
|
|
17
|
+
* // '{axis}/faq/index.html'
|
|
18
|
+
* normalizePathByMirrorAxis(['zh', 'faq', 'index.html'], axis);
|
|
19
|
+
* // '{axis}/faq/index.html' — same shape as the `en` page above
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export declare function normalizePathByMirrorAxis(paths: readonly string[], axis: MirrorAxis): string;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reduces a page's URL path to a shape that is stable across a detected
|
|
3
|
+
* {@link MirrorAxis} by replacing the segment at `axis.position` with a
|
|
4
|
+
* fixed placeholder whenever it is one of the axis's known values. Two pages
|
|
5
|
+
* that are mirrors of the same content under different axis values (e.g. the
|
|
6
|
+
* `en` and `zh` copies of the same page) normalize to the same shape; a page
|
|
7
|
+
* whose segment at that position is *not* one of the axis's values (so it
|
|
8
|
+
* isn't part of the mirror at all) is left untouched, since collapsing it
|
|
9
|
+
* too would falsely equate unrelated pages that merely share a path depth.
|
|
10
|
+
* @param paths A page's URL path segments (e.g. `ExURL.paths`).
|
|
11
|
+
* @param axis A `MirrorAxis` from {@link ./detect-mirror-axis.js | detectMirrorAxis}.
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* const axis = { position: 0, values: new Set(['en', 'zh']) };
|
|
15
|
+
* normalizePathByMirrorAxis(['en', 'faq', 'index.html'], axis);
|
|
16
|
+
* // '{axis}/faq/index.html'
|
|
17
|
+
* normalizePathByMirrorAxis(['zh', 'faq', 'index.html'], axis);
|
|
18
|
+
* // '{axis}/faq/index.html' — same shape as the `en` page above
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export function normalizePathByMirrorAxis(paths, axis) {
|
|
22
|
+
return paths
|
|
23
|
+
.map((segment, i) => i === axis.position && axis.values.has(segment) ? '{axis}' : segment)
|
|
24
|
+
.join('/');
|
|
25
|
+
}
|
|
@@ -4,6 +4,7 @@ import type { PerPageLandmarkInstance } from './per-page-landmark-signatures.js'
|
|
|
4
4
|
import type { ResolveBlockingGroupKeysOptions } from './resolve-blocking-group-keys.js';
|
|
5
5
|
import type { ResolveStructuralClusterKeysOptions } from './resolve-structural-cluster-keys.js';
|
|
6
6
|
import type { TokenizeOptions } from './types.js';
|
|
7
|
+
import type { ClusterPartitionReport } from './validate-cluster-partition.js';
|
|
7
8
|
/**
|
|
8
9
|
* Reinjects each page's *local* (non-corpus-wide) landmark-instance tokens
|
|
9
10
|
* into its block token set for Stage A clustering, restoring exactly the
|
|
@@ -201,6 +202,48 @@ export type ResolvePageClusterKeysOptions = TokenizeOptions & ResolveBlockingGro
|
|
|
201
202
|
* ```
|
|
202
203
|
*/
|
|
203
204
|
onClusterReason?: (clusterKey: string, reason: ClusterReason) => void;
|
|
205
|
+
/**
|
|
206
|
+
* Optional observability hook invoked once per run with a
|
|
207
|
+
* {@link ClusterPartitionReport} — evidence that Stage A/B's finished
|
|
208
|
+
* partition may need correcting, from
|
|
209
|
+
* {@link ./validate-cluster-partition.js | validateClusterPartition}.
|
|
210
|
+
*
|
|
211
|
+
* Setting this option does two things, both gated on its presence the
|
|
212
|
+
* same way `onClusterReason` gates its own bookkeeping (existing
|
|
213
|
+
* callers that omit it pay nothing and see no behavior change):
|
|
214
|
+
*
|
|
215
|
+
* 1. Computes the report from Stage B's finished grouping (no
|
|
216
|
+
* re-tokenization — reuses the token sets Stage A/B already hold).
|
|
217
|
+
* 2. Applies a fixed, built-in auto-merge policy to the *actual*
|
|
218
|
+
* returned cluster keys before this callback (and
|
|
219
|
+
* `onClusterReason`) ever sees them: every
|
|
220
|
+
* `report.crossClusterDuplicates` entry with `similarity === 1` or
|
|
221
|
+
* `corroboratedByMirrorAxis: true` is merged via
|
|
222
|
+
* {@link ./merge-validated-clusters.js | mergeValidatedClusters}.
|
|
223
|
+
* This is the one part of this library where an option changes the
|
|
224
|
+
* returned `clusterKey`s themselves, not just side-channel
|
|
225
|
+
* metadata — a caller who wants a *different* merge policy (or
|
|
226
|
+
* none at all) should omit this option and call
|
|
227
|
+
* `validateClusterPartition`/`findCrossClusterDuplicates`/
|
|
228
|
+
* `mergeValidatedClusters` directly on this function's own output.
|
|
229
|
+
*
|
|
230
|
+
* On the streaming path (`pageCount > CORPUS_INLINE_THRESHOLD`), the
|
|
231
|
+
* report is built only from pages Stage A/B actually retained token
|
|
232
|
+
* sets for — reservoir-sampled block representatives, not pages
|
|
233
|
+
* assigned in Pass 1b — the same sampling trade-off Stage B itself
|
|
234
|
+
* already makes for corpora too large to hold in full. Any merge the
|
|
235
|
+
* sampled evidence justifies is still applied to every page sharing
|
|
236
|
+
* the merged keys, sampled or not.
|
|
237
|
+
* @example
|
|
238
|
+
* ```ts
|
|
239
|
+
* const keys = await resolvePageClusterKeys(pages, {
|
|
240
|
+
* onPartitionReport: (report) => {
|
|
241
|
+
* for (const c of report.cohesion) if (c.suspicious) console.warn(c);
|
|
242
|
+
* },
|
|
243
|
+
* });
|
|
244
|
+
* ```
|
|
245
|
+
*/
|
|
246
|
+
onPartitionReport?: (report: ClusterPartitionReport) => void;
|
|
204
247
|
};
|
|
205
248
|
/**
|
|
206
249
|
* Corpus size at or below which the async factory-based
|