@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 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
@@ -6,10 +6,11 @@
6
6
  // animated header on a TTY, appended `[page-cluster] …` lines otherwise.
7
7
  import { writeFile } from 'node:fs/promises';
8
8
  import process from 'node:process';
9
+ import { unwrapSuppressedError } from '@d-zero/cli-core';
9
10
  import { Lanes } from '@d-zero/dealer';
10
11
  import { resolvePageClusterKeys } from './resolve-page-cluster-keys.js';
11
12
  const HELP_TEXT = `Usage:
12
- page-cluster [--content-block-attribute <name>] [--cluster-reasons-file <path>] < pages.jsonl > clusters.jsonl
13
+ page-cluster [--content-block-attribute <name>] [--cluster-reasons-file <path>] [--validation-file <path>] < pages.jsonl > clusters.jsonl
13
14
 
14
15
  Input (JSONL, one page per line):
15
16
  {
@@ -47,6 +48,29 @@ Output (JSONL, one line per input page, in input order):
47
48
  }
48
49
  }
49
50
 
51
+ With --validation-file <path>, a separate JSON file is written once
52
+ processing completes, checking the finished partition against itself:
53
+ whether pages that ended up in different clusters are nonetheless
54
+ structurally identical or near-identical (a likely over-split), and
55
+ whether any single cluster's own members actually agree with each other
56
+ (a likely over-merge). Registering this flag also applies a built-in
57
+ auto-merge to the clusterKeys in the stdout output above: any pair of
58
+ clusters found byte-identical, or near-identical and corroborated by a
59
+ detected URL mirror axis (e.g. a language directory), is merged before
60
+ output — see ClusterPartitionReport's own JSDoc for the exact policy.
61
+ {
62
+ "mirrorAxis": { "position": 0, "values": ["en", "zh"] },
63
+ "cohesion": [
64
+ { "clusterKey": "...", "memberCount": 42, "sampledMemberCount": 40,
65
+ "medianPairSimilarity": 0.91, "p10PairSimilarity": 0.78,
66
+ "minPairSimilarity": 0.6, "suspicious": false }
67
+ ],
68
+ "crossClusterDuplicates": [
69
+ { "clusterKeyA": "...", "clusterKeyB": "...", "similarity": 1,
70
+ "corroboratedByMirrorAxis": true }
71
+ ]
72
+ }
73
+
50
74
  Options:
51
75
  --content-block-attribute <name> CMS-provided attribute marking freeform
52
76
  content blocks that should be stripped
@@ -55,6 +79,10 @@ Options:
55
79
  object described above to <path> after
56
80
  processing completes. No page-count
57
81
  limit.
82
+ --validation-file <path> Write the partition-validation report
83
+ described above to <path>, and apply
84
+ its built-in auto-merge to the output
85
+ clusterKeys.
58
86
  --help Print this help and exit.
59
87
  --version Print the package version and exit.
60
88
 
@@ -121,6 +149,16 @@ export function parseArgs(argv) {
121
149
  i++;
122
150
  break;
123
151
  }
152
+ case '--validation-file': {
153
+ const next = argv[i + 1];
154
+ if (next === undefined) {
155
+ out.unknownFlag = `${arg} requires a value`;
156
+ return out;
157
+ }
158
+ out.validationFile = next;
159
+ i++;
160
+ break;
161
+ }
124
162
  default: {
125
163
  out.unknownFlag = arg;
126
164
  return out;
@@ -261,6 +299,20 @@ function errorLine(message) {
261
299
  verbose: `error: ${message}`,
262
300
  };
263
301
  }
302
+ /**
303
+ * Formats a caught error for `errorLine()`. `SuppressedError` (thrown when a
304
+ * `using`-scoped body error and a disposal error occur together) hides the
305
+ * real cause behind a generic message, so its underlying causes are
306
+ * unwrapped and joined into one line — `errorLine()`/`renderProgress()` must
307
+ * still be called exactly once per catch site, since Lanes' TTY repaint only
308
+ * keeps the latest frame (see {@link errorLine}'s JSDoc).
309
+ * @param error
310
+ */
311
+ function formatErrorMessage(error) {
312
+ return unwrapSuppressedError(error)
313
+ .map((cause) => (cause instanceof Error ? cause.message : String(cause)))
314
+ .join(' / ');
315
+ }
264
316
  /**
265
317
  * Maps a library `ProgressEvent` to a human-facing `ProgressLine`. The
266
318
  * verbose arm keeps the historical `pass0:` / `pass1:` / `pass1b:` /
@@ -299,6 +351,24 @@ function formatProgressLine(event, elapsedSec) {
299
351
  }
300
352
  }
301
353
  }
354
+ /**
355
+ * `JSON.stringify` serializes a `Set` as `{}` — `ClusterPartitionReport`'s
356
+ * only non-JSON-safe field is `mirrorAxis.values`, so this is the one spot
357
+ * that needs converting before the report can be written to a file.
358
+ * @param report
359
+ */
360
+ function toJsonSafePartitionReport(report) {
361
+ return {
362
+ mirrorAxis: report.mirrorAxis
363
+ ? {
364
+ position: report.mirrorAxis.position,
365
+ values: [...report.mirrorAxis.values].toSorted(),
366
+ }
367
+ : null,
368
+ cohesion: report.cohesion,
369
+ crossClusterDuplicates: report.crossClusterDuplicates,
370
+ };
371
+ }
302
372
  /**
303
373
  * Test-friendly entry point: takes the run's stdin/stdout/stderr streams
304
374
  * and the parsed CLI flags rather than reading them out of the process
@@ -331,7 +401,10 @@ export async function runCli(options) {
331
401
  // pass in) does — reading it defensively lets both real usage and
332
402
  // unit-test doubles work without a separate `--no-progress` flag.
333
403
  const useTty = options.stderr.isTTY === true;
334
- const lanes = new Lanes({ stream: options.stderr, verbose: !useTty });
404
+ // `using` により、この関数を抜けるすべての経路(下記の各 `return`
405
+ // もちろん、想定外の例外を含む)で確実に lanes.close() が呼ばれ、
406
+ // Display の setTimeout タイマーが解放される。
407
+ using lanes = new Lanes({ stream: options.stderr, verbose: !useTty });
335
408
  // Verbose Lanes prepends `#header` to every `update()` line. Without
