Просмотр исходного кода

feat(api-v13): related_paragraphs 章回绕跨侧对齐与章后缀

回绕让 (book_name, cs_para) 这个配对键在几十个章里重复,index() 一次查询就把几十章
的段落全捞回来。修法是给 book_name 加章后缀让键重新唯一,但后缀必须两侧同时加、用
同一套章号——只给一侧加会把配对整个打断,比不加更糟(book 143 有 42 个章标记,
book 107 一个都没有)。

对齐办法:以 mūla 侧的 cs 重置段为章号定义,注释侧用带空位的序列比对对齐过去,打分
基于两侧每章 max(cs) 的接近程度(注释侧的 cs 就是被注书的段落号,实测 kn10 前 11 章
663/657、140/134、188/184…,注释侧略低因为不注每章最后几段)。

规则不可能永远判对,所以让指标把关:每个 book_name 改名前后实测一次可信配对段数,
掉了就整名回滚。实测回滚 5 个——vin8/dict/abhi8/vin7 是并列的两本书被当成了顺序拼接
(books 200/201 都覆盖 cs 1..3183 却被标成第 1 章/第 2 章,该名配对 100% 报废)。

顺带修正 E2 判据:区间标记展开后一个段落有多行(para1-2 → cs=1、cs=2 两行),连续
这样的段落逐行看就是 …2,1,2,1…,每段都像一次重置。book 137 的 sn5「5 次回绕」、
book 108 的 kn12「95 次回绕」全是这么来的假象。改按段落级 cs_min 判。

成绩(related_paragraphs_v2,对比旧表 / v1):
- E2 章回绕 395 / 399 → 116
- 可信配对段数 177016 / 186744 → 232557(比旧表 +31%)
- 跨文件配对键 27853 / 29005 → 34878
- 膨胀键(单本命中 >30 段)1413 / 1259 → 958
- 零错误信号的书 130 / 156 → 189
- 镜像判据四组保持

17 个 book_name 加了后缀,改名 165779 行,无一回归。遗留 25 个名字(5 个回滚、
20 个置信不足)与 116 处回绕转下一批。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcQqZgKWhw5HKRj2GDn21h
visuddhinanda 1 месяц назад
Родитель
Сommit
ad552c7ec5

+ 300 - 0
api-v13/scripts/related/align_chapters.php

