@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.
package/README.md CHANGED
@@ -79,6 +79,7 @@ jq -c '.[]' crawl-output.json | page-cluster > clusters.jsonl
79
79
 
80
80
  - `--content-block-attribute <name>` — CMS が自由編集コンテンツブロックに付与している属性名(例: `data-bgb`)が分かっている場合に指定する。指定すると比較前にその属性を持つ要素配下を無視するので、同じテンプレートで本文構成だけ違うページを混同しなくなる。唯一の site-specific なオプションで、未指定でも `<main>` / `role="main"` を起点にした自動深さキャップが常時働く(詳細は `resolve-page-cluster-keys.ts` の JSDoc を参照)
81
81
  - `--cluster-reasons-file <path>` — 上記の「クラスタ選定理由」を `<path>` に JSON として書き出す。ページ数の上限はない。20,000 ページ以下のコーパスでは、指定すると進捗表示(後述)は出なくなる(進捗を出さない非ストリーミング経路に常に振り分けられるため。20,000 ページ超のストリーミング経路では進捗表示・クラスタ理由の両方が動く)
82
+ - `--validation-file <path>` — 分類結果の事後検証レポート(下記「分類結果の事後検証」参照)を `<path>` に JSON として書き出す。指定すると、レポートの検出結果のうち安全に統合できるもの(構造トークン集合が完全一致、またはミラー軸による裏付けあり)が **stdout の `clusterKey` にも自動的に反映される**
82
83
  - `--help` / `-h` — ヘルプを表示する
83
84
  - `--version` / `-v` — バージョンを表示する
84
85
 
@@ -119,6 +120,13 @@ silence したい場合は `2>/dev/null`。ログに残したい場合は `2> pr
119
120
  | `@d-zero/page-cluster/resolve-landmark-variant-keys` | `resolveLandmarkVariantKeys` — 特定ランドマークのデザインバリアントでページを分類 |
120
121
  | `@d-zero/page-cluster/is-chrome-landmark-instance` | `isChromeLandmarkInstance` — 1 つの landmark インスタンスのトークン集合と `ClusterReason.landmarks[type].shellTokens` のようなシェルトークン集合を突き合わせて chrome/content を判定するステートレス関数 |
121
122
  | `@d-zero/page-cluster/jaccard-similarity` | `jaccardSimilarity` — 2 つのトークン集合の Jaccard 類似度。`ClusterReason` 同士(`structuralCoreTokens` や `shellTokens`)を比較して兄弟クラスタとの差分を調べる用途などに使う |
123
+ | `@d-zero/page-cluster/validate-cluster-partition` | `ClusterPartitionReport` 型、`validateClusterPartition` — 分類済みの `clusterKey` を、分類そのものではなく分類結果同士を突き合わせて事後検証する(下記「分類結果の事後検証」参照)。通常は `resolvePageClusterKeys` の `onPartitionReport` 経由で使うので直接呼ぶ必要はない |
124
+ | `@d-zero/page-cluster/find-cross-cluster-duplicates` | `ClusteredPage` 型、`CrossClusterDuplicate` 型、`findCrossClusterDuplicates` — 別クラスタに分かれているが同一テンプレートらしいページ対を検出する |
125
+ | `@d-zero/page-cluster/compute-cluster-cohesion` | `ClusterCohesion` 型、`computeClusterCohesion` — クラスタ内メンバーが互いにどれだけ似ているかを分布として報告する(無関係なテンプレートが混ざったクラスタの検出に使う) |
126
+ | `@d-zero/page-cluster/detect-mirror-axis` | `MirrorAxis` 型、`detectMirrorAxis` — 言語ディレクトリなど、URL のあるセグメントだけを変えてサイトの一部をミラーしている「軸」を、事前知識なしに URL パス集合から発見する |
127
+ | `@d-zero/page-cluster/normalize-path-by-mirror-axis` | `normalizePathByMirrorAxis` — 検出した軸に沿ってページの URL パスを正規化し、ミラー元同士を同じ形状に揃える |
128
+ | `@d-zero/page-cluster/normalize-href-by-mirror-axis` | `normalizeHrefByMirrorAxis` — 検出した軸に沿って URL(主に stylesheet href)を正規化する |
129
+ | `@d-zero/page-cluster/merge-validated-clusters` | `mergeValidatedClusters` — 確認済みのクラスタ対の統合を `clusterKey` 配列に適用する純関数 |
122
130
 
123
131
  ```ts
124
132
  import { resolvePageClusterKeysFromArray } from '@d-zero/page-cluster/resolve-page-cluster-keys';
@@ -192,6 +200,33 @@ flowchart TD
192
200
 
193
201
  マージが起きるとユニットのメンバー構成が変わり、文書頻度も quorum core も変わる。そのため毎ラウンド、統合後のプールから全指標を**再計算**してマージを再試行する。fine stage・L2 stage の両方でマージが 1 件も出なくなった時点で不動点に到達したとみなして収束する(安全弁として最大 10 ラウンド。実データでは 7 ラウンド以内に収束)。L2 stage は fine stage が空振りしたラウンドでしか実行されない最後の粗い経路で、誤マージ防止のために shell(ランドマーク由来トークン)の相互裏付けを要求する。
194
202
 