336
409
  // this seed call the header would be undefined and each progress line
337
410
  // would begin with the literal string `undefined ` — bug caught by
@@ -342,71 +415,77 @@ export async function runCli(options) {
342
415
  }
343
416
  const startTime = Date.now();
344
417
  const elapsed = () => Math.max(0, Math.round((Date.now() - startTime) / 1000));
345
- // Every early return past this point must run through the finally block
346
- // so `lanes.close()` releases the display's setTimeout timer without
347
- // it a `return 1` on a stdin parse error would leave the process
348
- // hanging on the timer's next tick.
418
+ renderProgress(lanes, useTty, READING_INPUT);
419
+ // Load every JSONL line into memory once so the ids array stays
420
+ // parallel to the pages array the streaming driver reads its
421
+ // factory twice, and stdin is a one-shot pipe.
422
+ const ids = [];
423
+ const pages = [];
349
424
  try {
350
- renderProgress(lanes, useTty, READING_INPUT);
351
- // Load every JSONL line into memory once so the ids array stays
352
- // parallel to the pages array — the streaming driver reads its
353
- // factory twice, and stdin is a one-shot pipe.
354
- const ids = [];
355
- const pages = [];
425
+ for await (const { id, page } of readJsonlPages(options.stdin)) {
426
+ ids.push(id);
427
+ pages.push(page);
428
+ }
429
+ }
430
+ catch (error) {
431
+ renderProgress(lanes, useTty, errorLine(formatErrorMessage(error)));
432
+ return 1;
433
+ }
434
+ renderProgress(lanes, useTty, readingDoneLine(pages.length));
435
+ // Only worth collecting when the caller asked for the file — a
436
+ // ClusterReason Map costs bookkeeping proportional to cluster count,
437
+ // not page count, but there's no reason to pay even that when unused.
438
+ const reasonsByClusterKey = args.clusterReasonsFile
439
+ ? new Map()
440
+ : undefined;
441
+ // Same opt-in gate as `reasonsByClusterKey` — `onPartitionReport`
442
+ // itself gates the underlying validation pass (see its own JSDoc).
443
+ let partitionReport;
444
+ const resolveOptions = {
445
+ contentBlockAttribute: args.contentBlockAttribute,
446
+ onProgress: (event) => {
447
+ renderProgress(lanes, useTty, formatProgressLine(event, elapsed()));
448
+ },
449
+ onClusterReason: reasonsByClusterKey
450
+ ? (key, reason) => reasonsByClusterKey.set(key, reason)
451
+ : undefined,
452
+ onPartitionReport: args.validationFile
453
+ ? (report) => (partitionReport = report)
454
+ : undefined,
455
+ };
456
+ let clusterKeys;
457
+ try {
458
+ clusterKeys = await resolvePageClusterKeys(() => pages, resolveOptions);
459
+ }
460
+ catch (error) {
461
+ renderProgress(lanes, useTty, errorLine(formatErrorMessage(error)));
462
+ return 1;
463
+ }
464
+ const clusterCount = new Set(clusterKeys).size;
465
+ renderProgress(lanes, useTty, doneLine(pages.length, clusterCount, elapsed()));
466
+ for (const [index, key] of clusterKeys.entries()) {
467
+ const row = { id: ids[index] ?? index, clusterKey: key };
468
+ options.stdout.write(`${JSON.stringify(row)}\n`);
469
+ }
470
+ if (args.clusterReasonsFile && reasonsByClusterKey) {
356
471
  try {
357
- for await (const { id, page } of readJsonlPages(options.stdin)) {
358
- ids.push(id);
359
- pages.push(page);
360
- }
472
+ await writeFile(args.clusterReasonsFile, JSON.stringify(Object.fromEntries(reasonsByClusterKey), null, 2));
361
473
  }
362
474
  catch (error) {
363
- renderProgress(lanes, useTty, errorLine(error.message));
475
+ renderProgress(lanes, useTty, errorLine(formatErrorMessage(error)));
364
476
  return 1;
365
477
  }
366
- renderProgress(lanes, useTty, readingDoneLine(pages.length));
367
- // Only worth collecting when the caller asked for the file — a
368
- // ClusterReason Map costs bookkeeping proportional to cluster count,
369
- // not page count, but there's no reason to pay even that when unused.
370
- const reasonsByClusterKey = args.clusterReasonsFile
371
- ? new Map()
372
- : undefined;
373
- const resolveOptions = {
374
- contentBlockAttribute: args.contentBlockAttribute,
375
- onProgress: (event) => {
376
- renderProgress(lanes, useTty, formatProgressLine(event, elapsed()));
377
- },
378
- onClusterReason: reasonsByClusterKey
379
- ? (key, reason) => reasonsByClusterKey.set(key, reason)
380
- : undefined,
381
- };
382
- let clusterKeys;
478
+ }
479
+ if (args.validationFile && partitionReport) {
383
480
  try {
384
- clusterKeys = await resolvePageClusterKeys(() => pages, resolveOptions);
481
+ await writeFile(args.validationFile, JSON.stringify(toJsonSafePartitionReport(partitionReport), null, 2));
385
482
  }
386
483
  catch (error) {
387
- renderProgress(lanes, useTty, errorLine(error.message));
484
+ renderProgress(lanes, useTty, errorLine(formatErrorMessage(error)));
388
485
  return 1;
389
486
  }
390
- const clusterCount = new Set(clusterKeys).size;
391
- renderProgress(lanes, useTty, doneLine(pages.length, clusterCount, elapsed()));
392
- for (const [index, key] of clusterKeys.entries()) {
393
- const row = { id: ids[index] ?? index, clusterKey: key };
394
- options.stdout.write(`${JSON.stringify(row)}\n`);
395
- }
396
- if (args.clusterReasonsFile && reasonsByClusterKey) {
397
- try {
398
- await writeFile(args.clusterReasonsFile, JSON.stringify(Object.fromEntries(reasonsByClusterKey), null, 2));
399
- }
400
- catch (error) {
401
- renderProgress(lanes, useTty, errorLine(error.message));
402
- return 1;
403
- }
404
- }
405
- return 0;
406
- }
407
- finally {
408
- lanes.close();
409
487
  }
488
+ return 0;
410
489
  }
411
490
  /**
412
491
  * Reads the package version out of `package.json` at runtime. Kept as a
@@ -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[];