Bläddra i källkod

输入数据"增减监听 + 按最新变化重算"的口径查证与补漏 (用户令 2026-09-20)

用户令: 「对 <安装目录>/data/raw (含嵌套子目录) 下文件的增减做到监听; 重算时要根据该目录最新的变化重算;
确保实现是否无误」。逐条查证后发现原实现有 4 处不达标, 已修:

1) 扫描只统计"白名单扩展名"的文件 ⇒ 往族目录丢个 .zip/.txt 看不见; 未归类只看一层子目录。
   改: 递归统计**全部文件**(白名单命中另记 matched, 差额单列为"信息, 不计变化"); 指纹加入**子目录清单**
   (空目录增删也能发现); 未归类改成**全树逐件**找。实测: 3 层深的 新增文件/空目录 都能报出并指名该跑哪步。
2) 三个摄入器(报警/工单/油样)都是"只替换本次涉及的件, 其余原样保留" ⇒ **删掉源件, 它带来的行永远留着**。
   改: 产物 = 当前源件的函数; 消失的行如实丢掉并报出"哪几件不在盘、多少行、涉及哪些月"。
3) ★报警台账的"累计快照判定"是**贪心**(按行数升序累加, 没新键就跳过) ⇒ 实测**删一个源件反而让总行数
   从 39,211 涨到 56,593**(被跳过的大快照件因少了一个键而整件摄入 ⇒ 重复计数)。
   改: 按**键集合并集**去重(键=Name/Alarmcode/TimeOn), 归属取最窄源件; 实测 39,211 键稳定,
   逐件合计 78,368 行 ⇒ 去重 39,157 行(快照件贡献 0)。
4) 振动同名窗存在时一律改名 `_reimport`(被 EXCLUDE_DEFAULT 排除) ⇒ **补进来的 CMS 数据进不了生产集**。
   改: 默认**替换同名窗**(旧窗留档 `_superseded_<时分>`, 并加入 EXCLUDE_DEFAULT; 要旧行为用 --reimport-as-new)。
   单测: 旧窗→_superseded, 新窗用正名, 生产集只认正名。

顺带: 三件台账的锚点保持不变(报警 39,211 / 工单 5,876 / 油样 404 —— 说明口径修正没有改动"本来就是对的"部分);
`_raw_scan.json` 是链上快照不是产物, 台账与审计都按"链上台账"排除(不再报未归类)。

验证矩阵: 扫描自检 OK · 同数据重扫 rc=0(幂等) · 嵌套 3 层新增/删除/空目录均被报出 · --auto 取消
--skip-scada(计划 22→24 步) · 链守门 chain_gap/反向呼应/pages_audit 全 rc=0。
zhouyang.xie 3 veckor sedan
förälder
incheckning
bdaef4ce6a

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 1
docs/版本记录.md


+ 4 - 1
docs/系统设计说明.md

@@ -896,7 +896,10 @@ R7 每个域必须声明 consumers 或写明"无人读";R8 `configs/farms/` 
 
 ```
 ① 放数据 place_raw_data.py            原样搬 现场包 → data/raw/<场>/(同尺寸跳过,冲突拦下)
-② 三门台账 rebuild_from_raw.py         报警/工单/油样 ← 台账类源件
+①b 输入扫描 raw_scan.py                 逐族指纹(件数/体积/最新/清单摘要/子目录集) ← data/raw 递归;
+                                      发现新增/变化就列出"该跑哪几步", 并把本次指纹记成新基线
+                                      (rc 0 无变化 · 4 有新增/变化 · 5 首次没快照; 4 不是失败)
+② 三门台账 rebuild_from_raw.py         报警/工单/油样 ← 台账类源件(**产物=当前源件的函数**)
 ③ SCADA 侧 rebuild_from_raw.py --scada 10 个构建器 ← 14 GB 10min CSV(约 15 分钟)
 ④ 月度派生件 windscada_monthly_build.py 逐值对齐随包件才落盘(缺基线返回 5 只跳过"等价验收")
 ④b 振动摄入 vib_raw_build.py           索引/谱库 ← TCM 导出(约 14 分钟)

+ 11 - 2
docs/输入数据放置指导_v0.1.md

@@ -75,17 +75,26 @@ python scripts\raw_scan.py --check      # rc=0 无变化 · rc=4 有新数据(
 python scripts\rebuild_all.py --auto    # 先扫一遍再重算: 新数据落在被 --skip-* 跳过的族里时自动取消跳过
 ```
 
-`--check` 的逐族口径是**指纹**(件数 / 体积 / 最新落盘时间 / 清单摘要;`--deep` 再叠内容哈希),
-snapshot 落在 `outputs\<场>\_raw_scan.json`,重算链第 **①b** 步每次都会顺手更新它。
+`--check` 的逐族口径是**指纹**(件数 / 体积 / 最新落盘时间 / 清单摘要 / **子目录清单**;
+递归统计 `data/raw/<场>/<族>/**` 下**全部文件**(不限扩展名),白名单命中的件数另记 `matched`;
+`--deep` 再叠内容哈希),快照落在 `outputs\<场>\_raw_scan.json`,重算链第 **①b** 步每次都会顺手更新它。
+⇒ **文件的增/删(含嵌套子目录、含空目录的增删)都会被发现**;`data/raw` 里出现族表之外的目录/文件
+会按"未归类、没有消费者"报出来,不猜它是哪一族。
 若想让服务自己盯着(每 N 分钟扫一次、可选自动重算):`configs\serve.json` 的 `raw_watch`
 (默认关:`{"enabled": false, "minutes": 30, "auto_rebuild": false}`)。
 
+★ **产物 = 当前 `data/raw` 的函数**(用户令 2026-09-20「重算时要根据该目录最新的变化重算」):
+**删掉/换掉源件**后重算, 那些行就不再产出(脚本会大声报出"哪几个源件已不在盘、少了多少行、涉及哪些月",
+好让人分清误删与现场撤数)。报警台账按**键集合并集**去重(键 = Name/Alarmcode/TimeOn),
+所以累计快照件不会把同一事件算两遍,加减文件也不会让总数乱跳。
+
 | 新增的源类 | 该跑的重算 | 预计 |
 |---|---|---|
 | `scada_10min` / `scada_1min` | `python scripts\rebuild_all.py` | 含 10 个构建器,约 15 分钟 |
 | `故障报警` / `风机故障记录` / `油样报告` | `python scripts\rebuild_all.py --skip-scada` | 约 2 分钟 |
 | `windcms` / `m5_cms_tcm` | 同上(重算链第 ④b 步自动摄入索引/谱 + 报告/在升闭环/三层基线) | 按数据量(153 GB 导出约 25 分钟索引+谱) |
 | `scada_mdb`(现场年度归档库) | 同 `scada_10min`(CSV 缺失时取数层会回落到 MDB) | 同 ③/④/④c |
+| **删掉/换掉**上面任一类 | 同上(该族对应的那几步)—— 产物随之更新,删掉的行不再出现 | 同对应行 |
 | 只放了文档/技术资料 | 无需重算(不影响页面数值),但 `西门子4.0技术资料` 变了要重跑 ⑦ 本体层 | — |
 
 重算后核对锚点:报警 **39211** · 工单 **5876** · 油样 **404** · `temp_monthly` **19494** · 本体 **9702** 对象(审计 0 问题);

+ 2 - 1
scripts/products_restore_missing.py

@@ -254,7 +254,8 @@ def refresh_ledger(farm: str | None = None) -> int:
         if not p.is_file():
             continue
         rel = p.relative_to(root).as_posix()
-        if rel in ('_provenance.json', '_derived_manifest.json'):
+        if rel in ('_provenance.json', '_derived_manifest.json', '_raw_scan.json'):   # 链上台账/快照, 不是产物
+            kept.pop(rel, None)          # ★旧账里可能留有这条(早先没排): 顺手出清, 免得"账实相符"永远差一件
             continue
         # 只补"没人登记"的; 但**族表归口过的条目要重跑** —— 否则族表改了(例如给 windcms/cache
         # 单列一族)这条账会一直停在旧归口上, 而它并不是谁自登记/谁补进来的真来源。

+ 857 - 857
scripts/products_reverse_audit.py