203
+ fine stage・L2 stage いずれの経路で提案されたマージも、適用前に**凝集度ガード**を通る: 統合後の quorum core が統合前の core に対して一定比率を下回るなら、そのマージは破棄される。個々の経路が「統合前のペア類似度」だけを見て提案する一方、複数ラウンドにわたる連鎖的な統合は「統合後に実際どれだけまとまっているか」を悪化させ得る(無関係なテンプレート同士が少しずつ吸収し合う catch-all 化)ため、経路をまたいだ単一のチェックポイントとして機能する。あわせて、L2 stage 自体もラウンド開始前に判別力を検査し、参加ユニット全体が同一の署名形状に潰れている(`main` 直下の浅い階層しか手がかりが残っていない等)場合は、そのラウンドの L2 比較を丸ごとスキップする。詳細は `merge-cross-block-clusters.ts` の `filterMergesByCohesion` / `hasDiscriminatingL2Signatures` の JSDoc を参照。
204
+
195
205
  ### Self-tuning
196
206
 
197
207
  閾値の多くは **max-gap auto-cut**(度数分布の隣接ギャップ最大の中点を境界とする)でデータから自己発見される。① Stage A のカット高、② Stage B の shell 判定、③ chrome discovery のグローバル/ローカル判定、④ Pass 0 の URL パス深さ選択、の 4 箇所で同一プリミティブを再利用しているので、サイトごとにハイパーパラメータをチューニングする必要はない。詳細は `autoCutThreshold` の JSDoc を参照。例外的に Stage B fine stage の complete-linkage だけは固定閾値 0.8 を使う(理由は `merge-cross-block-clusters.ts` の JSDoc を参照)。
