Skip to content

Commit cab98ca

Browse files
authored
Merge pull request #949 from d-zero-dev/feat/page-cluster-cohesion-guard
fix(page-cluster): guard cross-block merges against cohesion collapse
2 parents 9e0d3f3 + d136f2a commit cab98ca

26 files changed

Lines changed: 2602 additions & 15 deletions

cspell.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@
1919
"unioned",
2020
"retags",
2121
"Vitter",
22+
"dedup",
23+
// page-cluster synthetic fixture CSS class abbreviations (global nav / footer nav)
24+
"gnav",
25+
"fnav",
2226

2327
//
2428
"gaxios",

packages/@d-zero/page-cluster/README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ jq -c '.[]' crawl-output.json | page-cluster > clusters.jsonl
7979

8080
- `--content-block-attribute <name>` — CMS が自由編集コンテンツブロックに付与している属性名(例: `data-bgb`)が分かっている場合に指定する。指定すると比較前にその属性を持つ要素配下を無視するので、同じテンプレートで本文構成だけ違うページを混同しなくなる。唯一の site-specific なオプションで、未指定でも `<main>` / `role="main"` を起点にした自動深さキャップが常時働く(詳細は `resolve-page-cluster-keys.ts` の JSDoc を参照)
8181
- `--cluster-reasons-file <path>` — 上記の「クラスタ選定理由」を `<path>` に JSON として書き出す。ページ数の上限はない。20,000 ページ以下のコーパスでは、指定すると進捗表示(後述)は出なくなる(進捗を出さない非ストリーミング経路に常に振り分けられるため。20,000 ページ超のストリーミング経路では進捗表示・クラスタ理由の両方が動く)
82+
- `--validation-file <path>` — 分類結果の事後検証レポート(下記「分類結果の事後検証」参照)を `<path>` に JSON として書き出す。指定すると、レポートの検出結果のうち安全に統合できるもの(構造トークン集合が完全一致、またはミラー軸による裏付けあり)が **stdout の `clusterKey` にも自動的に反映される**
8283
- `--help` / `-h` — ヘルプを表示する
8384
- `--version` / `-v` — バージョンを表示する
8485

@@ -119,6 +120,13 @@ silence したい場合は `2>/dev/null`。ログに残したい場合は `2> pr
119120
| `@d-zero/page-cluster/resolve-landmark-variant-keys` | `resolveLandmarkVariantKeys` — 特定ランドマークのデザインバリアントでページを分類 |
120121
| `@d-zero/page-cluster/is-chrome-landmark-instance` | `isChromeLandmarkInstance` — 1 つの landmark インスタンスのトークン集合と `ClusterReason.landmarks[type].shellTokens` のようなシェルトークン集合を突き合わせて chrome/content を判定するステートレス関数 |
121122
| `@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` 配列に適用する純関数 |
122130

123131
```ts
124132
import { resolvePageClusterKeysFromArray } from '@d-zero/page-cluster/resolve-page-cluster-keys';
@@ -192,6 +200,33 @@ flowchart TD
192200

193201
マージが起きるとユニットのメンバー構成が変わり、文書頻度も quorum core も変わる。そのため毎ラウンド、統合後のプールから全指標を**再計算**してマージを再試行する。fine stage・L2 stage の両方でマージが 1 件も出なくなった時点で不動点に到達したとみなして収束する(安全弁として最大 10 ラウンド。実データでは 7 ラウンド以内に収束)。L2 stage は fine stage が空振りしたラウンドでしか実行されない最後の粗い経路で、誤マージ防止のために shell(ランドマーク由来トークン)の相互裏付けを要求する。
194202

203+
fine stage・L2 stage いずれの経路で提案されたマージも、適用前に**凝集度ガード**を通る: 統合後の quorum core が統合前の core に対して一定比率を下回るなら、そのマージは破棄される。個々の経路が「統合前のペア類似度」だけを見て提案する一方、複数ラウンドにわたる連鎖的な統合は「統合後に実際どれだけまとまっているか」を悪化させ得る(無関係なテンプレート同士が少しずつ吸収し合う catch-all 化)ため、経路をまたいだ単一のチェックポイントとして機能する。あわせて、L2 stage 自体もラウンド開始前に判別力を検査し、参加ユニット全体が同一の署名形状に潰れている(`main` 直下の浅い階層しか手がかりが残っていない等)場合は、そのラウンドの L2 比較を丸ごとスキップする。詳細は `merge-cross-block-clusters.ts` の `filterMergesByCohesion` / `hasDiscriminatingL2Signatures` の JSDoc を参照。
204+
195205
### Self-tuning
196206

197207
閾値の多くは **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 を参照。

