@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.
@@ -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[];
@@ -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