208
+
209
+ ## 分類結果の事後検証
210
+
211
+ Stage A/B は「クラスタリング中に」正しい判断をしようとするが、判断材料はその時点でのペア類似度に限られる。分類が終わった**あとで**、確定したパーティション同士を突き合わせて検証する方が、同じ種類の誤りをかえって見つけやすい場合がある: マージ中は局所的なペア情報とマージ順序しか持たないが、事後なら分割全体を一度に見られるからだ。この事後検証は Stage A/B のアルゴリズムに一切依存しないので、`resolvePageClusterKeys` 以外で作られた分類結果(保存済みの `clusterKey` をアーカイブから読み戻した場合など)にも使える。
212
+
213
+ 3 つのチェックを行う。
214
+
215
+ - **クラスタ間重複検出**(`findCrossClusterDuplicates`) — 別クラスタに分かれているページ対のうち、構造トークン集合が完全一致するもの(軸の裏付けなしで確定)と、ミラー軸で裏付けられる近似一致のものを検出する
216
+ - **クラスタ内凝集度**(`computeClusterCohesion`) — クラスタ内のメンバー同士がどれだけ似ているかを中央値・10 パーセンタイル・最小値の分布として報告する。無関係なテンプレートが混ざったクラスタは、共通のシェル由来トークンだけが一致する形で `structuralCoreTokens` 自体は非空のまま残ることがあるため、core の有無だけでは過剰マージを検出できない
217
+ - **ミラー軸の自動発見**(`detectMirrorAxis`) — 言語ディレクトリのように、URL のあるセグメントだけを変えてサイトの一部をミラーしている構造を、言語コード等の事前知識なしに発見する。同じ値集合が何種類の異なるパス骨格にわたって反復するかを数え、`autoCutThreshold` で「たまたま値が 2 つ以上あるだけの兄弟ページ」と「サイト全体を貫く軸」を切り分ける
218
+
219
+ `validateClusterPartition` はこの 3 つをまとめて呼び出すエントリポイント。`ClusterReason` と同じく、判断結果ではなく構造化データだけを返す — 検出された重複をどう扱うか(統合するかどうか、`suspicious` フラグをどう解釈するか)は呼び出し側の責務。実際に統合を適用する場合は `mergeValidatedClusters` に確認済みのクラスタ対を渡す。
220
+
221
+ ```ts
222
+ import { mergeValidatedClusters } from '@d-zero/page-cluster/merge-validated-clusters';
223
+ import { validateClusterPartition } from '@d-zero/page-cluster/validate-cluster-partition';
224
+
225
+ const report = validateClusterPartition(pages); // pages: { clusterKey, tokens, paths, stylesheetHrefs }[]
226
+ const safeToMerge = report.crossClusterDuplicates.filter(
227
+ (d) => d.similarity === 1 || d.corroboratedByMirrorAxis,
228
+ );
229
+ const mergedKeys = mergeValidatedClusters(clusterKeys, safeToMerge);
230
+ ```
231
+
232
+ `resolvePageClusterKeys` に `onPartitionReport` コールバックを渡すと、この検証が Stage B 完了直後に自動的に走り、上記と同じ安全な統合ポリシー(完全一致、またはミラー軸による裏付けあり)が返り値の `clusterKey` にもそのまま反映される。`onClusterReason` と同じくオプトインで、渡さない限り既存の挙動・出力は一切変わらない。詳細はその JSDoc を参照。
@@ -0,0 +1,61 @@
1
+ import type { PageClusterSignals } from './resolve-page-cluster-keys.js';
2
+ /**
3
+ * Options for {@link buildMirroredTemplateFixture}.
4
+ */
5
+ export type MirroredTemplateFixtureOptions = {
6
+ /**
7
+ * URL segment values a whole section is mirrored under (e.g. language
8
+ * codes). Each value gets its own copy of every template, at its own
9
+ * per-value stylesheet href — modeling a site that duplicates a section's
10
+ * CSS per mirror instead of sharing one file.
11
+ */
12
+ readonly axisValues?: readonly string[];
13
+ /** Pages generated per (template, axis value) pair. */
14
+ readonly pagesPerTemplate?: number;
15
+ /**
16
+ * Tag wrapping each page's content. `'main'` reproduces the L2-stage
17
+ * signature collapse this fixture exists to catch (see
18
+ * `merge-cross-block-clusters.ts`'s `l2Signature` JSDoc): every template's
19
+ * content sits under a `main > article > div > section > div` chain, so
20
+ * `l2Signature`'s class-stripped, 2-level-past-`main` truncation reduces
21
+ * every template to the same key set. `'div'` (or any other tag) opts out
22
+ * of `l2Signature`'s `main`-anchoring, so the fine stage alone must
23
+ * separate the templates correctly.
24
+ */
25
+ readonly wrapperTag?: 'main' | 'div';
26
+ };
27
+ /**
28
+ * One generated page's ground truth, alongside the `PageClusterSignals` a
29
+ * clustering call actually sees. Tests assert against `template`/`axisValue`
30
+ * rather than parsing them back out of a `clusterKey` string.
31
+ */
32
+ export type MirroredTemplateFixturePage = {
33
+ readonly signals: PageClusterSignals;
34
+ readonly template: string;
35
+ readonly axisValue: string;
36
+ readonly page: number;
37
+ };
38
+ /**
39
+ * Builds a synthetic multi-template, multi-mirror site: every template
40
+ * shares a site-wide shell (header/gnav/footer) and shares
41
+ * breadcrumb/page-head/related content blocks, and each template's own
42
+ * content is a distinct ordered subset of a shared CMS module pool (see
43
+ * {@link MODULE_POOL}) so templates partially overlap in tokens rather than
44
+ * being trivially disjoint. Every template is duplicated once per
45
+ * `axisValues` entry, each with its own per-mirror stylesheet href — this
46
+ * shape collapses every template into one catch-all cluster when `main` is
47
+ * the wrapper tag (a `main`-anchored wrapper chain degenerates
48
+ * `l2Signature`), while the same site with content under a plain `<div>`
49
+ * clusters correctly (see `wrapperTag`'s own JSDoc).
50
+ * @param options
51
+ * @example
52
+ * ```ts
53
+ * const { pages } = buildMirroredTemplateFixture({ wrapperTag: 'main' });
54
+ * const keys = await resolvePageClusterKeysFromArray(pages.map((p) => p.signals));
55
+ * // keys collapse to a single value — the bug this fixture reproduces
56
+ * ```
57
+ */
58
+ export declare function buildMirroredTemplateFixture(options?: MirroredTemplateFixtureOptions): {
59
+ readonly pages: readonly MirroredTemplateFixturePage[];
60
+ readonly templates: readonly string[];
61
+ };
@@ -0,0 +1,127 @@
1
+ /**
2
+ * @param count
3
+ * @param render
4
+ */
5
+ function repeatMarkup(count, render) {
6
+ let out = '';
7
+ for (let i = 0; i < count; i++)
8
+ out += render(i);
9
+ return out;
10
+ }
11
+ /**
12
+ * Pool of CMS-style content blocks that {@link TEMPLATE_MODULES} composes
13
+ * templates from. Modeled after real CMS "block" systems: templates share
14
+ * modules with each other (a FAQ page and a contact page both use
15
+ * `notice`/`contact`), which is what makes this fixture exercise Stage B's
16
+ * cross-block containment/shape logic instead of trivially disjoint token
17
+ * sets.
18
+ */
19
+ const MODULE_POOL = {
20
+ lead: () => `<div class="mod-lead"><p class="mod-lead__text">x</p><p class="mod-lead__note">n</p></div>`,
21
+ heading: () => `<div class="mod-heading"><h2 class="mod-heading__ttl">h</h2><p class="mod-heading__sub">s</p></div>`,
22
+ photo: () => `<figure class="mod-photo"><img class="mod-photo__img" src="p.jpg"><figcaption class="mod-photo__cap">c</figcaption></figure>`,
23
+ table: (n) => `<div class="mod-table"><table class="mod-table__table"><thead><tr><th class="mod-table__th">h</th></tr></thead><tbody>${repeatMarkup(n, (i) => `<tr class="mod-table__tr"><td class="mod-table__td">v${i}</td></tr>`)}</tbody></table></div>`,
24
+ accordion: (n) => `<div class="mod-accordion">${repeatMarkup(n, (i) => `<dl class="mod-accordion__item"><dt class="mod-accordion__q"><button class="mod-accordion__toggle">q${i}</button></dt><dd class="mod-accordion__a"><p class="mod-accordion__text">a${i}</p></dd></dl>`)}</div>`,
25
+ cards: (n) => `<ul class="mod-cards">${repeatMarkup(n, (i) => `<li class="mod-cards__item"><a class="mod-cards__link" href="#${i}"><span class="mod-cards__fig"><img class="mod-cards__img" src="c.jpg"></span><span class="mod-cards__ttl">c${i}</span></a></li>`)}</ul>`,
26
+ steps: (n) => `<ol class="mod-steps">${repeatMarkup(n, (i) => `<li class="mod-steps__item"><span class="mod-steps__num">${i}</span><h3 class="mod-steps__ttl">s${i}</h3><p class="mod-steps__text">d${i}</p></li>`)}</ol>`,
27
+ map: () => `<div class="mod-map"><iframe class="mod-map__frame" src="map"></iframe><p class="mod-map__note">m</p></div>`,
28
+ deflist: (n) => `<dl class="mod-deflist">${repeatMarkup(n, (i) => `<dt class="mod-deflist__dt">d${i}</dt><dd class="mod-deflist__dd">v${i}</dd>`)}</dl>`,
29
+ gallery: (n) => `<ul class="mod-gallery">${repeatMarkup(n, (i) => `<li class="mod-gallery__item"><figure class="mod-gallery__fig"><img class="mod-gallery__img" src="g.jpg"><figcaption class="mod-gallery__cap">g${i}</figcaption></figure></li>`)}</ul>`,
30
+ form: (n) => `<form class="mod-form" action="#">${repeatMarkup(n, (i) => `<div class="mod-form__row"><label class="mod-form__label">l${i}</label><input class="mod-form__input"></div>`)}<button class="mod-form__submit">go</button></form>`,
31
+ notice: () => `<div class="mod-notice"><p class="mod-notice__text">n</p><a class="mod-notice__link" href="#">more</a></div>`,
32
+ timetable: (n) => `<div class="mod-timetable"><table class="mod-timetable__table"><tbody>${repeatMarkup(n, (i) => `<tr class="mod-timetable__tr"><td class="mod-timetable__time">t${i}</td><td class="mod-timetable__dest">d${i}</td></tr>`)}</tbody></table></div>`,
33
+ price: (n) => `<div class="mod-price"><ul class="mod-price__list">${repeatMarkup(n, (i) => `<li class="mod-price__item"><span class="mod-price__name">n${i}</span><span class="mod-price__yen">y${i}</span></li>`)}</ul></div>`,
34
+ banner: () => `<div class="mod-banner"><a class="mod-banner__link" href="#"><img class="mod-banner__img" src="b.jpg"></a></div>`,
35
+ video: () => `<div class="mod-video"><video class="mod-video__el" src="v.mp4"></video></div>`,
36
+ tabs: (n) => `<div class="mod-tabs"><ul class="mod-tabs__nav">${repeatMarkup(n, (i) => `<li class="mod-tabs__navitem"><button class="mod-tabs__btn">t${i}</button></li>`)}</ul><div class="mod-tabs__panel"><p class="mod-tabs__text">p</p></div></div>`,
37
+ download: (n) => `<ul class="mod-download">${repeatMarkup(n, (i) => `<li class="mod-download__item"><a class="mod-download__link" href="f${i}.pdf"><span class="mod-download__icon"></span><span class="mod-download__name">f${i}</span></a></li>`)}</ul>`,
38
+ contact: () => `<div class="mod-contact"><p class="mod-contact__tel">tel</p><p class="mod-contact__hours">h</p><a class="mod-contact__mail" href="#">m</a></div>`,
39
+ anchorlinks: (n) => `<nav class="mod-anchor"><ul class="mod-anchor__list">${repeatMarkup(n, (i) => `<li class="mod-anchor__item"><a class="mod-anchor__link" href="#a${i}">a${i}</a></li>`)}</ul></nav>`,
40
+ };
41
+ /**
42
+ * Templates as ordered subsets of {@link MODULE_POOL}, modeling how real CMS
43
+ * templates share some blocks and differ in others.
44
+ */
45
+ const TEMPLATE_MODULES = {
46
+ faq: ['heading', 'anchorlinks', 'accordion', 'notice', 'contact'],
47
+ course: ['heading', 'photo', 'steps', 'map', 'deflist', 'notice'],
48
+ index: ['heading', 'lead', 'cards', 'banner', 'notice'],
49
+ fare: ['heading', 'lead', 'price', 'table', 'notice', 'contact'],
50
+ article: ['heading', 'lead', 'photo', 'deflist', 'download'],
51
+ gallery: ['heading', 'gallery', 'video', 'banner'],
52
+ search: ['heading', 'form', 'tabs', 'notice'],
53
+ access: ['heading', 'map', 'timetable', 'deflist', 'contact'],
54
+ };
55
+ /**
56
+ * @param n
57
+ * @param render
58
+ */
59
+ function times(n, render) {
60
+ let out = '';
61
+ for (let i = 0; i < n; i++)
62
+ out += render(i);
63
+ return out;
64
+ }
65
+ const HEADER = `<header class="site-header"><div class="site-header__inner"><p class="site-logo"><a class="site-logo__link" href="/"><img class="site-logo__img" src="logo.svg"></a></p><nav class="gnav"><ul class="gnav__list">${times(8, (i) => `<li class="gnav__item"><a class="gnav__link" href="/s${i}/">n${i}</a></li>`)}</ul></nav><div class="site-header__util"><a class="site-header__lang" href="#">lang</a></div></div></header>`;
66
+ const FOOTER = `<footer class="site-footer"><div class="site-footer__inner"><nav class="fnav"><ul class="fnav__list">${times(10, (i) => `<li class="fnav__item"><a class="fnav__link" href="/f${i}/">f${i}</a></li>`)}</ul></nav><p class="site-copyright">c</p></div></footer>`;
67
+ const BREADCRUMB = `<nav class="breadcrumb"><ol class="breadcrumb__list">${times(3, (i) => `<li class="breadcrumb__item"><a class="breadcrumb__link" href="/b${i}/">b${i}</a></li>`)}</ol></nav>`;
68
+ const PAGE_HEAD = `<div class="page-head"><div class="page-head__inner"><h1 class="page-head__ttl">t</h1><p class="page-head__lead">l</p></div></div>`;
69
+ const RELATED = `<aside class="related"><h2 class="related__ttl">r</h2><ul class="related__list">${times(4, (i) => `<li class="related__item"><a class="related__link" href="/r${i}/">r${i}</a></li>`)}</ul></aside>`;
70
+ /**
71
+ * Builds a synthetic multi-template, multi-mirror site: every template
72
+ * shares a site-wide shell (header/gnav/footer) and shares
73
+ * breadcrumb/page-head/related content blocks, and each template's own
74
+ * content is a distinct ordered subset of a shared CMS module pool (see
75
+ * {@link MODULE_POOL}) so templates partially overlap in tokens rather than
76
+ * being trivially disjoint. Every template is duplicated once per
77
+ * `axisValues` entry, each with its own per-mirror stylesheet href — this
78
+ * shape collapses every template into one catch-all cluster when `main` is
79
+ * the wrapper tag (a `main`-anchored wrapper chain degenerates
80
+ * `l2Signature`), while the same site with content under a plain `<div>`
81
+ * clusters correctly (see `wrapperTag`'s own JSDoc).
82
+ * @param options
83
+ * @example
84
+ * ```ts
85
+ * const { pages } = buildMirroredTemplateFixture({ wrapperTag: 'main' });
86
+ * const keys = await resolvePageClusterKeysFromArray(pages.map((p) => p.signals));
87
+ * // keys collapse to a single value — the bug this fixture reproduces
88
+ * ```
89
+ */
90
+ export function buildMirroredTemplateFixture(options) {
91
+ const axisValues = options?.axisValues ?? ['en', 'zh', 'ko', 'th'];
92
+ const pagesPerTemplate = options?.pagesPerTemplate ?? 6;
93
+ const wrapperTag = options?.wrapperTag ?? 'main';
94
+ const pages = [];
95
+ for (const [template, moduleNames] of Object.entries(TEMPLATE_MODULES)) {
96
+ for (const axisValue of axisValues) {
97
+ for (let page = 0; page < pagesPerTemplate; page++) {
98
+ // Every third page drops its last module — models conditional
99
+ // rendering (an optional section) without changing the template.
100
+ const mods = page % 3 === 2 ? moduleNames.slice(0, -1) : moduleNames;
101
+ const body = mods
102
+ .map((m) => `<div class="bg-base"><section class="sec sec--${m}"><div class="container">${MODULE_POOL[m](2 + (page % 3))}</div></section></div>`)
103
+ .join('');
104
+ const inner = `<article>${PAGE_HEAD}${BREADCRUMB}${body}${RELATED}</article>`;
105
+ const wrapped = wrapperTag === 'main'
106
+ ? `<main>${inner}</main>`
107
+ : `<div class="main-area">${inner}</div>`;
108
+ pages.push({
109
+ template,
110
+ axisValue,
111
+ page,
112
+ signals: {
113
+ paths: [axisValue, template, `p${page}.html`],
114
+ stylesheetHrefs: [
115
+ 'https://example.test/css/reset.css',
116
+ 'https://example.test/css/style.css',
117
+ `https://example.test/${axisValue}/${template}/page.css`,
118
+ ],
119
+ host: 'example.test',
120
+ html: `<html><body>${HEADER}${wrapped}${FOOTER}</body></html>`,
121
+ },
122
+ });
123
+ }
124
+ }
125
+ }
126
+ return { pages, templates: Object.keys(TEMPLATE_MODULES) };
127
+ }
package/dist/cli.d.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  type CliArgs = {
7
7
  readonly contentBlockAttribute?: string;
8
8
  readonly clusterReasonsFile?: string;
9
+ readonly validationFile?: string;
9
10
  readonly help?: boolean;
10
11
  readonly version?: boolean;
11
12
  readonly unknownFlag?: string;
package/dist/cli.js CHANGED
@@ -9,7 +9,7 @@ import process from 'node:process';
9
9
  import { Lanes } from '@d-zero/dealer';
10
10
  import { resolvePageClusterKeys } from './resolve-page-cluster-keys.js';
11
11
  const HELP_TEXT = `Usage:
12
- page-cluster [--content-block-attribute <name>] [--cluster-reasons-file <path>] < pages.jsonl > clusters.jsonl
12
+ page-cluster [--content-block-attribute <name>] [--cluster-reasons-file <path>] [--validation-file <path>] < pages.jsonl > clusters.jsonl
13
13
 
14
14
  Input (JSONL, one page per line):
15
15
  {
@@ -47,6 +47,29 @@ Output (JSONL, one line per input page, in input order):
47
47
  }
48
48
  }
49
49
 
50
+ With --validation-file <path>, a separate JSON file is written once
51
+ processing completes, checking the finished partition against itself:
52
+ whether pages that ended up in different clusters are nonetheless
53
+ structurally identical or near-identical (a likely over-split), and
54
+ whether any single cluster's own members actually agree with each other
55
+ (a likely over-merge). Registering this flag also applies a built-in
56
+ auto-merge to the clusterKeys in the stdout output above: any pair of
57
+ clusters found byte-identical, or near-identical and corroborated by a
58
+ detected URL mirror axis (e.g. a language directory), is merged before
59
+ output — see ClusterPartitionReport's own JSDoc for the exact policy.
60
+ {
61
+ "mirrorAxis": { "position": 0, "values": ["en", "zh"] },
62
+ "cohesion": [
63
+ { "clusterKey": "...", "memberCount": 42, "sampledMemberCount": 40,
64
+ "medianPairSimilarity": 0.91, "p10PairSimilarity": 0.78,
65
+ "minPairSimilarity": 0.6, "suspicious": false }
66
+ ],
67
+ "crossClusterDuplicates": [
68
+ { "clusterKeyA": "...", "clusterKeyB": "...", "similarity": 1,
69
+ "corroboratedByMirrorAxis": true }
70
+ ]
71
+ }
72
+
50
73
  Options:
51
74
  --content-block-attribute <name> CMS-provided attribute marking freeform
52
75
  content blocks that should be stripped
@@ -55,6 +78,10 @@ Options:
55
78
  object described above to <path> after
56
79
  processing completes. No page-count
57
80
  limit.
81
+ --validation-file <path> Write the partition-validation report
82
+ described above to <path>, and apply
83
+ its built-in auto-merge to the output
84
+ clusterKeys.
58
85
  --help Print this help and exit.
59
86
  --version Print the package version and exit.
60
87
 
@@ -121,6 +148,16 @@ export function parseArgs(argv) {
121
148
  i++;
122
149
  break;
123
150
  }
151
+ case '--validation-file': {
152
+ const next = argv[i + 1];
153
+ if (next === undefined) {
154
+ out.unknownFlag = `${arg} requires a value`;
155
+ return out;
156
+ }
157
+ out.validationFile = next;
158
+ i++;
159
+ break;
160
+ }
124
161
  default: {
125
162
  out.unknownFlag = arg;
126
163
  return out;
@@ -299,6 +336,24 @@ function formatProgressLine(event, elapsedSec) {
299
336
  }
300
337
  }
301
338
  }
339
+ /**
340
+ * `JSON.stringify` serializes a `Set` as `{}` — `ClusterPartitionReport`'s
341
+ * only non-JSON-safe field is `mirrorAxis.values`, so this is the one spot
342
+ * that needs converting before the report can be written to a file.
343
+ * @param report
344
+ */
345
+ function toJsonSafePartitionReport(report) {
346
+ return {
347
+ mirrorAxis: report.mirrorAxis
348
+ ? {
349
+ position: report.mirrorAxis.position,
350
+ values: [...report.mirrorAxis.values].toSorted(),
351
+ }
352
+ : null,
353
+ cohesion: report.cohesion,
354
+ crossClusterDuplicates: report.crossClusterDuplicates,
355
+ };
356
+ }
302
357
  /**
303
358
  * Test-friendly entry point: takes the run's stdin/stdout/stderr streams
304
359
  * and the parsed CLI flags rather than reading them out of the process
@@ -370,6 +425,9 @@ export async function runCli(options) {
370
425
  const reasonsByClusterKey = args.clusterReasonsFile
371
426
  ? new Map()
372
427
  : undefined;
428
+ // Same opt-in gate as `reasonsByClusterKey` — `onPartitionReport`
429
+ // itself gates the underlying validation pass (see its own JSDoc).
430
+ let partitionReport;
373
431
  const resolveOptions = {
374
432
  contentBlockAttribute: args.contentBlockAttribute,
375
433
  onProgress: (event) => {
@@ -378,6 +436,9 @@ export async function runCli(options) {
378
436
  onClusterReason: reasonsByClusterKey
379
437
  ? (key, reason) => reasonsByClusterKey.set(key, reason)
380
438
  : undefined,
439
+ onPartitionReport: args.validationFile
440
+ ? (report) => (partitionReport = report)
441
+ : undefined,
381
442
  };
382
443
  let clusterKeys;
383
444
  try {
@@ -402,6 +463,15 @@ export async function runCli(options) {
402
463
  return 1;
403
464
  }
404
465
  }
466
+ if (args.validationFile && partitionReport) {
467
+ try {
468
+ await writeFile(args.validationFile, JSON.stringify(toJsonSafePartitionReport(partitionReport), null, 2));
469
+ }
470
+ catch (error) {
471
+ renderProgress(lanes, useTty, errorLine(error.message));
472
+ return 1;
473
+ }
474
+ }
405
475
  return 0;
406
476
  }
407
477
  finally {
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Options for {@link computeClusterCohesion}.
3
+ */
4
+ export type ClusterCohesionOptions = {
5
+ /** @see DEFAULT_MAX_SAMPLE_SIZE */
6
+ readonly maxSampleSize?: number;
7
+ /** @see DEFAULT_SUSPICIOUS_MEDIAN_BELOW */
8
+ readonly suspiciousMedianBelow?: number;
9
+ };
10
+ /**
11
+ * One cluster's internal-agreement summary: how similar its members'
12
+ * structural tokens actually are to each other, as opposed to
13
+ * {@link ./merge-cross-block-clusters.js | computeQuorumCore}'s frequency
14
+ * core, which only reports *which* tokens are shared, not *how much* of each
15
+ * member's tokens that core actually covers.
16
+ */
17
+ export type ClusterCohesion = {
18
+ readonly clusterKey: string;
19
+ readonly memberCount: number;
20
+ /** How many of `memberCount` members the pairwise comparison sampled. */
21
+ readonly sampledMemberCount: number;
22
+ /** `null` when fewer than 2 members were sampled (nothing to compare). */
23
+ readonly medianPairSimilarity: number | null;
24
+ /** 10th percentile (nearest-rank) of sampled pairwise similarities. */
25
+ readonly p10PairSimilarity: number | null;
26
+ readonly minPairSimilarity: number | null;
27
+ /** `medianPairSimilarity !== null && medianPairSimilarity < suspiciousMedianBelow`. */
28
+ readonly suspicious: boolean;
29
+ };
30
+ /**
31
+ * Reports, per cluster, how similar its members' structural token sets
32
+ * actually are to each other — not just which tokens they share (that's
33
+ * {@link ./merge-cross-block-clusters.js | computeQuorumCore}'s job), but
34
+ * whether the members hang together at all. A cluster built by merging
35
+ * unrelated templates has member pairs that mostly disagree even though a
36
+ * small frequency core still exists among them; this surfaces that
37
+ * disagreement directly, as a distribution rather than a single score, since
38
+ * a single "average similarity" would be pulled toward the middle by exactly
39
+ * the kind of partial-overlap noise this is meant to catch.
40
+ *
41
+ * Sampling uses {@link ./reservoir-sample.js | reservoirSample} seeded by
42
+ * each cluster's own key, so repeated calls on the same partition sample the
43
+ * same members and produce the same report.
44
+ *
45
+ * Deliberately returns only structured numbers, no verdict text — same
46
+ * design as {@link ./build-cluster-reason.js | ClusterReason} — so a caller
47
+ * decides what "suspicious" should mean for their own use (an interactive
48
+ * review queue vs. an automated gate might want different behavior for the
49
+ * same numbers).
50
+ * @param membersByKey Every cluster's member token sets, keyed by cluster key.
51
+ * @param options
52
+ * @example
53
+ * ```ts
54
+ * const report = computeClusterCohesion(membersByKey);
55
+ * const worstFirst = [...report].filter((r) => r.suspicious)
56
+ * .toSorted((a, b) => (a.medianPairSimilarity ?? 0) - (b.medianPairSimilarity ?? 0));
57
+ * ```
58
+ */
59
+ export declare function computeClusterCohesion(membersByKey: ReadonlyMap<string, readonly ReadonlySet<string>[]>, options?: ClusterCohesionOptions): readonly ClusterCohesion[];
@@ -0,0 +1,107 @@
1
+ import { jaccardSimilarity } from './jaccard-similarity.js';
2
+ import { reservoirSample } from './reservoir-sample.js';
3
+ /**
4
+ * Maximum members sampled per cluster before computing pairwise similarity.
5
+ * Bounds the cost to `O(maxSampleSize²)` per cluster regardless of how large
6
+ * the cluster actually is — 40² / 2 = 780 comparisons, cheap even across
7
+ * many clusters.
8
+ */
9
+ const DEFAULT_MAX_SAMPLE_SIZE = 40;
10
+ /**
11
+ * Below this median pairwise-similarity, a cluster is flagged `suspicious`.
12
+ *
13
+ * Chosen against the bundled `buildMirroredTemplateFixture` fixture (see
14
+ * `compute-cluster-cohesion.spec.ts`): every single-template cluster there
15
+ * has a median of `1.0` (structural tokens are identical across pages that
16
+ * differ only in text content and per-mirror stylesheet href), while every
17
+ * cluster built by mixing two *different* templates' members has a median
18
+ * at or below `0.70`. `0.75` sits with margin on both sides of that gap.
19
+ * Real crawl data can have single-template clusters with genuinely lower
20
+ * cohesion than this synthetic fixture models (legitimate content-driven
21
+ * structural variation the fixture doesn't produce) — `suspiciousMedianBelow`
22
+ * exists so a caller who has measured their own corpus can override it.
23
+ */
24
+ const DEFAULT_SUSPICIOUS_MEDIAN_BELOW = 0.75;
25
+ /**
26
+ * @param sortedAscending
27
+ */
28
+ function median(sortedAscending) {
29
+ const mid = Math.floor(sortedAscending.length / 2);
30
+ if (sortedAscending.length % 2 === 1)
31
+ return sortedAscending[mid];
32
+ return (sortedAscending[mid - 1] + sortedAscending[mid]) / 2;
33
+ }
34
+ /**
35
+ * @param sortedAscending
36
+ */
37
+ function p10(sortedAscending) {
38
+ const index = Math.floor(0.1 * (sortedAscending.length - 1));
39
+ return sortedAscending[index];
40
+ }
41
+ /**
42
+ * Reports, per cluster, how similar its members' structural token sets
43
+ * actually are to each other — not just which tokens they share (that's
44
+ * {@link ./merge-cross-block-clusters.js | computeQuorumCore}'s job), but
45
+ * whether the members hang together at all. A cluster built by merging
46
+ * unrelated templates has member pairs that mostly disagree even though a
47
+ * small frequency core still exists among them; this surfaces that
48
+ * disagreement directly, as a distribution rather than a single score, since
49
+ * a single "average similarity" would be pulled toward the middle by exactly
50
+ * the kind of partial-overlap noise this is meant to catch.
51
+ *
52
+ * Sampling uses {@link ./reservoir-sample.js | reservoirSample} seeded by
53
+ * each cluster's own key, so repeated calls on the same partition sample the
54
+ * same members and produce the same report.
55
+ *
56
+ * Deliberately returns only structured numbers, no verdict text — same
57
+ * design as {@link ./build-cluster-reason.js | ClusterReason} — so a caller
58
+ * decides what "suspicious" should mean for their own use (an interactive
59
+ * review queue vs. an automated gate might want different behavior for the
60
+ * same numbers).
61
+ * @param membersByKey Every cluster's member token sets, keyed by cluster key.
62
+ * @param options
63
+ * @example
64
+ * ```ts
65
+ * const report = computeClusterCohesion(membersByKey);
66
+ * const worstFirst = [...report].filter((r) => r.suspicious)
67
+ * .toSorted((a, b) => (a.medianPairSimilarity ?? 0) - (b.medianPairSimilarity ?? 0));
68
+ * ```
69
+ */
70
+ export function computeClusterCohesion(membersByKey, options) {
71
+ const maxSampleSize = options?.maxSampleSize ?? DEFAULT_MAX_SAMPLE_SIZE;
72
+ const suspiciousMedianBelow = options?.suspiciousMedianBelow ?? DEFAULT_SUSPICIOUS_MEDIAN_BELOW;
73
+ const results = [];
74
+ for (const [clusterKey, members] of membersByKey) {
75
+ const sample = reservoirSample(members, maxSampleSize, clusterKey);
76
+ if (sample.length < 2) {
77
+ results.push({
78
+ clusterKey,
79
+ memberCount: members.length,
80
+ sampledMemberCount: sample.length,
81
+ medianPairSimilarity: null,
82
+ p10PairSimilarity: null,
83
+ minPairSimilarity: null,
84
+ suspicious: false,
85
+ });
86
+ continue;
87
+ }
88
+ const similarities = [];
89
+ for (let a = 0; a < sample.length; a++) {
90
+ for (let b = a + 1; b < sample.length; b++) {
91
+ similarities.push(jaccardSimilarity(sample[a], sample[b]));
92
+ }
93
+ }
94
+ similarities.sort((x, y) => x - y);
95
+ const medianSimilarity = median(similarities);
96
+ results.push({
97
+ clusterKey,
98
+ memberCount: members.length,
99
+ sampledMemberCount: sample.length,
100
+ medianPairSimilarity: medianSimilarity,
101
+ p10PairSimilarity: p10(similarities),
102
+ minPairSimilarity: similarities[0],
103
+ suspicious: medianSimilarity < suspiciousMedianBelow,
104
+ });
105
+ }
106
+ return results;
107
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * A URL path segment position at which a whole site section is mirrored
3
+ * under a fixed set of alternative values — most commonly a language
4
+ * directory (`/en/`, `/zh/`, `/ko/`, `/th/`), but the same shape also arises
5
+ * from staging/production mirrors, device-variant subsites (`/sp/`), or
6
+ * versioned documentation trees. Detected by {@link detectMirrorAxis} from
7
+ * `paths` alone, with no built-in list of language codes or other
8
+ * site-specific vocabulary.
9
+ */
10
+ export type MirrorAxis = {
11
+ /** Index into a page's `paths` array where the mirrored value sits. */
12
+ readonly position: number;
13
+ /** The alternative values observed at `position` across the mirror. */
14
+ readonly values: ReadonlySet<string>;
15
+ };
16
+ /**
17
+ * Options for {@link detectMirrorAxis}.
18
+ */
19
+ export type DetectMirrorAxisOptions = {
20
+ /**
21
+ * How many leading path segments to scan for a mirror axis. Bounds the
22
+ * cost to `O(maxPosition × pageCount)`; a mirror axis deep enough to need
23
+ * a larger value is unusual (language/environment/version directories
24
+ * are conventionally near the root).
25
+ */
26
+ readonly maxPosition?: number;
27
+ /**
28
+ * Minimum number of distinct path skeletons that must share the exact
29
+ * same value set at a position before it is even considered a candidate
30
+ * axis. A single skeleton with multiple values at some position (e.g. a
31
+ * `/faq/{01,02,03}` set of sibling pages) is not a mirror — nothing about
32
+ * it repeats — so admitting `skeletonCount === 1` candidates would
33
+ * misidentify ordinary sibling pages as a site-wide axis. 3 is the
34
+ * smallest count for which "this exact value set recurs" stops being
35
+ * describable as coincidence.
36
+ */
37
+ readonly minSkeletonCount?: number;
38
+ };
39
+ /**
40
+ * Finds a mirror axis in a page corpus's URL paths, with no built-in
41
+ * knowledge of language codes or any other site-specific vocabulary — the
42
+ * axis is inferred purely from how often the exact same set of alternative
43
+ * values recurs across otherwise-identical path skeletons.
44
+ *
45
+ * For each scanned segment position, every page's path is reduced to a
46
+ * "skeleton" (that position blanked to `*`); skeletons that recur with
47
+ * `≥ 2` distinct values at that position are candidates, keyed by their
48
+ * exact value set. Candidates are ranked by how many distinct skeletons
49
+ * share that value set — the more independent path shapes that recur under
50
+ * the same alternative values, the more likely that position is a genuine
51
+ * site-wide mirror rather than a coincidence — and the top-ranked position's
52
+ * value set is accepted unless {@link autoCutThreshold}'s max-gap cut (using
53
+ * the top candidate's own count as `upperBound`, so the cut never selects a
54
+ * value the top candidate doesn't already clear) finds the next-best
55
+ * candidate too close behind to call decisively.
56
+ *
57
+ * Returns `null` when no position has any candidate clearing
58
+ * `minSkeletonCount` — including a single-language/single-mirror corpus
59
+ * (nothing recurs under alternative values at all) and a corpus whose only
60
+ * repeated-value-set position is a single skeleton's sibling pages (e.g.
61
+ * `/faq/{01,02,03}` — excluded by `minSkeletonCount`, see its own JSDoc).
62
+ * @param pagePaths Every page's URL path segments (e.g. `ExURL.paths`).
63
+ * @param options
64
+ * @example
65
+ * ```ts
66
+ * const axis = detectMirrorAxis([
67
+ * ['en', 'faq', 'index.html'], ['zh', 'faq', 'index.html'],
68
+ * ['en', 'access', 'index.html'], ['zh', 'access', 'index.html'],
69
+ * ['en', 'gallery', 'index.html'], ['zh', 'gallery', 'index.html'],
70
+ * ]);
71
+ * // { position: 0, values: Set(['en', 'zh']) }
72
+ * ```
73
+ */
74
+ export declare function detectMirrorAxis(pagePaths: readonly (readonly string[])[], options?: DetectMirrorAxisOptions): MirrorAxis | null;
Binary file