check.php 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. <?php
  2. /**
  3. * related_paragraphs 记分卡:按 known_issues.json 的探测器给任意一张 related 表打分。
  4. *
  5. * 这是整个重建工程的标尺——每一轮「导出 → 检测 → 交给模型评估 → 改规则」都靠它
  6. * 回答两个问题:错误变少了没有?本来正确的书有没有被搞坏?
  7. *
  8. * 用法:
  9. * php scripts/related/check.php --table=related_paragraphs_bak_20260909 --save-baseline
  10. * php scripts/related/check.php --table=related_paragraphs_v1
  11. *
  12. * 依赖 rp_markers(先跑 build_markers.php)。
  13. */
  14. require __DIR__.'/lib/boot.php';
  15. use Illuminate\Support\Facades\DB;
  16. [$options] = rp_args($argv);
  17. $table = rp_assert_table($options['table'] ?? 'related_paragraphs');
  18. $markers = $options['markers'] ?? 'rp_markers';
  19. $saveBaseline = isset($options['save-baseline']);
  20. $known = rp_json_read(__DIR__.'/known_issues.json');
  21. $params = $known['params'];
  22. $log = new RpLogger('check.'.$table, rp_report_dir());
  23. $log->info("被检表:$table");
  24. $log->info("标记表:$markers");
  25. $one = fn (string $sql, array $bind = []) => (array) DB::selectOne($sql, $bind);
  26. $all = fn (string $sql, array $bind = []) => array_map(fn ($r) => (array) $r, DB::select($sql, $bind));
  27. $results = [];
  28. /** 记一条探测结果,并与 known_issues.json 的基线对照。 */
  29. $record = function (string $id, array $measured, ?string $note = null) use (&$results, $known, $log) {
  30. $spec = null;
  31. foreach ($known['detectors'] as $d) {
  32. if ($d['id'] === $id) {
  33. $spec = $d;
  34. break;
  35. }
  36. }
  37. $results[$id] = [
  38. 'title' => $spec['title'] ?? $id,
  39. 'severity' => $spec['severity'] ?? 'unknown',
  40. 'baseline' => $spec['baseline'] ?? null,
  41. 'target_v1' => $spec['target_v1'] ?? null,
  42. 'measured' => $measured,
  43. 'note' => $note,
  44. ];
  45. $log->info(sprintf('[%-24s] %s', $id, json_encode($measured, JSON_UNESCAPED_UNICODE)));
  46. if ($note) {
  47. $log->info(' '.$note);
  48. }
  49. };
  50. // ── E1 cs 乱码值 ──────────────────────────────────────────────────────────
  51. $threshold = $params['cs_garbage_threshold'];
  52. $record('E1_cs_garbage', $one(
  53. "select count(*)::int as rows, count(distinct book)::int as books from $table where cs_para > ?",
  54. [$threshold]
  55. ));
  56. // ── E2 章回绕:同 (book, book_name) 内 cs 从大值回到 1 ────────────────────
  57. // 必须按段落级 cs_min 判,不能逐行比。区间标记展开后一个段落有多行(para1-2 展开
  58. // 成 cs=1、cs=2 两行),连续这样的段落逐行看就是 …2,1,2,1… ——每段都像一次重置。
  59. // book 137 的 sn5「5 次回绕」、book 108 的 kn12「95 次回绕」都是这么来的假象。
  60. $record('E2_chapter_wrap', $one("
  61. with p as (
  62. select book, book_name, para, min(cs_para) as cs_min
  63. from {$table} where cs_para > 0 and book_name <> ''
  64. group by book, book_name, para
  65. ),
  66. s as (
  67. select book, book_name, para, cs_min,
  68. lag(cs_min) over (partition by book, book_name order by para) as prev_min
  69. from p
  70. )
  71. select count(*)::int as wrap_points, count(distinct book)::int as books
  72. from s where cs_min = 1 and prev_min > 1
  73. "));
  74. // ── E3 kn10 家族 ─────────────────────────────────────────────────────────
  75. $record('E3_kn10_missing', $one("
  76. select
  77. count(*) filter (where book_name = 'kn10')::int as kn10_rows,
  78. count(*) filter (where book_name = 'kn10_A')::int as kn10_a_rows,
  79. count(*) filter (where book_name = 'kn10_B')::int as kn10_b_rows
  80. from $table
  81. "));
  82. // ── E4 空 book_name(预期保留) ───────────────────────────────────────────
  83. $record('E4_empty_book_name', $one(
  84. "select count(*)::int as rows, count(distinct book)::int as books from $table where book_name = ''"
  85. ));
  86. // ── E5 book_id 未赋值 ────────────────────────────────────────────────────
  87. $record('E5_book_id_zero', $one(
  88. "select count(*)::int as rows, count(distinct book)::int as books from $table where book_id = 0"
  89. ));
  90. // ── E6 覆盖缺口:wbw 有这个段落,related 没有任何行 ────────────────────────
  91. $record('E6_coverage_gap', $one("
  92. with wbw as (select distinct book, paragraph from pali_texts),
  93. rel as (select distinct book, para from $table)
  94. select count(*)::int as segments, count(distinct w.book)::int as books
  95. from wbw w left join rel r on r.book = w.book and r.para = w.paragraph
  96. where r.book is null
  97. "));
  98. // ── E7 缩写区间:旧生成器在这里丢段或产废值 ────────────────────────────────
  99. $record('E7_abbrev_interval_lost', $one("
  100. with seg as (select distinct book, paragraph from $markers where is_abbrev),
  101. hit as (
  102. select s.book, s.paragraph,
  103. count(r.id)::int as rows,
  104. count(r.id) filter (where r.cs_para > $threshold)::int as garbage
  105. from seg s left join $table r on r.book = s.book and r.para = s.paragraph
  106. group by 1, 2
  107. )
  108. select
  109. count(*)::int as marker_segments,
  110. count(distinct book)::int as books,
  111. count(*) filter (where rows = 0)::int as segments_without_rows,
  112. count(*) filter (where rows > 0 and garbage > 0)::int as segments_with_garbage
  113. from hit
  114. "));
  115. // ── E8 书边界污染平铺 ─────────────────────────────────────────────────────
  116. // 污染的准确签名是「整段一个 cs 标记都没有,却被写进了非零 cs」——那个值只能是
  117. // 从上一本沿用下来的。早先按「cs 值种类少、行数多」判会把章回绕误判成污染
  118. // (book 152 有 516 个标记却只 24 个 cs 值,那是 22 次回绕),按标记判才分得开。
  119. // cs 恒为 0 的整段不算污染:0 是「未赋值」,不是编造出来的值。
  120. $flat = $all("
  121. with seg as (
  122. select bt.book, bt.sn as book_id, bt.paragraph as p_from,
  123. coalesce(lead(bt.paragraph) over (partition by bt.book order by bt.paragraph) - 1, 2147483647) as p_to
  124. from book_titles bt
  125. ),
  126. book_marks as (
  127. select book, count(*) as n from $markers where kind in ('single','interval') group by 1
  128. )
  129. select s.book, s.book_id, s.p_from, x.rows, x.max_cs, x.vals
  130. from seg s
  131. join book_marks bm on bm.book = s.book
  132. cross join lateral (
  133. select count(*)::int as rows, coalesce(max(cs_para), 0)::int as max_cs,
  134. count(distinct cs_para)::int as vals
  135. from $table r where r.book = s.book and r.para between s.p_from and s.p_to
  136. ) x
  137. where x.rows > 0 and x.max_cs > 0
  138. and not exists (
  139. select 1 from $markers m
  140. where m.book = s.book and m.paragraph between s.p_from and s.p_to
  141. and m.kind in ('single','interval')
  142. )
  143. order by x.rows desc
  144. ");
  145. $record('E8_flat_pollution', [
  146. 'groups' => count($flat),
  147. 'rows' => array_sum(array_column($flat, 'rows')),
  148. ], $flat === [] ? null : '污染段:'.json_encode($flat, JSON_UNESCAPED_UNICODE));
  149. // ── E9 全书无标记:related 有行但 wbw 无 para 标记 ─────────────────────────
  150. $noMarker = $all("
  151. with rel as (select book, count(*)::int as rows from $table group by 1),
  152. mk as (select book, count(*)::int as markers from $markers
  153. where kind in ('single', 'interval') group by 1)
  154. select rel.book, rel.rows from rel left join mk on mk.book = rel.book
  155. where coalesce(mk.markers, 0) = 0 order by rel.book
  156. ");
  157. $record('E9_no_marker_books', [
  158. 'books' => count($noMarker),
  159. 'book_list' => array_column($noMarker, 'book'),
  160. ], $noMarker === [] ? null : '各本行数:'.json_encode($noMarker, JSON_UNESCAPED_UNICODE));
  161. // ── E10 自造名(预期保留) ────────────────────────────────────────────────
  162. $record('E10_selfmade_names', $one("
  163. select
  164. count(*) filter (where book_name = 'NK')::int as nk_rows,
  165. count(*) filter (where book_name like 'subo%')::int as subo_rows,
  166. count(*) filter (where book_name = 'dict')::int as dict_rows
  167. from $table
  168. "));
  169. // ── D 连续性:同 (book, book_name, cs_para) 的 para 应构成连续区间 ──────────
  170. $record('D_continuity', $one("
  171. with g as (
  172. select book, book_name, cs_para, para,
  173. para - row_number() over (partition by book, book_name, cs_para order by para) as grp
  174. from $table where cs_para > 0 and book_name <> ''
  175. ), seg as (
  176. select book, book_name, cs_para, count(distinct grp)::int as segments
  177. from g group by 1, 2, 3
  178. )
  179. select count(*)::int as broken_groups, count(distinct book)::int as books,
  180. coalesce(sum(segments - 1), 0)::int as extra_segments
  181. from seg where segments > 1
  182. "));
  183. // ── D 镜像对齐:注释链条两侧同名书的 cs 上界应一致 ─────────────────────────
  184. $watch = ['dn1', 'kn6', 'kn9', 'abhi5', 'abhi3', 'abhi4', 'abhi6', 'abhi7'];
  185. $mirror = [];
  186. foreach ($watch as $name) {
  187. // 必须排除乱码值:旧表里 abhi4/5/6/7 的 max 全是六位废值(44022 等),
  188. // 不滤掉就看不出真实的跨侧对齐情况。
  189. $perFile = $all(
  190. "select book, max(cs_para)::int as max_cs, count(*)::int as rows
  191. from $table where book_name = ? and cs_para <= ? group by 1 order by 1",
  192. [$name, $threshold]
  193. );
  194. $mirror[$name] = [
  195. 'overall_max' => $perFile === [] ? null : max(array_column($perFile, 'max_cs')),
  196. 'per_file' => $perFile,
  197. ];
  198. }
  199. $aligned = $known['detectors'];
  200. $expectedMax = [];
  201. foreach ($aligned as $d) {
  202. if ($d['id'] === 'D_mirror_alignment') {
  203. $expectedMax = $d['baseline']['aligned'];
  204. }
  205. }
  206. $deviations = [];
  207. foreach ($expectedMax as $name => $expected) {
  208. if (($mirror[$name]['overall_max'] ?? null) !== $expected) {
  209. $deviations[$name] = ['expected' => $expected, 'actual' => $mirror[$name]['overall_max'] ?? null];
  210. }
  211. }
  212. $record('D_mirror_alignment', [
  213. 'deviations' => count($deviations),
  214. 'detail' => $deviations,
  215. ], '各名 max(cs_para):'.json_encode(
  216. array_map(fn ($m) => $m['overall_max'], $mirror),
  217. JSON_UNESCAPED_UNICODE
  218. ));
  219. $results['D_mirror_alignment']['per_file'] = $mirror;
  220. // ── D 区间展开:区间标记的展开行数必须等于 cs_to − cs_from + 1 ──────────────
  221. $record('D_interval_expansion', $one("
  222. with mk as (
  223. select book, paragraph, min(cs_from)::int as cs_from, max(cs_to)::int as cs_to,
  224. max(cs_to) - min(cs_from) + 1 as expect
  225. from $markers where kind = 'interval' group by 1, 2
  226. ), got as (
  227. select mk.book, mk.paragraph, mk.expect,
  228. count(distinct r.cs_para)::int as actual
  229. from mk left join $table r on r.book = mk.book and r.para = mk.paragraph
  230. and r.cs_para between mk.cs_from and mk.cs_to
  231. group by 1, 2, 3
  232. )
  233. select count(*)::int as interval_markers,
  234. count(*) filter (where actual <> expect)::int as mismatched_markers,
  235. count(*) filter (where actual = 0)::int as empty_markers
  236. from got
  237. "));
  238. // ── D 配对质量:章后缀方案到底有没有帮上忙,只有这把尺子能回答 ────────────
  239. // index() 拿 (book_name, cs_para) 全表取行。回绕让同一个 key 在几十个章里重复,
  240. // 一次查询就把几十章的段落全捞回来——召回没问题,精度崩了。所以要同时看两头:
  241. // 可信配对:key 跨文件命中、且在任何单本书里都不超过 10 段——这才是真配对。
  242. // 直接数 rows_paired 会把回绕造成的噪声算成资产(kn10:1 在 56 个章里重复,
  243. // 一次查询捞回上千段,那不是配对)。加对章后缀时可信配对会涨,加错才会掉。
  244. // 膨胀键:某本书里命中超过 10 / 30 段的 key,回绕的直接症状
  245. $record('D_pairing', $one("
  246. with per_book as (
  247. select book_name, cs_para, book, count(distinct para)::int as paras
  248. from {$table} where cs_para > 0 and book_name <> ''
  249. group by book_name, cs_para, book
  250. ),
  251. k as (
  252. select book_name, cs_para, count(*)::int as books,
  253. sum(paras)::int as rows, max(paras)::int as max_paras
  254. from per_book group by book_name, cs_para
  255. )
  256. select
  257. count(*)::int as keys,
  258. count(*) filter (where books >= 2)::int as keys_cross_book,
  259. sum(rows) filter (where books >= 2)::int as rows_paired,
  260. sum(rows) filter (where books >= 2 and max_paras <= 10)::int as rows_paired_trusted,
  261. sum(rows)::int as rows_total,
  262. round(avg(books), 3)::float as mean_books_per_key,
  263. count(*) filter (where max_paras > 10)::int as keys_bloated,
  264. count(*) filter (where max_paras > 30)::int as keys_bloated_bad,
  265. max(max_paras)::int as worst_paras_in_one_book
  266. from k
  267. "));
  268. // ── 逐本记分卡:把 book 级错误信号汇总,零信号的书即基线冻结对象 ─────────────
  269. $log->info('生成逐本记分卡…');
  270. $scorecard = $all("
  271. with rel as (
  272. select book,
  273. count(*)::int as rows,
  274. count(distinct book_name)::int as names,
  275. count(*) filter (where cs_para > $threshold)::int as garbage_rows,
  276. count(*) filter (where book_id = 0)::int as no_book_id_rows,
  277. count(*) filter (where book_name = '')::int as empty_name_rows
  278. from $table group by 1
  279. ),
  280. wrap as (
  281. select book, count(*)::int as wrap_points from (
  282. select book, book_name, para, min(cs_para) as cs_min,
  283. lag(min(cs_para)) over (partition by book, book_name order by para) as prev_min
  284. from {$table} where cs_para > 0 and book_name <> ''
  285. group by book, book_name, para
  286. ) s where cs_min = 1 and prev_min > 1 group by 1
  287. ),
  288. abbrev as (
  289. select s.book, count(*) filter (where h.rows = 0)::int as abbrev_lost from (
  290. select distinct book, paragraph from $markers where is_abbrev
  291. ) s join lateral (
  292. select count(r.id)::int as rows from $table r where r.book = s.book and r.para = s.paragraph
  293. ) h on true group by 1
  294. ),
  295. gap as (
  296. select w.book, count(*)::int as gap_segments
  297. from (select distinct book, paragraph from wbw_templates) w
  298. left join (select distinct book, para from $table) r on r.book = w.book and r.para = w.paragraph
  299. where r.book is null group by 1
  300. )
  301. select rel.book, rel.rows, rel.names, rel.garbage_rows, rel.no_book_id_rows, rel.empty_name_rows,
  302. coalesce(wrap.wrap_points, 0) as wrap_points,
  303. coalesce(abbrev.abbrev_lost, 0) as abbrev_lost,
  304. coalesce(gap.gap_segments, 0) as gap_segments
  305. from rel
  306. left join wrap on wrap.book = rel.book
  307. left join abbrev on abbrev.book = rel.book
  308. left join gap on gap.book = rel.book
  309. order by rel.book
  310. ");
  311. $flatBooks = [];
  312. foreach ($flat as $f) {
  313. $flatBooks[$f['book']] = ($flatBooks[$f['book']] ?? 0) + 1;
  314. }
  315. $clean = [];
  316. $dirty = [];
  317. foreach ($scorecard as &$row) {
  318. $row['flat_groups'] = $flatBooks[$row['book']] ?? 0;
  319. // 空 book_name 是 E4 的预期状态,不算错误信号;其余四类算。
  320. $row['signals'] = $row['garbage_rows'] + $row['wrap_points']
  321. + $row['abbrev_lost'] + $row['flat_groups'] + $row['gap_segments'];
  322. if ($row['signals'] === 0) {
  323. $clean[] = $row['book'];
  324. } else {
  325. $dirty[] = $row['book'];
  326. }
  327. }
  328. unset($row);
  329. $log->info(sprintf('逐本记分:合格 %d 本,有信号 %d 本,共 %d 本',
  330. count($clean), count($dirty), count($scorecard)));
  331. $summary = [
  332. 'checked_at' => date('c'),
  333. 'database' => DB::connection()->getDatabaseName(),
  334. 'table' => $table,
  335. 'markers_table' => $markers,
  336. 'total_rows' => DB::table($table)->count(),
  337. 'detectors' => $results,
  338. 'books' => ['clean' => $clean, 'dirty' => $dirty, 'scorecard' => $scorecard],
  339. ];
  340. $reportPath = rp_report_dir()."/check.$table.json";
  341. rp_json_write($reportPath, $summary);
  342. $log->info("报告:$reportPath");
  343. if ($saveBaseline) {
  344. $baselinePath = __DIR__.'/baseline.json';
  345. rp_json_write($baselinePath, [
  346. 'frozen_at' => date('c'),
  347. 'source_table' => $table,
  348. 'database' => DB::connection()->getDatabaseName(),
  349. 'purpose' => '旧表的实测基线。clean_books 是零错误信号的书,重建后这些书一本都不许掉出来;detectors 是各探测器的旧表读数,用于回答「错误是否减少」。',
  350. 'clean_books' => $clean,
  351. 'detectors' => array_map(fn ($r) => $r['measured'], $results),
  352. 'scorecard' => $scorecard,
  353. ]);
  354. $log->info("基线已冻结:$baselinePath");
  355. }
  356. $log->finish();