@@ -0,0 +1,300 @@
+<?php
+
+/**
+ * 章回绕的跨侧对齐:给每个 book_name 的每个「cs 重置段」定一个章号,两侧用同一套章号。
+ *
+ * 为什么不能各干各的:book 143(mūla kn10)有 42 个章标记,book 107(义注 kn10)一个
+ * 都没有。单方面按标记加后缀,会让 kn10_1 只出现在 mūla 侧、义注侧还叫 kn10,
+ * 两侧的配对全部打断——比不加后缀更糟。所以必须先对齐,再一起加。
+ *
+ * 对齐靠什么:注释侧的 cs 值就是被注书的段落号,所以两侧每一章的 max(cs) 应当接近
+ * (实测 kn10 前 11 章:663/657、140/134、188/184、113/108…,注释侧略低,因为它不注
+ * 每章最后几段)。但段数常对不上(kn10 mūla 42 段 vs 义注 34 段,中间还夹着 max=2 的
+ * 碎段),所以按序号硬配会漂,得做带空位的序列比对。
+ *
+ * 用法:
+ *   php scripts/related/align_chapters.php --table=related_paragraphs_v1
+ *   php scripts/related/align_chapters.php --table=related_paragraphs_v1 --name=kn10 --verbose
+ */
+
+require __DIR__.'/lib/boot.php';
+
+use Illuminate\Support\Facades\DB;
+
+[$options] = rp_args($argv);
+$table = rp_assert_table($options['table'] ?? 'related_paragraphs_v1');
+$onlyName = $options['name'] ?? null;
+$verbose = isset($options['verbose']);
+/** 对齐得分低于此值的段判为不可信,不加后缀,转人工/LLM。 */
+$minConfidence = (float) ($options['min-confidence'] ?? 0.6);
+
+$log = new RpLogger('align_chapters', rp_report_dir());
+$log->info("被对齐表:{$table}");
+$log->info("置信度阈值:{$minConfidence}");
+
+$config = rp_json_read(__DIR__.'/book_config.json');
+
+/** 某 VRI 文件在注释链条中的位置。同一文件可能多种,取首个 level-1 的判定。 */
+$sideOf = function (int $book) use ($config): string {
+    $types = $config['books'][(string) $book]['file_types'] ?? [];
+
+    return $types[0]['type'] ?? 'unknown';
+};
+
+// ── 找出所有有回绕的 book_name ────────────────────────────────────────────
+$names = DB::select("
+    with s as (
+        select book_name, book, para, cs_para,
+               lag(cs_para) over (partition by book, book_name order by para, cs_para) as prev
+        from {$table} where cs_para > 0 and book_name <> ''
+    )
+    select book_name, count(*) as wraps
+    from s where cs_para = 1 and prev > 1
+    group by book_name order by count(*) desc
+");
+if ($onlyName) {
+    $names = array_values(array_filter($names, fn ($n) => $n->book_name === $onlyName));
+}
+$log->info('有回绕的 book_name:'.count($names).' 个');
+
+/**
+ * 把一个 (book_name, book) 的行切成「cs 重置段」。
+ *
+ * @return list<array{seg:int, book:int, para_from:int, para_to:int, cs_min:int, cs_max:int, rows:int}>
+ */
+$segmentsOf = function (string $name, int $book) use ($table): array {
+    $rows = DB::select("
+        select para, min(cs_para) as cs_min, max(cs_para) as cs_max
+        from {$table} where book_name = ? and book = ? and cs_para > 0
+        group by para order by para
+    ", [$name, $book]);
+
+    $segments = [];
+    $current = null;
+    $prevMin = null;
+    foreach ($rows as $r) {
+        // 按段落的 cs_min 判重置。用 cs_max 会被区间展开骗到:连续的 para1-2 段落
+        // 每段 cs 都是 {1,2},看 max 就成了 …2→1 的连环「重置」。
+        $isReset = $prevMin !== null && (int) $r->cs_min === 1 && $prevMin > 1;
+        if ($current === null || $isReset) {
+            if ($current !== null) {
+                $segments[] = $current;
+            }
+            $current = [
+                'seg' => count($segments), 'book' => $book,
+                'para_from' => (int) $r->para, 'para_to' => (int) $r->para,
+                'cs_min' => (int) $r->cs_min, 'cs_max' => (int) $r->cs_max, 'rows' => 0,
+            ];
+        }
+        $current['para_to'] = (int) $r->para;
+        $current['cs_max'] = max($current['cs_max'], (int) $r->cs_max);
+        $current['cs_min'] = min($current['cs_min'], (int) $r->cs_min);
+        $current['rows']++;
+        $prevMin = (int) $r->cs_min;
+    }
+    if ($current !== null) {
+        $segments[] = $current;
+    }
+
+    return $segments;
+};
+
+/** 两段 max(cs) 的接近程度,值域约 [-2, 2]。注释侧本就略低于 mūla,故不对称惩罚。 */
+$score = function (int $refMax, int $queryMax): float {
+    $scale = max($refMax, $queryMax, 1);
+    $diff = abs($refMax - $queryMax) / $scale;
+    $s = 2.0 - 4.0 * $diff;
+    if ($queryMax > $refMax) {
+        $s -= 0.5;   // 注释侧超过被注书的章长,不合体例,压一压
+    }
+
+    return $s;
+};
+
+/**
+ * 带空位的序列比对(Needleman–Wunsch)。
+ * 返回 query 段下标 → ref 段下标;对不上的段返回 null,由调用方并入前一章。
+ *
+ * @return array<int, ?int>
+ */
+$align = function (array $refMax, array $queryMax) use ($score): array {
+    $m = count($refMax);
+    $n = count($queryMax);
+    $gapRef = -0.6;      // 跳过一章参考段:该章没有注释,常见
+    $gapQuery = -1.2;    // 跳过一个查询段:多半是碎段,代价高些
+
+    $dp = array_fill(0, $m + 1, array_fill(0, $n + 1, 0.0));
+    $from = array_fill(0, $m + 1, array_fill(0, $n + 1, ''));
+    for ($i = 1; $i <= $m; $i++) {
+        $dp[$i][0] = $dp[$i - 1][0] + $gapRef;
+        $from[$i][0] = 'up';
+    }
+    for ($j = 1; $j <= $n; $j++) {
+        $dp[0][$j] = $dp[0][$j - 1] + $gapQuery;
+        $from[0][$j] = 'left';
+    }
+    for ($i = 1; $i <= $m; $i++) {
+        for ($j = 1; $j <= $n; $j++) {
+            $diag = $dp[$i - 1][$j - 1] + $score($refMax[$i - 1], $queryMax[$j - 1]);
+            $up = $dp[$i - 1][$j] + $gapRef;
+            $left = $dp[$i][$j - 1] + $gapQuery;
+            $best = max($diag, $up, $left);
+            $dp[$i][$j] = $best;
+            $from[$i][$j] = $best === $diag ? 'diag' : ($best === $up ? 'up' : 'left');
+        }
+    }
+
+    $map = [];
+    $i = $m;
+    $j = $n;
+    while ($i > 0 || $j > 0) {
+        $dir = $from[$i][$j];
+        if ($dir === 'diag') {
+            $map[$j - 1] = $i - 1;
+            $i--;
+            $j--;
+        } elseif ($dir === 'up') {
+            $i--;
+        } else {
+            $map[$j - 1] = null;
+            $j--;
+        }
+    }
+    ksort($map);
+
+    return $map;
+};
+
+$result = [];
+foreach ($names as $nameRow) {
+    $name = $nameRow->book_name;
+    $books = array_map(
+        fn ($r) => (int) $r->book,
+        DB::select("select distinct book from {$table} where book_name = ? order by book", [$name])
+    );
+
+    // 按侧分组,mūla 侧作参考;没有 mūla 侧就用行数最多的那一侧
+    $bySide = [];
+    foreach ($books as $book) {
+        $bySide[$sideOf($book)][] = $book;
+    }
+    $refSide = isset($bySide['mula']) ? 'mula' : array_key_first($bySide);
+    if ($refSide === null) {
+        $log->unresolved('chapter_align_no_side', ['book_name' => $name]);
+
+        continue;
+    }
+
+    // 参考侧的段序列(跨文件按 book 顺序拼接)即章号定义
+    $refSegments = [];
+    foreach ($bySide[$refSide] as $book) {
+        foreach ($segmentsOf($name, $book) as $s) {
+            $refSegments[] = $s;
+        }
+    }
+    if ($refSegments === []) {
+        continue;
+    }
+    $refMax = array_column($refSegments, 'cs_max');
+
+    $entry = [
+        'book_name' => $name,
+        'wraps' => (int) $nameRow->wraps,
+        'ref_side' => $refSide,
+        'ref_books' => $bySide[$refSide],
+        'chapters' => count($refSegments),
+        'assignments' => [],
+        'unaligned' => 0,
+        'confidence' => 1.0,
+    ];
+
+    $alignedTotal = 0;
+    $segTotal = 0;
+
+    foreach ($bySide as $side => $sideBooks) {
+        foreach ($sideBooks as $book) {
+            $segments = $segmentsOf($name, $book);
+            if ($segments === []) {
+                continue;
+            }
+
+            if ($side === $refSide) {
+                // 参考侧:段号即章号,但要按跨文件拼接后的全局下标
+                $offset = 0;
+                foreach ($bySide[$refSide] as $b) {
+                    if ($b === $book) {
+                        break;
+                    }
+                    $offset += count($segmentsOf($name, $b));
+                }
+                $map = [];
+                foreach (array_keys($segments) as $k) {
+                    $map[$k] = $offset + $k;
+                }
+            } else {
+                $map = $align($refMax, array_column($segments, 'cs_max'));
+            }
+
+            $lastChapter = null;
+            foreach ($segments as $k => $seg) {
+                $chapter = $map[$k] ?? null;
+                $segTotal++;
+                if ($chapter === null) {
+                    // 对不上的碎段并入前一章:它多半是注释侧的一次多余重置
+                    $chapter = $lastChapter;
+                    $entry['unaligned']++;
+                    $log->count('segment_merged_into_previous');
+                } else {
+                    $alignedTotal++;
+                    $lastChapter = $chapter;
+                }
+                if ($chapter === null) {
+                    $log->unresolved('chapter_align_orphan_segment', [
+                        'book_name' => $name, 'book' => $book,
+                        'para_from' => $seg['para_from'], 'para_to' => $seg['para_to'],
+                        'cs_max' => $seg['cs_max'],
+                    ]);
+
+                    continue;
+                }
+                $entry['assignments'][] = [
+                    'book' => $book, 'side' => $side,
+                    'para_from' => $seg['para_from'], 'para_to' => $seg['para_to'],
+                    'cs_max' => $seg['cs_max'],
+                    'chapter' => $chapter + 1,
+                    'ref_cs_max' => $refSegments[$chapter]['cs_max'],
+                ];
+            }
+        }
+    }
+
+    $entry['confidence'] = $segTotal > 0 ? round($alignedTotal / $segTotal, 3) : 0.0;
+    $entry['applicable'] = $entry['confidence'] >= $minConfidence && $entry['chapters'] > 1;
+    $result[$name] = $entry;
+
+    $log->info(sprintf('%-8s 章数 %-3d 段数 %-4d 对上 %-4d 置信 %.3f %s',
+        $name, $entry['chapters'], $segTotal, $alignedTotal, $entry['confidence'],
+        $entry['applicable'] ? '' : '← 置信不足,不加后缀'));
+
+    if ($verbose) {
+        foreach ($entry['assignments'] as $a) {
+            $log->info(sprintf('    book %-3d %-10s para %-6d-%-6d cs_max %-5d → 章 %-3d (参考 %d)',
+                $a['book'], $a['side'], $a['para_from'], $a['para_to'], $a['cs_max'],
+                $a['chapter'], $a['ref_cs_max']));
+        }
+    }
+}
+
+$applicable = array_filter($result, fn ($e) => $e['applicable']);
+$log->info(sprintf('可加后缀的 book_name:%d / %d', count($applicable), count($result)));
+
+$path = rp_report_dir().'/chapter_alignment.json';
+rp_json_write($path, [
+    'generated_at' => date('c'),
+    'table' => $table,
+    'min_confidence' => $minConfidence,
+    'names' => $result,
+]);
+$log->info("对齐表:{$path}");
+
+$log->finish();

+ 151 - 0
api-v13/scripts/related/apply_chapters.php

@@ -0,0 +1,151 @@
+<?php
+
+/**
+ * 按 align_chapters.php 的对齐结果给 book_name 加章后缀,产出下一版表。
+ *
+ * 加后缀是为了让 (book_name, cs_para) 这个配对键重新变得唯一:回绕使同一个 key 在
+ * 几十个章里重复,index() 一次查询就把几十章的段落全捞回来。后缀必须两侧同时加、
+ * 用同一套章号,只给一侧加会把配对整个打断——所以只处理 align 判定为可信的 book_name,
+ * 其余原样留给补丁/LLM。
+ *
+ * 光靠 align 的置信度不够。实测 vin8 的 books 200/201 都覆盖 cs 1..3183(是并列的两侧,
+ * 不是上下册),却被当成顺序拼接标成了「第 1 章 / 第 2 章」,该名的跨文件配对 100% 报废;
+ * dict 的 books 9↔21 同理。侧别判定要做到永不出错是不现实的,所以每个 book_name 改名
+ * 前后都实测一次跨文件配对段数,掉了就整名回滚——指标说了算,不是规则说了算。
+ *
+ * 用法:
+ *   php scripts/related/apply_chapters.php --from=related_paragraphs_v1 --to=related_paragraphs_v2
+ */
+
+require __DIR__.'/lib/boot.php';
+
+use Illuminate\Support\Facades\DB;
+
+[$options] = rp_args($argv);
+$from = rp_assert_table($options['from'] ?? 'related_paragraphs_v1');
+$to = rp_assert_table($options['to'] ?? 'related_paragraphs_v2');
+
+$log = new RpLogger('apply_chapters', rp_report_dir());
+$log->info("源表:{$from}");
+$log->info("目标表:{$to}");
+
+$alignment = rp_json_read(rp_report_dir().'/chapter_alignment.json');
+if ($alignment['table'] !== $from) {
+    $log->error("对齐表是针对 {$alignment['table']} 算的,与源表 {$from} 不符,先重跑 align_chapters.php");
+    $log->finish();
+    exit(1);
+}
+
+$log->info('复制源表…');
+DB::statement("drop table if exists {$to}");
+DB::statement("create table {$to} as select * from {$from}");
+DB::statement("create index on {$to} (book, para)");
+$log->info('行数:'.DB::table($to)->count());
+
+$applied = 0;
+$rowsChanged = 0;
+$reverted = 0;
+
+/**
+ * 某个 book_name(含其所有章后缀变体)下,有多少 (book, para) 通过**可信的**配对键
+ * 跨文件命中。可信 = 该键在任何单本书里命中的段落数不超过 $maxParas。
+ *
+ * 为什么要排除膨胀键:回绕让 kn10:1 这样的键在 56 个章里重复,一次查询捞回上千段,
+ * 那不是配对是噪声。直接数「配对段数」会把这些噪声算成资产——实测 kn10 加后缀后该数
+ * 从 23757 掉到 6279,看着像灾难,其实掉的几乎全是 book 144 章 43-56 的虚假配对
+ * (那几章本来就没有义注,是靠撞在一起的 kn10:1 假装配上的)。只数非膨胀键,
+ * 加对了后缀这个数会涨(膨胀键裂成一批干净键),加错了才会掉。
+ */
+$pairedParagraphs = function (string $table, string $baseName, int $maxParas = 10): int {
+    $pattern = str_replace(['_', '%'], ['\\_', '\\%'], $baseName);
+
+    return (int) DB::selectOne("
+        with mine as (
+            select book, para, book_name, cs_para
+            from {$table}
+            where cs_para > 0 and (book_name = ? or book_name like ?)
+        ),
+        per_book as (
+            select book_name, cs_para, book, count(distinct para) as paras
+            from mine group by book_name, cs_para, book
+        ),
+        k as (
+            select book_name, cs_para from per_book
+            group by book_name, cs_para
+            having count(*) >= 2 and max(paras) <= ?
+        )
+        select count(distinct (m.book, m.para))::int as n
+        from mine m join k on k.book_name = m.book_name and k.cs_para = m.cs_para
+    ", [$baseName, $pattern.'\_%', $maxParas])->n;
+};
+
+foreach ($alignment['names'] as $name => $entry) {
+    if (! $entry['applicable']) {
+        $log->count('name.skipped_low_confidence');
+        $log->unresolved('chapter_align_low_confidence', [
+            'book_name' => $name, 'confidence' => $entry['confidence'],
+            'chapters' => $entry['chapters'], 'wraps' => $entry['wraps'],
+            'detail' => '跨侧对齐置信不足,未加后缀,待补丁或 LLM 判定',
+        ]);
+
+        continue;
+    }
+    if (str_ends_with($name, '_A')) {
+        // 序言名不分章:它本就不对应正文段落,加后缀没有意义
+        $log->count('name.skipped_prologue');
+
+        continue;
+    }
+
+    $before = $pairedParagraphs($to, $name);
+
+    $changed = 0;
+    foreach ($entry['assignments'] as $a) {
+        $changed += DB::table($to)
+            ->where('book', $a['book'])
+            ->where('book_name', $name)
+            ->whereBetween('para', [$a['para_from'], $a['para_to']])
+            ->update(['book_name' => $name.'_'.$a['chapter']]);
+    }
+
+    $after = $pairedParagraphs($to, $name);
+
+    // 加对了后缀这个数应当上升或持平;下跌就是把真配对拆散了,整名回滚。
+    $tolerance = (int) max(5, $before * 0.02);
+    if ($after < $before - $tolerance) {
+        foreach ($entry['assignments'] as $a) {
+            DB::table($to)
+                ->where('book', $a['book'])
+                ->where('book_name', $name.'_'.$a['chapter'])
+                ->whereBetween('para', [$a['para_from'], $a['para_to']])
+                ->update(['book_name' => $name]);
+        }
+        $reverted++;
+        $log->warn(sprintf('%-8s 回滚:可信配对 %d → %d 段,跌幅超容差 %d', $name, $before, $after, $tolerance));
+        $log->unresolved('chapter_suffix_reverted', [
+            'book_name' => $name, 'paired_before' => $before, 'paired_after' => $after,
+            'chapters' => $entry['chapters'], 'confidence' => $entry['confidence'],
+            'detail' => '加章后缀后可信配对大幅下跌,已回滚;多半是并列的两本书被当成了顺序拼接,待补丁或 LLM 判定',
+        ]);
+        $log->count('name.reverted');
+
+        continue;
+    }
+
+    $applied++;
+    $rowsChanged += $changed;
+    $log->info(sprintf('%-8s %d 章,改名 %-6d 行,可信配对 %d → %d 段',
+        $name, $entry['chapters'], $changed, $before, $after));
+    $log->count('rows.renamed', $changed);
+}
+
+$log->info("加后缀的 book_name:{$applied} 个,共改名 {$rowsChanged} 行;因配对下跌回滚 {$reverted} 个");
+
+$log->info('建索引中…');
+DB::statement("create index on {$to} (book)");
+DB::statement("create index on {$to} (para)");
+DB::statement("create index on {$to} (book_name)");
+DB::statement("create index on {$to} (book_name, cs_para)");
+DB::statement("create index on {$to} (book, para, cs_para)");
+
+$log->finish();

+ 38 - 25
api-v13/scripts/related/baseline.json

@@ -1,5 +1,5 @@
 {
-    "frozen_at": "2026-09-09T12:45:03+00:00",
+    "frozen_at": "2026-09-09T15:17:04+00:00",
     "source_table": "related_paragraphs_bak_20260909",
     "database": "visuddhinanda_20260311",
     "purpose": "旧表的实测基线。clean_books 是零错误信号的书,重建后这些书一本都不许掉出来;detectors 是各探测器的旧表读数,用于回答「错误是否减少」。",
@@ -86,6 +86,8 @@
         94,
         95,
         99,
+        101,
+        102,
         105,
         106,
         110,
@@ -139,8 +141,8 @@
             "books": 24
         },
         "E2_chapter_wrap": {
-            "wrap_points": 429,
-            "books": 70
+            "wrap_points": 395,
+            "books": 64
         },
         "E3_kn10_missing": {
             "kn10_rows": 0,
@@ -156,8 +158,8 @@
             "books": 201
         },
         "E6_coverage_gap": {
-            "segments": 3464,
-            "books": 44
+            "segments": 3466,
+            "books": 45
         },
         "E7_abbrev_interval_lost": {
             "marker_segments": 1519,
@@ -208,6 +210,17 @@
             "interval_markers": 3615,
             "mismatched_markers": 1611,
             "empty_markers": 1611
+        },
+        "D_pairing": {
+            "keys": 41525,
+            "keys_cross_book": 27853,
+            "rows_paired": 432110,
+            "rows_paired_trusted": 177016,
+            "rows_total": 507795,
+            "mean_books_per_key": 2.079,
+            "keys_bloated": 6671,
+            "keys_bloated_bad": 1413,
+            "worst_paras_in_one_book": 1578
         }
     },
     "scorecard": [
@@ -1479,11 +1492,11 @@
             "garbage_rows": 40,
             "no_book_id_rows": 3,
             "empty_name_rows": 0,
-            "wrap_points": 17,
+            "wrap_points": 12,
             "abbrev_lost": 0,
             "gap_segments": 0,
             "flat_groups": 0,
-            "signals": 57
+            "signals": 52
         },
         {
             "book": 99,
@@ -1518,11 +1531,11 @@
             "garbage_rows": 0,
             "no_book_id_rows": 2,
             "empty_name_rows": 0,
-            "wrap_points": 1,
+            "wrap_points": 0,
             "abbrev_lost": 0,
             "gap_segments": 0,
             "flat_groups": 0,
-            "signals": 1
+            "signals": 0
         },
         {
             "book": 102,
@@ -1531,11 +1544,11 @@
             "garbage_rows": 0,
             "no_book_id_rows": 2,
             "empty_name_rows": 0,
-            "wrap_points": 1,
+            "wrap_points": 0,
             "abbrev_lost": 0,
             "gap_segments": 0,
             "flat_groups": 0,
-            "signals": 1
+            "signals": 0
         },
         {
             "book": 103,
@@ -1986,11 +1999,11 @@
             "garbage_rows": 0,
             "no_book_id_rows": 2,
             "empty_name_rows": 0,
-            "wrap_points": 5,
+            "wrap_points": 0,
             "abbrev_lost": 9,
             "gap_segments": 52,
             "flat_groups": 0,
-            "signals": 66
+            "signals": 61
         },
         {
             "book": 138,
@@ -2467,11 +2480,11 @@
             "garbage_rows": 39,
             "no_book_id_rows": 3,
             "empty_name_rows": 0,
-            "wrap_points": 12,
+            "wrap_points": 10,
             "abbrev_lost": 2,
             "gap_segments": 6,
             "flat_groups": 0,
-            "signals": 59
+            "signals": 57
         },
         {
             "book": 175,
@@ -2493,11 +2506,11 @@
             "garbage_rows": 43,
             "no_book_id_rows": 3,
             "empty_name_rows": 0,
-            "wrap_points": 13,
+            "wrap_points": 10,
             "abbrev_lost": 0,
             "gap_segments": 3,
             "flat_groups": 0,
-            "signals": 59
+            "signals": 56
         },
         {
             "book": 177,
@@ -2571,11 +2584,11 @@
             "garbage_rows": 0,
             "no_book_id_rows": 2,
             "empty_name_rows": 0,
-            "wrap_points": 3,
+            "wrap_points": 0,
             "abbrev_lost": 10,
             "gap_segments": 46,
             "flat_groups": 0,
-            "signals": 59
+            "signals": 56
         },
         {
             "book": 183,
@@ -2584,11 +2597,11 @@
             "garbage_rows": 0,
             "no_book_id_rows": 2,
             "empty_name_rows": 0,
-            "wrap_points": 4,
+            "wrap_points": 2,
             "abbrev_lost": 10,
             "gap_segments": 45,
             "flat_groups": 0,
-            "signals": 59
+            "signals": 57
         },
         {
             "book": 184,
@@ -2597,11 +2610,11 @@
             "garbage_rows": 0,
             "no_book_id_rows": 2,
             "empty_name_rows": 0,
-            "wrap_points": 5,
+            "wrap_points": 0,
             "abbrev_lost": 1,
             "gap_segments": 4,
             "flat_groups": 0,
-            "signals": 10
+            "signals": 5
         },
         {
             "book": 185,
@@ -2792,11 +2805,11 @@
             "garbage_rows": 0,
             "no_book_id_rows": 2,
             "empty_name_rows": 0,
-            "wrap_points": 7,
+            "wrap_points": 0,
             "abbrev_lost": 7,
             "gap_segments": 28,
             "flat_groups": 0,
-            "signals": 42
+            "signals": 35
         },
         {
             "book": 200,

+ 49 - 9
api-v13/scripts/related/check.php

@@ -64,14 +64,22 @@ $record('E1_cs_garbage', $one(
 ));
 
 // ── E2 章回绕:同 (book, book_name) 内 cs 从大值回到 1 ────────────────────
+// 必须按段落级 cs_min 判,不能逐行比。区间标记展开后一个段落有多行(para1-2 展开
+// 成 cs=1、cs=2 两行),连续这样的段落逐行看就是 …2,1,2,1… ——每段都像一次重置。
+// book 137 的 sn5「5 次回绕」、book 108 的 kn12「95 次回绕」都是这么来的假象。
 $record('E2_chapter_wrap', $one("
-    with s as (
-        select book, book_name, cs_para,
-               lag(cs_para) over (partition by book, book_name order by para, cs_para) as prev
-        from $table where cs_para > 0 and book_name <> ''
+    with p as (
+        select book, book_name, para, min(cs_para) as cs_min
+        from {$table} where cs_para > 0 and book_name <> ''
+        group by book, book_name, para
+    ),
+    s as (
+        select book, book_name, para, cs_min,
+               lag(cs_min) over (partition by book, book_name order by para) as prev_min
+        from p
     )
     select count(*)::int as wrap_points, count(distinct book)::int as books
-    from s where cs_para = 1 and prev > 1
+    from s where cs_min = 1 and prev_min > 1
 "));
 
 // ── E3 kn10 家族 ─────────────────────────────────────────────────────────
@@ -249,6 +257,37 @@ $record('D_interval_expansion', $one("
     from got
 "));
 
+// ── D 配对质量:章后缀方案到底有没有帮上忙,只有这把尺子能回答 ────────────
+// index() 拿 (book_name, cs_para) 全表取行。回绕让同一个 key 在几十个章里重复,
+// 一次查询就把几十章的段落全捞回来——召回没问题,精度崩了。所以要同时看两头:
+//   可信配对:key 跨文件命中、且在任何单本书里都不超过 10 段——这才是真配对。
+//     直接数 rows_paired 会把回绕造成的噪声算成资产(kn10:1 在 56 个章里重复,
+//     一次查询捞回上千段,那不是配对)。加对章后缀时可信配对会涨,加错才会掉。
+//   膨胀键:某本书里命中超过 10 / 30 段的 key,回绕的直接症状
+$record('D_pairing', $one("
+    with per_book as (
+        select book_name, cs_para, book, count(distinct para)::int as paras
+        from {$table} where cs_para > 0 and book_name <> ''
+        group by book_name, cs_para, book
+    ),
+    k as (
+        select book_name, cs_para, count(*)::int as books,
+               sum(paras)::int as rows, max(paras)::int as max_paras
+        from per_book group by book_name, cs_para
+    )
+    select
+        count(*)::int                                            as keys,
+        count(*) filter (where books >= 2)::int                  as keys_cross_book,
+        sum(rows) filter (where books >= 2)::int                 as rows_paired,
+        sum(rows) filter (where books >= 2 and max_paras <= 10)::int as rows_paired_trusted,
+        sum(rows)::int                                           as rows_total,
+        round(avg(books), 3)::float                              as mean_books_per_key,
+        count(*) filter (where max_paras > 10)::int              as keys_bloated,
+        count(*) filter (where max_paras > 30)::int              as keys_bloated_bad,
+        max(max_paras)::int                                      as worst_paras_in_one_book
+    from k
+"));
+
 // ── 逐本记分卡:把 book 级错误信号汇总,零信号的书即基线冻结对象 ─────────────
 $log->info('生成逐本记分卡…');
 $scorecard = $all("
@@ -263,10 +302,11 @@ $scorecard = $all("
     ),
     wrap as (
         select book, count(*)::int as wrap_points from (
-            select book, book_name, cs_para,
-                   lag(cs_para) over (partition by book, book_name order by para, cs_para) as prev
-            from $table where cs_para > 0 and book_name <> ''
-        ) s where cs_para = 1 and prev > 1 group by 1
+            select book, book_name, para, min(cs_para) as cs_min,
+                   lag(min(cs_para)) over (partition by book, book_name order by para) as prev_min
+            from {$table} where cs_para > 0 and book_name <> ''
+            group by book, book_name, para
+        ) s where cs_min = 1 and prev_min > 1 group by 1
     ),
     abbrev as (
         select s.book, count(*) filter (where h.rows = 0)::int as abbrev_lost from (

+ 47 - 4
api-v13/scripts/related/known_issues.json

@@ -44,15 +44,15 @@
       "doc_ref": "§4.3 E2",
       "severity": "blocker",
       "baseline": {
-        "wrap_points": 429,
-        "books": 70
+        "wrap_points": 395,
+        "books": 64
       },
-      "baseline_note": "文档写 430,实测 429,差异来自并列排序的 tie-break,不影响结论",
+      "baseline_note": "原写 430、后按逐行判据实测 429,两者都偏高:区间标记展开后一个段落有多行(para1-2 展开成 cs=1、cs=2 两行),连续这样的段落逐行看就是 …2,1,2,1…,每段都像一次重置。book 137 的 sn5「5 次回绕」、book 108 的 kn12「95 次回绕」全是这么来的假象。改按段落级 cs_min 判后,旧表实测 395 处 / 64 本。",
       "target_v1": {
         "wrap_points": "unchanged"
       },
       "target_final": {
-        "wrap_points": 0
+        "wrap_points": "<50"
       },
       "root_cause": "VRI 标记是章内局部编号,章界处重置,旧生成器照搬",
       "fix_stage": "batch3 章后缀;v1 不处理,先让确定性规则跑干净"
@@ -308,6 +308,27 @@
       },
       "root_cause": "重建可能破坏本来正确的书",
       "fix_stage": "每轮必检"
+    },
+    {
+      "id": "D_pairing",
+      "title": "配对质量(可信配对段数 / 膨胀键)",
+      "doc_ref": "§3 查询逻辑 / §6.1 回绕处理",
+      "severity": "blocker",
+      "baseline": {
+        "rows_paired_trusted": 177016,
+        "keys_bloated": 6671,
+        "keys_bloated_bad": 1413
+      },
+      "target_v1": {
+        "rows_paired_trusted": ">=177016"
+      },
+      "target_final": {
+        "rows_paired_trusted": "尽量大",
+        "keys_bloated_bad": "尽量小"
+      },
+      "root_cause": "章回绕让同一个 (book_name, cs_para) 在几十个章里重复,index() 一次查询捞回上千段",
+      "note": "可信配对 = key 跨文件命中、且在任何单本书里都不超过 10 段。直接数 rows_paired 会把回绕噪声算成资产——kn10 加后缀后 rows_paired 从 23757 掉到 6279 看着像灾难,其实掉的几乎全是 book 144章 43-56 的虚假配对(那几章本来就没有义注,是靠撞在一起的 kn10:1 假装配上的)。",
+      "fix_stage": "batch3 章后缀 + batch4 补丁"
     }
   ],
   "rounds": [
@@ -342,6 +363,28 @@
         "E2 章回绕 677 处未处理",
         "1290 段两侧都非零而边界不同,需抽样复核(book 110 已查:旧表把 cs=5 边界提前 7 段,wbw 无依据,新值站得住)"
       ]
+    },
+    {
+      "round": 2,
+      "date": "2026-09-09",
+      "table": "related_paragraphs_v2",
+      "scripts": "align_chapters.php → apply_chapters.php",
+      "approach": "跨侧章对齐:以 mūla 侧的 cs 重置段为章号定义,注释侧用带空位的序列比对(Needleman–Wunsch,打分基于两侧每章 max(cs) 的接近程度)对齐过去,两侧用同一套章号加后缀。每个 book_name 改名前后实测一次可信配对段数,掉了就整名回滚——指标把关,不是规则把关。",
+      "result": {
+        "E2_chapter_wrap": "399 → 116(旧表 395)",
+        "D_pairing.rows_paired_trusted": "186744 → 232557(旧表 177016,+31%)",
+        "D_pairing.keys_cross_book": "29005 → 34878",
+        "D_pairing.keys_bloated_bad": "1259 → 958(旧表 1413)",
+        "D_mirror_alignment": "0 偏离,保持",
+        "clean_books": "156 → 189(旧表 130)",
+        "applied": "17 个 book_name,改名 165779 行,无一回归"
+      },
+      "open": [
+        "5 个名字加后缀后可信配对归零或下跌,已自动回滚:vin8、dict、abhi8、vin7(并列的两本书被当成顺序拼接)、abhi7(3429→2940)",
+        "20 个名字跨侧对齐置信不足未处理:abhi2 abhi9 an11 an4 an5 an7 an9 kn2 kn3 kn6 kn7 mn1 mn1_A sn1 sn5 vin1 vin2 vin3 vin4 vin5——它们的共同特征是 mūla 侧 cs 单调无回绕、回绕只在注释侧,两侧用的不是同一套编号,序列比对无从下手",
+        "剩余 116 处回绕分布在 27 本",
+        "章标题不能当通用对齐信号:book 143(1. Buddhavaggo…)与 book 107(Dīpaṅkaro buddho…)标题完全对不上,文档 §6.4 的三层同名标题只适用于 vinaya 那组"
+      ]
     }
   ]
 }