check.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345
  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. $record('E2_chapter_wrap', $one("
  58. with s as (
  59. select book, book_name, cs_para,
  60. lag(cs_para) over (partition by book, book_name order by para, cs_para) as prev
  61. from $table where cs_para > 0 and book_name <> ''
  62. )
  63. select count(*)::int as wrap_points, count(distinct book)::int as books
  64. from s where cs_para = 1 and prev > 1
  65. "));
  66. // ── E3 kn10 家族 ─────────────────────────────────────────────────────────
  67. $record('E3_kn10_missing', $one("
  68. select
  69. count(*) filter (where book_name = 'kn10')::int as kn10_rows,
  70. count(*) filter (where book_name = 'kn10_A')::int as kn10_a_rows,
  71. count(*) filter (where book_name = 'kn10_B')::int as kn10_b_rows
  72. from $table
  73. "));
  74. // ── E4 空 book_name(预期保留) ───────────────────────────────────────────
  75. $record('E4_empty_book_name', $one(
  76. "select count(*)::int as rows, count(distinct book)::int as books from $table where book_name = ''"
  77. ));
  78. // ── E5 book_id 未赋值 ────────────────────────────────────────────────────
  79. $record('E5_book_id_zero', $one(
  80. "select count(*)::int as rows, count(distinct book)::int as books from $table where book_id = 0"
  81. ));
  82. // ── E6 覆盖缺口:wbw 有这个段落,related 没有任何行 ────────────────────────
  83. $record('E6_coverage_gap', $one("
  84. with wbw as (select distinct book, paragraph from pali_texts),
  85. rel as (select distinct book, para from $table)
  86. select count(*)::int as segments, count(distinct w.book)::int as books
  87. from wbw w left join rel r on r.book = w.book and r.para = w.paragraph
  88. where r.book is null
  89. "));
  90. // ── E7 缩写区间:旧生成器在这里丢段或产废值 ────────────────────────────────
  91. $record('E7_abbrev_interval_lost', $one("
  92. with seg as (select distinct book, paragraph from $markers where is_abbrev),
  93. hit as (
  94. select s.book, s.paragraph,
  95. count(r.id)::int as rows,
  96. count(r.id) filter (where r.cs_para > $threshold)::int as garbage
  97. from seg s left join $table r on r.book = s.book and r.para = s.paragraph
  98. group by 1, 2
  99. )
  100. select
  101. count(*)::int as marker_segments,
  102. count(distinct book)::int as books,
  103. count(*) filter (where rows = 0)::int as segments_without_rows,
  104. count(*) filter (where rows > 0 and garbage > 0)::int as segments_with_garbage
  105. from hit
  106. "));
  107. // ── E8 书边界污染平铺 ─────────────────────────────────────────────────────
  108. // 污染的准确签名是「整段一个 cs 标记都没有,却被写进了非零 cs」——那个值只能是
  109. // 从上一本沿用下来的。早先按「cs 值种类少、行数多」判会把章回绕误判成污染
  110. // (book 152 有 516 个标记却只 24 个 cs 值,那是 22 次回绕),按标记判才分得开。
  111. // cs 恒为 0 的整段不算污染:0 是「未赋值」,不是编造出来的值。
  112. $flat = $all("
  113. with seg as (
  114. select bt.book, bt.sn as book_id, bt.paragraph as p_from,
  115. coalesce(lead(bt.paragraph) over (partition by bt.book order by bt.paragraph) - 1, 2147483647) as p_to
  116. from book_titles bt
  117. ),
  118. book_marks as (
  119. select book, count(*) as n from $markers where kind in ('single','interval') group by 1
  120. )
  121. select s.book, s.book_id, s.p_from, x.rows, x.max_cs, x.vals
  122. from seg s
  123. join book_marks bm on bm.book = s.book
  124. cross join lateral (
  125. select count(*)::int as rows, coalesce(max(cs_para), 0)::int as max_cs,
  126. count(distinct cs_para)::int as vals
  127. from $table r where r.book = s.book and r.para between s.p_from and s.p_to
  128. ) x
  129. where x.rows > 0 and x.max_cs > 0
  130. and not exists (
  131. select 1 from $markers m
  132. where m.book = s.book and m.paragraph between s.p_from and s.p_to
  133. and m.kind in ('single','interval')
  134. )
  135. order by x.rows desc
  136. ");
  137. $record('E8_flat_pollution', [
  138. 'groups' => count($flat),
  139. 'rows' => array_sum(array_column($flat, 'rows')),
  140. ], $flat === [] ? null : '污染段:'.json_encode($flat, JSON_UNESCAPED_UNICODE));
  141. // ── E9 全书无标记:related 有行但 wbw 无 para 标记 ─────────────────────────
  142. $noMarker = $all("
  143. with rel as (select book, count(*)::int as rows from $table group by 1),
  144. mk as (select book, count(*)::int as markers from $markers
  145. where kind in ('single', 'interval') group by 1)
  146. select rel.book, rel.rows from rel left join mk on mk.book = rel.book
  147. where coalesce(mk.markers, 0) = 0 order by rel.book
  148. ");
  149. $record('E9_no_marker_books', [
  150. 'books' => count($noMarker),
  151. 'book_list' => array_column($noMarker, 'book'),
  152. ], $noMarker === [] ? null : '各本行数:'.json_encode($noMarker, JSON_UNESCAPED_UNICODE));
  153. // ── E10 自造名(预期保留) ────────────────────────────────────────────────
  154. $record('E10_selfmade_names', $one("
  155. select
  156. count(*) filter (where book_name = 'NK')::int as nk_rows,
  157. count(*) filter (where book_name like 'subo%')::int as subo_rows,
  158. count(*) filter (where book_name = 'dict')::int as dict_rows
  159. from $table
  160. "));
  161. // ── D 连续性:同 (book, book_name, cs_para) 的 para 应构成连续区间 ──────────
  162. $record('D_continuity', $one("
  163. with g as (
  164. select book, book_name, cs_para, para,
  165. para - row_number() over (partition by book, book_name, cs_para order by para) as grp
  166. from $table where cs_para > 0 and book_name <> ''
  167. ), seg as (
  168. select book, book_name, cs_para, count(distinct grp)::int as segments
  169. from g group by 1, 2, 3
  170. )
  171. select count(*)::int as broken_groups, count(distinct book)::int as books,
  172. coalesce(sum(segments - 1), 0)::int as extra_segments
  173. from seg where segments > 1
  174. "));
  175. // ── D 镜像对齐:注释链条两侧同名书的 cs 上界应一致 ─────────────────────────
  176. $watch = ['dn1', 'kn6', 'kn9', 'abhi5', 'abhi3', 'abhi4', 'abhi6', 'abhi7'];
  177. $mirror = [];
  178. foreach ($watch as $name) {
  179. // 必须排除乱码值:旧表里 abhi4/5/6/7 的 max 全是六位废值(44022 等),
  180. // 不滤掉就看不出真实的跨侧对齐情况。
  181. $perFile = $all(
  182. "select book, max(cs_para)::int as max_cs, count(*)::int as rows
  183. from $table where book_name = ? and cs_para <= ? group by 1 order by 1",
  184. [$name, $threshold]
  185. );
  186. $mirror[$name] = [
  187. 'overall_max' => $perFile === [] ? null : max(array_column($perFile, 'max_cs')),
  188. 'per_file' => $perFile,
  189. ];
  190. }
  191. $aligned = $known['detectors'];
  192. $expectedMax = [];
  193. foreach ($aligned as $d) {
  194. if ($d['id'] === 'D_mirror_alignment') {
  195. $expectedMax = $d['baseline']['aligned'];
  196. }
  197. }
  198. $deviations = [];
  199. foreach ($expectedMax as $name => $expected) {
  200. if (($mirror[$name]['overall_max'] ?? null) !== $expected) {
  201. $deviations[$name] = ['expected' => $expected, 'actual' => $mirror[$name]['overall_max'] ?? null];
  202. }
  203. }
  204. $record('D_mirror_alignment', [
  205. 'deviations' => count($deviations),
  206. 'detail' => $deviations,
  207. ], '各名 max(cs_para):'.json_encode(
  208. array_map(fn ($m) => $m['overall_max'], $mirror),
  209. JSON_UNESCAPED_UNICODE
  210. ));
  211. $results['D_mirror_alignment']['per_file'] = $mirror;
  212. // ── D 区间展开:区间标记的展开行数必须等于 cs_to − cs_from + 1 ──────────────
  213. $record('D_interval_expansion', $one("
  214. with mk as (
  215. select book, paragraph, min(cs_from)::int as cs_from, max(cs_to)::int as cs_to,
  216. max(cs_to) - min(cs_from) + 1 as expect
  217. from $markers where kind = 'interval' group by 1, 2
  218. ), got as (
  219. select mk.book, mk.paragraph, mk.expect,
  220. count(distinct r.cs_para)::int as actual
  221. from mk left join $table r on r.book = mk.book and r.para = mk.paragraph
  222. and r.cs_para between mk.cs_from and mk.cs_to
  223. group by 1, 2, 3
  224. )
  225. select count(*)::int as interval_markers,
  226. count(*) filter (where actual <> expect)::int as mismatched_markers,
  227. count(*) filter (where actual = 0)::int as empty_markers
  228. from got
  229. "));
  230. // ── 逐本记分卡:把 book 级错误信号汇总,零信号的书即基线冻结对象 ─────────────
  231. $log->info('生成逐本记分卡…');
  232. $scorecard = $all("
  233. with rel as (
  234. select book,
  235. count(*)::int as rows,
  236. count(distinct book_name)::int as names,
  237. count(*) filter (where cs_para > $threshold)::int as garbage_rows,
  238. count(*) filter (where book_id = 0)::int as no_book_id_rows,
  239. count(*) filter (where book_name = '')::int as empty_name_rows
  240. from $table group by 1
  241. ),
  242. wrap as (
  243. select book, count(*)::int as wrap_points from (
  244. select book, book_name, cs_para,
  245. lag(cs_para) over (partition by book, book_name order by para, cs_para) as prev
  246. from $table where cs_para > 0 and book_name <> ''
  247. ) s where cs_para = 1 and prev > 1 group by 1
  248. ),
  249. abbrev as (
  250. select s.book, count(*) filter (where h.rows = 0)::int as abbrev_lost from (
  251. select distinct book, paragraph from $markers where is_abbrev
  252. ) s join lateral (
  253. select count(r.id)::int as rows from $table r where r.book = s.book and r.para = s.paragraph
  254. ) h on true group by 1
  255. ),
  256. gap as (
  257. select w.book, count(*)::int as gap_segments
  258. from (select distinct book, paragraph from wbw_templates) w
  259. left join (select distinct book, para from $table) r on r.book = w.book and r.para = w.paragraph
  260. where r.book is null group by 1
  261. )
  262. select rel.book, rel.rows, rel.names, rel.garbage_rows, rel.no_book_id_rows, rel.empty_name_rows,
  263. coalesce(wrap.wrap_points, 0) as wrap_points,
  264. coalesce(abbrev.abbrev_lost, 0) as abbrev_lost,
  265. coalesce(gap.gap_segments, 0) as gap_segments
  266. from rel
  267. left join wrap on wrap.book = rel.book
  268. left join abbrev on abbrev.book = rel.book
  269. left join gap on gap.book = rel.book
  270. order by rel.book
  271. ");
  272. $flatBooks = [];
  273. foreach ($flat as $f) {
  274. $flatBooks[$f['book']] = ($flatBooks[$f['book']] ?? 0) + 1;
  275. }
  276. $clean = [];
  277. $dirty = [];
  278. foreach ($scorecard as &$row) {
  279. $row['flat_groups'] = $flatBooks[$row['book']] ?? 0;
  280. // 空 book_name 是 E4 的预期状态,不算错误信号;其余四类算。
  281. $row['signals'] = $row['garbage_rows'] + $row['wrap_points']
  282. + $row['abbrev_lost'] + $row['flat_groups'] + $row['gap_segments'];
  283. if ($row['signals'] === 0) {
  284. $clean[] = $row['book'];
  285. } else {
  286. $dirty[] = $row['book'];
  287. }
  288. }
  289. unset($row);
  290. $log->info(sprintf('逐本记分:合格 %d 本,有信号 %d 本,共 %d 本',
  291. count($clean), count($dirty), count($scorecard)));
  292. $summary = [
  293. 'checked_at' => date('c'),
  294. 'database' => DB::connection()->getDatabaseName(),
  295. 'table' => $table,
  296. 'markers_table' => $markers,
  297. 'total_rows' => DB::table($table)->count(),
  298. 'detectors' => $results,
  299. 'books' => ['clean' => $clean, 'dirty' => $dirty, 'scorecard' => $scorecard],
  300. ];
  301. $reportPath = rp_report_dir()."/check.$table.json";
  302. rp_json_write($reportPath, $summary);
  303. $log->info("报告:$reportPath");
  304. if ($saveBaseline) {
  305. $baselinePath = __DIR__.'/baseline.json';
  306. rp_json_write($baselinePath, [
  307. 'frozen_at' => date('c'),
  308. 'source_table' => $table,
  309. 'database' => DB::connection()->getDatabaseName(),
  310. 'purpose' => '旧表的实测基线。clean_books 是零错误信号的书,重建后这些书一本都不许掉出来;detectors 是各探测器的旧表读数,用于回答「错误是否减少」。',
  311. 'clean_books' => $clean,
  312. 'detectors' => array_map(fn ($r) => $r['measured'], $results),
  313. 'scorecard' => $scorecard,
  314. ]);
  315. $log->info("基线已冻结:$baselinePath");
  316. }
  317. $log->finish();