packages/@d-zero/page-cluster/package.json

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,34 @@
3636
"./build-cluster-reason": {
3737
"import": "./dist/build-cluster-reason.js",
3838
"types": "./dist/build-cluster-reason.d.ts"
39+
},
40+
"./detect-mirror-axis": {
41+
"import": "./dist/detect-mirror-axis.js",
42+
"types": "./dist/detect-mirror-axis.d.ts"
43+
},
44+
"./normalize-path-by-mirror-axis": {
45+
"import": "./dist/normalize-path-by-mirror-axis.js",
46+
"types": "./dist/normalize-path-by-mirror-axis.d.ts"
47+
},
48+
"./normalize-href-by-mirror-axis": {
49+
"import": "./dist/normalize-href-by-mirror-axis.js",
50+
"types": "./dist/normalize-href-by-mirror-axis.d.ts"
51+
},
52+
"./compute-cluster-cohesion": {
53+
"import": "./dist/compute-cluster-cohesion.js",
54+
"types": "./dist/compute-cluster-cohesion.d.ts"
55+
},
56+
"./find-cross-cluster-duplicates": {
57+
"import": "./dist/find-cross-cluster-duplicates.js",
58+
"types": "./dist/find-cross-cluster-duplicates.d.ts"
59+
},
60+
"./validate-cluster-partition": {
61+
"import": "./dist/validate-cluster-partition.js",
62+
"types": "./dist/validate-cluster-partition.d.ts"
63+
},
64+
"./merge-validated-clusters": {
65+
"import": "./dist/merge-validated-clusters.js",
66+
"types": "./dist/merge-validated-clusters.d.ts"
3967
}
4068
},
4169
"bin": "dist/cli.js",
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { describe, expect, test } from 'vitest';
2+
3+
import { buildMirroredTemplateFixture } from './build-mirrored-template-fixture.js';
4+
5+
describe('buildMirroredTemplateFixture', () => {
6+
test('generates one page per (template, axis value, page index) combination', () => {
7+
const { pages, templates } = buildMirroredTemplateFixture();
8+
expect(templates).toHaveLength(8);
9+
expect(pages).toHaveLength(8 * 4 * 6);
10+
});
11+
12+
test('respects custom axisValues and pagesPerTemplate', () => {
13+
const { pages } = buildMirroredTemplateFixture({
14+
axisValues: ['a', 'b'],
15+
pagesPerTemplate: 2,
16+
});
17+
expect(pages).toHaveLength(8 * 2 * 2);
18+
expect(new Set(pages.map((p) => p.axisValue))).toEqual(new Set(['a', 'b']));
19+
});
20+
21+
test('every page carries its own axis-specific stylesheet href', () => {
22+
const { pages } = buildMirroredTemplateFixture({ axisValues: ['en', 'zh'] });
23+
for (const p of pages) {
24+
expect(
25+
p.signals.stylesheetHrefs.some((h) =>
26+
h.includes(`/${p.axisValue}/${p.template}/`),
27+
),
28+
).toBe(true);
29+
}
30+
});
31+
32+
test('wrapperTag "main" and "div" wrap byte-identical inner content', () => {
33+
// Strips exactly the wrapper tag pair (not any other `<div>` in the
34+
// page — HEADER/FOOTER contain plenty). The wrapper's closing tag is
35+
// found by searching backward from `<footer`, since FOOTER (which
36+
// itself contains `</div>`) always immediately follows the wrapper.
37+
/**
38+
*
39+
* @param html
40+
* @param open
41+
* @param close
42+
*/
43+
function unwrap(html: string, open: string, close: string): string {
44+
const start = html.indexOf(open);
45+
const footerStart = html.indexOf('<footer');
46+
expect(start).toBeGreaterThanOrEqual(0);
47+
expect(footerStart).toBeGreaterThan(start);
48+
const end = html.lastIndexOf(close, footerStart);
49+
expect(end).toBeGreaterThan(start);
50+
return (
51+
html.slice(0, start) +
52+
html.slice(start + open.length, end) +
53+
html.slice(end + close.length)
54+
);
55+
}
56+
57+
const withMain = buildMirroredTemplateFixture({ wrapperTag: 'main' });
58+
const withDiv = buildMirroredTemplateFixture({ wrapperTag: 'div' });
59+
expect(withMain.pages).toHaveLength(withDiv.pages.length);
60+
for (const [i, mainPage] of withMain.pages.entries()) {
61+
const divPage = withDiv.pages[i]!;
62+
const mainInner = unwrap(mainPage.signals.html, '<main>', '</main>');
63+
const divInner = unwrap(divPage.signals.html, '<div class="main-area">', '</div>');
64+
expect(mainInner).toBe(divInner);
65+
}
66+
});
67+
68+
test('paths encode the axis value at a fixed segment position', () => {
69+
const { pages } = buildMirroredTemplateFixture({ axisValues: ['en', 'zh'] });
70+
for (const p of pages) {
71+
expect(p.signals.paths[0]).toBe(p.axisValue);
72+
expect(p.signals.paths[1]).toBe(p.template);
73+
}
74+
});
75+
});

0 commit comments

Comments
 (0)