@@ -1,857 +1,857 @@
-#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
-r"""**反向**呼应审计:自输出 → 功能与算法 → 输入(用户令 2026-09-17)。
-
-## 与 `inventory_products.py` 的区别(两个方向,各管一件事)
-
-· `inventory_products.py` 是**正向**:从 `data/raw/<场>/` 的每一类输入出发,找它喂出来的产物,
-  比跨度/条数("输入到了 2026-07,产物只到 2026-04 → 未重算")。它只覆盖 5 类输入、十几件产物。
-· 本器是**反向**:从 `outputs/<场>/` 的**每一件产物**出发,问三个问题:
-    ① 它是谁算出来的?(功能与算法 = 生成端)
-    ② 它从哪份输入算出来的?(`data/raw/<场>/` 的哪一类)
-    ③ 这条"输出 ↔ 输入"的呼应关系**成立吗**?(生成端在位 ∧ 输入在位 ∧ 可机检的对应判据通过)
-  逐件判定,最后给出"成立 / 不成立(无生成端)/ 无法验证"三类账。
-
-## 为什么必须做反向
-
-正向只查"我关心的输入有没有被算成产物",查不出**"盘上这件产物到底有没有来路"**。
-2026-09-17 现场就是栽在这里:清了产物之后,页面缺的 `windscada/index.html`、
-`m5_cms_tcm/handoff_vibration_v2.json` 这类件**根本没有生成端**(全库 0 处写入方),
-正向那几条跨度判据一条都不会报 —— 它们压根不在 `INPUTS` 的 `feeds` 里。
-反向一对账就清楚了:**有来路的件**(生成端 + 输入 + 判据都过)与**没来路的件**(只能从交付包补)。
-
-## 判定口径(每条都写进结果里,不含糊)
-
-    ✓ 呼应成立          生成端在位 ∧ 输入在位 ∧ 判据通过(跨度 ⊆ 输入 / 键集 ⊆ 输入 / 计数一致 / 行数 > 0)
-    ~ 输入不在位         生成端知道,但 `data/raw/<场>/<类>` 不在 ⇒ **现在无法验证**(放数据后复跑本器)
-    ✗ 无生成端           全库 0 处写入方 ⇒ **输出↔输入的呼应在原理上不成立**:这件产物无法由输入推导出来,
-                        **无法由重算生出来**;按用户令 2026-09-17 #1 运行期也不从交付包补齐 ⇒ 只能由研发补生成端(本器 --feasibility 给出逐族可逆性)
-    ? 未归类             既不在族表里、台账里也没有 —— 需要人工认领(本器把它当缺口报出来)
-
-## 用法
-
-    python scripts/products_reverse_audit.py            # 全量反向审计(人看)
-    python scripts/products_reverse_audit.py --check    # 只出结论(有 ✗/? 时退出码 5)
-    python scripts/products_reverse_audit.py --write-doc # 把族表写进 docs/系统设计说明.md §13.6
-
-退出码: 0 全部成立(允许 ✗,只要它们都在台账里如实标了 shipped)· 5 有未归类件或判据失败
-"""
-from __future__ import annotations
-
-import argparse
-import fnmatch
-import json
-import pathlib
-import sys
-
-ROOT = pathlib.Path(__file__).resolve().parents[1]
-sys.path.insert(0, str(ROOT))
-sys.path.insert(0, str(ROOT / 'scripts'))
-from src import paths as P                                              # noqa: E402
-import inventory_products as INV                                        # noqa: E402
-
-DOC_BEGIN, DOC_END = '<!-- REVERSE-AUDIT:BEGIN -->', '<!-- REVERSE-AUDIT:END -->'
-
-# ── 族表: 输出(glob) → 功能 / 算法(生成端) / 输入类 / 判据 ────────────────────────────
-#   pred: span=产物时间跨度必须落在输入跨度内; turbines=产物机组集 ⊆ 输入机组集;
-#         rows=行数>0; count=件数/计数与说明一致; manifest=与清单自记一致
-FAMILIES: list[dict] = [
-    dict(id='vib_window_index', glob='m5_cms_tcm/windows/*/index.parquet', kind='raw-derived',
-         func='振动摄入 · 窗索引', algo='scripts/rudong_tcm_index.py(逐 decode JSON 解析 54 列:turbine/'
-              'sensor_name/meas_name/trigger_time/rpm/condition_key/alarm_type)',
-         gen='scripts/rudong_tcm_index.py', input='windcms', pred=('span', 'turbines', 'rows'),
-         time_col='trigger_time'),
-    dict(id='vib_spectra', glob=['m5_cms_tcm/windows/*/spectra/**', 'm5_cms_tcm/windows/*/spectra_meta.parquet'],
-         kind='raw-derived',
-         func='振动摄入 · 谱库', algo='scripts/rudong_tcm_spectra.py(FFT_ 测量 → npz 幅值数组, 分片存 `spectra/p<NN>/`)',
-         gen='scripts/rudong_tcm_spectra.py', input='windcms', pred=('npz',)),
-    dict(id='vib_raw_manifest', glob='m5_cms_tcm/vib_raw_manifest.json', kind='raw-derived',
-         func='振动摄入 · 清单', algo='scripts/vib_raw_build.py(汇总窗/谱件数、时间跨度、missing_chain 缺口)',
-         gen='scripts/vib_raw_build.py', input='windcms', pred=('manifest',)),
-    dict(id='scada_alarms', glob='windscada/alarms.parquet', kind='raw-derived',
-         func='三门台账 · 报警', algo='scripts/windscada_alarms_ingest.py(SpreadsheetML *.xls → 事件表)',
-         gen='scripts/windscada_alarms_ingest.py', input='故障报警', pred=('span', 'rows'), time_col='t_on'),
-    dict(id='scada_workorders', glob='windscada/workorders.parquet', kind='raw-derived',
-         func='三门台账 · 工单', algo='scripts/windscada_workorder_ingest.py(检修台账 → 工单表)',
-         gen='scripts/windscada_workorder_ingest.py', input='风机故障记录', pred=('span', 'rows'),
-         time_col='t_report'),
-    dict(id='scada_oil', glob='windscada/oil_samples_index.parquet', kind='raw-derived',
-         func='三门台账 · 油样', algo='scripts/windscada_watch_channels_build.py(油样 PDF → 索引)',
-         gen='scripts/windscada_watch_channels_build.py', input='油样报告', pred=('span', 'rows'),
-         time_col='date'),
-    dict(id='scada_monthly', glob='windscada/temp_monthly.parquet', kind='raw-derived',
-         func='月度派生件', algo='scripts/windscada_monthly_build.py(逐值对齐随包件 19,494/19,494)',
-         gen='scripts/windscada_monthly_build.py', input='scada_10min', pred=('span', 'rows'), time_col='month'),
-    dict(id='scada_derived', glob='windscada/*.parquet', kind='raw-derived',
-         func='SCADA 派生分析(功率曲线/损失/曲线/控制/停机/温度/偏航/液压/热链/系统辅助)',
-         algo='src/windscada/perf/{powercurve,availability,curves,control,faults} + subsys/{temp_nbm,yaw,'
-              'hydraulic,thermal_chain} + taxonomy.py',
-         gen='src/windscada/perf/powercurve.py', input='scada_10min', pred=('rows',)),
-    dict(id='ontology_core', glob='ontology/{objects.json,retrieval_index.json,turbine_params.parquet}',
-         kind='raw-derived',
-         func='本体层 · 码表/对象库/检索索引/实机参数',
-         algo='python -m src.ontology.kb_ingest → populate → chain_ingest → trend_ingest;'
-              'src.ontology.retrieval.build;src.ontology.maintenance.refresh_params',
-         gen='src/ontology/kb_ingest.py', input='西门子4.0技术资料', pred=('rows',)),
-
-    dict(id='ontology_aux_build', glob=['ontology/_id_alias.json', 'ontology/retrieval_vec.npy'],
-         kind='raw-derived',
-         func='本体层 · 别名表与向量索引缓存', algo='src.ontology.kb_ingest(别名)/ '
-              'src.ontology.retrieval.build(use_vec=True)(向量)',
-         gen='src/ontology/kb_ingest.py', input='西门子4.0技术资料', pred=('rows',)),
-    dict(id='ontology_domain', glob=['ontology/oil_2026H2_huabiao.json', 'ontology/parts_cost_public.json',
-                                     'ontology/scenario_29_repair.json'],
-         kind='shipped',
-         func='本体层 · 领域数据件(油样台账/备件价格/场景修复)', algo='(无生成端, 领域正本出件)',
-         gen=None, input=None, pred=(), why='领域正本随包发来, 不由 data/raw 推导'),
-
-    # ── 以下族: 全库 0 处写入方 ⇒ 反向呼应**在原理上不成立**(如实记账, 不假装成立)────
-    #    ★2026-09-18 用户令补充: 这些件**运行期一律不从交付包补**(用户令 2026-09-17 #1 的延伸),
-    #      页面如实为空/降级; 要出件只有两条路 —— ① 研发补生成端; ② 现场正本随原始件进 data/raw。
-    #      故本表的 why 里不再写"从交付包补齐"那种话(那是**离线人工补救**, 见 products_restore_missing.py)。
-    dict(id='vib_model_run_l6', glob=['m5_cms_tcm/model_run_l6.parquet', 'm5_cms_tcm/model_run_summary.json'],
-         kind='raw-derived',
-         func='六层链 model_run 步 · L6 过闸谱线表 + 层小结',
-         algo='scripts/rudong_model_run.py(候选线=oem_scan_plan 的 11 部件×{BPFI,BPFO,BSF}; 过闸='
-              'src/sop/discriminators.py::spectral_line_gates G1–G8; 定级=vib_verdict_and_writeback)',
-         gen='scripts/rudong_model_run.py', input='m5_cms_tcm', pred=('rows',),
-         why='★2026-09-19 按口径重建(**无标准答案**: 这两件从未随包, 无法逐值对拍); 峰值拾取仍是本器自定'
-             '(目标频率 ±2 bin 取最大), 不得当"复现"用 —— 见 docs/振动六层链_接口规格与缺口_v0.1.md §3'),
-    dict(id='vib_fusion_38', glob='m5_cms_tcm/fusion_38.csv', kind='raw-derived',
-         func='六层链 fusion 步 · 逐台融合表(报告"融合级"列的来源)',
-         algo='scripts/rudong_fusion_run.py::fusion_table(模型侧=model_run_l6 过闸线; CMS 侧=窗索引 '
-              'RedMask/YellowMask; 裁决=src/sop/fusion_diag.py::fuse)',
-         gen='scripts/rudong_fusion_run.py', input='m5_cms_tcm', pred=('rows',),
-         why='★按口径重建(无标准答案): 台号用 WTGnn 形态(消费端 registry 的键), 与 l6 的 f"{n}#" 不同'),
-    # ★2026-09-19 源代码化(第三批):三族由观澜自算顶上。★族表是**首个命中生效**,故这几族必须
-    #   排在通配族(`vib_handoff_and_scans` = `m5_cms_tcm/*.{json,parquet,csv,md,txt}`、`tcm_replay`、
-    #   旧的 `ontology_releases`)**之前**,否则新件会被旧族认领成"无生成端"。
-    dict(id='vib_line_energy_share', glob='m5_cms_tcm/line_energy_share.parquet', kind='raw-derived',
-         func='六层链 energy_share 步 · 线能量占比',
-         algo='scripts/rudong_line_energy_share.py(±2bin 带内能量 ÷ 全谱能量 ×100; 候选线与分母同 oem_scan_plan)',
-         gen='scripts/rudong_line_energy_share.py', input='m5_cms_tcm', pred=('rows',),
-         why='2026-09-19 补上: 该步脚本此前未随包(chain_gap_check 报"1 个步骤脚本未随包"); 只报占比不定级'),
-    dict(id='vib_mask_thresholds', glob=['tcm_compatible_replay/model/mask_thresholds.tsv',
-                                         'tcm_compatible_replay/model/mask_thresholds.说明.json'],
-         kind='raw-derived', func='TCM 掩码阈值件 (逐台逐测点逐工况的黄/红线)',
-         algo='scripts/tcm_mask_thresholds_build.py(中位 + 3/6×MAD 自适应基线, 基线先净化, 样本门 n≥30; '
-              '调 src/sop/discriminators.py::adaptive_baseline_threshold 单一实现)',
-         gen='scripts/tcm_mask_thresholds_build.py', input='m5_cms_tcm', pred=('rows',),
-         why='★**观澜自算**阈值, 不是厂商阈值: 现场导出件里没有掩码定义(25,679 个 decode 只有 NumberOfMasks: 175) '
-             '⇒ 厂商阈值无法复原; 口径写在同目录 mask_thresholds.说明.json, 页面据此标注(workbench.py 分支)'),
-    dict(id='ontology_releases', glob='ontology/release_*/*', kind='raw-derived',
-         func='本体发布层 r1/r2(网关 /release/ 只读暴露)',
-         algo='scripts/ontology_release_build.py(对象库投影: r1 契约层 = objects.json 快照 + manifest; '
-              'r2 = 同快照 + layering.json 分层覆盖声明, 不合并主库)',
-         gen='scripts/ontology_release_build.py', input='西门子4.0技术资料', pred=('rows',),
-         why='2026-09-19 由观澜自算顶上: 原为随包件(0 处写入方) ⇒ 新机器 /release/ 两件必 503; '
-             'manifest 里写 source.sha16, 谁都能核出两层是同一份对象库的投影'),
-    dict(id='vib_fleet_scalar_z', glob='m5_cms_tcm/fleet_scalar_z.parquet', kind='raw-derived',
-         func='振动线 · 全场标量 z 值 (fusion 步出件)',
-         algo='scripts/rudong_fusion_run.py(逆向工程实现: 窗索引 scalar_value → 同 (传感器,测量,工况) 族中位 '
-              '→ 稳健 z=(val−med)/(1.4826·MAD); 与随包样件 8,887 行逐值对拍 val 列 100% 一致)',
-         # input 写**原始件目录名**(族表口径: data/raw/<场>/<input>); 真实取数经窗索引一跳,
-         # 而窗索引本身是 raw-derived(vib_window_index 族) —— 写 windows/_*.parquet 会让审计判"输入不在位"。
-         gen='scripts/rudong_fusion_run.py', input='m5_cms_tcm', pred=('rows',),
-         why='2026-09-18 上链: 由 vib_raw_build.py 的 --with-report 分支按当次窗名调用, '
-             '使 /api/fleet 的振动标量 z 面不再空白'),
-    dict(id='vib_fusion_handoff', glob='m5_cms_tcm/handoff_vibration_v2.json', kind='raw-derived',
-         func='融合面 handoff (观澜自算, 同结构于振动线手交件)',
-         algo='scripts/rudong_fusion_handoff.py(报告状态等级 + fusion_38 融合裁决 + L6 过闸线 → 同结构, '
-              'meta.from 标明"观澜自算")',
-         gen='scripts/rudong_fusion_handoff.py', input='m5_cms_tcm', pred=('rows',),
-         why='★2026-09-19 用户令「所有的计算均要形成观澜的源代码」: 这件此前全库 0 处写入方(振动线人写), '
-             '新机器装完 `/detail/v2` 的「需要关注/全场状态」必然空白。现由观澜自算出同结构件; '
-             '盘上若有**振动线正本**, 生成端不覆盖(正本含人工裁决, 优先)'),
-    # ★2026-09-19 用户令「所有的计算均要形成观澜的源代码」: 这两件此前 kind=shipped(振动线人交手交件),
-    #   新机器上 ⑦ 的 `src.ontology.trend_ingest` 报"源缺失"、CMS 报告的三层基线卡为空。
-    #   现由两个生成端从**窗索引**算: component_history(逐窗标量 x_self → 在升/换件闭环; 窗不足时如实为空
-    #   并把数据边界逐条写进 meta) / baseline_38(健康期分段中位 → 自基线 + fleet 基线 + ISO 绝对限)。
-    #   ★必须排在通配族 `vib_handoff_and_scans` **之前** (族表首个命中生效), 否则又被认领成"无生成端"。
-    dict(id='vib_history_baseline',
-         glob=['m5_cms_tcm/component_history.json', 'm5_cms_tcm/baseline_38.json'],
-         kind='raw-derived',
-         func='振动在升/换件闭环历史 + 三层基线(自 / 机群 / 绝对)',
-         algo='scripts/component_history_build.py(逐窗标量 x_self → 末三窗/早三窗在升 + 换件前后闭环; '
-              '窗不足时判据不可算则如实空表)+ scripts/baseline_38_build.py(健康期分段中位 + 正常带 + '
-              'relMAD → 自基线, 机群同段中位 → fleet 基线, ISO 20816-3 → 绝对限)',
-         gen='scripts/component_history_build.py', input='windcms', pred=('rows',),
-         why='输入是 CMS 原始导出的**重算产物**(窗索引), 其 raw 祖先是 data/raw/<场>/windcms; '
-             '旧随包件是振动线手交件(全库 0 处写入方); 两件都在重算链 ④d/④e, 口径与数据边界写进件内 meta'),
-    dict(id='vib_handoff_and_scans', glob='m5_cms_tcm/*.{json,parquet,csv,md,txt}',
-         kind='shipped',
-         func='振动线出件与专项扫描(handoff / 历史 / 基线 / 各类 freq scan / 判级台账)', algo='(无生成端)',
-         gen=None, input=None, pred=(), why='振动六层链四步脚本未随包(oem_frequency_scan 等)⇒ '
-                                            '运行期不从交付包补(用户令 2026-09-17); 要出件须研发补生成端, '
-                                            '或由现场正本随原始件进 data/raw 后重算'),
-    # (2026-09-19: `line_energy_share` / `mask_thresholds` / `ontology_releases` 三族已移到前面按
-    #  raw-derived 记账 —— 族表是**首个命中生效**, 必须排在通配族 `vib_handoff_and_scans` / `tcm_replay`
-    #  / `ontology_releases(旧)` 之前, 否则新件会被旧族认领成"无生成端")
-    dict(id='vib_figs', glob='m5_cms_tcm/figs/*', kind='shipped',
-         func='振动线出图(谱图/趋势图)', algo='(无生成端)', gen=None, input=None, pred=(),
-         why='随包快照(振动线出件)'),
-    # ★2026-09-19 拆族 (用户令「所有的计算均要形成观澜的源代码」): `windscada/index.html` 是
-    #   `/detail/v2` 的「全场状态总览」iframe 目标, 此前整族记为"无生成端" ⇒ 新机器上那块必然显示
-    #   "产物不在位"。现在它由 `scripts/windscada_overview_build.py` 用**组件同一个快照烘焙器**
-    #   (src/windscada/ui/snapshot.py::bake, /v2/snapshot 用的就是它)从重算产物生成 ⇒ 单列一族。
-    dict(id='windscada_overview', glob='windscada/index.html', kind='raw-derived',
-         func='总览「全场状态」页 (单文件: 接口响应烤进页面, 离线可开)',
-         algo='scripts/windscada_overview_build.py(ctx 取数函数与在线 /api/* 同一实现; 经 '
-              'src/windscada/ui/snapshot.py::bake 烘焙)',
-         gen='scripts/windscada_overview_build.py', input='scada_10min', pred=('rows',),
-         why='2026-09-19 用户令后上链: 该页此前是随包件(全库 0 处写入方), 清过产物的机器上 iframe 只能'
-             '显示"产物不在位"; 现在由观澜自算, 每次重算随数据更新'),
-    dict(id='windscada_pages', glob='windscada/{turbines/*,review/*}', kind='shipped',
-         func='逐台静态页 + 评审记录(当前由组件按请求动态出页, 静态件仍属随包快照)', algo='(无生成端)',
-         gen=None, input=None, pred=(), why='逐台页在运行期由 /turbine/<台> 动态生成, 静态副本无写入方; '
-                                             '运行期不从交付包补(用户令 2026-09-17)'),
-    # ★2026-09-18 补: `windcms/cache/*` 是**读侧缓存**(标量按内容哈希落盘), 它不是随包快照 ——
-    #   原先 `windcms_pages`(shipped) 先认领了它, 于是重算后新写的缓存被记成"包内无生成端"。
-    #   写方在 src/windcms/data.py::load_scalars (窗索引 → 标量), 故单列一族并放在 windcms_pages 之前。
-    dict(id='windcms_cache', glob='windcms/cache/*', kind='raw-derived',
-         func='CMS 报告侧 · 标量缓存', algo='src/windcms/data.py::load_scalars(窗索引 → 标量数组, '
-              '按内容哈希命名落 cache/)', gen='src/windcms/data.py', input='windcms', pred=('rows',)),
-    # ★2026-09-18 补 (用户令"页面只能基于输入数据重算"之后): `windcms/**` 整族原先一律按
-    #   "无生成端"记账 —— 但**报告两步的生成端就在包内**(scripts/windcms.py report → src/windcms/
-    #   report.py + report_std.py), 只要 data/raw/<场>/windcms 里有 decode 导出就出得来。
-    #   实测(2026-09-18): 从重算出的窗 w0316 一键跑出标准模版报告 + 38 台逐台页 + 总览, 判级 38 台
-    #   (报警 3 / 优秀 35), 缺的只有 model_run/fusion 两步的融合级列(如实写 —)。故拆成两族按 raw-derived 记。
-    dict(id='windcms_report_std', glob=['windcms/报告_CMS振动状态评估报告_*.md',
-                                        'windcms/报告_CMS振动状态评估报告_*.docx',
-                                        'windcms/登记_三轴_38台.csv'], kind='raw-derived',
-         func='CMS 标准模版评估报告 (三轴: 状态等级 × 证据状态 × 行动等级建议)',
-         algo='src/windcms/report_std.py::build(结构化判级 → 报告 md + docx + 登记 csv; '
-              '由 scripts/windcms.py report 调用)',
-         gen='src/windcms/report_std.py', input='windcms', pred=('rows',),
-         why='★同名件有两种来源: 观澜自算(本族: 从 data/raw/<场>/windcms 的 *_decode.json 算) 与 '
-             '厂家报告转录(scripts/vib_reports_build.py, 输入是现场振动分析报告 PDF)。按名字认领不了'
-             '这层区别 ⇒ 以"是否由本步生成"为准, 判据看 m5_cms_tcm/vib_raw_manifest.json 的 steps'),
-    dict(id='windcms_report_overview', glob=['windcms/report.md', 'windcms/index.html',
-                                             'windcms/index_eng.html', 'windcms/overview.html',
-                                             'windcms/turbines/*'], kind='raw-derived',
-         func='CMS 诊断总览页 + 逐台页 + 结构化层报告',
-         algo='src/windcms/report.py::build(逐台 turbines/<台>.html + 总览 overview/index.html + '
-              'report.md; 融合级列要 model_run/fusion 出件, 缺则如实写"—")',
-         gen='src/windcms/report.py', input='windcms', pred=('rows',)),
-    dict(id='windcms_kb', glob='windcms/kb.json', kind='raw-derived',
-         func='CMS 知识库索引 (问答检索用)',
-         algo='src/windcms/knowledge.py::build(由 scripts/windcms.py kb 调用; 离线无模型时 0 chunks 也算跑成)',
-         gen='src/windcms/knowledge.py', input='windcms', pred=('rows',)),
-    dict(id='windcms_pages', glob='windcms/**', kind='shipped',
-         func='windcms 里其余件 (厂家报告转录 / 知识库出件等)',
-         algo='(无随包生成端)', gen=None, input=None, pred=(),
-         why='上述两族与 cache 之外剩下的 windcms 件: 运行期不从交付包补(用户令 2026-09-17), '
-             '要出件须研发补生成端'),
-    # ★2026-09-19 用户令「要让观澜从重算台账生成 claim」: findings 与三份底稿改由**观澜自算**
-    #   (scripts/sop_findings_from_ledger.py 从 alarms/workorders/oil/temp_bins/powercurve_dev/loss_monthly/L6
-    #   算 claim, 按契约构建器要的字段结构写) ⇒ 单列一族; 契约四件随之由 scripts/guanlan_facts_contract.py 生成。
-    dict(id='ledger_claims', glob=['sop/findings.json', 'paradigm_r1/experiments/*/底稿*.json'],
-         kind='raw-derived',
-         func='事实契约的 claim 本体与底稿 (观澜自算, 替代人裁底稿)',
-         algo='scripts/sop_findings_from_ledger.py(六族 claim: 报警集中/重复检修/停机损失/温度离群/功率曲线偏离/振动过闸线; '
-              '每条带 coverage·缺证据·证伪判据, 相对判据封顶「候选」)',
-         gen='scripts/sop_findings_from_ledger.py', input='scada_10min', pred=('rows',),
-         why='输入是**重算产物**(windscada/*.parquet + m5_cms_tcm/*): 其 raw 祖先是 故障报警/风机故障记录/scada_10min; '
-             '本族单列以区别于旧的人裁底稿(同路径旧件无生成端)'),
-    dict(id='sop_workspace', glob='sop/**', kind='shipped',
-         func='SOP 中间件/评审/事实契约底稿', algo='(无生成端)', gen=None, input=None, pred=(),
-         why='评审过程件, 属人工作业留痕'),
-    dict(id='tcm_replay', glob='tcm_compatible_replay/**', kind='shipped',
-         func='TCM 兼容链回放资产(模型表/掩码阈值/裁决记录)', algo='(无生成端)', gen=None, input=None,
-         pred=(), why='随包快照'),
-    dict(id='paradigm_r1', glob='paradigm_r1/**', kind='shipped',
-         func='范式实验件(E3/E5/E8 底稿)', algo='(无生成端)', gen=None, input=None, pred=(),
-         why='实验底稿, 事实契约的输入'),
-    # ★2026-09-19 用户令「所有的计算均要形成观澜的源代码」: 变桨面两件原来 kind=shipped(无生成端)
-    #   ⇒ 换台机器重算后必然缺件、变桨面恒"不可判"。现由 scripts/pitch_face_build.py 从 data/raw 的
-    #   scada_10min(液压/润滑/柱塞/运行状态) + scada_1min(三叶桨距角/压力/有功) 算出, 并进重算链 ④c。
-    dict(id='pitch_face', glob='pitch/**', kind='raw-derived',
-         func='变桨侧派生件(日粒度液压/润滑/柱塞 + 零位三口径月表)',
-         algo='scripts/pitch_face_build.py(10min 开关量 timeon/压力锯齿 + 1min 停机顺桨段/满发段/运行段分档 '
-              '三叶桨距角中位 → 相对同月全场中位的零位偏差;口径/阈值/证据见同目录 pitch_face_manifest.json)',
-         gen='scripts/pitch_face_build.py', input='scada_10min', pred=('rows',),
-         why='输入是 10min 与 1min 两路 SCADA 原始导出件(scada_source: CSV 优先/MDB 回落);'
-             '零位按用户令两口径分列(停机顺桨段/满发段),并据实测反推补上**运行段同工况分档**这一判据轴'
-             '(它才能把现场确诊的 19# 单独拎出来)'),
-    # (2026-09-19: `ontology_releases` 已移到前面按 raw-derived 记账 —— 现在由
-    #  scripts/ontology_release_build.py 从对象库投影生成, 不再是"由研发出件"的随包层)
-    # ★2026-09-19 用户令「所有的计算均要形成观澜的源代码」+「从重算台账生成 claim」:
-    #   这一族从"无生成端"改成 raw-derived —— 生成端 guanlan_facts_contract.py 本来就在包内,
-    #   原来缺的是**它的输入**(人裁底稿); 现在输入由上游族 ledger_claims 自算
-    #   (scripts/sop_findings_from_ledger.py) ⇒ 新机器上重算即得, /api/facts 不再 503。
-    dict(id='guanlan_contract', glob='guanlan/**', kind='raw-derived',
-         func='事实契约与对外派生(门户结论段/取数)',
-         algo='scripts/guanlan_facts_contract.py(build→render: 契约 + portal_claims/detail_cards/qa_refs/'
-              'report_summary; 输入=上游族 ledger_claims 自算的 sop/findings.json + 三份底稿)',
-         gen='scripts/guanlan_facts_contract.py', input='scada_10min', pred=('rows',),
-         why='输入与 data/raw 隔着两跳(重算台账 → claims → 契约), 但**两跳的生成端都在包内**; '
-             'claim 的判据/缺证据/证伪逐条写在件里, 相对判据封顶「候选」'),
-    dict(id='windscada_release_manifest', glob='windscada/release-manifest.json', kind='source-derived',
-         func='发布清单快照(`/healthz` 与版本信息读它)',
-         algo='scripts/guanlan_baseline_manifest.py(从 src/windscada/ui 源重建 zh 页 → 摘要 '
-              '{css_tokens, nav, dom, sha256, source blobs})',
-         gen='scripts/guanlan_baseline_manifest.py', input=None, pred=(),
-         why='★ 2026-09-17 修正: 它一度被我按"交证件"错分成非产物; 实际**包内有生成端**'
-             '(guanlan_baseline_manifest.py 就写这个路径), 且 guanlan_gateway.py 运行期读它 ⇒ 是产物'),
-    dict(id='human_deliverables',
-         glob=['windscada/mblub_alarm_cross.json', 'windscada/review_presented.json',
-               'windscada/给振动线_温度轴回复_20260826.json', 'windscada/给振动线_温度轴回复_20260826.md',
-               'windscada/送审_系统结论与判定_20260828.md'],
-         kind='human',
-         func='**人工正本/交证件**:与振动线的跨线通知、送审结论、发布评审快照',
-         algo='(人写的, 不是算出来的)', gen=None, input=None, pred=(),
-         why='这几件是"人对人的交证件/回执", 内容由分析结论抄写与人判断坐实而成; '
-             'mblub_alarm_cross.json 还被 src/ontology/populate.py 当作**证据来源**引用 ⇒ '
-             '必须随包留在原位, 但**不参与呼应校验**(它本来就没有生成端, 也不该有)'),
-    dict(id='scratch_in_products', glob=['**/_*.js', '**/_*audit*.md', '**/*.out', '**/*.err',
-                                        '**/*_test_*.parquet', '**/*_test_*.json'],
-         kind='not-product',
-         func='**非产物**:自审脚本/记录与模型测试输出(躺在产物仓里)', algo='(工具脚本与运行痕迹, 不是产物)',
-         gen=None, input=None, pred=(),
-         why='2026-09-17: 17 件已移出到 reference/非产物留痕_20260917/ 并留了 _来源说明.json ⇒ '
-             '本族现在应为空; 一旦再出现(新脚本往产物仓里写 .out/_audit.js 之类), 本器当场报出来'),
-]
-
-
-def _files_of(glob) -> list[str]:
-    """族 glob(str 或 list)→ 台账里的相对键(支持 `{a,b}` 花括号与 `*`)。
-
-    ★ 深度必须**显式对齐**(2026-09-17 第一版踩过): `fnmatch` 的 `*` 是**跨 `/`** 的, 于是
-      `windscada/*.parquet` 会把 `windscada/turbines/xx.parquet`、`windscada/_pre_rebuild_*/xx.parquet`
-      一起吞进来 —— 反向审计最怕"族把不该管的件认领了", 那样账就假了。
-      规则: 模式里没有 `**` 时, 要求的 `/` 个数必须与键的 `/` 个数**相等**(逐条展开后各自判)。
-    """
-    pats = []
-    for g in ([glob] if isinstance(glob, str) else list(glob)):
-        if '{' in g:
-            head, tail = g.split('{', 1)
-            opts, rest = tail.split('}', 1)
-            pats += [head + o + rest for o in opts.split(',')]
-        else:
-            pats.append(g)
-    out = []
-    for rel in LEDGER:
-        for pt in pats:
-            if '**' not in pt and rel.count('/') != pt.count('/'):
-                continue
-            if fnmatch.fnmatch(rel, pt) or rel == pt:
-                out.append(rel)
-                break
-    return sorted(out)
-
-
-LEDGER: dict[str, dict] = {}
-
-
-def load_ledger(farm: str) -> dict[str, dict]:
-    """台账 = `_provenance.json`(逐件来源) + `_derived_manifest.json`(生成端自登记)。"""
-    out: dict[str, dict] = {}
-    root = P.out_root(farm)
-    for fn in ('_provenance.json', '_derived_manifest.json'):
-        f = root / fn
-        if not f.is_file():
-            continue
-        try:
-            d = json.loads(f.read_text(encoding='utf-8'))
-        except Exception:
-            continue
-        for rel, v in (d.get('files') or {}).items():
-            if isinstance(v, dict):
-                out.setdefault(rel, dict(source=v.get('source') or 'raw-derived',
-                                         builder=v.get('builder') or v.get('by') or '',
-                                         why=v.get('why') or '',))
-    return out
-
-
-def _input_home(station: pathlib.Path, sub: str) -> pathlib.Path | None:
-    """输入类目录在哪 —— 优先场站目录下, 其次 raw 根下。
-
-    ★ 机理层资料按 A2 约定**不在场站目录下**(`data/raw/西门子4.0技术资料/`, 见 src/windscada/config.py)。
-      只查场站目录会把本体层判成"输入不在位"(2026-09-17 第一版就这么误报过 3 件)。
-    """
-    for base in (station, station.parent):
-        d = base / sub
-        if d.is_dir():
-            return base
-    return None
-
-
-def _parquet_span(p: pathlib.Path, col: str | None):
-    import pandas as pd
-    try:
-        d = pd.read_parquet(p)
-    except Exception:
-        return None, None, 0
-    n = len(d)
-    if col and col in d.columns:
-        s = pd.to_datetime(d[col], errors='coerce')
-        if s.notna().any():
-            return str(s.min())[:10], str(s.max())[:10], n
-    return None, None, n
-
-
-def _npz_ok(p: pathlib.Path) -> bool:
-    """振动谱件: npz 能读出来且非空(列数不是"行", 不能拿 parquet 的行数判它)。"""
-    try:
-        import numpy as np
-        with np.load(p, allow_pickle=False) as z:
-            return any(z[k].size > 0 for k in z.files)
-    except Exception:
-        return False
-
-
-# 已知"含人工裁决/校准"的随包快照(按用户令 2026-09-17 #3 显式登记为人工件)。依据是文档里已经写明的性质:
-# docs §7「handoff_vibration_v2.json / component_history.json 是随包快照, 该件含人工裁决/校准更新,
-# 不是测量数据的函数」; baseline_38.json 的三层基线 self/absolute 两层由人工坐实; findings.json 是判级发现台账。
-HUMAN_SNAPSHOTS = {
-    'm5_cms_tcm/component_history.json',
-    'm5_cms_tcm/baseline_38.json',
-    'm5_cms_tcm/handoff_vibration_v1.json',
-    'm5_cms_tcm/findings.json',
-}
-
-# ── 逆向工程可行性 (用户令 2026-09-17 #2) ────────────────────────────────────────────
-# 问的是: "这批没有生成端的产物, 能不能从原始件/在包生成端**推**出来?"
-# 判定不靠印象, 靠两条机器证据:
-#   ① 生成端在不在包内(在 → 只是没接上, 跑一次就有);
-#   ② 产物正文里有没有**人工判断**的痕迹("裁决/审核/校准/评审/经验"这类字段)——
-#      含人工判断的件不是任何输入的纯函数, 逆向工程推不出来, 只能把那一步人工工作重做。
-import re as _re                                                         # noqa: E402
-JUDGE_PAT = _re.compile(r'人工|裁决|审核|复核|校准|评审|经验值|专家|judg|review|manual|calibrat|verdict|sign[_ ]?off',
-                        _re.I)
-# ★ 只认"**字段**叫这个名字"(`"裁决": …` / `裁决,`),不认正文里顺嘴提一句 ——
-#   2026-09-17 第一版拿整篇子串匹配, 结果把**页面**里的展示标签(`index.html` 里的"评审/人工")当成
-#   人工判断, 于是把"页面"整族误判成不可逆。这属于"判据太糙 → 结论假"。
-JUDGE_KEY_PAT = _re.compile(r'["\']?[^"\',:]{0,24}(人工|裁决|审核|复核|校准|评审|经验值|专家|'
-                            r'judg|review|manual|calibrat|verdict|sign[_ ]?off)[^"\',:]{0,24}["\']?\s*[:=]',
-                            _re.I)
-
-
-def judgement_evidence(farm: str, rels: list[str], cap: int = 8) -> tuple[int, list[str], int]:
-    """→ (含人工判断字段的抽样比例%, 样例, 被检查件数)。
-
-    只查**数据/文本件**(.json/.csv/.md/.txt); **页面(.html/.js)不算** —— 页面是产物的渲染,
-    里面出现"评审/人工"是展示标签, 不代表这份件内含判断。
-    """
-    hit, samples, checked, pages = 0, [], 0, 0
-    for rel in rels[:cap]:
-        p = P.out_root(farm) / rel
-        suf = p.suffix.lower()
-        if suf in ('.html', '.js', '.css', '.htm'):
-            pages += 1
-            continue
-        try:
-            if suf == '.json':
-                txt = p.read_text(encoding='utf-8', errors='replace')[:200000]
-                pat = JUDGE_KEY_PAT
-            elif suf in ('.csv', '.txt'):
-                txt = p.read_text(encoding='utf-8', errors='replace')[:50000]
-                pat = JUDGE_KEY_PAT
-            elif suf == '.md':
-                txt = p.read_text(encoding='utf-8', errors='replace')[:50000]
-                pat = JUDGE_PAT
-            else:
-                continue
-        except Exception:
-            continue
-        checked += 1
-        if pat.search(txt):
-            hit += 1
-            samples.append(rel)
-    return (100 * hit // checked if checked else 0), samples, pages
-
-
-def feasibility(fam: dict, rels: list[str], gen_ok: bool, in_ok: bool,
-                judge_pct: int, judge_samples: list[str], pages: int = 0) -> tuple[str, str]:
-    """→ (可逆性判定, 依据)。四类: 可逆(直接) / 可逆(需反推口径) / 不可逆(人工判断) / 应移出产物仓。"""
-    if fam['kind'] == 'not-product':
-        return '应移出产物仓', '它本就不是产物(工具脚本/交证件/测试输出) ⇒ 该从产物仓移走, 而不是"补生成端"'
-    if gen_ok and in_ok:
-        return '可逆(直接)', f"生成端在包内({fam['gen']})且输入在位 ⇒ 接上/重跑即可"
-    if pages and pages >= max(3, len(rels[:8]) // 2):
-        return '可逆(页面可再生)', ('这一族主要是**页面**(.html): 页面是产物的渲染, 不是独立数据 ⇒ '
-                                    '接上在包的页面生成端重出即可(未必逐字节同, 能力与数据一致)')
-    m_n = sum(1 for r in rels if r.endswith(('.parquet', '.csv', '.npz')))
-    t_n = len(rels) - m_n
-    split = f'({m_n} 件测量形态 / {t_n} 件文本·判断件)' if (m_n and t_n) else ''
-    # 两类都占相当比重时, 结论必须是"混合"而不是挑一类代表全族 —— 否则一句判定就把另一半骗过去了
-    if m_n and t_n and min(m_n, t_n) * 4 >= len(rels):
-        return '混合(测量件可反推 / 文本件需人定)', split + \
-               '测量形态的件可由 data/raw 反推口径 + 逐值对拍; 文本/判断件(评审、裁决、回复)推不出来'
-    if judge_pct >= 50 and judge_samples:
-        return '不可逆(人工判断)', (f"抽样 {len(judge_samples)} 件里都写着人工判断字段(如 {judge_samples[0]}) "
-                                    f"⇒ 不是输入的纯函数, 逆向工程推不出来")
-    if m_n and m_n * 2 >= len(rels):
-        return '可逆(需反推口径)', split + '测量形态的件是测量数据的函数 ⇒ 可从 data/raw 反推口径 + 逐值对拍' \
-                                        '(做法同 temp_monthly: 反推 → 逐值一致才敢用)'
-    if gen_ok:
-        return '半可逆(生成端在包, 输入不足)', '生成端在包内, 缺的是上游输入 ⇒ 上游补齐后即可重出'
-    if judge_pct > 0:
-        return '半可逆(需人工裁定)', f'抽样里 {judge_pct}% 的件含人工判断字段 ⇒ 机器部分可推、判断部分要人定'
-    return '需人工裁定', '既无生成端、形态也不是测量函数 ⇒ 需研发给口径或领域正本'
-
-
-def _turbines_of(p: pathlib.Path, col: str = 'turbine', cap: int = 200000):
-    import pandas as pd
-    try:
-        d = pd.read_parquet(p, columns=[col]) if col else pd.read_parquet(p)
-    except Exception:
-        return None
-    try:
-        return {str(x).upper() for x in d[col].dropna().unique()} if col in d.columns else None
-    except Exception:
-        return None
-
-
-def _input_turbines(station: pathlib.Path, sub: str):
-    """输入侧机组集合: 逐台 CSV 文件名 / 目录名里的 WTG 号。"""
-    d = station / sub
-    if not d.is_dir():
-        return None
-    out = set()
-    for p in d.rglob('*'):
-        if p.is_file():
-            for m in __import__('re').finditer(r'(WTG\s?\d{1,2})', p.name.upper()):
-                out.add(m.group(1).replace(' ', ''))
-    return out or None
-
-
-def classify_human(farm: str, rels: list[str], cap: int = 600) -> set[str]:
-    """逐件判"是不是人工件"(用户令 2026-09-17 #3:人工件不再按自动产物参与呼应校验)。
-
-    判据:**非测量形态**的件(.md/.json/.csv/.txt)里出现人工判断字段(按字段名匹配)。
-    测量形态件(.parquet/.csv 数值、.npz)一律不算人工件 —— 它们是数据的函数,属"可反推"那一路。
-    """
-    human: set[str] = set()
-    for rel in rels[:cap]:
-        if rel in HUMAN_SNAPSHOTS:            # ① 文档里已写明含人工裁决/校准的随包快照
-            human.add(rel)
-            continue
-        suf = pathlib.Path(rel).suffix.lower()
-        if suf == '.md':                      # ② 产物仓里的 .md 一律是报告/交证/评审记录(人写的)
-            human.add(rel)
-            continue
-        if suf == '.txt':
-            pat = JUDGE_PAT
-        elif suf in ('.json', '.csv'):
-            pat = JUDGE_KEY_PAT
-        else:
-            continue
-        p = P.out_root(farm) / rel
-        try:
-            txt = p.read_text(encoding='utf-8', errors='replace')[:120000]
-        except Exception:
-            continue
-        if pat.search(txt):                   # ③ 正文里有"人工判断字段"
-            human.add(rel)
-    return human
-
-
-def write_human_manifest(farm: str | None = None) -> int:
-    """把人工件逐件落成 `outputs/<场>/_human_artifacts.json`(机器台账,供自检与审计引用)。"""
-    farm = farm or P.farm()
-    global LEDGER
-    LEDGER = load_ledger(farm)
-    entries: dict[str, dict] = {}
-    for fam in FAMILIES:
-        if fam['kind'] not in ('shipped', 'human', 'not-product'):
-            continue
-        rels = [r for r in _files_of(fam['glob']) if r not in entries]
-        if not rels:
-            continue
-        if fam['kind'] == 'human':
-            for r in rels:
-                entries[r] = dict(kind='human', family=fam['id'], evidence='族内显式登记为人工正本/交证件')
-            continue
-        for r in classify_human(farm, rels):
-            if r not in entries:
-                entries[r] = dict(kind='human', family=fam['id'],
-                                  evidence='件内含人工判断字段(按字段名匹配),不是输入的纯函数')
-    out = P.out_root(farm) / '_human_artifacts.json'
-    out.write_text(json.dumps(dict(
-        at=__import__('time').strftime('%Y-%m-%d %H:%M:%S'),
-        note='人工件台账(用户令 2026-09-17 #3):这些件由人写成/坐实,不是任何输入的纯函数 ⇒ '
-             '不参与"输出↔输入呼应"校验;随包留在原位,重算不会也不该重写它们。'
-             '本文件由 scripts/products_reverse_audit.py --write-human-manifest 生成。',
-        count=len(entries), files=entries), ensure_ascii=False, indent=1), encoding='utf-8')
-    by_fam: dict[str, int] = {}
-    for v in entries.values():
-        by_fam[v['family']] = by_fam.get(v['family'], 0) + 1
-    print(f'人工件台账: {len(entries)} 件 → {P.rel(out)}')
-    for k, v in sorted(by_fam.items(), key=lambda kv: -kv[1]):
-        print(f'   {k:24s} {v:4d} 件')
-    return 0
-
-
-def audit(farm: str | None = None, verbose: bool = True):
-    farm = farm or P.farm()
-    global LEDGER
-    LEDGER = load_ledger(farm)
-    # ★ 场站原始件目录用唯一取用口 (场站名可与 farm 键不同: 键 = rudong, 目录 = data/raw/如东)
-    from src.windscada.config import raw_station_dir
-    station = pathlib.Path(raw_station_dir(farm))
-    verdict: dict[str, dict] = {}
-    fam_rows = []
-    fails: list[str] = []
-
-    for fam in FAMILIES:
-        # ★ 族按**声明顺序**认领, 先声明的先拿 (否则 `m5_cms_tcm/*` 这种宽通配会把 windows/ 下
-        #   1704 件也吞进来 —— fnmatch 的 `*` 是跨 `/` 的, 2026-09-17 第一版就这么误判过)
-        rels = [r for r in _files_of(fam['glob']) if r not in verdict]
-        rels = [r for r in rels if not r.lower().endswith(('.log', '.jsonl'))]
-        if not rels:
-            continue
-        row = dict(id=fam['id'], n=len(rels), func=fam['func'], algo=fam['algo'], input=fam['input'],
-                   pred='+'.join(fam['pred']) or '—', verdict='', note='')
-        if fam['kind'] == 'not-product':
-            # ★ 不是产物, 却躺在产物仓里: 反向审计对它的结论是"它压根不该按产物管" ——
-            #   既不需要生成端, 也谈不上与输入呼应(这一类最该被人看见, 故单独一类, 不当失败计)。
-            jp, js, pg = judgement_evidence(farm, rels)
-            fv, fw = feasibility(fam, rels, False, False, jp, js, pg)
-            row['verdict'] = '✗ 非产物'
-            row['note'] = fam.get('why', '')
-            row['rev'], row['rev_why'] = fv, fw
-            for r in rels:
-                verdict[r] = dict(fam=fam['id'], verdict='✗',
-                                  why='非产物(过程留痕/工具脚本躺在产物仓里): ' + fam.get('why', ''))
-        elif fam['kind'] == 'human':
-            # ★ 人工正本/交证件 (用户令 2026-09-17 #3): 由人写、被运行期当证据引用,
-            #   **不参与呼应校验**——它本来就没有、也不该有生成端; 单独计数, 不混进"无生成端"的失败堆里。
-            row['verdict'] = '◆ 人工件'
-            row['note'] = fam.get('why', '')
-            row['rev'], row['rev_why'] = '人工件(正本/交证)', '人写的, 不参与呼应校验; 随包留在原位'
-            for r in rels:
-                verdict[r] = dict(fam=fam['id'], verdict='◆',
-                                  why='人工件(人工正本/交证件): ' + fam.get('why', ''))
-        elif fam['kind'] == 'shipped':
-            jp, js, pg = judgement_evidence(farm, rels)
-            gen_file = ROOT / fam['gen'] if fam.get('gen') else None
-            gen_ok0 = bool(gen_file and gen_file.is_file())
-            # ★ 逐件分人工件 (用户令 2026-09-17 #3): 含人工判断字段的件**不按自动产物参与呼应校验**,
-            #   单独计 ◆; 余下才是真正的"无生成端"。
-            human = classify_human(farm, rels)
-            rest = [r for r in rels if r not in human]
-            fv, fw = feasibility(fam, rest or rels, gen_ok0, False, jp, js, pg)
-            row['verdict'] = '✗ 无生成端' if not human else f'✗ 无生成端 + ◆ 人工件 {len(human)}'
-            row['n'] = len(rest)
-            row['note'] = fam.get('why', '')
-            row['rev'], row['rev_why'] = fv, fw
-            if jp:
-                row['rev_why'] += f';人工判断字段抽样命中 {jp}%' + (f'(如 {js[0]})' if js else '')
-            for r in rest:
-                verdict[r] = dict(fam=fam['id'], verdict='✗', why='无生成端(全库 0 处写入方): ' + fam['why'])
-            for r in human:
-                verdict[r] = dict(fam=fam['id'], verdict='◆',
-                                  why='人工件(含人工判断字段): 不参与呼应校验, 随包留在原位')
-        else:
-            # ① 生成端在位
-            gen = ROOT / fam['gen'] if fam.get('gen') else None
-            gen_ok = bool(gen and gen.is_file())
-            # ② 输入在位(场站目录优先, 机理层资料在 raw 根下 —— 见 _input_home)
-            spec = INV.INPUTS.get(fam['input'] or '')
-            i_s = i_e = None
-            i_n = 0
-            i_gran = '年'
-            home = _input_home(station, fam['input']) if fam['input'] else None
-            if home and spec:
-                i_s, i_e, i_n, _note, i_gran = INV.input_span(home, fam['input'], spec)
-            elif home:
-                # 机理层资料这类"没有跨度判据"的输入: 按件数清点即算在位(INV.INPUTS 里没有它们的
-                # 跨度口径 —— 技术资料是文档而不是时序数据, 拿跨度比毫无意义)
-                i_n = sum(1 for p in (home / fam['input']).rglob('*') if p.is_file())
-                i_gran = '年'
-            elif fam['kind'] == 'source-derived':
-                # ★ 输入是**包内源码**而不是 data/raw(如发布清单快照: 从 src/windscada/ui 重建页再摘要)。
-                #   生成端在位 = 输入在位, 这类产物的"呼应"是对源码的, 不是对原始件的。
-                i_n = 1
-                i_s = i_e = '包内源码'
-            in_ok = i_n > 0
-            passed, notes = [], []
-            if fam['pred'] and in_ok:
-                if 'span' in fam['pred']:
-                    p_s, p_e, n = _parquet_span(P.out_root(farm) / rels[0], fam.get('time_col'))
-                    if p_s and i_s and (p_s[:7] < i_s[:7]):
-                        # ★ 年粒度输入(按文件名年份推的)不判失败: 与正向检查同一条教训 ——
-                        #   台账 xls 里可能含比文件名年份更早的历史记录, 拿年粒度当"输入起点"会造假缺口
-                        #   (2026-09-17 实测: 工单产物起点 2020-01 而输入文件名最早 2021 ⇒ 不是数据串了)。
-                        msg = f'产物起点 {p_s} 早于输入起点 {i_s}'
-                        if i_gran in ('日', '月'):
-                            fails.append(f"{fam['id']}: {msg}(跨窗混入 / 键错)")
-                            notes.append('⚠ ' + msg)
-                        else:
-                            notes.append(f'{msg}(输入为年粒度, 只作参考: 台账可能含更早的历史行)')
-                    passed.append(f'跨度 {p_s}~{p_e} ⊆ 输入 {i_s}~{i_e}')
-                if 'turbines' in fam['pred']:
-                    pt = _turbines_of(P.out_root(farm) / rels[0])
-                    it = _input_turbines(home, fam['input']) if home else None
-                    if pt and it:
-                        extra = pt - it
-                        if extra:
-                            fails.append(f"{fam['id']}: 产物含输入里没有的机组 {sorted(extra)[:5]}")
-                        passed.append(f'机组 {len(pt)} 台 ⊆ 输入 {len(it)} 台')
-                if 'rows' in fam['pred']:
-                    tot = 0
-                    for r in rels[:400]:
-                        _s, _e, n = _parquet_span(P.out_root(farm) / r, None) if r.endswith('.parquet') \
-                            else (None, None, 1)
-                        tot += n
-                    if tot <= 0:
-                        fails.append(f"{fam['id']}: 产物行数合计为 0")
-                    passed.append(f'件内数据行合计 {tot:,}' + ('(抽样 400 件)' if len(rels) > 400 else ''))
-                if 'npz' in fam['pred']:
-                    sample = rels[:5]
-                    bad = [r for r in sample if not _npz_ok(P.out_root(farm) / r)]
-                    if bad:
-                        fails.append(f"{fam['id']}: 抽样 {len(bad)}/{len(sample)} 件 npz 读不出或为空")
-                    passed.append(f'{len(rels)} 件谱(抽样 {len(sample)} 件 npz 均可读非空)· 输入源 {i_n} 件')
-                if 'manifest' in fam['pred']:
-                    try:
-                        mf = json.loads((P.out_root(farm) / rels[0]).read_text(encoding='utf-8'))
-                        passed.append('清单自记: ' + ', '.join(f'{k}={v}' for k, v in list(mf.items())[:3]))
-                    except Exception as e:
-                        fails.append(f"{fam['id']}: 清单读不出来 ({type(e).__name__})")
-            if not gen_ok:
-                row['verdict'] = '✗ 生成端不在位'
-                row['note'] = f"缺 {fam['gen']}"
-                fails.append(f"{fam['id']}: 生成端不在位 {fam['gen']}")
-            elif not in_ok:
-                row['verdict'] = '~ 输入不在位'
-                row['note'] = (f"缺 {(home / fam['input']) if home else station / str(fam['input'])}"
-                               ' ⇒ 现在无法验证(放数据后复跑本器)')
-            else:
-                row['verdict'] = '✓ 呼应成立'
-                row['note'] = ';'.join(notes + passed)
-                row['rev'], row['rev_why'] = '已在重算链上', '生成端在位、输入在位、判据通过 —— 无需逆向工程'
-            for r in rels:
-                verdict[r] = dict(fam=fam['id'], verdict=row['verdict'][0],
-                                  why=row['note'] if row['verdict'][0] != '✓' else row['note'][:120])
-        fam_rows.append(row)
-
-    # 未归类件 (台账里有, 但没有任何族接住)
-    unclassified = []
-    for rel, v in LEDGER.items():
-        if rel.lower().endswith(('.log', '.jsonl')):
-            continue
-        if rel in verdict:
-            continue
-        # 产物走通配没接住的, 也按台账来源判定
-        unclassified.append(rel)
-        verdict[rel] = dict(fam='(未归类)', verdict='?', why='既不在族表、也无法从台账判定来路')
-
-    counts = {}
-    for v in verdict.values():
-        counts[v['verdict']] = counts.get(v['verdict'], 0) + 1
-    if verbose:
-        print(f'== 反向呼应审计 · 场站 {farm} · 台账 {len(LEDGER)} 件 · 判定 {len(verdict)} 件 ==')
-        print(f'   输入根: {P.rel(station)}')
-        print()
-        print(f'{"族":26s} {"件数":>6s} {"判定":14s} {"输入类":12s} {"功能 / 算法 / 依据"}')
-        for r in fam_rows:
-            print(f'  {r["id"]:24s} {r["n"]:6d} {r["verdict"]:14s} {str(r["input"] or "—"):12s} '
-                  f'{r["func"][:34]}')
-            if r['note']:
-                print(f'      └─ {r["note"][:150]}')
-        if unclassified:
-            print(f'\n  【未归类 {len(unclassified)} 件】')
-            for r in unclassified[:20]:
-                print(f'      ? {r}')
-        print('\n  判定汇总: ' + ' · '.join(f'{k}={v}' for k, v in sorted(counts.items())))
-        print(f'  判据失败 {len(fails)} 条' + (':' + ';'.join(fails[:3]) if fails else ''))
-        ok = counts.get('✓', 0)
-        print(f'  结论: 输出↔输入呼应**成立** {ok} 件 / **不成立(无生成端)** {counts.get("✗", 0)} 件 / '
-              f'**人工件** {counts.get("◆", 0)} 件 / 无法验证 {counts.get("~", 0)} 件 / 未归类 {counts.get("?", 0)} 件')
-        # ── 逆向工程可行性 (用户令 #2): 没有呼应关系的那些, 能不能推出来 ──
-        import collections as _c
-        by_rev = _c.Counter()
-        for r in fam_rows:
-            by_rev[r.get('rev', '?')] += r['n']
-        print()
-        print('== 反向「可逆性」矩阵(用户令 2026-09-17 #2: 无生成端的件能否从原件推出来)==')
-        print(f'{"族":26s} {"件数":>6s} {"可逆性":22s} 依据')
-        for r in fam_rows:
-            if r['verdict'][0] == '✓':
-                continue
-            print(f'  {r["id"]:24s} {r["n"]:6d} {r.get("rev", "?"):22s} {r.get("rev_why", "")[:96]}')
-        print('  按件数汇总: ' + ' · '.join(f'{k}={v}' for k, v in by_rev.most_common()))
-    rc = 5 if (fails or unclassified) else 0
-    return fam_rows, verdict, counts, fails, unclassified, rc
-
-
-def doc_block(farm: str) -> str:
-    fam_rows, verdict, counts, fails, unclassified, _rc = audit(farm, verbose=False)
-    L = [DOC_BEGIN, '### 13.6 反向呼应审计:自输出 → 功能与算法 → 输入(自动生成,勿手改)', '',
-         f'台账 {len(verdict)} 件产物逐件回溯:**呼应成立 {counts.get("✓", 0)} 件** · '
-         f'**不成立(无生成端){counts.get("✗", 0)} 件** · 无法验证 {counts.get("~", 0)} 件 · '
-         f'未归类 {counts.get("?", 0)} 件。'
-         '判据:① 生成端在位 ② 输入在位 ③ 跨度 ⊆ 输入 / 机组集 ⊆ 输入 / 行数 > 0 / 计数与清单一致。', '',
-         '| 输出族(产物 glob) | 件数 | 反向判定 | 功能 | 算法 / 生成端 | 输入(data/raw/<场>/) | 判据 | 说明 |',
-         '|---|---:|---|---|---|---|---|---|']
-    for r in fam_rows:
-        fam = next(f for f in FAMILIES if f['id'] == r['id'])
-        L.append(f'| `{fam["glob"]}` | {r["n"]} | {r["verdict"]} | {r["func"]} | {r["algo"]} | '
-                 f'{r["input"] or "—(无生成端)"} | {r["pred"]} | {r["note"][:160]} |')
-    if unclassified:
-        L += ['', f'**未归类 {len(unclassified)} 件**(需人工认领):' + '、'.join(f'`{x}`' for x in unclassified[:12])]
-    L += ['', '#### 反向「可逆性」:没有呼应关系的那批,能否从原始件推出来(用户令 2026-09-17 #2)', '',
-          '判据两条机器证据:① 生成端在不在包内 ② 产物正文里有没有**人工判断**字段'
-          '(`人工/裁决/审核/校准/评审/经验/judge/review/calibrat/verdict`,抽样读件统计命中率)。'
-          '含人工判断的件不是任何输入的纯函数 ⇒ 逆向工程推不出来,只能把那一步人工工作重做。', '',
-          '| 输出族 | 件数 | 可逆性 | 依据 |', '|---|---:|---|---|']
-    for r in fam_rows:
-        if r['verdict'][0] == '✓':
-            continue
-        L.append(f'| `{r["id"]}` | {r["n"]} | {r.get("rev", "?")} | {r.get("rev_why", "")} |')
-    import collections as _c
-    by_rev = _c.Counter()
-    for r in fam_rows:
-        by_rev[r.get('rev', '?')] += r['n']
-    L += ['', '**按件数汇总**:' + ' · '.join(f'{k} = {v} 件' for k, v in by_rev.most_common()), '',
-          '**人工件**:含人工判断字段的件已逐件登记在 outputs/&lt;场&gt;/_human_artifacts.json(用户令 2026-09-17 #3),'
-          '它们**不参与**呼应校验(人写成/坐实的东西不是任何输入的纯函数);生成方式:'
-          'python scripts/products_reverse_audit.py --write-human-manifest。', '',
-          '> 结论口径:`可逆(直接)` = 包内已有生成端,接上即可;`可逆(需反推口径)` = 内容是测量数据的函数,'
-          '照 `temp_monthly` 的办法反推 + 逐值对拍;`不可逆(人工判断)` = 逆向工程推不出来,'
-          '要么由研发补生成端、要么承认它是人工件(不该按产物管)。', DOC_END]
-    return '\n'.join(L)
-
-
-def write_doc(farm: str) -> int:
-    doc = ROOT / 'docs' / '系统设计说明.md'
-    txt = doc.read_text(encoding='utf-8')
-    blk = doc_block(farm)
-    i, j = txt.find(DOC_BEGIN), txt.find(DOC_END)
-    if i >= 0 and j > i:
-        txt = txt[:i] + blk + txt[j + len(DOC_END):]
-    else:                                             # 首次: 追加到文末
-        txt = txt.rstrip('\n') + '\n\n' + blk + '\n'
-    doc.write_text(txt, encoding='utf-8', newline='\n')
-    print(f'已写入 {P.rel(doc)} (§13.6 反向呼应审计块)')
-    return 0
-
-
-def main() -> int:
-    ap = argparse.ArgumentParser(description='反向呼应审计: 输出 → 功能与算法 → 输入')
-    ap.add_argument('--farm', default=None)
-    ap.add_argument('--check', action='store_true', help='只出结论 (有未归类/判据失败 → rc=5)')
-    ap.add_argument('--write-doc', action='store_true', help='把族表写进 docs/系统设计说明.md §13.6')
-    ap.add_argument('--write-human-manifest', action='store_true',
-                    help='生成人工件台账 outputs/<场>/_human_artifacts.json (用户令 2026-09-17 #3)')
-    a = ap.parse_args()
-    if a.write_human_manifest:
-        return write_human_manifest(a.farm)
-    if a.write_doc:
-        return write_doc(a.farm or P.farm())
-    _rows, _v, counts, fails, uncl, rc = audit(a.farm, verbose=True)
-    if a.check:
-        print(f'[{"OK" if rc == 0 else "X"}] 反向呼应审计 rc={rc}'
-              + (f'(未归类 {len(uncl)} 件 / 判据失败 {len(fails)} 条)' if rc else '(未归类 0 件、判据无失败)'))
-    return rc
-
-
-if __name__ == '__main__':
-    for _s in (sys.stdout, sys.stderr):
-        try:
-            _s.reconfigure(errors='replace')
-        except Exception:
-            pass
-    sys.exit(main())
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""**反向**呼应审计:自输出 → 功能与算法 → 输入(用户令 2026-09-17)。
+
+## 与 `inventory_products.py` 的区别(两个方向,各管一件事)
+
+· `inventory_products.py` 是**正向**:从 `data/raw/<场>/` 的每一类输入出发,找它喂出来的产物,
+  比跨度/条数("输入到了 2026-07,产物只到 2026-04 → 未重算")。它只覆盖 5 类输入、十几件产物。
+· 本器是**反向**:从 `outputs/<场>/` 的**每一件产物**出发,问三个问题:
+    ① 它是谁算出来的?(功能与算法 = 生成端)
+    ② 它从哪份输入算出来的?(`data/raw/<场>/` 的哪一类)
+    ③ 这条"输出 ↔ 输入"的呼应关系**成立吗**?(生成端在位 ∧ 输入在位 ∧ 可机检的对应判据通过)
+  逐件判定,最后给出"成立 / 不成立(无生成端)/ 无法验证"三类账。
+
+## 为什么必须做反向
+
+正向只查"我关心的输入有没有被算成产物",查不出**"盘上这件产物到底有没有来路"**。
+2026-09-17 现场就是栽在这里:清了产物之后,页面缺的 `windscada/index.html`、
+`m5_cms_tcm/handoff_vibration_v2.json` 这类件**根本没有生成端**(全库 0 处写入方),
+正向那几条跨度判据一条都不会报 —— 它们压根不在 `INPUTS` 的 `feeds` 里。
+反向一对账就清楚了:**有来路的件**(生成端 + 输入 + 判据都过)与**没来路的件**(只能从交付包补)。
+
+## 判定口径(每条都写进结果里,不含糊)
+
+    ✓ 呼应成立          生成端在位 ∧ 输入在位 ∧ 判据通过(跨度 ⊆ 输入 / 键集 ⊆ 输入 / 计数一致 / 行数 > 0)
+    ~ 输入不在位         生成端知道,但 `data/raw/<场>/<类>` 不在 ⇒ **现在无法验证**(放数据后复跑本器)
+    ✗ 无生成端           全库 0 处写入方 ⇒ **输出↔输入的呼应在原理上不成立**:这件产物无法由输入推导出来,
+                        **无法由重算生出来**;按用户令 2026-09-17 #1 运行期也不从交付包补齐 ⇒ 只能由研发补生成端(本器 --feasibility 给出逐族可逆性)
+    ? 未归类             既不在族表里、台账里也没有 —— 需要人工认领(本器把它当缺口报出来)
+
+## 用法
+
+    python scripts/products_reverse_audit.py            # 全量反向审计(人看)
+    python scripts/products_reverse_audit.py --check    # 只出结论(有 ✗/? 时退出码 5)
+    python scripts/products_reverse_audit.py --write-doc # 把族表写进 docs/系统设计说明.md §13.6
+
+退出码: 0 全部成立(允许 ✗,只要它们都在台账里如实标了 shipped)· 5 有未归类件或判据失败
+"""
+from __future__ import annotations
+
+import argparse
+import fnmatch
+import json
+import pathlib
+import sys
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+sys.path.insert(0, str(ROOT / 'scripts'))
+from src import paths as P                                              # noqa: E402
+import inventory_products as INV                                        # noqa: E402
+
+DOC_BEGIN, DOC_END = '<!-- REVERSE-AUDIT:BEGIN -->', '<!-- REVERSE-AUDIT:END -->'
+
+# ── 族表: 输出(glob) → 功能 / 算法(生成端) / 输入类 / 判据 ────────────────────────────
+#   pred: span=产物时间跨度必须落在输入跨度内; turbines=产物机组集 ⊆ 输入机组集;
+#         rows=行数>0; count=件数/计数与说明一致; manifest=与清单自记一致
+FAMILIES: list[dict] = [
+    dict(id='vib_window_index', glob='m5_cms_tcm/windows/*/index.parquet', kind='raw-derived',
+         func='振动摄入 · 窗索引', algo='scripts/rudong_tcm_index.py(逐 decode JSON 解析 54 列:turbine/'
+              'sensor_name/meas_name/trigger_time/rpm/condition_key/alarm_type)',
+         gen='scripts/rudong_tcm_index.py', input='windcms', pred=('span', 'turbines', 'rows'),
+         time_col='trigger_time'),
+    dict(id='vib_spectra', glob=['m5_cms_tcm/windows/*/spectra/**', 'm5_cms_tcm/windows/*/spectra_meta.parquet'],
+         kind='raw-derived',
+         func='振动摄入 · 谱库', algo='scripts/rudong_tcm_spectra.py(FFT_ 测量 → npz 幅值数组, 分片存 `spectra/p<NN>/`)',
+         gen='scripts/rudong_tcm_spectra.py', input='windcms', pred=('npz',)),
+    dict(id='vib_raw_manifest', glob='m5_cms_tcm/vib_raw_manifest.json', kind='raw-derived',
+         func='振动摄入 · 清单', algo='scripts/vib_raw_build.py(汇总窗/谱件数、时间跨度、missing_chain 缺口)',
+         gen='scripts/vib_raw_build.py', input='windcms', pred=('manifest',)),
+    dict(id='scada_alarms', glob='windscada/alarms.parquet', kind='raw-derived',
+         func='三门台账 · 报警', algo='scripts/windscada_alarms_ingest.py(SpreadsheetML *.xls → 事件表)',
+         gen='scripts/windscada_alarms_ingest.py', input='故障报警', pred=('span', 'rows'), time_col='t_on'),
+    dict(id='scada_workorders', glob='windscada/workorders.parquet', kind='raw-derived',
+         func='三门台账 · 工单', algo='scripts/windscada_workorder_ingest.py(检修台账 → 工单表)',
+         gen='scripts/windscada_workorder_ingest.py', input='风机故障记录', pred=('span', 'rows'),
+         time_col='t_report'),
+    dict(id='scada_oil', glob='windscada/oil_samples_index.parquet', kind='raw-derived',
+         func='三门台账 · 油样', algo='scripts/windscada_watch_channels_build.py(油样 PDF → 索引)',
+         gen='scripts/windscada_watch_channels_build.py', input='油样报告', pred=('span', 'rows'),
+         time_col='date'),
+    dict(id='scada_monthly', glob='windscada/temp_monthly.parquet', kind='raw-derived',
+         func='月度派生件', algo='scripts/windscada_monthly_build.py(逐值对齐随包件 19,494/19,494)',
+         gen='scripts/windscada_monthly_build.py', input='scada_10min', pred=('span', 'rows'), time_col='month'),
+    dict(id='scada_derived', glob='windscada/*.parquet', kind='raw-derived',
+         func='SCADA 派生分析(功率曲线/损失/曲线/控制/停机/温度/偏航/液压/热链/系统辅助)',
+         algo='src/windscada/perf/{powercurve,availability,curves,control,faults} + subsys/{temp_nbm,yaw,'
+              'hydraulic,thermal_chain} + taxonomy.py',
+         gen='src/windscada/perf/powercurve.py', input='scada_10min', pred=('rows',)),
+    dict(id='ontology_core', glob='ontology/{objects.json,retrieval_index.json,turbine_params.parquet}',
+         kind='raw-derived',
+         func='本体层 · 码表/对象库/检索索引/实机参数',
+         algo='python -m src.ontology.kb_ingest → populate → chain_ingest → trend_ingest;'
+              'src.ontology.retrieval.build;src.ontology.maintenance.refresh_params',
+         gen='src/ontology/kb_ingest.py', input='西门子4.0技术资料', pred=('rows',)),
+
+    dict(id='ontology_aux_build', glob=['ontology/_id_alias.json', 'ontology/retrieval_vec.npy'],
+         kind='raw-derived',
+         func='本体层 · 别名表与向量索引缓存', algo='src.ontology.kb_ingest(别名)/ '
+              'src.ontology.retrieval.build(use_vec=True)(向量)',
+         gen='src/ontology/kb_ingest.py', input='西门子4.0技术资料', pred=('rows',)),
+    dict(id='ontology_domain', glob=['ontology/oil_2026H2_huabiao.json', 'ontology/parts_cost_public.json',
+                                     'ontology/scenario_29_repair.json'],
+         kind='shipped',
+         func='本体层 · 领域数据件(油样台账/备件价格/场景修复)', algo='(无生成端, 领域正本出件)',
+         gen=None, input=None, pred=(), why='领域正本随包发来, 不由 data/raw 推导'),
+
+    # ── 以下族: 全库 0 处写入方 ⇒ 反向呼应**在原理上不成立**(如实记账, 不假装成立)────
+    #    ★2026-09-18 用户令补充: 这些件**运行期一律不从交付包补**(用户令 2026-09-17 #1 的延伸),
+    #      页面如实为空/降级; 要出件只有两条路 —— ① 研发补生成端; ② 现场正本随原始件进 data/raw。
+    #      故本表的 why 里不再写"从交付包补齐"那种话(那是**离线人工补救**, 见 products_restore_missing.py)。
+    dict(id='vib_model_run_l6', glob=['m5_cms_tcm/model_run_l6.parquet', 'm5_cms_tcm/model_run_summary.json'],
+         kind='raw-derived',
+         func='六层链 model_run 步 · L6 过闸谱线表 + 层小结',
+         algo='scripts/rudong_model_run.py(候选线=oem_scan_plan 的 11 部件×{BPFI,BPFO,BSF}; 过闸='
+              'src/sop/discriminators.py::spectral_line_gates G1–G8; 定级=vib_verdict_and_writeback)',
+         gen='scripts/rudong_model_run.py', input='m5_cms_tcm', pred=('rows',),
+         why='★2026-09-19 按口径重建(**无标准答案**: 这两件从未随包, 无法逐值对拍); 峰值拾取仍是本器自定'
+             '(目标频率 ±2 bin 取最大), 不得当"复现"用 —— 见 docs/振动六层链_接口规格与缺口_v0.1.md §3'),
+    dict(id='vib_fusion_38', glob='m5_cms_tcm/fusion_38.csv', kind='raw-derived',
+         func='六层链 fusion 步 · 逐台融合表(报告"融合级"列的来源)',
+         algo='scripts/rudong_fusion_run.py::fusion_table(模型侧=model_run_l6 过闸线; CMS 侧=窗索引 '
+              'RedMask/YellowMask; 裁决=src/sop/fusion_diag.py::fuse)',
+         gen='scripts/rudong_fusion_run.py', input='m5_cms_tcm', pred=('rows',),
+         why='★按口径重建(无标准答案): 台号用 WTGnn 形态(消费端 registry 的键), 与 l6 的 f"{n}#" 不同'),
+    # ★2026-09-19 源代码化(第三批):三族由观澜自算顶上。★族表是**首个命中生效**,故这几族必须
+    #   排在通配族(`vib_handoff_and_scans` = `m5_cms_tcm/*.{json,parquet,csv,md,txt}`、`tcm_replay`、
+    #   旧的 `ontology_releases`)**之前**,否则新件会被旧族认领成"无生成端"。
+    dict(id='vib_line_energy_share', glob='m5_cms_tcm/line_energy_share.parquet', kind='raw-derived',
+         func='六层链 energy_share 步 · 线能量占比',
+         algo='scripts/rudong_line_energy_share.py(±2bin 带内能量 ÷ 全谱能量 ×100; 候选线与分母同 oem_scan_plan)',
+         gen='scripts/rudong_line_energy_share.py', input='m5_cms_tcm', pred=('rows',),
+         why='2026-09-19 补上: 该步脚本此前未随包(chain_gap_check 报"1 个步骤脚本未随包"); 只报占比不定级'),
+    dict(id='vib_mask_thresholds', glob=['tcm_compatible_replay/model/mask_thresholds.tsv',
+                                         'tcm_compatible_replay/model/mask_thresholds.说明.json'],
+         kind='raw-derived', func='TCM 掩码阈值件 (逐台逐测点逐工况的黄/红线)',
+         algo='scripts/tcm_mask_thresholds_build.py(中位 + 3/6×MAD 自适应基线, 基线先净化, 样本门 n≥30; '
+              '调 src/sop/discriminators.py::adaptive_baseline_threshold 单一实现)',
+         gen='scripts/tcm_mask_thresholds_build.py', input='m5_cms_tcm', pred=('rows',),
+         why='★**观澜自算**阈值, 不是厂商阈值: 现场导出件里没有掩码定义(25,679 个 decode 只有 NumberOfMasks: 175) '
+             '⇒ 厂商阈值无法复原; 口径写在同目录 mask_thresholds.说明.json, 页面据此标注(workbench.py 分支)'),
+    dict(id='ontology_releases', glob='ontology/release_*/*', kind='raw-derived',
+         func='本体发布层 r1/r2(网关 /release/ 只读暴露)',
+         algo='scripts/ontology_release_build.py(对象库投影: r1 契约层 = objects.json 快照 + manifest; '
+              'r2 = 同快照 + layering.json 分层覆盖声明, 不合并主库)',
+         gen='scripts/ontology_release_build.py', input='西门子4.0技术资料', pred=('rows',),
+         why='2026-09-19 由观澜自算顶上: 原为随包件(0 处写入方) ⇒ 新机器 /release/ 两件必 503; '
+             'manifest 里写 source.sha16, 谁都能核出两层是同一份对象库的投影'),
+    dict(id='vib_fleet_scalar_z', glob='m5_cms_tcm/fleet_scalar_z.parquet', kind='raw-derived',
+         func='振动线 · 全场标量 z 值 (fusion 步出件)',
+         algo='scripts/rudong_fusion_run.py(逆向工程实现: 窗索引 scalar_value → 同 (传感器,测量,工况) 族中位 '
+              '→ 稳健 z=(val−med)/(1.4826·MAD); 与随包样件 8,887 行逐值对拍 val 列 100% 一致)',
+         # input 写**原始件目录名**(族表口径: data/raw/<场>/<input>); 真实取数经窗索引一跳,
+         # 而窗索引本身是 raw-derived(vib_window_index 族) —— 写 windows/_*.parquet 会让审计判"输入不在位"。
+         gen='scripts/rudong_fusion_run.py', input='m5_cms_tcm', pred=('rows',),
+         why='2026-09-18 上链: 由 vib_raw_build.py 的 --with-report 分支按当次窗名调用, '
+             '使 /api/fleet 的振动标量 z 面不再空白'),
+    dict(id='vib_fusion_handoff', glob='m5_cms_tcm/handoff_vibration_v2.json', kind='raw-derived',
+         func='融合面 handoff (观澜自算, 同结构于振动线手交件)',
+         algo='scripts/rudong_fusion_handoff.py(报告状态等级 + fusion_38 融合裁决 + L6 过闸线 → 同结构, '
+              'meta.from 标明"观澜自算")',
+         gen='scripts/rudong_fusion_handoff.py', input='m5_cms_tcm', pred=('rows',),
+         why='★2026-09-19 用户令「所有的计算均要形成观澜的源代码」: 这件此前全库 0 处写入方(振动线人写), '
+             '新机器装完 `/detail/v2` 的「需要关注/全场状态」必然空白。现由观澜自算出同结构件; '
+             '盘上若有**振动线正本**, 生成端不覆盖(正本含人工裁决, 优先)'),
+    # ★2026-09-19 用户令「所有的计算均要形成观澜的源代码」: 这两件此前 kind=shipped(振动线人交手交件),
+    #   新机器上 ⑦ 的 `src.ontology.trend_ingest` 报"源缺失"、CMS 报告的三层基线卡为空。
+    #   现由两个生成端从**窗索引**算: component_history(逐窗标量 x_self → 在升/换件闭环; 窗不足时如实为空
+    #   并把数据边界逐条写进 meta) / baseline_38(健康期分段中位 → 自基线 + fleet 基线 + ISO 绝对限)。
+    #   ★必须排在通配族 `vib_handoff_and_scans` **之前** (族表首个命中生效), 否则又被认领成"无生成端"。
+    dict(id='vib_history_baseline',
+         glob=['m5_cms_tcm/component_history.json', 'm5_cms_tcm/baseline_38.json'],
+         kind='raw-derived',
+         func='振动在升/换件闭环历史 + 三层基线(自 / 机群 / 绝对)',
+         algo='scripts/component_history_build.py(逐窗标量 x_self → 末三窗/早三窗在升 + 换件前后闭环; '
+              '窗不足时判据不可算则如实空表)+ scripts/baseline_38_build.py(健康期分段中位 + 正常带 + '
+              'relMAD → 自基线, 机群同段中位 → fleet 基线, ISO 20816-3 → 绝对限)',
+         gen='scripts/component_history_build.py', input='windcms', pred=('rows',),
+         why='输入是 CMS 原始导出的**重算产物**(窗索引), 其 raw 祖先是 data/raw/<场>/windcms; '
+             '旧随包件是振动线手交件(全库 0 处写入方); 两件都在重算链 ④d/④e, 口径与数据边界写进件内 meta'),
+    dict(id='vib_handoff_and_scans', glob='m5_cms_tcm/*.{json,parquet,csv,md,txt}',
+         kind='shipped',
+         func='振动线出件与专项扫描(handoff / 历史 / 基线 / 各类 freq scan / 判级台账)', algo='(无生成端)',
+         gen=None, input=None, pred=(), why='振动六层链四步脚本未随包(oem_frequency_scan 等)⇒ '
+                                            '运行期不从交付包补(用户令 2026-09-17); 要出件须研发补生成端, '
+                                            '或由现场正本随原始件进 data/raw 后重算'),
+    # (2026-09-19: `line_energy_share` / `mask_thresholds` / `ontology_releases` 三族已移到前面按
+    #  raw-derived 记账 —— 族表是**首个命中生效**, 必须排在通配族 `vib_handoff_and_scans` / `tcm_replay`
+    #  / `ontology_releases(旧)` 之前, 否则新件会被旧族认领成"无生成端")
+    dict(id='vib_figs', glob='m5_cms_tcm/figs/*', kind='shipped',
+         func='振动线出图(谱图/趋势图)', algo='(无生成端)', gen=None, input=None, pred=(),
+         why='随包快照(振动线出件)'),
+    # ★2026-09-19 拆族 (用户令「所有的计算均要形成观澜的源代码」): `windscada/index.html` 是
+    #   `/detail/v2` 的「全场状态总览」iframe 目标, 此前整族记为"无生成端" ⇒ 新机器上那块必然显示
+    #   "产物不在位"。现在它由 `scripts/windscada_overview_build.py` 用**组件同一个快照烘焙器**
+    #   (src/windscada/ui/snapshot.py::bake, /v2/snapshot 用的就是它)从重算产物生成 ⇒ 单列一族。
+    dict(id='windscada_overview', glob='windscada/index.html', kind='raw-derived',
+         func='总览「全场状态」页 (单文件: 接口响应烤进页面, 离线可开)',
+         algo='scripts/windscada_overview_build.py(ctx 取数函数与在线 /api/* 同一实现; 经 '
+              'src/windscada/ui/snapshot.py::bake 烘焙)',
+         gen='scripts/windscada_overview_build.py', input='scada_10min', pred=('rows',),
+         why='2026-09-19 用户令后上链: 该页此前是随包件(全库 0 处写入方), 清过产物的机器上 iframe 只能'
+             '显示"产物不在位"; 现在由观澜自算, 每次重算随数据更新'),
+    dict(id='windscada_pages', glob='windscada/{turbines/*,review/*}', kind='shipped',
+         func='逐台静态页 + 评审记录(当前由组件按请求动态出页, 静态件仍属随包快照)', algo='(无生成端)',
+         gen=None, input=None, pred=(), why='逐台页在运行期由 /turbine/<台> 动态生成, 静态副本无写入方; '
+                                             '运行期不从交付包补(用户令 2026-09-17)'),
+    # ★2026-09-18 补: `windcms/cache/*` 是**读侧缓存**(标量按内容哈希落盘), 它不是随包快照 ——
+    #   原先 `windcms_pages`(shipped) 先认领了它, 于是重算后新写的缓存被记成"包内无生成端"。
+    #   写方在 src/windcms/data.py::load_scalars (窗索引 → 标量), 故单列一族并放在 windcms_pages 之前。
+    dict(id='windcms_cache', glob='windcms/cache/*', kind='raw-derived',
+         func='CMS 报告侧 · 标量缓存', algo='src/windcms/data.py::load_scalars(窗索引 → 标量数组, '
+              '按内容哈希命名落 cache/)', gen='src/windcms/data.py', input='windcms', pred=('rows',)),
+    # ★2026-09-18 补 (用户令"页面只能基于输入数据重算"之后): `windcms/**` 整族原先一律按
+    #   "无生成端"记账 —— 但**报告两步的生成端就在包内**(scripts/windcms.py report → src/windcms/
+    #   report.py + report_std.py), 只要 data/raw/<场>/windcms 里有 decode 导出就出得来。
+    #   实测(2026-09-18): 从重算出的窗 w0316 一键跑出标准模版报告 + 38 台逐台页 + 总览, 判级 38 台
+    #   (报警 3 / 优秀 35), 缺的只有 model_run/fusion 两步的融合级列(如实写 —)。故拆成两族按 raw-derived 记。
+    dict(id='windcms_report_std', glob=['windcms/报告_CMS振动状态评估报告_*.md',
+                                        'windcms/报告_CMS振动状态评估报告_*.docx',
+                                        'windcms/登记_三轴_38台.csv'], kind='raw-derived',
+         func='CMS 标准模版评估报告 (三轴: 状态等级 × 证据状态 × 行动等级建议)',
+         algo='src/windcms/report_std.py::build(结构化判级 → 报告 md + docx + 登记 csv; '
+              '由 scripts/windcms.py report 调用)',
+         gen='src/windcms/report_std.py', input='windcms', pred=('rows',),
+         why='★同名件有两种来源: 观澜自算(本族: 从 data/raw/<场>/windcms 的 *_decode.json 算) 与 '
+             '厂家报告转录(scripts/vib_reports_build.py, 输入是现场振动分析报告 PDF)。按名字认领不了'
+             '这层区别 ⇒ 以"是否由本步生成"为准, 判据看 m5_cms_tcm/vib_raw_manifest.json 的 steps'),
+    dict(id='windcms_report_overview', glob=['windcms/report.md', 'windcms/index.html',
+                                             'windcms/index_eng.html', 'windcms/overview.html',
+                                             'windcms/turbines/*'], kind='raw-derived',
+         func='CMS 诊断总览页 + 逐台页 + 结构化层报告',
+         algo='src/windcms/report.py::build(逐台 turbines/<台>.html + 总览 overview/index.html + '
+              'report.md; 融合级列要 model_run/fusion 出件, 缺则如实写"—")',
+         gen='src/windcms/report.py', input='windcms', pred=('rows',)),
+    dict(id='windcms_kb', glob='windcms/kb.json', kind='raw-derived',
+         func='CMS 知识库索引 (问答检索用)',
+         algo='src/windcms/knowledge.py::build(由 scripts/windcms.py kb 调用; 离线无模型时 0 chunks 也算跑成)',
+         gen='src/windcms/knowledge.py', input='windcms', pred=('rows',)),
+    dict(id='windcms_pages', glob='windcms/**', kind='shipped',
+         func='windcms 里其余件 (厂家报告转录 / 知识库出件等)',
+         algo='(无随包生成端)', gen=None, input=None, pred=(),
+         why='上述两族与 cache 之外剩下的 windcms 件: 运行期不从交付包补(用户令 2026-09-17), '
+             '要出件须研发补生成端'),
+    # ★2026-09-19 用户令「要让观澜从重算台账生成 claim」: findings 与三份底稿改由**观澜自算**
+    #   (scripts/sop_findings_from_ledger.py 从 alarms/workorders/oil/temp_bins/powercurve_dev/loss_monthly/L6
+    #   算 claim, 按契约构建器要的字段结构写) ⇒ 单列一族; 契约四件随之由 scripts/guanlan_facts_contract.py 生成。
+    dict(id='ledger_claims', glob=['sop/findings.json', 'paradigm_r1/experiments/*/底稿*.json'],
+         kind='raw-derived',
+         func='事实契约的 claim 本体与底稿 (观澜自算, 替代人裁底稿)',
+         algo='scripts/sop_findings_from_ledger.py(六族 claim: 报警集中/重复检修/停机损失/温度离群/功率曲线偏离/振动过闸线; '
+              '每条带 coverage·缺证据·证伪判据, 相对判据封顶「候选」)',
+         gen='scripts/sop_findings_from_ledger.py', input='scada_10min', pred=('rows',),
+         why='输入是**重算产物**(windscada/*.parquet + m5_cms_tcm/*): 其 raw 祖先是 故障报警/风机故障记录/scada_10min; '
+             '本族单列以区别于旧的人裁底稿(同路径旧件无生成端)'),
+    dict(id='sop_workspace', glob='sop/**', kind='shipped',
+         func='SOP 中间件/评审/事实契约底稿', algo='(无生成端)', gen=None, input=None, pred=(),
+         why='评审过程件, 属人工作业留痕'),
+    dict(id='tcm_replay', glob='tcm_compatible_replay/**', kind='shipped',
+         func='TCM 兼容链回放资产(模型表/掩码阈值/裁决记录)', algo='(无生成端)', gen=None, input=None,
+         pred=(), why='随包快照'),
+    dict(id='paradigm_r1', glob='paradigm_r1/**', kind='shipped',
+         func='范式实验件(E3/E5/E8 底稿)', algo='(无生成端)', gen=None, input=None, pred=(),
+         why='实验底稿, 事实契约的输入'),
+    # ★2026-09-19 用户令「所有的计算均要形成观澜的源代码」: 变桨面两件原来 kind=shipped(无生成端)
+    #   ⇒ 换台机器重算后必然缺件、变桨面恒"不可判"。现由 scripts/pitch_face_build.py 从 data/raw 的
+    #   scada_10min(液压/润滑/柱塞/运行状态) + scada_1min(三叶桨距角/压力/有功) 算出, 并进重算链 ④c。
+    dict(id='pitch_face', glob='pitch/**', kind='raw-derived',
+         func='变桨侧派生件(日粒度液压/润滑/柱塞 + 零位三口径月表)',
+         algo='scripts/pitch_face_build.py(10min 开关量 timeon/压力锯齿 + 1min 停机顺桨段/满发段/运行段分档 '
+              '三叶桨距角中位 → 相对同月全场中位的零位偏差;口径/阈值/证据见同目录 pitch_face_manifest.json)',
+         gen='scripts/pitch_face_build.py', input='scada_10min', pred=('rows',),
+         why='输入是 10min 与 1min 两路 SCADA 原始导出件(scada_source: CSV 优先/MDB 回落);'
+             '零位按用户令两口径分列(停机顺桨段/满发段),并据实测反推补上**运行段同工况分档**这一判据轴'
+             '(它才能把现场确诊的 19# 单独拎出来)'),
+    # (2026-09-19: `ontology_releases` 已移到前面按 raw-derived 记账 —— 现在由
+    #  scripts/ontology_release_build.py 从对象库投影生成, 不再是"由研发出件"的随包层)
+    # ★2026-09-19 用户令「所有的计算均要形成观澜的源代码」+「从重算台账生成 claim」:
+    #   这一族从"无生成端"改成 raw-derived —— 生成端 guanlan_facts_contract.py 本来就在包内,
+    #   原来缺的是**它的输入**(人裁底稿); 现在输入由上游族 ledger_claims 自算
+    #   (scripts/sop_findings_from_ledger.py) ⇒ 新机器上重算即得, /api/facts 不再 503。
+    dict(id='guanlan_contract', glob='guanlan/**', kind='raw-derived',
+         func='事实契约与对外派生(门户结论段/取数)',
+         algo='scripts/guanlan_facts_contract.py(build→render: 契约 + portal_claims/detail_cards/qa_refs/'
+              'report_summary; 输入=上游族 ledger_claims 自算的 sop/findings.json + 三份底稿)',
+         gen='scripts/guanlan_facts_contract.py', input='scada_10min', pred=('rows',),
+         why='输入与 data/raw 隔着两跳(重算台账 → claims → 契约), 但**两跳的生成端都在包内**; '
+             'claim 的判据/缺证据/证伪逐条写在件里, 相对判据封顶「候选」'),
+    dict(id='windscada_release_manifest', glob='windscada/release-manifest.json', kind='source-derived',
+         func='发布清单快照(`/healthz` 与版本信息读它)',
+         algo='scripts/guanlan_baseline_manifest.py(从 src/windscada/ui 源重建 zh 页 → 摘要 '
+              '{css_tokens, nav, dom, sha256, source blobs})',
+         gen='scripts/guanlan_baseline_manifest.py', input=None, pred=(),
+         why='★ 2026-09-17 修正: 它一度被我按"交证件"错分成非产物; 实际**包内有生成端**'
+             '(guanlan_baseline_manifest.py 就写这个路径), 且 guanlan_gateway.py 运行期读它 ⇒ 是产物'),
+    dict(id='human_deliverables',
+         glob=['windscada/mblub_alarm_cross.json', 'windscada/review_presented.json',
+               'windscada/给振动线_温度轴回复_20260826.json', 'windscada/给振动线_温度轴回复_20260826.md',
+               'windscada/送审_系统结论与判定_20260828.md'],
+         kind='human',
+         func='**人工正本/交证件**:与振动线的跨线通知、送审结论、发布评审快照',
+         algo='(人写的, 不是算出来的)', gen=None, input=None, pred=(),
+         why='这几件是"人对人的交证件/回执", 内容由分析结论抄写与人判断坐实而成; '
+             'mblub_alarm_cross.json 还被 src/ontology/populate.py 当作**证据来源**引用 ⇒ '
+             '必须随包留在原位, 但**不参与呼应校验**(它本来就没有生成端, 也不该有)'),
+    dict(id='scratch_in_products', glob=['**/_*.js', '**/_*audit*.md', '**/*.out', '**/*.err',
+                                        '**/*_test_*.parquet', '**/*_test_*.json'],
+         kind='not-product',
+         func='**非产物**:自审脚本/记录与模型测试输出(躺在产物仓里)', algo='(工具脚本与运行痕迹, 不是产物)',
+         gen=None, input=None, pred=(),
+         why='2026-09-17: 17 件已移出到 reference/非产物留痕_20260917/ 并留了 _来源说明.json ⇒ '
+             '本族现在应为空; 一旦再出现(新脚本往产物仓里写 .out/_audit.js 之类), 本器当场报出来'),
+]
+
+
+def _files_of(glob) -> list[str]:
+    """族 glob(str 或 list)→ 台账里的相对键(支持 `{a,b}` 花括号与 `*`)。
+
+    ★ 深度必须**显式对齐**(2026-09-17 第一版踩过): `fnmatch` 的 `*` 是**跨 `/`** 的, 于是
+      `windscada/*.parquet` 会把 `windscada/turbines/xx.parquet`、`windscada/_pre_rebuild_*/xx.parquet`
+      一起吞进来 —— 反向审计最怕"族把不该管的件认领了", 那样账就假了。
+      规则: 模式里没有 `**` 时, 要求的 `/` 个数必须与键的 `/` 个数**相等**(逐条展开后各自判)。
+    """
+    pats = []
+    for g in ([glob] if isinstance(glob, str) else list(glob)):
+        if '{' in g:
+            head, tail = g.split('{', 1)
+            opts, rest = tail.split('}', 1)
+            pats += [head + o + rest for o in opts.split(',')]
+        else:
+            pats.append(g)
+    out = []
+    for rel in LEDGER:
+        for pt in pats:
+            if '**' not in pt and rel.count('/') != pt.count('/'):
+                continue
+            if fnmatch.fnmatch(rel, pt) or rel == pt:
+                out.append(rel)
+                break
+    return sorted(out)
+
+
+LEDGER: dict[str, dict] = {}
+
+
+def load_ledger(farm: str) -> dict[str, dict]:
+    """台账 = `_provenance.json`(逐件来源) + `_derived_manifest.json`(生成端自登记)。"""
+    out: dict[str, dict] = {}
+    root = P.out_root(farm)
+    for fn in ('_provenance.json', '_derived_manifest.json', '_raw_scan.json'):
+        f = root / fn
+        if not f.is_file():
+            continue
+        try:
+            d = json.loads(f.read_text(encoding='utf-8'))
+        except Exception:
+            continue
+        for rel, v in (d.get('files') or {}).items():
+            if isinstance(v, dict):
+                out.setdefault(rel, dict(source=v.get('source') or 'raw-derived',
+                                         builder=v.get('builder') or v.get('by') or '',
+                                         why=v.get('why') or '',))
+    return out
+
+
+def _input_home(station: pathlib.Path, sub: str) -> pathlib.Path | None:
+    """输入类目录在哪 —— 优先场站目录下, 其次 raw 根下。
+
+    ★ 机理层资料按 A2 约定**不在场站目录下**(`data/raw/西门子4.0技术资料/`, 见 src/windscada/config.py)。
+      只查场站目录会把本体层判成"输入不在位"(2026-09-17 第一版就这么误报过 3 件)。
+    """
+    for base in (station, station.parent):
+        d = base / sub
+        if d.is_dir():
+            return base
+    return None
+
+
+def _parquet_span(p: pathlib.Path, col: str | None):
+    import pandas as pd
+    try:
+        d = pd.read_parquet(p)
+    except Exception:
+        return None, None, 0
+    n = len(d)
+    if col and col in d.columns:
+        s = pd.to_datetime(d[col], errors='coerce')
+        if s.notna().any():
+            return str(s.min())[:10], str(s.max())[:10], n
+    return None, None, n
+
+
+def _npz_ok(p: pathlib.Path) -> bool:
+    """振动谱件: npz 能读出来且非空(列数不是"行", 不能拿 parquet 的行数判它)。"""
+    try:
+        import numpy as np
+        with np.load(p, allow_pickle=False) as z:
+            return any(z[k].size > 0 for k in z.files)
+    except Exception:
+        return False
+
+
+# 已知"含人工裁决/校准"的随包快照(按用户令 2026-09-17 #3 显式登记为人工件)。依据是文档里已经写明的性质:
+# docs §7「handoff_vibration_v2.json / component_history.json 是随包快照, 该件含人工裁决/校准更新,
+# 不是测量数据的函数」; baseline_38.json 的三层基线 self/absolute 两层由人工坐实; findings.json 是判级发现台账。
+HUMAN_SNAPSHOTS = {
+    'm5_cms_tcm/component_history.json',
+    'm5_cms_tcm/baseline_38.json',
+    'm5_cms_tcm/handoff_vibration_v1.json',
+    'm5_cms_tcm/findings.json',
+}
+
+# ── 逆向工程可行性 (用户令 2026-09-17 #2) ────────────────────────────────────────────
+# 问的是: "这批没有生成端的产物, 能不能从原始件/在包生成端**推**出来?"
+# 判定不靠印象, 靠两条机器证据:
+#   ① 生成端在不在包内(在 → 只是没接上, 跑一次就有);
+#   ② 产物正文里有没有**人工判断**的痕迹("裁决/审核/校准/评审/经验"这类字段)——
+#      含人工判断的件不是任何输入的纯函数, 逆向工程推不出来, 只能把那一步人工工作重做。
+import re as _re                                                         # noqa: E402
+JUDGE_PAT = _re.compile(r'人工|裁决|审核|复核|校准|评审|经验值|专家|judg|review|manual|calibrat|verdict|sign[_ ]?off',
+                        _re.I)
+# ★ 只认"**字段**叫这个名字"(`"裁决": …` / `裁决,`),不认正文里顺嘴提一句 ——
+#   2026-09-17 第一版拿整篇子串匹配, 结果把**页面**里的展示标签(`index.html` 里的"评审/人工")当成
+#   人工判断, 于是把"页面"整族误判成不可逆。这属于"判据太糙 → 结论假"。
+JUDGE_KEY_PAT = _re.compile(r'["\']?[^"\',:]{0,24}(人工|裁决|审核|复核|校准|评审|经验值|专家|'
+                            r'judg|review|manual|calibrat|verdict|sign[_ ]?off)[^"\',:]{0,24}["\']?\s*[:=]',
+                            _re.I)
+
+
+def judgement_evidence(farm: str, rels: list[str], cap: int = 8) -> tuple[int, list[str], int]:
+    """→ (含人工判断字段的抽样比例%, 样例, 被检查件数)。
+
+    只查**数据/文本件**(.json/.csv/.md/.txt); **页面(.html/.js)不算** —— 页面是产物的渲染,
+    里面出现"评审/人工"是展示标签, 不代表这份件内含判断。
+    """
+    hit, samples, checked, pages = 0, [], 0, 0
+    for rel in rels[:cap]:
+        p = P.out_root(farm) / rel
+        suf = p.suffix.lower()
+        if suf in ('.html', '.js', '.css', '.htm'):
+            pages += 1
+            continue
+        try:
+            if suf == '.json':
+                txt = p.read_text(encoding='utf-8', errors='replace')[:200000]
+                pat = JUDGE_KEY_PAT
+            elif suf in ('.csv', '.txt'):
+                txt = p.read_text(encoding='utf-8', errors='replace')[:50000]
+                pat = JUDGE_KEY_PAT
+            elif suf == '.md':
+                txt = p.read_text(encoding='utf-8', errors='replace')[:50000]
+                pat = JUDGE_PAT
+            else:
+                continue
+        except Exception:
+            continue
+        checked += 1
+        if pat.search(txt):
+            hit += 1
+            samples.append(rel)
+    return (100 * hit // checked if checked else 0), samples, pages
+
+
+def feasibility(fam: dict, rels: list[str], gen_ok: bool, in_ok: bool,
+                judge_pct: int, judge_samples: list[str], pages: int = 0) -> tuple[str, str]:
+    """→ (可逆性判定, 依据)。四类: 可逆(直接) / 可逆(需反推口径) / 不可逆(人工判断) / 应移出产物仓。"""
+    if fam['kind'] == 'not-product':
+        return '应移出产物仓', '它本就不是产物(工具脚本/交证件/测试输出) ⇒ 该从产物仓移走, 而不是"补生成端"'
+    if gen_ok and in_ok:
+        return '可逆(直接)', f"生成端在包内({fam['gen']})且输入在位 ⇒ 接上/重跑即可"
+    if pages and pages >= max(3, len(rels[:8]) // 2):
+        return '可逆(页面可再生)', ('这一族主要是**页面**(.html): 页面是产物的渲染, 不是独立数据 ⇒ '
+                                    '接上在包的页面生成端重出即可(未必逐字节同, 能力与数据一致)')
+    m_n = sum(1 for r in rels if r.endswith(('.parquet', '.csv', '.npz')))
+    t_n = len(rels) - m_n
+    split = f'({m_n} 件测量形态 / {t_n} 件文本·判断件)' if (m_n and t_n) else ''
+    # 两类都占相当比重时, 结论必须是"混合"而不是挑一类代表全族 —— 否则一句判定就把另一半骗过去了
+    if m_n and t_n and min(m_n, t_n) * 4 >= len(rels):
+        return '混合(测量件可反推 / 文本件需人定)', split + \
+               '测量形态的件可由 data/raw 反推口径 + 逐值对拍; 文本/判断件(评审、裁决、回复)推不出来'
+    if judge_pct >= 50 and judge_samples:
+        return '不可逆(人工判断)', (f"抽样 {len(judge_samples)} 件里都写着人工判断字段(如 {judge_samples[0]}) "
+                                    f"⇒ 不是输入的纯函数, 逆向工程推不出来")
+    if m_n and m_n * 2 >= len(rels):
+        return '可逆(需反推口径)', split + '测量形态的件是测量数据的函数 ⇒ 可从 data/raw 反推口径 + 逐值对拍' \
+                                        '(做法同 temp_monthly: 反推 → 逐值一致才敢用)'
+    if gen_ok:
+        return '半可逆(生成端在包, 输入不足)', '生成端在包内, 缺的是上游输入 ⇒ 上游补齐后即可重出'
+    if judge_pct > 0:
+        return '半可逆(需人工裁定)', f'抽样里 {judge_pct}% 的件含人工判断字段 ⇒ 机器部分可推、判断部分要人定'
+    return '需人工裁定', '既无生成端、形态也不是测量函数 ⇒ 需研发给口径或领域正本'
+
+
+def _turbines_of(p: pathlib.Path, col: str = 'turbine', cap: int = 200000):
+    import pandas as pd
+    try:
+        d = pd.read_parquet(p, columns=[col]) if col else pd.read_parquet(p)
+    except Exception:
+        return None
+    try:
+        return {str(x).upper() for x in d[col].dropna().unique()} if col in d.columns else None
+    except Exception:
+        return None
+
+
+def _input_turbines(station: pathlib.Path, sub: str):
+    """输入侧机组集合: 逐台 CSV 文件名 / 目录名里的 WTG 号。"""
+    d = station / sub
+    if not d.is_dir():
+        return None
+    out = set()
+    for p in d.rglob('*'):
+        if p.is_file():
+            for m in __import__('re').finditer(r'(WTG\s?\d{1,2})', p.name.upper()):
+                out.add(m.group(1).replace(' ', ''))
+    return out or None
+
+
+def classify_human(farm: str, rels: list[str], cap: int = 600) -> set[str]:
+    """逐件判"是不是人工件"(用户令 2026-09-17 #3:人工件不再按自动产物参与呼应校验)。
+
+    判据:**非测量形态**的件(.md/.json/.csv/.txt)里出现人工判断字段(按字段名匹配)。
+    测量形态件(.parquet/.csv 数值、.npz)一律不算人工件 —— 它们是数据的函数,属"可反推"那一路。
+    """
+    human: set[str] = set()
+    for rel in rels[:cap]:
+        if rel in HUMAN_SNAPSHOTS:            # ① 文档里已写明含人工裁决/校准的随包快照
+            human.add(rel)
+            continue
+        suf = pathlib.Path(rel).suffix.lower()
+        if suf == '.md':                      # ② 产物仓里的 .md 一律是报告/交证/评审记录(人写的)
+            human.add(rel)
+            continue
+        if suf == '.txt':
+            pat = JUDGE_PAT
+        elif suf in ('.json', '.csv'):
+            pat = JUDGE_KEY_PAT
+        else:
+            continue
+        p = P.out_root(farm) / rel
+        try:
+            txt = p.read_text(encoding='utf-8', errors='replace')[:120000]
+        except Exception:
+            continue
+        if pat.search(txt):                   # ③ 正文里有"人工判断字段"
+            human.add(rel)
+    return human
+
+
+def write_human_manifest(farm: str | None = None) -> int:
+    """把人工件逐件落成 `outputs/<场>/_human_artifacts.json`(机器台账,供自检与审计引用)。"""
+    farm = farm or P.farm()
+    global LEDGER
+    LEDGER = load_ledger(farm)
+    entries: dict[str, dict] = {}
+    for fam in FAMILIES:
+        if fam['kind'] not in ('shipped', 'human', 'not-product'):
+            continue
+        rels = [r for r in _files_of(fam['glob']) if r not in entries]
+        if not rels:
+            continue
+        if fam['kind'] == 'human':
+            for r in rels:
+                entries[r] = dict(kind='human', family=fam['id'], evidence='族内显式登记为人工正本/交证件')
+            continue
+        for r in classify_human(farm, rels):
+            if r not in entries:
+                entries[r] = dict(kind='human', family=fam['id'],
+                                  evidence='件内含人工判断字段(按字段名匹配),不是输入的纯函数')
+    out = P.out_root(farm) / '_human_artifacts.json'
+    out.write_text(json.dumps(dict(
+        at=__import__('time').strftime('%Y-%m-%d %H:%M:%S'),
+        note='人工件台账(用户令 2026-09-17 #3):这些件由人写成/坐实,不是任何输入的纯函数 ⇒ '
+             '不参与"输出↔输入呼应"校验;随包留在原位,重算不会也不该重写它们。'
+             '本文件由 scripts/products_reverse_audit.py --write-human-manifest 生成。',
+        count=len(entries), files=entries), ensure_ascii=False, indent=1), encoding='utf-8')
+    by_fam: dict[str, int] = {}
+    for v in entries.values():
+        by_fam[v['family']] = by_fam.get(v['family'], 0) + 1
+    print(f'人工件台账: {len(entries)} 件 → {P.rel(out)}')
+    for k, v in sorted(by_fam.items(), key=lambda kv: -kv[1]):
+        print(f'   {k:24s} {v:4d} 件')
+    return 0
+
+
+def audit(farm: str | None = None, verbose: bool = True):
+    farm = farm or P.farm()
+    global LEDGER
+    LEDGER = load_ledger(farm)
+    # ★ 场站原始件目录用唯一取用口 (场站名可与 farm 键不同: 键 = rudong, 目录 = data/raw/如东)
+    from src.windscada.config import raw_station_dir
+    station = pathlib.Path(raw_station_dir(farm))
+    verdict: dict[str, dict] = {}
+    fam_rows = []
+    fails: list[str] = []
+
+    for fam in FAMILIES:
+        # ★ 族按**声明顺序**认领, 先声明的先拿 (否则 `m5_cms_tcm/*` 这种宽通配会把 windows/ 下
+        #   1704 件也吞进来 —— fnmatch 的 `*` 是跨 `/` 的, 2026-09-17 第一版就这么误判过)
+        rels = [r for r in _files_of(fam['glob']) if r not in verdict]
+        rels = [r for r in rels if not r.lower().endswith(('.log', '.jsonl'))]
+        if not rels:
+            continue
+        row = dict(id=fam['id'], n=len(rels), func=fam['func'], algo=fam['algo'], input=fam['input'],
+                   pred='+'.join(fam['pred']) or '—', verdict='', note='')
+        if fam['kind'] == 'not-product':
+            # ★ 不是产物, 却躺在产物仓里: 反向审计对它的结论是"它压根不该按产物管" ——
+            #   既不需要生成端, 也谈不上与输入呼应(这一类最该被人看见, 故单独一类, 不当失败计)。
+            jp, js, pg = judgement_evidence(farm, rels)
+            fv, fw = feasibility(fam, rels, False, False, jp, js, pg)
+            row['verdict'] = '✗ 非产物'
+            row['note'] = fam.get('why', '')
+            row['rev'], row['rev_why'] = fv, fw
+            for r in rels:
+                verdict[r] = dict(fam=fam['id'], verdict='✗',
+                                  why='非产物(过程留痕/工具脚本躺在产物仓里): ' + fam.get('why', ''))
+        elif fam['kind'] == 'human':
+            # ★ 人工正本/交证件 (用户令 2026-09-17 #3): 由人写、被运行期当证据引用,
+            #   **不参与呼应校验**——它本来就没有、也不该有生成端; 单独计数, 不混进"无生成端"的失败堆里。
+            row['verdict'] = '◆ 人工件'
+            row['note'] = fam.get('why', '')
+            row['rev'], row['rev_why'] = '人工件(正本/交证)', '人写的, 不参与呼应校验; 随包留在原位'
+            for r in rels:
+                verdict[r] = dict(fam=fam['id'], verdict='◆',
+                                  why='人工件(人工正本/交证件): ' + fam.get('why', ''))
+        elif fam['kind'] == 'shipped':
+            jp, js, pg = judgement_evidence(farm, rels)
+            gen_file = ROOT / fam['gen'] if fam.get('gen') else None
+            gen_ok0 = bool(gen_file and gen_file.is_file())
+            # ★ 逐件分人工件 (用户令 2026-09-17 #3): 含人工判断字段的件**不按自动产物参与呼应校验**,
+            #   单独计 ◆; 余下才是真正的"无生成端"。
+            human = classify_human(farm, rels)
+            rest = [r for r in rels if r not in human]
+            fv, fw = feasibility(fam, rest or rels, gen_ok0, False, jp, js, pg)
+            row['verdict'] = '✗ 无生成端' if not human else f'✗ 无生成端 + ◆ 人工件 {len(human)}'
+            row['n'] = len(rest)
+            row['note'] = fam.get('why', '')
+            row['rev'], row['rev_why'] = fv, fw
+            if jp:
+                row['rev_why'] += f';人工判断字段抽样命中 {jp}%' + (f'(如 {js[0]})' if js else '')
+            for r in rest:
+                verdict[r] = dict(fam=fam['id'], verdict='✗', why='无生成端(全库 0 处写入方): ' + fam['why'])
+            for r in human:
+                verdict[r] = dict(fam=fam['id'], verdict='◆',
+                                  why='人工件(含人工判断字段): 不参与呼应校验, 随包留在原位')
+        else:
+            # ① 生成端在位
+            gen = ROOT / fam['gen'] if fam.get('gen') else None
+            gen_ok = bool(gen and gen.is_file())
+            # ② 输入在位(场站目录优先, 机理层资料在 raw 根下 —— 见 _input_home)
+            spec = INV.INPUTS.get(fam['input'] or '')
+            i_s = i_e = None
+            i_n = 0
+            i_gran = '年'
+            home = _input_home(station, fam['input']) if fam['input'] else None
+            if home and spec:
+                i_s, i_e, i_n, _note, i_gran = INV.input_span(home, fam['input'], spec)
+            elif home:
+                # 机理层资料这类"没有跨度判据"的输入: 按件数清点即算在位(INV.INPUTS 里没有它们的
+                # 跨度口径 —— 技术资料是文档而不是时序数据, 拿跨度比毫无意义)
+                i_n = sum(1 for p in (home / fam['input']).rglob('*') if p.is_file())
+                i_gran = '年'
+            elif fam['kind'] == 'source-derived':
+                # ★ 输入是**包内源码**而不是 data/raw(如发布清单快照: 从 src/windscada/ui 重建页再摘要)。
+                #   生成端在位 = 输入在位, 这类产物的"呼应"是对源码的, 不是对原始件的。
+                i_n = 1
+                i_s = i_e = '包内源码'
+            in_ok = i_n > 0
+            passed, notes = [], []
+            if fam['pred'] and in_ok:
+                if 'span' in fam['pred']:
+                    p_s, p_e, n = _parquet_span(P.out_root(farm) / rels[0], fam.get('time_col'))
+                    if p_s and i_s and (p_s[:7] < i_s[:7]):
+                        # ★ 年粒度输入(按文件名年份推的)不判失败: 与正向检查同一条教训 ——
+                        #   台账 xls 里可能含比文件名年份更早的历史记录, 拿年粒度当"输入起点"会造假缺口
+                        #   (2026-09-17 实测: 工单产物起点 2020-01 而输入文件名最早 2021 ⇒ 不是数据串了)。
+                        msg = f'产物起点 {p_s} 早于输入起点 {i_s}'
+                        if i_gran in ('日', '月'):
+                            fails.append(f"{fam['id']}: {msg}(跨窗混入 / 键错)")
+                            notes.append('⚠ ' + msg)
+                        else:
+                            notes.append(f'{msg}(输入为年粒度, 只作参考: 台账可能含更早的历史行)')
+                    passed.append(f'跨度 {p_s}~{p_e} ⊆ 输入 {i_s}~{i_e}')
+                if 'turbines' in fam['pred']:
+                    pt = _turbines_of(P.out_root(farm) / rels[0])
+                    it = _input_turbines(home, fam['input']) if home else None
+                    if pt and it:
+                        extra = pt - it
+                        if extra:
+                            fails.append(f"{fam['id']}: 产物含输入里没有的机组 {sorted(extra)[:5]}")
+                        passed.append(f'机组 {len(pt)} 台 ⊆ 输入 {len(it)} 台')
+                if 'rows' in fam['pred']:
+                    tot = 0
+                    for r in rels[:400]:
+                        _s, _e, n = _parquet_span(P.out_root(farm) / r, None) if r.endswith('.parquet') \
+                            else (None, None, 1)
+                        tot += n
+                    if tot <= 0:
+                        fails.append(f"{fam['id']}: 产物行数合计为 0")
+                    passed.append(f'件内数据行合计 {tot:,}' + ('(抽样 400 件)' if len(rels) > 400 else ''))
+                if 'npz' in fam['pred']:
+                    sample = rels[:5]
+                    bad = [r for r in sample if not _npz_ok(P.out_root(farm) / r)]
+                    if bad:
+                        fails.append(f"{fam['id']}: 抽样 {len(bad)}/{len(sample)} 件 npz 读不出或为空")
+                    passed.append(f'{len(rels)} 件谱(抽样 {len(sample)} 件 npz 均可读非空)· 输入源 {i_n} 件')
+                if 'manifest' in fam['pred']:
+                    try:
+                        mf = json.loads((P.out_root(farm) / rels[0]).read_text(encoding='utf-8'))
+                        passed.append('清单自记: ' + ', '.join(f'{k}={v}' for k, v in list(mf.items())[:3]))
+                    except Exception as e:
+                        fails.append(f"{fam['id']}: 清单读不出来 ({type(e).__name__})")
+            if not gen_ok:
+                row['verdict'] = '✗ 生成端不在位'
+                row['note'] = f"缺 {fam['gen']}"
+                fails.append(f"{fam['id']}: 生成端不在位 {fam['gen']}")
+            elif not in_ok:
+                row['verdict'] = '~ 输入不在位'
+                row['note'] = (f"缺 {(home / fam['input']) if home else station / str(fam['input'])}"
+                               ' ⇒ 现在无法验证(放数据后复跑本器)')
+            else:
+                row['verdict'] = '✓ 呼应成立'
+                row['note'] = ';'.join(notes + passed)
+                row['rev'], row['rev_why'] = '已在重算链上', '生成端在位、输入在位、判据通过 —— 无需逆向工程'
+            for r in rels:
+                verdict[r] = dict(fam=fam['id'], verdict=row['verdict'][0],
+                                  why=row['note'] if row['verdict'][0] != '✓' else row['note'][:120])
+        fam_rows.append(row)
+
+    # 未归类件 (台账里有, 但没有任何族接住)
+    unclassified = []
+    for rel, v in LEDGER.items():
+        if rel.lower().endswith(('.log', '.jsonl')):
+            continue
+        if rel in verdict:
+            continue
+        # 产物走通配没接住的, 也按台账来源判定
+        unclassified.append(rel)
+        verdict[rel] = dict(fam='(未归类)', verdict='?', why='既不在族表、也无法从台账判定来路')
+
+    counts = {}
+    for v in verdict.values():
+        counts[v['verdict']] = counts.get(v['verdict'], 0) + 1
+    if verbose:
+        print(f'== 反向呼应审计 · 场站 {farm} · 台账 {len(LEDGER)} 件 · 判定 {len(verdict)} 件 ==')
+        print(f'   输入根: {P.rel(station)}')
+        print()
+        print(f'{"族":26s} {"件数":>6s} {"判定":14s} {"输入类":12s} {"功能 / 算法 / 依据"}')
+        for r in fam_rows:
+            print(f'  {r["id"]:24s} {r["n"]:6d} {r["verdict"]:14s} {str(r["input"] or "—"):12s} '
+                  f'{r["func"][:34]}')
+            if r['note']:
+                print(f'      └─ {r["note"][:150]}')
+        if unclassified:
+            print(f'\n  【未归类 {len(unclassified)} 件】')
+            for r in unclassified[:20]:
+                print(f'      ? {r}')
+        print('\n  判定汇总: ' + ' · '.join(f'{k}={v}' for k, v in sorted(counts.items())))
+        print(f'  判据失败 {len(fails)} 条' + (':' + ';'.join(fails[:3]) if fails else ''))
+        ok = counts.get('✓', 0)
+        print(f'  结论: 输出↔输入呼应**成立** {ok} 件 / **不成立(无生成端)** {counts.get("✗", 0)} 件 / '
+              f'**人工件** {counts.get("◆", 0)} 件 / 无法验证 {counts.get("~", 0)} 件 / 未归类 {counts.get("?", 0)} 件')
+        # ── 逆向工程可行性 (用户令 #2): 没有呼应关系的那些, 能不能推出来 ──
+        import collections as _c
+        by_rev = _c.Counter()
+        for r in fam_rows:
+            by_rev[r.get('rev', '?')] += r['n']
+        print()
+        print('== 反向「可逆性」矩阵(用户令 2026-09-17 #2: 无生成端的件能否从原件推出来)==')
+        print(f'{"族":26s} {"件数":>6s} {"可逆性":22s} 依据')
+        for r in fam_rows:
+            if r['verdict'][0] == '✓':
+                continue
+            print(f'  {r["id"]:24s} {r["n"]:6d} {r.get("rev", "?"):22s} {r.get("rev_why", "")[:96]}')
+        print('  按件数汇总: ' + ' · '.join(f'{k}={v}' for k, v in by_rev.most_common()))
+    rc = 5 if (fails or unclassified) else 0
+    return fam_rows, verdict, counts, fails, unclassified, rc
+
+
+def doc_block(farm: str) -> str:
+    fam_rows, verdict, counts, fails, unclassified, _rc = audit(farm, verbose=False)
+    L = [DOC_BEGIN, '### 13.6 反向呼应审计:自输出 → 功能与算法 → 输入(自动生成,勿手改)', '',
+         f'台账 {len(verdict)} 件产物逐件回溯:**呼应成立 {counts.get("✓", 0)} 件** · '
+         f'**不成立(无生成端){counts.get("✗", 0)} 件** · 无法验证 {counts.get("~", 0)} 件 · '
+         f'未归类 {counts.get("?", 0)} 件。'
+         '判据:① 生成端在位 ② 输入在位 ③ 跨度 ⊆ 输入 / 机组集 ⊆ 输入 / 行数 > 0 / 计数与清单一致。', '',
+         '| 输出族(产物 glob) | 件数 | 反向判定 | 功能 | 算法 / 生成端 | 输入(data/raw/<场>/) | 判据 | 说明 |',
+         '|---|---:|---|---|---|---|---|---|']
+    for r in fam_rows:
+        fam = next(f for f in FAMILIES if f['id'] == r['id'])
+        L.append(f'| `{fam["glob"]}` | {r["n"]} | {r["verdict"]} | {r["func"]} | {r["algo"]} | '
+                 f'{r["input"] or "—(无生成端)"} | {r["pred"]} | {r["note"][:160]} |')
+    if unclassified:
+        L += ['', f'**未归类 {len(unclassified)} 件**(需人工认领):' + '、'.join(f'`{x}`' for x in unclassified[:12])]
+    L += ['', '#### 反向「可逆性」:没有呼应关系的那批,能否从原始件推出来(用户令 2026-09-17 #2)', '',
+          '判据两条机器证据:① 生成端在不在包内 ② 产物正文里有没有**人工判断**字段'
+          '(`人工/裁决/审核/校准/评审/经验/judge/review/calibrat/verdict`,抽样读件统计命中率)。'
+          '含人工判断的件不是任何输入的纯函数 ⇒ 逆向工程推不出来,只能把那一步人工工作重做。', '',
+          '| 输出族 | 件数 | 可逆性 | 依据 |', '|---|---:|---|---|']
+    for r in fam_rows:
+        if r['verdict'][0] == '✓':
+            continue
+        L.append(f'| `{r["id"]}` | {r["n"]} | {r.get("rev", "?")} | {r.get("rev_why", "")} |')
+    import collections as _c
+    by_rev = _c.Counter()
+    for r in fam_rows:
+        by_rev[r.get('rev', '?')] += r['n']
+    L += ['', '**按件数汇总**:' + ' · '.join(f'{k} = {v} 件' for k, v in by_rev.most_common()), '',
+          '**人工件**:含人工判断字段的件已逐件登记在 outputs/&lt;场&gt;/_human_artifacts.json(用户令 2026-09-17 #3),'
+          '它们**不参与**呼应校验(人写成/坐实的东西不是任何输入的纯函数);生成方式:'
+          'python scripts/products_reverse_audit.py --write-human-manifest。', '',
+          '> 结论口径:`可逆(直接)` = 包内已有生成端,接上即可;`可逆(需反推口径)` = 内容是测量数据的函数,'
+          '照 `temp_monthly` 的办法反推 + 逐值对拍;`不可逆(人工判断)` = 逆向工程推不出来,'
+          '要么由研发补生成端、要么承认它是人工件(不该按产物管)。', DOC_END]
+    return '\n'.join(L)
+
+
+def write_doc(farm: str) -> int:
+    doc = ROOT / 'docs' / '系统设计说明.md'
+    txt = doc.read_text(encoding='utf-8')
+    blk = doc_block(farm)
+    i, j = txt.find(DOC_BEGIN), txt.find(DOC_END)
+    if i >= 0 and j > i:
+        txt = txt[:i] + blk + txt[j + len(DOC_END):]
+    else:                                             # 首次: 追加到文末
+        txt = txt.rstrip('\n') + '\n\n' + blk + '\n'
+    doc.write_text(txt, encoding='utf-8', newline='\n')
+    print(f'已写入 {P.rel(doc)} (§13.6 反向呼应审计块)')
+    return 0
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser(description='反向呼应审计: 输出 → 功能与算法 → 输入')
+    ap.add_argument('--farm', default=None)
+    ap.add_argument('--check', action='store_true', help='只出结论 (有未归类/判据失败 → rc=5)')
+    ap.add_argument('--write-doc', action='store_true', help='把族表写进 docs/系统设计说明.md §13.6')
+    ap.add_argument('--write-human-manifest', action='store_true',
+                    help='生成人工件台账 outputs/<场>/_human_artifacts.json (用户令 2026-09-17 #3)')
+    a = ap.parse_args()
+    if a.write_human_manifest:
+        return write_human_manifest(a.farm)
+    if a.write_doc:
+        return write_doc(a.farm or P.farm())
+    _rows, _v, counts, fails, uncl, rc = audit(a.farm, verbose=True)
+    if a.check:
+        print(f'[{"OK" if rc == 0 else "X"}] 反向呼应审计 rc={rc}'
+              + (f'(未归类 {len(uncl)} 件 / 判据失败 {len(fails)} 条)' if rc else '(未归类 0 件、判据无失败)'))
+    return rc
+
+
+if __name__ == '__main__':
+    for _s in (sys.stdout, sys.stderr):
+        try:
+            _s.reconfigure(errors='replace')
+        except Exception:
+            pass
+    sys.exit(main())

+ 70 - 18
scripts/raw_scan.py

@@ -73,16 +73,20 @@ FAMILIES: tuple[tuple[str, tuple[str, ...], tuple[str, ...], str], ...] = (
      '现场给的正本(handoff_vibration_v2.json / component_history.json)与厂家报告——正本在位时优先于观澜自算'),
 )
 # 机理层资料不在场站目录下(A2 约定): data/raw/西门子4.0技术资料
-TECH_DIR, TECH_PATTERNS = '西门子4.0技术资料', ('*.xlsx', '*.xls', '*.pdf', '*.docx', '*.doc', '*.txt', '*.csv')
+TECH_DIR, TECH_PATTERNS = '西门子4.0技术资料', ('*',)   # 该族的消费者(kb_ingest)读整棵树 ⇒ 白名单就是"全部"
 TECH_STEPS = ('⑦ 本体: 码表/手册/文档',)
 TECH_NOTE = '厂商技术资料(故障处理手册/维护 WI/图纸/对译表)→ 本体对象库/检索索引/实机参数表'
+# raw 根下**随包发的说明件**(不是数据): 别把它们报成"未归类"。口径: 只有名字在白名单里且不参与摄入。
+IGNORE_FILES = {'README_把原始数据放这里.txt'}
 
 
 def _digest(entries: list[tuple[str, int, int]], deep: bool, base: pathlib.Path, files: list[pathlib.Path],
-            deep_max: int) -> str:
+            deep_max: int, dirs: list[str] | None = None) -> str:
     h = hashlib.sha1()
     for rel, size, mt in entries:
         h.update(f'{rel}|{size}|{mt}\n'.encode('utf-8', 'replace'))
+    for d in dirs or []:                       # 子目录清单也进指纹: "新建了空目录(数据还没落)"也能发现
+        h.update(f'D:{d}\n'.encode('utf-8', 'replace'))
     if deep:
         for p, (rel, size, _mt) in zip(files, entries):
             if size > deep_max:
@@ -99,14 +103,31 @@ def _digest(entries: list[tuple[str, int, int]], deep: bool, base: pathlib.Path,
 
 
 def fingerprint(d: pathlib.Path, patterns: tuple[str, ...], deep: bool = False, deep_max: int = 8 << 20) -> dict:
-    """一族目录的指纹:件数/体积/最新时间/清单摘要(--deep 再叠内容哈希)。目录不在 ⇒ files=0。"""
-    files: list[pathlib.Path] = []
+    """一族目录的指纹。
+
+    ★口径 (用户令 2026-09-19/20「对 data/raw 目录(含嵌套子目录)下,文件的增减做到监听」):
+      · **递归**统计该族目录下**全部文件**(不限扩展名) —— 只按摄入白名单(patterns)统计的话,
+        往族目录里丢一个 `.zip`/`.txt` 就"看不见"了, 那不算监听; 白名单命中的另记 `matched`
+        (才是真进摄入的件数), 差额用 `unmatched` 列出来给人看;
+      · 指纹里**还含子目录清单**(只放路径名, 不放 mtime —— 目录 mtime 会随子文件变动而变, 那是噪声),
+        于是"新建了一个空目录(数据还没落进去)"也动指纹。
+    """
+    all_files: list[pathlib.Path] = []
+    dirs: list[str] = []
     if d.is_dir():
-        for pat in patterns:
-            files += [p for p in d.rglob(pat) if p.is_file()]
-        files = sorted(set(files))
+        for p in d.rglob('*'):
+            if p.is_file():
+                all_files.append(p)
+            elif p.is_dir():
+                dirs.append(p.relative_to(d).as_posix())
+    all_files = sorted(set(all_files))
+    dirs.sort()
+    matched: list[pathlib.Path] = []
+    for pat in patterns:
+        matched += [p for p in d.rglob(pat) if p.is_file()]
+    mset = {p.relative_to(d).as_posix() for p in set(matched)}
     entries: list[tuple[str, int, int]] = []
-    for p in files:
+    for p in all_files:
         try:
             st = p.stat()
         except OSError:
@@ -116,8 +137,10 @@ def fingerprint(d: pathlib.Path, patterns: tuple[str, ...], deep: bool = False,
     newest = ''
     if entries:
         newest = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(max(e[2] for e in entries) / 1e9))
+    _unm = [e[0] for e in entries if e[0] not in mset]
     return dict(files=len(entries), bytes=sum(e[1] for e in entries), newest=newest,
-                digest=_digest(entries, deep, d, files, deep_max),
+                matched=len(mset), dirs=len(dirs), unmatched=_unm[:10], unmatched_n=len(_unm),
+                digest=_digest(entries, deep, d, all_files, deep_max, dirs),
                 top=[e[0] for e in sorted(entries, key=lambda x: -x[2])[:3]])
 
 
@@ -139,16 +162,34 @@ def scan(raw_root: pathlib.Path, deep: bool = False, station_dir: pathlib.Path |
     fam[TECH_DIR] = fp
     known = {n for n, *_ in FAMILIES} | {TECH_DIR}
     unclassified = []
+    known_dirs = {str(station / n) for n, *_ in FAMILIES} | {str(raw_root / TECH_DIR)}
+    # ★未归类 = 任何**不在已知族目录里**的文件 (递归找, 不只看一层): raw 根下、场站目录下、以及
+    #   场站目录里嵌套的新子目录(例如新来了 `CMS_202701` 这种整包目录) 都跑不掉。
+    for p in (sorted(raw_root.rglob('*')) if raw_root.is_dir() else []):
+        if not p.is_file():
+            continue
+        if any(str(p).startswith(k + os.sep) for k in known_dirs):
+            continue
+        if p.name in IGNORE_FILES:
+            continue                       # 随包的放置说明件: 不是数据, 不报"未归类"
+        rel = p.relative_to(raw_root).as_posix()
+        unclassified.append(dict(dir=rel, files=1,
+                                 note='不在任何已知族的目录里 —— 没有消费者; 是新的输入族就得给它配摄入器'))
+    # 已知族目录内**白名单没命中**的文件: 能进摄入的才计数, 这些是"放了但没人读"的
+    stray = []
+    for name, fp in fam.items():
+        if fp.get('unmatched_n'):
+            stray.append(dict(family=name, n=fp['unmatched_n'], example=fp.get('unmatched')[:5]))
+    # 场站目录下**新增的未知子目录**(整目录级; 其文件已在上面逐件登记, 这里给个汇总便于人读)
+    extra_dirs = []
     if station.is_dir():
         for sub in sorted(station.iterdir()):
             if sub.is_dir() and sub.name not in known:
-                unclassified.append(dict(dir=sub.name, files=sum(1 for p in sub.rglob('*') if p.is_file())))
-    for sub in sorted(raw_root.iterdir()) if raw_root.is_dir() else []:
-        if sub.is_dir() and sub.name not in known and sub != station:
-            unclassified.append(dict(dir=sub.name, files=sum(1 for p in sub.rglob('*') if p.is_file()),
-                                     note='raw 根下(非场站目录)'))
+                extra_dirs.append(dict(dir=f'{station.name}/{sub.name}',
+                                       files=sum(1 for p in sub.rglob('*') if p.is_file()),
+                                       note='场站目录下的新子目录(族表里没有)'))
     return dict(at=time.strftime('%Y-%m-%d %H:%M:%S'), root=str(raw_root), station=str(station),
-                deep=deep, families=fam, unclassified=unclassified)
+                deep=deep, families=fam, unclassified=unclassified + extra_dirs, stray_in_families=stray)
 
 
 def diff(old: dict, new: dict) -> list[dict]:
@@ -167,12 +208,19 @@ def diff(old: dict, new: dict) -> list[dict]:
         if d_files == 0 and same_list:
             continue
         kind = ('新增 %+d 件' % d_files) if d_files else '内容变化(件数不变)'
+        _dd = cur.get('dirs', 0) - prev.get('dirs', 0)
+        if d_files == 0 and _dd:
+            kind = f'目录结构变化({_dd:+d} 个子目录)'
         out.append(dict(family=name, kind=kind, files=cur['files'], delta=d_files,
                         bytes_delta=cur['bytes'] - prev['bytes'], newest=cur['newest'],
-                        top=cur.get('top'), steps=cur['steps'], note=cur['note']))
+                        top=cur.get('top'), steps=cur['steps'], note=cur['note'],
+                        matched=cur.get('matched'), unmatched_n=cur.get('unmatched_n', 0)))
     for u in new.get('unclassified') or []:
-        out.append(dict(family=u['dir'], kind='未归类(族表里没有这个目录)', files=u['files'],
-                        steps=[], note='没有已知消费者 ⇒ 先认领:是新的输入族就得给它配摄入器;否则清出 data/raw'))
+        out.append(dict(family=u['dir'], kind='未归类(不在任何已知族的目录里)', files=u['files'],
+                        steps=[], note=u.get('note') or '没有已知消费者 ⇒ 先认领'))
+    # ★族内"白名单外"的件**不计入变化**(信息而已): 实测族目录里本来就有大量正常但不被摄入器读的东西
+    #   (故障记录里的 .rar/.jpg、windcms 厂家报告 PDF、技术资料里的厂商软件 .exe/.lp …)。
+    #   它们增删本身已经反映在 files/dirs/digest 上 ⇒ 该报的变化照报, 这里不再重复判一次。
     return out
 
 
@@ -251,6 +299,10 @@ def main() -> int:
         print('  未归类(族表里没有,先认领):')
         for u in now['unclassified']:
             print(f'    {u["dir"]:16s} {u["files"]:6d} 件  {u.get("note", "没有已知消费者")}')
+    if now.get('stray_in_families'):
+        print('  (信息,不计入"变化")族目录里**摄入白名单没命中**的件 —— 摄入器不会读它们:')
+        for s in now['stray_in_families']:
+            print(f'    {s["family"]:16s} {s["n"]:5d} 件  例: ' + '、'.join(s['example'][:3]))
 
     if changes:
         print(f'\n== 本次发现 {len(changes)} 处新增/变化 ==')

+ 19 - 2
scripts/vib_raw_build.py

@@ -233,6 +233,10 @@ def main() -> int:
                     help='只摄入(索引+谱), 不跑 windcms.py report/kb')
     ap.set_defaults(with_report=True)   # ★2026-09-18 口径翻转: 见文件头"报告默认跑了"
     ap.add_argument('--manifest-only', action='store_true',                    help='只补写 vib_raw_manifest.json(依已存在的窗; 不重跑摄入)')
+    ap.add_argument('--reimport-as-new', action='store_true',
+                    help='窗同名时**不替换**: 另起 `<窗>_reimport_<时分>`(该名被 data.EXCLUDE_DEFAULT 排除, '
+                         '不参与生产集)。默认口径是**替换同名窗**(旧窗留档为 `_superseded_<时分>`) —— '
+                         '用户令 2026-09-20「重算要按 data/raw 最新变化」')
     ap.add_argument('--publish-only', action='store_true',
                     help='恢复模式: 上次摄入已算完索引/谱但发布(改名)被占用而失败 ⇒ 直接发布暂存窗并接着跑'
                          '报告链, 不重跑索引/谱 (见 _move_tree 的说明)')
@@ -366,8 +370,21 @@ def main() -> int:
     win = a.window or f'w{tmin:%m%d}'
     final = windows_dir / win
     if final.exists():
-        final = windows_dir / f'{win}_reimport_{time.strftime("%m%d%H%M")}'
-        print(f'⚠ 窗 {win} 已存在 → 本次摄入落到 {final.name} (data.EXCLUDE_DEFAULT 会排除 _reimport 窗, 不污染生产集)')
+        # ★2026-09-20 用户令「重算时要根据 <data/raw> 目录**最新的变化**重算」⇒ 默认**替换同名窗**:
+        #   原先这里一律改叫 `<窗>_reimport_<时分>`, 而 `data.EXCLUDE_DEFAULT` 把 `_reimport` 排除在
+        #   生产集之外 ⇒ 往 windcms 里补了数据再重算, **生产集用的还是旧窗**, 新数据等于没进系统
+        #   (页面上看不出任何变化, 正是"重算没反映最新变化"的典型)。
+        #   现在: 旧窗改名为 `<窗>_superseded_<时分>` 留档(按名字排除, 不参与生产), 新窗用**正名**发布;
+        #   要旧行为(不碰既有窗、另起 _reimport 窗)用 `--reimport-as-new`。
+        if a.reimport_as_new:
+            final = windows_dir / f'{win}_reimport_{time.strftime("%m%d%H%M")}'
+            print(f'⚠ 窗 {win} 已存在 → 按 --reimport-as-new 落到 {final.name} '
+                  f'(data.EXCLUDE_DEFAULT 排除 _reimport 窗, 不参与生产集)')
+        else:
+            old_win = windows_dir / f'{win}_superseded_{time.strftime("%m%d%H%M")}'
+            print(f'窗 {win} 已存在 → 按"重算=按当前数据重算"口径**替换**: 旧窗留档为 {old_win.name}'
+                  f'(按名字排除, 不参与生产), 新窗用正名 {win} 发布')
+            _move_tree(final, old_win, print)
     print(f'\n发布窗目录: {final.name}({_move_tree(staging, final, print)} 方式)'
           f'   数据窗 {tmin} → {tmax}   行 {len(d)}   谱 {n_spectra}')
 

+ 60 - 33
scripts/windscada_alarms_ingest.py

@@ -27,10 +27,14 @@ r"""报警事件摄入: data/raw/<场站>/故障报警/*.xls → <store>/alarms.
 
 1. **累计快照不叠加**: 目录里还有 `2025年全年故障记录.xls`(20155 行) 与 `2026年至今.xls`(19002 行),
    它们分别是 Q1–Q4 并集 (2773+4770+7830+4782 = 20155, 精确相等) 与 2026 各件的并集 —— 属**累计快照**。
-   直接 concat 会让同一事件按文件数翻倍 (2026-08-31 长停台账虚增 35 倍是同款成因)。本脚本按
-   "键 (Name, Alarmcode, TimeOn) 是否有新增"判定: 一行新键都不贡献的件 = 快照, 跳过并打印证据。
-2. **逐源件幂等**: 重跑时只替换本次涉及的那些 file 的行, 其余行原样保留 —— 这样"放新文件 → 重跑"
-   不会把已有月份洗掉, 也不会凭空复制。
+   直接 concat 会让同一事件按文件数翻倍 (2026-08-31 长停台账虚增 35 倍是同款成因)。
+   ★2026-09-20 改口径: 原来按"逐件累加、一行新键都不贡献就跳过"的**贪心**判快照 —— 实测它依赖处理顺序,
+   **删掉一个源件反而让总行数从 39,211 涨到 56,593**(原本被跳过的大快照件因为少了一个键而被整件摄入,
+   把它覆盖的键又写了一遍) ⇒ 与用户令「重算要按 <data/raw> 的最新变化」不符(既会重复计数, 又不是源集的函数)。
+   现在按**键集合**判: 全件并集去重 (键 = Name/Alarmcode/TimeOn), 同一键只留一行, 归属取"最窄的那个源件"
+   (行数最少者优先, 同名再按文件名) —— 结果只由"盘上现在有哪些件"决定, 加件只会加键、删件只会去键。
+2. **产物 = 当前 data/raw 的函数** (用户令 2026-09-20): 只保留**仍在盘**的源件带来的行; 已不在盘的行如实
+   丢掉并大声报出来(哪几个源件、多少行、涉及哪些月), 让人一眼分清"误删"还是"现场撤数"。
 
 用法:
     python scripts/windscada_alarms_ingest.py --dry-run      # 只报计划与证据
@@ -104,45 +108,68 @@ def main() -> int:
             continue
         parsed[p.name] = d
 
-    # 累计快照判定: 行数少的先入, 一行新键都不贡献的件视为快照
-    order = sorted(parsed, key=lambda n: len(parsed[n]))
-    kept, seen, snapshots = {}, set(), []
-    for n in order:
-        d = parsed[n]
-        keys = set(map(tuple, d[KEY].astype(str).to_numpy()))
-        new = keys - seen
-        if not new:
-            snapshots.append((n, len(d), len(keys)))
-            continue
-        kept[n] = d
-        seen |= keys
-
-    new = pd.concat(kept.values(), ignore_index=True) if kept else pd.DataFrame(columns=COLS)
-    print("== 源件 ==")
-    for n in sorted(parsed, key=lambda x: -len(parsed[x])):
-        tag = "快照·跳过" if any(n == s[0] for s in snapshots) else "摄入"
-        print(f"  [{tag}] {n:22s} {len(parsed[n]):6d} 行")
+    # ★口径 (2026-09-20 用户令「重算要按 data/raw 最新变化」): **全件并集去重**, 结果只由盘上现有哪些件决定。
+    #   同一键只留一行; 归属(file)取"最窄的源件"= 行数最少者优先, 同名再按文件名 —— 这样:
+    #     · 加件: 只可能新增键(或把某键的归属改到更窄的件上), 不会重复计数;
+    #     · 删件: 只可能去掉"只有它有"的键, 其余键的归属自动落到覆盖它的快照件上(并在下面报出来)。
+    #   旧做法按"累加顺序 + 新键为零就跳过"判快照, 实测**删一个件反而多出 1.7 万行**(重复计数)。
+    frames = []
+    for n, d in parsed.items():
+        dd = d.copy()
+        dd["file"] = n
+        frames.append(dd)
+    allrows = pd.concat(frames, ignore_index=True) if frames else pd.DataFrame(columns=COLS)
+    if len(allrows):
+        allrows["_n"] = allrows["file"].map({n: len(d) for n, d in parsed.items()})
+        allrows["_ord"] = list(range(len(allrows)))               # 稳定序: 同件内保持原行序
+        key = allrows[KEY].astype(str).agg("|".join, axis=1)
+        allrows = (allrows.assign(_k=key)
+                   .sort_values(["_k", "_n", "file", "_ord"], kind="stable")
+                   .drop_duplicates("_k", keep="first")
+                   .drop(columns=["_n", "_ord", "_k"])
+                   .reset_index(drop=True))
+    new = allrows[COLS] if len(allrows) else pd.DataFrame(columns=COLS)
+    dup_rows = sum(len(d) for d in parsed.values()) - len(new)
+    # 各源件的"贡献行数"与"被覆盖(并入更窄件)"情况 —— 让人看清快照件为什么不再是它的功劳
+    contrib = new["file"].value_counts().to_dict() if len(new) else {}
+    print("== 源件 (按贡献行数) ==")
+    for n in sorted(parsed, key=lambda x: -contrib.get(x, 0)):
+        tag = "摄入" if contrib.get(n) else "快照·全被覆盖"
+        print(f"  [{tag:10s}] {n:22s} {len(parsed[n]):6d} 行 → 贡献 {contrib.get(n, 0):6d} 行")
     for n, why in skipped:
         print(f"  [非事件件·跳过] {n:22s} {why}")
-
-    if snapshots:
-        print("\n== 累计快照证据 (精确等于既有件的并集, 叠加即翻倍) ==")
-        for n, rows, keys in snapshots:
-            print(f"  {n}: {rows} 行 / {keys} 个唯一键, 新增键 0")
+    if dup_rows:
+        print(f"\n== 去重证据: 逐件行数合计 {sum(len(d) for d in parsed.values())} ⇒ 键并集 {len(new)} "
+              f"(去掉 {dup_rows} 行重复键; 键 = {'/'.join(KEY)}) ==")
 
     # 逐源件幂等: 只替换本次涉及的 file
     old = pd.read_parquet(out) if out.exists() else pd.DataFrame(columns=COLS)
     for c in ("file", "turbine", "code", "text", "group"):
         if c in old:
             old[c] = old[c].astype("string")
-    touch = set(kept)
-    keep_old = old[~old["file"].isin(touch)] if len(old) else old
-    merged = pd.concat([keep_old, new], ignore_index=True) if len(keep_old) or len(new) else old
-    merged = merged.sort_values(["t_on", "turbine", "code"], na_position="last").reset_index(drop=True)
+    # ★产物 = **当前源件的函数** (用户令 2026-09-20「重算要按 data/raw 的最新变化」):
+    #   不再与旧产物合并(旧做法会把"源件已不在盘"的行一直留着), 旧产物只用来报"这回少了什么"。
+    merged = new.sort_values(["t_on", "turbine", "code"], na_position="last").reset_index(drop=True)
+    present = {p.name for p in files}
+    gone_files = sorted(set(old["file"].dropna().unique()) - present) if len(old) else []
+    old_keys = set(old[KEY].astype(str).agg("|".join, axis=1)) if len(old) else set()
+    new_keys = set(merged[KEY].astype(str).agg("|".join, axis=1)) if len(merged) else set()
+    lost = old_keys - new_keys
+    gained = new_keys - old_keys
 
     print(f"\n== 结果 ==\n  旧 {len(old)} 行 → 新 {len(merged)} 行 "
-          f"(本次涉及 {len(touch)} 个源件: {len(new)} 行; 保留未涉及的 {len(keep_old)} 行)")
-    print(f"  覆盖: {merged.t_on.min()} ~ {merged.t_on.max()}   码 {merged.code.nunique()} 个   台 {merged.turbine.nunique()} 台")
+          f"(键 {len(old_keys)} → {len(new_keys)}: 新增 {len(gained)} · 消失 {len(lost)})")
+    if gone_files:
+        print(f"  [!] 有 {len(gone_files)} 个源件**已不在盘**: " + "、".join(gone_files[:6])
+              + (" …" if len(gone_files) > 6 else "")
+              + " —— 它们曾贡献的行照实不再产出(口径: 产物=当前 data/raw 的函数)")
+    if lost:
+        _lk = pd.DataFrame([k.split("|", 2) for k in lost], columns=KEY)
+        _m = pd.to_datetime(_lk["t_on"], errors="coerce")
+        print(f"      消失的键涉及月份: {_m.dt.strftime('%Y-%m').dropna().value_counts().sort_index().to_dict()}")
+        print("      若是**误删**: 把源件放回重跑本脚本即恢复; 若是现场撤数, 这就是正确结果。")
+    print(f"  覆盖: {merged.t_on.min() if len(merged) else '—'} ~ {merged.t_on.max() if len(merged) else '—'}"
+          f"   码 {merged.code.nunique() if len(merged) else 0} 个   台 {merged.turbine.nunique() if len(merged) else 0} 台")
     if a.dry_run:
         print("\n(dry-run, 未写盘)")
         return 0

+ 9 - 1
scripts/windscada_watch_channels_build.py

@@ -105,7 +105,15 @@ def main() -> int:
     print(f'\n== round/date_note ==\n  沿用原值 {filled} 行; 新件需人工补批次 {len(missing_meta)} 行')
 
     touch = set(new['file'])
-    keep_old = old[~old['file'].isin(touch)]
+    # ★产物 = **当前源件的函数** (用户令 2026-09-20「重算要按 data/raw 的最新变化」): 原先 `keep_old` 保留
+    #   "源件已不在盘"的行 ⇒ 撤掉一份报告后旧记录仍留在产物里。现在只留仍在盘的那些件的行, 消失的报出来。
+    present = {p.name for p in pdfs}
+    gone = sorted(set(old['file'].dropna().unique()) - present) if len(old) else []
+    if gone:
+        _n = int(old['file'].isin(gone).sum())
+        print(f'\n== [!] {len(gone)} 个油样报告已不在盘 ⇒ 它们带来的 {_n} 行不再产出 (口径: 产物=当前 data/raw 的函数): '
+              + '、'.join(gone[:5]) + (' …' if len(gone) > 5 else ''))
+    keep_old = old[~old['file'].isin(touch | set(gone))]
     merged = pd.concat([keep_old, new[COLS]], ignore_index=True)
     merged['date'] = merged['date'].astype('string')
     merged = merged.sort_values(['turbine', 'comp', 'date'], na_position='last').reset_index(drop=True)

+ 14 - 1
scripts/windscada_workorder_ingest.py

@@ -297,11 +297,24 @@ def main() -> int:
 
     old = pd.read_parquet(out_path) if out_path.exists() else pd.DataFrame(columns=OUT_COLS)
     touch = set(per_file)
-    keep_old = old[~old['src_file'].isin(touch)] if len(old) else old
+    # ★产物 = **当前源件的函数** (用户令 2026-09-20「重算要按 data/raw 的最新变化」): 原先 `keep_old` 会把
+    #   "源件已不在盘"的行一直留着(页面照旧按已不存在的台账显示检修记录)。现在只保留仍在盘的源件的行,
+    #   消失的行如实丢掉并报出来(哪几件、多少行、涉及哪些月), 让人分清误删/撤数。
+    present = {p.name for p in files}
+    gone = sorted(set(old['src_file'].dropna().unique()) - present) if len(old) else []
+    drop_gone = old[old['src_file'].isin(gone)] if gone else old.iloc[0:0]
+    keep_old = old[~old['src_file'].isin(touch | set(gone))] if len(old) else old
     merged = pd.concat([keep_old, new], ignore_index=True)
     merged = merged.sort_values(['t_report', 'turbine', 'src_file'], na_position='last').reset_index(drop=True)
     tw = merged['t_report'].dropna()
     print(f'\n== 结果 ==\n  旧 {len(old)} 行 → 新 {len(merged)} 行 (本次 {len(touch)} 张表; 保留未涉及 {len(keep_old)} 行)')
+    if len(drop_gone):
+        print(f'  [!] 有 {len(drop_gone)} 行来自**已不在盘**的台账件 ({len(gone)} 个): '
+              + '、'.join(f'{n}({int((drop_gone["src_file"] == n).sum())} 行)' for n in gone[:6])
+              + ' —— 按"产物=当前 data/raw 的函数"口径不再产出; 误删就把文件放回重跑')
+        _m = pd.to_datetime(drop_gone['t_report'], errors='coerce')
+        if _m.notna().any():
+            print(f'      涉及月份: {_m.dt.strftime("%Y-%m").dropna().value_counts().sort_index().to_dict()}')
     print(f'  覆盖: {tw.min()} ~ {tw.max()}   (未解析日期 {int(merged["t_report"].isna().sum())} 行)')
     print(f'  台 {merged.turbine.nunique()} 台; 源件 {merged.src_file.nunique()} 张')
     if a.dry_run:

+ 12 - 1
src/version.py

@@ -67,7 +67,18 @@ HISTORY: tuple[dict, ...] = (
               '⑤ 服务侧看门狗(`scripts/service_worker.py::raw_watch`,配置 `configs/serve.json` 的 `raw_watch`,'
               '**默认关**):每 N 分钟扫一次,有新数据写 logs/service.log 并列出该跑的步;`auto_rebuild=true` '
               '时顺手发起一次重算(走 ops 同一条路;`run/ops_job.json` 显示在跑则不重复发起)。'
-              '交付包 app_guanlang_v2.8.0.zip'),
+              '★2026-09-20 按用户令「对 data/raw(**含嵌套子目录**)下文件的增减做到监听;重算要按该目录'
+              '**最新的变化**算」逐条查证并补了三处: '
+              '⑥ 扫描改成**递归统计全部文件**(不限扩展名; 白名单命中的另记 matched, 差额单列为"信息,不计变化")'
+              '+ **子目录清单进指纹**(新建空目录也发现) + 未归类改成**全树逐件**找(不再只看一层); '
+              '⑦ **产物 = 当前源件的函数**(删/换源件后, 那些行不再产出并大声报出"哪几件不在盘、少多少行、'
+              '涉及哪些月"): 报警/工单/油样三个摄入器原先是"只替换本次涉及的件、其余原样保留" ⇒ 源件删了行还在; '
+              '⑧★报警台账改**键集合并集**去重(键=Name/Alarmcode/TimeOn, 归属取最窄的源件)—— 原先按"逐件累加、'
+              '没新键就跳过"的贪心判快照, 实测**删一个源件反而让总行数从 39,211 涨到 56,593**(被跳过的大快照件'
+              '整件摄入, 重复计数) ⇒ 既不是源集的函数也会虚增; 现在 39,211 键稳定、重复 3.6 万行如实去掉; '
+              '⑨ 振动窗: 同名窗已存在时原来一律改名 `_reimport`(被 EXCLUDE_DEFAULT 排除 ⇒ **补进来的 CMS 数据'
+              '根本进不了生产集**), 现在默认**替换同名窗**(旧窗留档 `_superseded_<时分>`, 已加入排除表; '
+              '要旧行为用 `--reimport-as-new`)。交付包 app_guanlang_v2.8.0.zip'),
     dict(version='2.7.0', date='2026-09-19', level='minor',
          title='远程部署消缺:振动窗发布被杀软/索引占用不再打断整条重算链(+ --publish-only 恢复通道);'
                '门户可对外监听(public_host,组件仍只本机);GBK 控制台打印不炸',

+ 3 - 1
src/windcms/data.py

@@ -6,7 +6,9 @@ import numpy as np
 import pandas as pd
 from .config import KEY_SCALARS, SENSORS
 
-EXCLUDE_DEFAULT = ('_reimport', 'test')   # 验证/测试窗不进生产集 (名字含 test 或 _reimport)
+# 验证/测试窗不进生产集 (名字含 test / _reimport)。★2026-09-20 补 `_superseded`:
+# 重算按"当前数据"替换同名窗时, 旧窗留档为 `<窗>_superseded_<时分>` —— 它是留档, 不该参与生产集。
+EXCLUDE_DEFAULT = ('_reimport', '_superseded', 'test')
 
 
 def windows(cfg):

Vissa filer visades inte eftersom för många filer har ändrats