Commit d2a208a3 by luoqi

docs(gap): 集合式重写方案 —— 含两条改变边界的实测

① 全口场景(K05/K07)的 lateral PG 已自动删除(useless-left-join removal),
   本方案对它们零收益;测试机 K05 9m13s 的钱在患者级 NOT EXISTS,不在 resolvedTeeth。
   → 全轮预期改善下修到 35~40%,不足以单独装进 2 小时窗口。
② 本地 30K 库单分支对拍只快 1.26×,库太小全热不能外推 → 先建对拍与基准,
   单子场景试点达标(≥3×)再铺开;不达标就停,只留对拍工具。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 21329f36
# resolvedTeeth 集合式重写 · 方案
> 目标:把 `buildGapCore` 的 `resolvedTeethSql` 从**逐 (患者×信号) 行相关子查询**改成**集合式预聚合 + 连接**,
> 让 plan 全量重算能装进 2 小时增量窗口。
> 现状锚点:生产全轮 场景段 3h11m / 总 3h41m(work_mem=256MB 之后);测试机全轮 23.3 分钟。
---
## 0. 开工前先复核的两件事(2026-08-30 本地实测,直接改变了方案边界)
### 0.1 ⛔ 全口场景(K05/K07)的 lateral **PostgreSQL 已经自动删掉了** —— 集合式重写对它们零收益
`wholeMouth: true``sigToothExpr = NULL::text``st = {}`;
`toothOutput``CASE WHEN TRUE THEN NULL`,`gapWhere``CASE WHEN TRUE THEN NOT EXISTS(...)` ——
两处常量折叠后 `lat` 无人引用,PG 的 useless-left-join removal 直接摘掉整个 LATERAL。
本地 pac-postgres 用**贴真形态**(lateral 内引用 `sig`、外层挂 ⑤ 闸)EXPLAIN ANALYZE 验证:
```
Nested Loop
-> Parallel Bitmap Heap Scan on patient_facts sig
-> Memoize -> Index Scan on patients p
Filter: (active AND (NOT (SubPlan 1))) ← 只剩患者级 NOT EXISTS
计划里没有任何 lateral / Append 节点
```
**推论**:测试机 23.3 分钟里 **K05 牙周 9m13s** 花的不是 resolvedTeeth 的钱,是
「1.22M 信号行扫描 + 5 个患者级 NOT EXISTS 闸」的钱。本方案**帮不上 K05/K07**,
它们要单独立项(反连接化 / 候选预筛),不在本文范围。
> 收益上限因此要下修:测试机两条慢查询 K05(9m13s,不受益)+ K01(13m,受益),
> 本方案吃掉的是 K01 那一半里的 68%。**全轮预期改善 ≈ 35~40%,不足以单独装进 2 小时。**
> Phase 0 必须先量生产上「牙位级子场景合计耗时占比」,别砍掉 68% 的一个小分母。
### 0.2 本地单分支对拍只快 1.26× —— 收益必须先量,不能先写
30K 患者本地库、K01、只取 (a) 治疗家族一个分支:
| 形态 | 墙钟 |
|---|---|
| A 现状(逐行相关子查询) | 1484 ms |
| B 集合式(cand → scope → res 预聚合 → 反连接) | 1178 ms |
同一分支 UNION 三份 → 1693 ms,**边际分支代价只有 ~105ms**,说明本地瓶颈根本不在分支数上。
本地库太小、全热、去重比只有 1.55×(15,195 候选行 / 9,789 患者),**不能外推到生产**
但足以定一条纪律:**先建对拍与基准,单子场景试点达标再铺开** ——
这条分支上已经有两个「理论漂亮、实测为零」的前科(子场景并发、信号码部分索引)。
---
## 1. 适用面
| 子场景 | primaryCode | wholeMouth | 本方案是否受益 |
|---|---|---|---|
| missing_tooth | K08 | ✗ | ✅(分支最多,12 条) |
| caries_no_filling | K02 | ✗ | ✅ |
| hard_tissue_damage | K03 | ✗ | ✅ |
| endo_no_rct | K04 | ✗ | ✅ |
| impacted_tooth | K01 | ✗ | ✅(生产/测试最慢那条) |
| jaw_cyst | K09 | ✗ | ✅ |
| development_eruption | K00 | ✗ | ✅ |
| gum_alveolar_lesion | K06 | ✗ | ✅ |
| extraction_recommended | EXTRACTION_RECOMMENDED | ✗ | ✅ |
| perio_no_srp | K05 | ✓ | ❌ 见 §0.1 |
| ortho_no_consult | K07 | ✓ | ❌ 见 §0.1 |
**关键不变式(要写成断言)**:`excludeIfEverTreated ⟺ wholeMouth`(当前只有 K05/K07 两者同时为真)。
所以 **9 个牙位级场景里 `afterDxFor` 永远是 sig 锚点形态**,集合式路径不必处理 `latestDxOfCode` 那一支。
未来谁给某个牙位级 rule 加 `excludeIfEverTreated`,断言必须炸。
---
## 2. 核心恒等式
时间门全部是 `∃ 行 x : t(x) ⋛ 锚点` 的形状,而
```
∃ x ∈ G : t(x) >= a ⟺ max{ t(x) : x ∈ G } >= a
∃ x ∈ G : t(x) > a ⟺ max{ t(x) : x ∈ G } > a
```
分组 G = `(patient_id, tooth)`,组内其余谓词(category / status / 正则 / 牙位纯度…)**全部与 sig 无关**,
所以可以先按组聚合 `max(t)`,再跟每个信号的锚点比大小 —— 一次算完全部患者。
---
## 3. 13 个分支的相关性分类(这是重写的全部依据)
| # | 代码标记 | 别名 | 相关类型 | 聚合键 | 门 |
|---|---|---|---|---|---|
| 1 | (a) 治疗家族 resolver | rtx | 时间 | (pid, tooth) | `max(occurred_at) >= anchor` |
| 2 | (a'') 桥区间填洞 | btx | 时间 | (pid, arch_tooth) | `max(occurred_at) >= anchor` |
| 3 | (a''') 缺牙间隙关闭 | emrx | 时间 | (pid, tooth) | `max(occurred_at) >= anchor` |
| 4 | (a'''') 患者拒绝 | rfx | 时间 | (pid, tooth) | `max(COALESCE(occ,planned)) >= anchor` |
| 5 | (b) 更晚结构诊断 | ldx | 时间(**严格 >**) | (pid, tooth) | `max(occurred_at) > anchor` |
| 6 | (c) 建议优先于诊断 | rdx | 时间 **+ sig.type** | (pid, tooth) | `max(COALESCE) >= anchor``sig.type='diagnosis_record'` |
| 7 | (a''''') 复查即治疗 | rvx | 时间 | (pid, tooth) | `max >= anchor` |
| 8 | (a'''''') 抛光即修复 | plx | 时间 | (pid, tooth) | `max >= anchor` |
| 9 | (a''''''') 病历文本自证 | tvx ⋈ emx | 时间 | (pid, tooth) | `max >= anchor` |
| 10 | (a'''''''') 检查所见修复在位 | rix | 时间 | (pid, tooth) | `max >= anchor` |
| 11 | (a''''''''') 整颌活动义齿 | adx | **等值(病历号)** | (pid, emr_ext_id, tooth) | `emr_ext_id = sig.source_encounter_external_id` |
| 12 | §E 正畸减数位 | ex | **无** | (pid, tooth) | 恒真 |
| 13 | 建议拔除让位病种 | dxx | **无** | (pid, tooth) | 恒真 |
只有三类:**时间门 / 病历号等值 / 无相关**。第 6 条多一个 `sig.type` 标量谓词,提到连接条件即可。
分支 9 的 `tvx ⋈ emx` 与分支 11 的 `adx ⋈ sig` 要分清:前者是**源内部**的连接(与 sig 无关,照常预聚合),
后者才是**与 sig 的等值相关**(进聚合键)。
---
## 4. 目标 SQL 形态
```sql
WITH cand AS MATERIALIZED ( -- 候选 (患者×信号),已过 ①隔离 ②合规 ③信号 ④cooldown
SELECT p.id AS patient_id, sig.id AS sig_id,
COALESCE(sig.occurred_at, sig.planned_for) AS anchor,
sig.type AS sig_type,
sig.content->>'source_encounter_external_id' AS sig_enc,
<toothArrSql(sigToothExpr, 乳牙/智齿剔除)> AS sig_teeth,
...投影列(code / name_zh / clinic_id / days_since )
FROM patients p JOIN patient_profiles pp ON JOIN patient_facts sig ON
WHERE <①②③④ + patientFilter + restorationIneligible + congenital>
),
scope AS MATERIALIZED (SELECT DISTINCT patient_id FROM cand),
res AS MATERIALIZED ( -- 13 个分支 UNION ALL,统一四元组
-- (patient_id, tooth, gate, enc, strict, needs_dx_sig)
-- gate = 'infinity'::timestamptz 表示"无时间门"(恒过);时间门分支保证 gate NOT NULL
SELECT patient_id, tooth, max(gate) AS gate, enc, strict, needs_dx_sig
FROM ( <branch1> UNION ALL <branch2> UNION ALL <branch13> ) b
GROUP BY patient_id, tooth, enc, strict, needs_dx_sig
),
rem AS ( -- 牙位级反连接
SELECT c.sig_id, array_agg(u.x ORDER BY u.ord) AS remaining_teeth
FROM cand c
CROSS JOIN LATERAL unnest(c.sig_teeth) WITH ORDINALITY AS u(x, ord)
WHERE NOT EXISTS (
SELECT 1 FROM res r
WHERE r.patient_id = c.patient_id
AND r.tooth = u.x
AND (CASE WHEN r.strict THEN r.gate > c.anchor ELSE r.gate >= c.anchor END)
AND (r.enc IS NULL OR r.enc = c.sig_enc)
AND (NOT r.needs_dx_sig OR c.sig_type = 'diagnosis_record')
)
GROUP BY c.sig_id
)
SELECT , array_to_string(COALESCE(rem.remaining_teeth, ARRAY[]::text[]), ';') AS tooth
FROM cand c LEFT JOIN rem ON rem.sig_id = c.sig_id
WHERE cardinality(COALESCE(rem.remaining_teeth, ARRAY[]::text[])) > 0
AND <b 未来预约 / f 到诊冷静 / g 未来回访 —— 召回独有,画像不加>
```
要点:
- 每个分支源表 **`JOIN scope s ON s.patient_id = x.patient_id`** —— 没有这一句,CTE 会全表扫 34GB heap。
- `toothArrSql` 从「每候选行算一次」降到「每源事实行算一次」,这是省钱的第二处。
- 画像单患者路径 `scope` = 1 行 → 全部走 `patient_facts_patient_id_type_status_idx`,形态不退化。
---
## 5. 五个必须踩准的坑(每一个都通向静默少召)
1. **NULL 时间不能靠 max() 自然消化**`max()` 忽略 NULL,全 NULL 组聚成 NULL;
若拿 NULL 表示「无时间门」,这种组会被当成恒过 → 误销 → 少召。
⇒ 时间门分支内 `WHERE occurred_at IS NOT NULL`(等价:NULL 本来就过不了 `>= anchor`),
无时间门分支显式写 `'infinity'::timestamptz`**`gate` 列永不为 NULL。**
2. **分支 5 是严格 `>`**,其余是 `>=`。合并成一列后必须带 `strict` 标志,不能"统一成 >= 反正差不多"。
3. **`remaining_teeth` 的顺序与重复要原样保留**。现状是 `unnest(st)` 的自然顺序、不去重;
`tooth` 字符串会落进 plan_reasons 给客服看,顺序变了就是 diff 噪音。⇒ `WITH ORDINALITY` + `ORDER BY ord`
4. **`rem` 无行 ≠ `{}`**。全部牙位被解决的 sig 在 `rem` 里没有行,LEFT JOIN 得 NULL;
`cardinality(NULL) > 0` 是 NULL(等价于假,行为一致),但 `toothOutput` 会从 `''` 变 NULL。⇒ 一律 `COALESCE(…, '{}')`
5. **CTE 没有统计信息**`WITH … AS MATERIALIZED` 的行数估计是瞎猜,可能选错连接方式。
先例:`apps/pac-service/sql/verify-recall.sql` 用的是**临时表 + CREATE INDEX + ANALYZE**,
注释里写明「13 万患者秒级(旧逐行相关子查询版会 O(n²) 卡死)」。
⇒ 若 CTE 形态在测试机上计划走歪,退回临时表形态(代价:一次事务内多两条 DDL)。
---
## 6. 接口与代码量
| 文件 | 改动 | 量 |
|---|---|---|
| `clinical-gap/potential-treatment-gap.sql.ts` | `resolvedTeethSql`(107 行)→ 13 个分支 CTE 生成器 + `res`/`rem` 拼装;`GapCorePieces` 新增 `withCtes` / `candProjection` / `remainingExpr`;`buildGapCore` 新增入参 `scope: Prisma.Sql``variant: 'legacy'|'setbased'` | +190 / −110 |
| `plan/engine/scenarios/treatment-initiation-recall.scenario.ts` | 主查询包 `WITH`,⑤ 闸留在外层 | +20 |
| `clinical-gap/potential-treatment.selector.ts` | 同上,`scope` = 单患者 | +18 |
| `cli/recompute-plans.cli.ts` | `--only=<subKey>` 试点开关 | +8 |
| **新** `sql/verify-gap-equivalence.sql`(或 CLI) | 对拍工具 —— **真正的工作量** | ~250 |
| `tests/gap-setbased-shape.spec.ts`(新) | 锁「13 分支都进了 res」「wholeMouth 不生成 lateral」「gate 列无 NULL 路径」 | ~120 |
`variant`**临时脚手架**:对拍工具靠它在同一份主查询里跑新旧两版。全量验收通过后连同 legacy 分支一起删。
### 现有测试保护不了这次重写(已核)
`arch-denture-false-missing` / `polish-implies-restoration` / `restoration-in-place-from-exam` /
`treated-evidence-from-emr-text` / `review-implies-treatment` 五个 spec **全部是纯 JS 常量与正则断言**,
不碰 SQL 行为 —— 重写后它们照样全绿。**101 套 / 1790 个测试对本次改动的保护 ≈ 0。**
这就是为什么对拍工具不是"顺便加的验证",而是本项目的主体。
---
## 7. 分期与验收门
### Phase 0 · 对拍 + 基准(不改任何逻辑,独立可交付)
- 产出 `verify-gap-equivalence`:对每个 primaryCode 跑两版,落 `(patient_id, sig_id, tooth)` 三元组,FULL OUTER JOIN 差分。
- **必须同一快照**:两版在**同一个事务**里跑(`REPEATABLE READ`),否则并发增量摄入会造出假差异。
- 顺带量清楚:生产 11 个子场景各自 `sql=ms` 占比 → 确认牙位级到底占多少(见 §0.1 的分母问题)。
- **门:对 legacy 自己跑两遍必须零差异**(先证明工具本身可信),且拿到生产逐子场景耗时分布。
### Phase 1 · 单子场景试点(K01)
- 只改 K01 路径走 setbased,测试机跑。
- **门(两条都要过)**:① 全量对拍**零差异**(不是"差异很少");② `sql=ms` 至少快 **3×**
- **不到 3× 就停**,只留 Phase 0 的对拍工具。风险与收益不成比例。
### Phase 2 · 9 个牙位级场景全量
- **门**:9 个场景生产全量对拍零差异 + 全轮墙钟对比 + 命中患者数逐场景对比(参照上次「−0.43% / −2.5%」那套核法)。
### Phase 3 · 画像消费方
- 画像是**逐患者**调用(547K 次),任何单次开销都会被放大。
- **门**:单患者 p95 不劣化 >10%;`recompute-persona --force` 全量墙钟不劣化;
`persona_features``detail[].teeth` 与旧版逐患者比对零差异。
### Phase 4 · 清理
-`variant` 脚手架与 legacy 分支;更新 scenario 里那段耗时归因注释;
写一条 memory(集合式重写的结论 + 对拍工具位置)。
**工期**:核心改写 1 天;对拍工具 1 天;试点与全量验证 2~3 天(全量跑批本身 3~6 小时/轮,是墙钟大头)。
---
## 8. 明确不做
- **不动 `gapWhere` 的 wholeMouth 分支**(§0.1:没收益,纯风险)。
- **不动打分 / ⑤b ⑤f ⑤g 闸 / GAP_FLAGS / 词表正则** —— 本次只换计算形态,口径一个字节不改。
- **不把 11 条子场景合并成一条 SQL**。诱人(公共分支只算一次),但对拍粒度会崩,
`resolverCats` 逐场景不同。等集合式站稳、对拍工具成熟后再单独评估。
- **不上 `CREATE INDEX`**。同分支已实测:EXPLAIN 成本降 13× 而墙钟 24.5 vs 23.3 分钟,无效。
## 9. K05/K07 另立一项(本文不解决)
测试机 9m13s 的钱在「信号扫描 + 患者级 NOT EXISTS」。已知线索:
`NOT EXISTS` 被裹在 `CASE WHEN <wholeMouthFlag> THEN … END` 里 → PG 不做 sublink 上拉,
只能走 per-row SubPlan(本地实测:裸写 `NOT EXISTS` 会变成 Anti Join,`CASE` 包住则是 SubPlan)。
本地两者差距只有 13%(库小全热),生产是否放大**未验**
在 TS 侧按 `rule.wholeMouth` 分叉生成 SQL(而不是靠 SQL 的 CASE)是零风险的第一步,值得单独试。
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment