Commit 1bde4764 by luoqi

perf(gap): resolvedTeeth 集合式形态 + 对拍工具(默认 legacy,生产不启用)

把 buildGapCore 的 resolvedTeethSql 从**逐 (患者×信号) 行相关子查询**改成
**集合式预聚合 + 反连接**。核心恒等式 ∃x∈G: t(x) ⋛ a ⟺ max{t(x)} ⋛ a,
分组 G=(患者,牙位)。13 个分支对 sig 的相关性只有三类:
时间门(10)/ 病历号等值(1)/ 无相关(2),外加「建议优先」一条的 sig.type 标量谓词。

️ 集合式是**独立重写一份**,刻意不与 legacy 共用片段 —— 共用则重构写错的地方两边
   一起错、对拍互相抵消。等价性靠 verify-gap-equivalence 逐行差分来证。
全口码(K05/K07)不进集合式:其 lateral PG 本来就会摘掉(零收益),硬套还慢 2~3 倍。

**默认 legacy**,靠 PAC_GAP_VARIANT=setbased 显式开启。生产不设该变量 → 行为零变化。

验证(测试机 585K 患者 / 本地 30K):
  · SQL 层 11/11 子场景逐 (患者×信号×牙位) **零差异**,行数逐个相同
  · 画像消费方 2000 位患者零差异,单患者 p95 47→27ms 不劣化
  · 端到端 plan_reasons 行级 diff=0;测试机 32 万条差异仅时间漂移、**零删除**
  · 交互路径(详情页刷新)200 位 × 11 子场景零差异,一次刷新 89→75ms
  · 13 条分支源行全部非空 —— 零差异不是空转
收益(测试机相邻两轮,并发4):场景段 794s → 545s(×1.46)。**生产未验**。

新增 src/cli/verify-gap-equivalence.cli.ts:两版同一 REPEATABLE READ 快照双向
EXCEPT ALL 差分;--self 自对拍先证工具可信;--persona/--single/--conc 覆盖
画像、交互、并发标定四条路径。
新增 tests/gap-setbased-parity.spec.ts:结构对拍,守「两种形态的分支集合不许走散」
(加分支只改一边 = 静默错召,tsc 和现有 spec 都发现不了)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 61ba24de
......@@ -30,6 +30,8 @@
"recompute-persona": "ts-node --transpile-only src/cli/recompute-persona.cli.ts",
"backfill-plan-labels": "ts-node --transpile-only src/cli/backfill-plan-labels.cli.ts",
"recompute-persona:prod": "node --max-old-space-size=8192 dist/cli/recompute-persona.cli.js",
"verify-gap-equivalence": "ts-node --transpile-only src/cli/verify-gap-equivalence.cli.ts",
"verify-gap-equivalence:prod": "node --max-old-space-size=8192 dist/cli/verify-gap-equivalence.cli.js",
"recompute-plans": "ts-node --transpile-only src/cli/recompute-plans.cli.ts",
"recompute-plans:prod": "node --max-old-space-size=8192 dist/cli/recompute-plans.cli.js",
"timeline": "ts-node --transpile-only src/cli/timeline.cli.ts",
......
/**
* verify-gap-equivalence — gap 计算形态对拍(legacy ↔ setbased)
*
* 为什么必须有这个工具:
* `buildGapCore` 是【召回】与【画像】共用的单一真理源,改错的后果是**静默少召** ——
* 不报错、不炸测试,几个月后才由一线反馈冒出来。而现有 spec(arch-denture /
* polish-implies / restoration-in-place / treated-evidence / review-implies)
* 全是纯 JS 常量与正则断言,**不碰 SQL 行为**,重写后照样全绿 → 对这次改动的保护 ≈ 0。
* 所以正确性只能靠"两版跑同一份数据、逐 (患者×信号×牙位) 差分"来证。
*
* 怎么保证可信:
* ① 两版在**同一个 REPEATABLE READ 事务**里跑 —— 否则并发增量摄入会造出假差异。
* ② SQL 来自 scenario 自己的 `buildScenarioSql()`,不是这里另抄一份 ——
* 另抄就变成"验证我抄得对不对",而不是验证线上行为。
* ③ `--self` 模式让 legacy 跟自己对拍,先证明工具本身可信(必须零差异)。
*
* Usage:
* pnpm verify-gap-equivalence -- --host=jvs-dw # 全部 11 个子场景
* pnpm verify-gap-equivalence -- --host=jvs-dw --sub=impacted_tooth
* pnpm verify-gap-equivalence -- --host=jvs-dw --self # 自对拍(工具自检)
* pnpm verify-gap-equivalence -- --host=jvs-dw --bench # 只测耗时,不差分
*
* 退出码:0 = 零差异;1 = 有差异或出错(可直接进 CI / 部署脚本)。
*/
import { NestFactory } from '@nestjs/core';
import { Prisma } from '@prisma/client';
import { lookupDxTreatment } from '@pac/types';
import { AppModule } from '../app.module';
import { PrismaService } from '../prisma/prisma.service';
import { TreatmentInitiationRecallScenario } from '../modules/plan/engine/scenarios/treatment-initiation-recall.scenario';
import { PotentialTreatmentSelector } from '../modules/clinical-gap/potential-treatment.selector';
import type { ScenarioScope } from '../modules/plan/engine/scenario.interface';
import type { GapVariant } from '../modules/clinical-gap/potential-treatment-gap.sql';
import { disableSchedulersForCli } from './bootstrap-flags';
interface Args {
host: string;
sub?: string;
self: boolean;
bench: boolean;
samples: number;
/// >0 时改跑画像消费方对拍:抽 N 位患者,逐个跑两版 selectForPatient 比 gap 列表
persona: number;
/// 并发标定:批量路径的子场景并发度(--conc=N),配 --subset=M 只读跑一轮
conc: number;
/// 并发标定的患者子集规模(0=全量)
subset: number;
/// >0 时改跑**交互路径**对拍:抽 N 位患者,按 scope.patientId 单患者跑召回 SQL 两版
/// —— 详情页「刷新」(plan.controller recomputeForPatient)走的就是这条,直接面向用户,
/// 批量快不快是运维的事,这条慢了是用户当场感受得到的。
single: number;
}
function parseArgs(argv: string[]): Args {
const a: Args = { host: 'demo', self: false, bench: false, samples: 20, persona: 0, single: 0, conc: 0, subset: 0 };
for (const s of argv) {
if (s.startsWith('--host=')) a.host = s.slice('--host='.length);
else if (s.startsWith('--sub=')) a.sub = s.slice('--sub='.length);
else if (s.startsWith('--samples=')) a.samples = Number(s.slice('--samples='.length)) || 20;
else if (s === '--self') a.self = true;
else if (s === '--bench') a.bench = true;
else if (s.startsWith('--persona=')) a.persona = Number(s.slice('--persona='.length)) || 0;
else if (s.startsWith('--single=')) a.single = Number(s.slice('--single='.length)) || 0;
else if (s.startsWith('--conc=')) a.conc = Number(s.slice('--conc='.length)) || 0;
else if (s.startsWith('--subset=')) a.subset = Number(s.slice('--subset='.length)) || 0;
}
return a;
}
interface DiffRow {
side: string;
patient_id: string;
signal_fact_id: string;
tooth: string | null;
}
async function bootstrap(): Promise<number> {
const args = parseArgs(process.argv.slice(2));
// 报告一律走 console:Nest 的 logger 级别会被 createApplicationContext 全局压掉,
// 而这个 CLI 的输出就是它的全部产物 —— 不能被日志级别吃掉。
const out = (m: string): void => console.log(m);
const bad_ = (m: string): void => console.error(m);
// ⚠️ 必须在建应用上下文之前:否则会清掉 service 里正在跑的同步锁(见 bootstrap-flags)
disableSchedulersForCli();
const app = await NestFactory.createApplicationContext(AppModule, {
logger: ['warn', 'error'],
});
let bad = 0;
try {
const prisma = app.get(PrismaService);
const scenario = app.get(TreatmentInitiationRecallScenario);
const host = await prisma.host.findUnique({ where: { name: args.host } });
if (!host) throw new Error(`Host '${args.host}' not found`);
const tenants = await prisma.patient.findMany({
where: { hostId: host.id },
select: { tenantId: true },
distinct: ['tenantId'],
});
if (!tenants.length) throw new Error('No tenants found for host');
// 🔴 now 固定一次:两版必须拿同一个时间锚,否则 cooldown 边界上的信号会来回抖。
const now = new Date();
// ══ 批量路径并发标定(--conc=N [--subset=M])══
// 只读:每条子场景 SQL 外面套一层 count(*),不写库。
// 目的:量出「子场景并发」在**这台机器**上到底有没有扩展性 ——
// 2026-08-29 曾以「并发3=24.1分 vs 串行23.3分」判其无效并写进代码注释,
// 但 3% 落在 ±25% 的环境噪音里,那次测试什么也没证明(见方案 §11)。
// 判据用**墙钟**,不看各查询耗时之和(并发下单条会被争抢拉长,和不可比)。
if (args.conc > 0) {
let subsetIds: string[] | undefined;
if (args.subset > 0) {
const rows = await prisma.$queryRaw<{ id: string }[]>(Prisma.sql`
SELECT p.id FROM patients p
WHERE p.host_id = ${host.id}::uuid AND p.active = true
ORDER BY p.id LIMIT ${args.subset}`);
subsetIds = rows.map((r) => r.id);
}
const scopeB: ScenarioScope = {
hostId: host.id,
tenantId: tenants[0]!.tenantId,
now,
...(subsetIds ? { patientIds: subsetIds } : {}),
};
const variant: GapVariant = args.self ? 'setbased' : 'legacy';
const jobs = Object.entries(TreatmentInitiationRecallScenario.SUB_SCENARIOS).map(
([subKey, cfg]) => {
const rule = lookupDxTreatment(cfg.primaryCode);
if (!rule) throw new Error(`${subKey} 无 rule`);
return { subKey, sql: scenario.buildScenarioSql(scopeB, cfg.primaryCode, rule, variant) };
},
);
const runOne = async (j: { subKey: string; sql: Prisma.Sql }): Promise<string> => {
const t = Date.now();
const [, rows] = await prisma.$transaction([
prisma.$executeRaw`SET LOCAL work_mem = '256MB'`,
prisma.$queryRaw<{ n: bigint }[]>(Prisma.sql`SELECT count(*)::bigint AS n FROM (${j.sql}) q`),
]);
return `${j.subKey}=${Date.now() - t}ms/${Number(rows[0]?.n ?? 0)}`;
};
out(
` 并发标定 conc=${args.conc} 形态=${variant} 患者域=${subsetIds ? `${subsetIds.length} 位子集` : '全量'}`,
);
const t0 = Date.now();
const results: string[] = [];
for (let i = 0; i < jobs.length; i += args.conc) {
const chunk = await Promise.all(jobs.slice(i, i + args.conc).map(runOne));
results.push(...chunk);
}
const wall = Date.now() - t0;
out(` ${results.join(' ')}`);
out(` 墙钟 = ${wall}ms (${(wall / 1000).toFixed(1)}s) 这是唯一可比的数`);
return 0;
}
// ══ 交互路径对拍(详情页「刷新」:单患者召回)══
// 批量慢是运维问题,这条慢是**用户当场感受得到**的问题 —— 必须单独量。
if (args.single > 0) {
const sample = await prisma.$queryRaw<{ id: string; tenant_id: string }[]>(Prisma.sql`
SELECT p.id, p.tenant_id
FROM patients p
WHERE p.host_id = ${host.id}::uuid AND p.active = true
AND EXISTS (
SELECT 1 FROM patient_facts f
WHERE f.patient_id = p.id AND f.status = 'active'
AND f.type IN ('diagnosis_record','recommendation_record')
)
ORDER BY p.id
LIMIT ${args.single}`);
out(` 交互路径对拍(单患者召回):抽样 ${sample.length} 位`);
const entriesAll = Object.entries(TreatmentInitiationRecallScenario.SUB_SCENARIOS);
const msL: number[] = [];
const msR: number[] = [];
let diffN = 0;
for (const p of sample) {
// 一位患者 = 跑完 11 个子场景(详情页刷新的真实代价)
const scope1: ScenarioScope = {
hostId: host.id,
tenantId: p.tenant_id,
now,
patientId: p.id,
};
for (const [, cfg] of entriesAll) {
const rule = lookupDxTreatment(cfg.primaryCode);
if (!rule) continue;
const lSql = scenario.buildScenarioSql(scope1, cfg.primaryCode, rule, 'legacy');
const rSql = scenario.buildScenarioSql(
scope1,
cfg.primaryCode,
rule,
args.self ? 'legacy' : 'setbased',
);
const runOne = async (sql: Prisma.Sql): Promise<{ ms: number; key: string }> => {
const t = Date.now();
const rows = await prisma.$queryRaw<{ patient_id: string; signal_fact_id: string; tooth: string | null }[]>(
Prisma.sql`SELECT patient_id, signal_fact_id, tooth FROM (${sql}) q ORDER BY 2, 3`,
);
return {
ms: Date.now() - t,
key: rows.map((x) => `${x.signal_fact_id}#${x.tooth ?? ''}`).join('|'),
};
};
const a1 = await runOne(lSql);
const b1 = await runOne(rSql);
msL.push(a1.ms);
msR.push(b1.ms);
if (a1.key !== b1.key) {
diffN++;
if (diffN <= args.samples) {
bad_(` patient=${p.id} sub=${cfg.primaryCode}`);
bad_(` legacy : ${a1.key || '(空)'}`);
bad_(` setbased: ${b1.key || '(空)'}`);
}
}
}
}
const pct = (arr: number[], q: number): number => {
const v = [...arr].sort((x, y) => x - y);
return v[Math.min(v.length - 1, Math.floor(v.length * q))] ?? 0;
};
const sum = (arr: number[]): number => arr.reduce((x, y) => x + y, 0);
out(
` 单查询 legacy p50=${pct(msL, 0.5)}ms p95=${pct(msL, 0.95)}ms | ` +
`另一版 p50=${pct(msR, 0.5)}ms p95=${pct(msR, 0.95)}ms`,
);
out(
` 一次「刷新」(11 个子场景合计) legacy≈${Math.round(sum(msL) / sample.length)}ms | ` +
`另一版≈${Math.round(sum(msR) / sample.length)}ms`,
);
if (diffN === 0) out(` ✅ 交互路径零差异(${sample.length} 位 × 11 子场景)`);
else { bad++; bad_(` ❌ 交互路径 ${diffN} 处不一致`); }
return bad === 0 ? 0 : 1;
}
// ══ 画像消费方对拍(第二个 buildGapCore 消费方)══
// 画像是**逐患者**调用,SQL 形态与召回不同(scope 恒 1 行),必须单独验。
// 只读、不写库:直接调 selectForPatient 两次比返回值。
if (args.persona > 0) {
const selector = app.get(PotentialTreatmentSelector);
// 抽样偏向"有诊断信号的患者",否则大多数抽中的人两版都返回空数组,验了个寂寞。
const sample = await prisma.$queryRaw<{ id: string; tenant_id: string }[]>(Prisma.sql`
SELECT p.id, p.tenant_id
FROM patients p
WHERE p.host_id = ${host.id}::uuid AND p.active = true
AND EXISTS (
SELECT 1 FROM patient_facts f
WHERE f.patient_id = p.id AND f.status = 'active'
AND f.type IN ('diagnosis_record','recommendation_record')
)
ORDER BY p.id
LIMIT ${args.persona}`);
out(`▶ 画像对拍:抽样 ${sample.length} 位患者(有 active 诊断/建议信号)`);
let diffN = 0;
let withGap = 0;
const msLegacy: number[] = [];
const msRight: number[] = [];
for (const p of sample) {
const codes = await prisma.$queryRaw<{ code: string }[]>(Prisma.sql`
SELECT DISTINCT f.content->>'code' AS code FROM patient_facts f
WHERE f.patient_id = ${p.id}::uuid AND f.status = 'active'
AND f.type IN ('diagnosis_record','recommendation_record')
AND f.content->>'code' IS NOT NULL`);
const activeCodes = new Set(codes.map((c) => c.code));
const base = { hostId: host.id, tenantId: p.tenant_id, patientId: p.id, now, activeCodes };
// 🔴 画像是逐患者调用(全量 54.7 万次),单次开销会被放大 54.7 万倍 ——
// 所以这里除了比结果,还必须比**每次调用的耗时分布**(p50/p95)。
const t0 = Date.now();
const l = await selector.selectForPatient({ ...base, variant: 'legacy' });
const t1 = Date.now();
const r = await selector.selectForPatient({
...base,
variant: args.self ? 'legacy' : 'setbased',
});
msLegacy.push(t1 - t0);
msRight.push(Date.now() - t1);
const key = (g: { primaryCode: string; factId: string; tooth: string | null }): string =>
`${g.primaryCode}#${g.factId}#${g.tooth ?? ''}`;
const ls = l.map(key).sort().join('|');
const rs = r.map(key).sort().join('|');
if (l.length) withGap++;
if (ls !== rs) {
diffN++;
if (diffN <= args.samples) {
bad_(` patient=${p.id}`);
bad_(` legacy : ${ls || '(空)'}`);
bad_(` setbased: ${rs || '(空)'}`);
}
}
}
const pct = (a: number[], q: number): number => {
const v = [...a].sort((x, y) => x - y);
return v[Math.min(v.length - 1, Math.floor(v.length * q))] ?? 0;
};
const sum = (a: number[]): number => a.reduce((x, y) => x + y, 0);
out(
` 耗时/患者 legacy p50=${pct(msLegacy, 0.5)}ms p95=${pct(msLegacy, 0.95)}ms 合计=${sum(msLegacy)}ms | ` +
`另一版 p50=${pct(msRight, 0.5)}ms p95=${pct(msRight, 0.95)}ms 合计=${sum(msRight)}ms`,
);
if (diffN === 0) {
out(` ✅ 画像零差异(${sample.length} 位,其中 ${withGap} 位有 gap)`);
} else {
bad++;
bad_(` ❌ 画像 ${diffN}/${sample.length} 位患者不一致`);
}
return bad === 0 ? 0 : 1;
}
const entries = Object.entries(TreatmentInitiationRecallScenario.SUB_SCENARIOS).filter(
([k]) => !args.sub || k === args.sub,
);
if (!entries.length) throw new Error(`--sub=${args.sub} 不是已知子场景`);
for (const t of tenants) {
const scope: ScenarioScope = { hostId: host.id, tenantId: t.tenantId, now };
out(`▶ host=${args.host} tenant=${t.tenantId} 子场景=${entries.length} 个`);
for (const [subKey, cfg] of entries) {
const rule = lookupDxTreatment(cfg.primaryCode);
if (!rule) throw new Error(`${subKey}.primaryCode=${cfg.primaryCode} 不在 DiagnosisTreatmentMap`);
const leftVariant: GapVariant = 'legacy';
const rightVariant: GapVariant = args.self ? 'legacy' : 'setbased';
const left = scenario.buildScenarioSql(scope, cfg.primaryCode, rule, leftVariant);
const right = scenario.buildScenarioSql(scope, cfg.primaryCode, rule, rightVariant);
// ── 计时:两版各单独跑一次(不在同一事务里,避免第二次白蹭第一次的缓存判断失真;
// 对拍另开一个事务)。work_mem 与线上一致。
const timeOne = async (sql: Prisma.Sql): Promise<{ ms: number; rows: number }> => {
const t0 = Date.now();
const [, rows] = await prisma.$transaction([
prisma.$executeRaw`SET LOCAL work_mem = '256MB'`,
prisma.$queryRaw<{ n: bigint }[]>(
Prisma.sql`SELECT count(*)::bigint AS n FROM (${sql}) q`,
),
]);
return { ms: Date.now() - t0, rows: Number(rows[0]?.n ?? 0) };
};
const l = await timeOne(left);
const r = await timeOne(right);
const speed = r.ms > 0 ? (l.ms / r.ms).toFixed(2) : 'n/a';
out(
` ${subKey.padEnd(24)} ${leftVariant}=${String(l.ms).padStart(7)}ms/${l.rows}行 ` +
`${rightVariant}=${String(r.ms).padStart(7)}ms/${r.rows}行 ×${speed}`,
);
if (args.bench) continue;
// ── 差分:两版同一快照,双向 EXCEPT ALL(ALL 保留重复,行数不一致也会暴露)──
const key = Prisma.raw('patient_id, signal_fact_id, tooth');
const diffSql = Prisma.sql`
WITH lg AS (${left}), sb AS (${right}),
d1 AS (SELECT ${key} FROM lg EXCEPT ALL SELECT ${key} FROM sb),
d2 AS (SELECT ${key} FROM sb EXCEPT ALL SELECT ${key} FROM lg)
SELECT 'only_legacy'::text AS side, ${key} FROM d1
UNION ALL
SELECT 'only_setbased'::text AS side, ${key} FROM d2`;
const diffs = await prisma.$transaction(
async (tx) => {
await tx.$executeRaw`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ`;
await tx.$executeRaw`SET LOCAL work_mem = '256MB'`;
return tx.$queryRaw<DiffRow[]>(diffSql);
},
{ timeout: 3 * 60 * 60 * 1000, maxWait: 120_000 },
);
if (diffs.length === 0) {
out(` ${subKey.padEnd(24)} ✅ 零差异`);
} else {
bad++;
const onlyL = diffs.filter((d) => d.side === 'only_legacy').length;
const onlyR = diffs.filter((d) => d.side === 'only_setbased').length;
bad_(
` ${subKey.padEnd(24)} ❌ 差异 ${diffs.length} 条(只在 legacy=${onlyL} / 只在 setbased=${onlyR})`,
);
for (const d of diffs.slice(0, args.samples)) {
bad_(` ${d.side} patient=${d.patient_id} sig=${d.signal_fact_id} tooth=${d.tooth ?? 'NULL'}`);
}
}
}
}
} catch (e) {
bad_(e instanceof Error ? (e.stack ?? e.message) : String(e));
bad++;
} finally {
await app.close();
}
if (bad === 0) {
console.log('══ 全部零差异 ══');
return 0;
}
console.error(`══ ${bad} 个子场景不一致 / 出错 ══`);
return 1;
}
bootstrap().then((code) => process.exit(code));
......@@ -118,6 +118,58 @@ export interface GapCoreInput {
cfgFlags: GapCfgFlags;
allCodes: readonly string[]; // dxCodes ∪ recCodes
resolverCats: readonly string[]; // resolverCategoriesFor(primaryCode)
/**
* 计算形态。**只换形态,不换口径** —— 两者必须逐 (患者×信号×牙位) 完全一致。
* 'legacy' 逐 (患者×信号) 行跑相关子查询(2026-08 之前唯一形态)
* 'setbased' 先按 (患者,牙位) 预聚合全部 resolved 证据,再做反连接
* 默认 legacy。切换与验证见 docs/design/gap-set-based-rewrite-plan.md,
* 对拍工具 src/cli/verify-gap-equivalence.cli.ts —— **改任何一边都要重跑对拍**。
*/
variant?: GapVariant;
}
export type GapVariant = 'legacy' | 'setbased';
/**
* gap 计算形态开关。默认 legacy(逐行相关子查询);PAC_GAP_VARIANT=setbased 切集合式。
*
* ⚠️ 两种形态**必须产出完全相同的行集** —— 切换前后要跑
* `pnpm verify-gap-equivalence --host=<host>`(逐 患者×信号×牙位 差分,必须零差异)。
* 设成环境开关而不是写死,是为了让同一个进程能在同一份数据上跑两版做对拍,
* 也为了生产上万一发现差异能立刻回退,不用重新发版。
*/
export function gapVariant(): GapVariant {
return process.env.PAC_GAP_VARIANT === 'setbased' ? 'setbased' : 'legacy';
}
/**
* 集合式形态的拼装件。消费方主查询变成:
*
* WITH gap_cand AS MATERIALIZED (
* SELECT <自己的投影>, ${candExtraCols}
* FROM patients p JOIN patient_profiles pp … JOIN patient_facts sig …
* WHERE <自己的闸> ${candWhere}
* )${postCtes}
* SELECT <自己的投影(从 c 取)>, ${toothOutput} AS tooth
* FROM gap_cand c ${remJoin}
* WHERE TRUE ${outerWhere}
*
* 全口码(K05/K07)时 postCtes/remJoin/outerWhere 为空、判定全在 candWhere 里 ——
* 因为全口场景根本不做牙位相减(见方案 §0.1:那条 lateral PG 本来就会自动删掉)。
*/
export interface GapSetBasedPieces {
/// gap_cand 的额外投影列(每项前带逗号)
candExtraCols: Prisma.Sql;
/// gap_cand 的 WHERE add-on(外院已治疗闸;全口码另加两个 NOT EXISTS)
candWhere: Prisma.Sql;
/// gap_cand 之后的 CTE 链(前带逗号),牙位级非空 / 全口码为空
postCtes: Prisma.Sql;
/// 主查询的 gap_rem 连接
remJoin: Prisma.Sql;
/// tooth 输出表达式
toothOutput: Prisma.Sql;
/// 主查询 WHERE add-on(牙位级:剩余非空)
outerWhere: Prisma.Sql;
}
export interface GapCorePieces {
......@@ -135,6 +187,8 @@ export interface GapCorePieces {
toothOutput: Prisma.Sql;
/// ⑤a gap 判定(WHERE add-on):全口 → NOT EXISTS 同类治疗;有牙位 → 剩余非空
gapWhere: Prisma.Sql;
/// 集合式拼装件(variant='setbased' 时非空;legacy 时 undefined)
setBased?: GapSetBasedPieces;
}
/**
......@@ -571,5 +625,401 @@ export function buildGapCore(input: GapCoreInput): GapCorePieces {
lateralJoin,
toothOutput,
gapWhere,
setBased: input.variant === 'setbased' ? (buildGapSetBased(input) ?? undefined) : undefined,
};
}
// ═══════════════════════════════════════════════════════════════════════════
// 集合式形态(setbased)—— 与上面的 legacy 形态**独立重写一份**
//
// ⚠️ 刻意不与 legacy 共用分支片段。共用意味着"重构时写错的地方两边一起错、对拍互相抵消",
// 那样对拍就失去意义。这里是**独立再推导一遍**,靠 verify-gap-equivalence 逐
// (患者×信号×牙位) 差分来证明两份等价 —— 同 sql/verify-recall.sql 里
// 「vt_codes 重述一份、不一致即暴露」的思路。
// legacy 分支在集合式全量验收通过、生产稳定跑过一轮之后再删。
//
// 核心恒等式: ∃x∈G: t(x) ⋛ a ⟺ max{t(x) : x∈G} ⋛ a
// 分组 G = (patient_id, tooth);组内其余谓词(category/status/正则/牙位纯度)全与 sig 无关,
// 所以能先按组聚出 max(t),再跟每个信号的锚点比大小 —— 一次算完全部患者。
//
// 13 个分支对 sig 的相关性只有三类:
// ① 时间门(10 条) → 聚合键 (pid, tooth),gate = max(时间)
// ② 病历号等值(1 条) → 聚合键 (pid, tooth, enc),enc = 该病历的 emr_external_id
// ③ 无相关(2 条) → gate = 'infinity'(恒过)
// 另有 1 条((c) 建议优先)多一个 sig.type 标量谓词 → ndx 标志位
//
// 🔴 gate 列**永不为 NULL**:时间门分支一律加 `IS NOT NULL`(等价 —— NULL 本来就过不了
// `>= 锚点`),无门分支写死 'infinity'。若让 NULL 表示"无门",全 NULL 组会被 max()
// 聚成 NULL 而当成恒过 → 误销 → 静默少召。这是本次重写最危险的一个坑。
// ═══════════════════════════════════════════════════════════════════════════
/// 集合式分支的统一行形状(顺序即列序,13 个分支 UNION ALL 必须对齐)
/// patient_id | tooth | gate | enc | strict | ndx
const SB_COLS = Prisma.raw('patient_id, tooth, gate, enc, strict, ndx');
/// 无时间门 → 恒过(不能用 NULL,见上面 🔴)
const SB_NO_GATE = Prisma.sql`'infinity'::timestamptz`;
/// 患者收窄:所有分支都必须挂,否则 CTE 会全表扫 patient_facts(生产 34GB heap)
const sbScope = (alias: string): Prisma.Sql =>
Prisma.sql`${Prisma.raw(alias)}.patient_id IN (SELECT patient_id FROM gap_scope)`;
export function buildGapSetBased(input: GapCoreInput): GapSetBasedPieces | null {
const { rule, cfgFlags, allCodes, resolverCats } = input;
// 不变式:excludeIfEverTreated ⟺ wholeMouth(当前只有 K05/K07 两者同时为真)。
// 牙位级场景因此永远是 sig 锚点形态,集合式路径不必处理 latestDxOfCode 那一支。
// 谁给某个牙位级 rule 加了 excludeIfEverTreated,这里必须炸,而不是静默算错。
if (rule.excludeIfEverTreated && !rule.wholeMouth) {
throw new Error(
'buildGapSetBased: excludeIfEverTreated 目前只在 wholeMouth 规则上出现;' +
'牙位级规则要用它,得先给集合式补 latestDxOfCode 分支并重跑对拍。',
);
}
// ══ 全口码(K05/K07):**不进集合式,原样走 legacy** ══
// §0.1 已实测:全口场景 sigToothExpr=NULL → st={},toothOutput/gapWhere 都是
// `CASE WHEN TRUE`,常量折叠后 lat 无人引用 → PG 的 useless-left-join removal
// 把整个 LATERAL 摘掉。也就是说**它们本来就没在跑 resolvedTeeth**,集合式零收益。
//
// 2026-08-30 本地实测,曾试着把它们也套进 gap_cand 统一形态,结果**变慢 2~3 倍**:
// ortho_no_consult 1309ms → 4439ms(×0.29)
// perio_no_srp 1388ms → 3065ms(×0.45)
// MATERIALIZED 挡住了规划器对这两条(判定全是患者级 NOT EXISTS)的原有安排。
// ⛔ 别再为了"形态统一好看"把它们并进来 —— 没收益、纯风险、还慢。
if (rule.wholeMouth) return null;
const sigToothExpr = Prisma.sql`sig.content->>'tooth_position'`; // 全口码已早退,这里必有牙位
// ── gap_cand 的额外列(前缀 gap_ 避免跟消费方自己的投影撞名)──
const candExtraCols = Prisma.sql`,
p.id AS gap_patient_id,
sig.id AS gap_sig_id,
COALESCE(sig.occurred_at, sig.planned_for) AS gap_anchor,
sig.type AS gap_sig_type,
sig.content->>'source_encounter_external_id' AS gap_sig_enc,
COALESCE(${toothArrSql(sigToothExpr, {
dropDeciduous: cfgFlags.excludeDeciduous === true,
dropThirdMolar: cfgFlags.excludeThirdMolar === true,
})}, ARRAY[]::text[]) AS gap_sig_teeth`;
// ── 外院已治疗(回访 result)患者级排除 —— 与 legacy 同口径,放进 gap_cand ──
const externalTreatmentGate = Prisma.sql`AND NOT EXISTS (
SELECT 1 FROM patient_return_visits rv
WHERE rv.patient_id = p.id
AND rv.result ~ ${EXTERNAL_TREATMENT_VISIT_POS_RE}
AND rv.result !~ ${EXTERNAL_TREATMENT_VISIT_NEG_RE}
AND rv.task_date >= COALESCE(sig.occurred_at, sig.planned_for)::date
)`;
const refusalRe = TREATMENT_REFUSAL_SUBTYPE_PATTERNS.join('|');
const refusalImagingRe = TREATMENT_REFUSAL_IMAGING_EXCLUDE_RE;
const refusalSubtypeMatch = (alias: string): Prisma.Sql =>
Prisma.sql`regexp_replace(${Prisma.raw(alias)}.content->>'subtype', ${refusalImagingRe}, '', 'g') ~ ${refusalRe}`;
// ══ 牙位级:13 个分支预聚合 ══
const branches: Prisma.Sql[] = [];
// ① (a) 治疗家族 resolver —— 同牙做了 resolverCats 家族里任一治疗
branches.push(Prisma.sql`
SELECT rtx.patient_id, rtt AS tooth, rtx.occurred_at AS gate,
NULL::text AS enc, FALSE AS strict, FALSE AS ndx
FROM patient_facts rtx
CROSS JOIN unnest(${toothArrSql(Prisma.sql`rtx.content->>'tooth_position'`)}) AS rtt
WHERE ${sbScope('rtx')}
AND rtx.type = 'treatment_record' AND rtx.kind = 'actual'
AND rtx.status IN ('active', 'fulfilled')
AND COALESCE(NULLIF(trim(rtx.content->>'tooth_position'), ''), '') != ''
AND rtx.content->>'category' = ANY(${resolverCats}::text[])
AND rtx.occurred_at IS NOT NULL`);
// ② (a'') 桥类修复的牙位盲点 —— 基牙跨度区间内全部牙位计入
branches.push(Prisma.sql`
SELECT btx.patient_id, cov.arch_tooth AS tooth, btx.occurred_at AS gate,
NULL::text AS enc, FALSE AS strict, FALSE AS ndx
FROM patient_facts btx
CROSS JOIN LATERAL (
SELECT min(z.idx) AS mi, max(z.idx) AS ma, z.arch
FROM (
SELECT CASE substr(bt,1,1)
WHEN '1' THEN 9 - substr(bt,2,1)::int
WHEN '2' THEN 8 + substr(bt,2,1)::int
WHEN '4' THEN 9 - substr(bt,2,1)::int
WHEN '3' THEN 8 + substr(bt,2,1)::int END AS idx,
CASE WHEN substr(bt,1,1) IN ('1','2') THEN 'U' ELSE 'L' END AS arch
FROM unnest(${toothArrSql(Prisma.sql`btx.content->>'tooth_position'`)}) AS bt
WHERE bt ~ '^[1-4][1-8]$'
) z GROUP BY z.arch HAVING count(*) >= 2
) span
CROSS JOIN LATERAL (
SELECT CASE WHEN span.arch = 'U'
THEN CASE WHEN gi <= 8 THEN '1' || (9 - gi)::text ELSE '2' || (gi - 8)::text END
ELSE CASE WHEN gi <= 8 THEN '4' || (9 - gi)::text ELSE '3' || (gi - 8)::text END
END AS arch_tooth
FROM generate_series(span.mi, span.ma) AS gi
) cov
WHERE ${sbScope('btx')}
AND btx.type = 'treatment_record' AND btx.kind = 'actual'
AND btx.status IN ('active', 'fulfilled')
AND btx.content->>'category' = 'prosthodontic'
AND btx.occurred_at IS NOT NULL`);
// ③ (a''') 检查所见"缺牙但间隙关闭/无修复间隙" → 该牙无修复指征
const noRestorMsgRe = NO_RESTORATION_GAP_EXAM_PATTERNS.join('|');
branches.push(Prisma.sql`
SELECT src.patient_id, nrt AS tooth, src.occurred_at AS gate,
NULL::text AS enc, FALSE AS strict, FALSE AS ndx
FROM (
SELECT emrx.patient_id, emrx.content->>'exam_findings' AS ef_text, emrx.occurred_at
FROM patient_facts emrx
WHERE ${sbScope('emrx')}
AND emrx.type = 'emr_record' AND emrx.status IN ('active', 'fulfilled')
AND emrx.content->>'exam_findings' ~ '^\\['
AND emrx.content->>'exam_findings' ~ '缺[牙失]'
AND emrx.content->>'exam_findings' ~ ${noRestorMsgRe}
AND emrx.occurred_at IS NOT NULL
) src
CROSS JOIN LATERAL jsonb_array_elements(src.ef_text::jsonb) AS ef
CROSS JOIN unnest(${toothArrSql(Prisma.sql`ef->>'toothPosition'`)}) AS nrt
WHERE (ef->>'message') ~ '缺[牙失]'
AND (ef->>'message') ~ ${noRestorMsgRe}`);
// ④ (a'''') 患者无意愿:该 category 治疗被患者拒绝
branches.push(Prisma.sql`
SELECT rfx.patient_id, rft AS tooth,
COALESCE(rfx.occurred_at, rfx.planned_for) AS gate,
NULL::text AS enc, FALSE AS strict, FALSE AS ndx
FROM patient_facts rfx
CROSS JOIN unnest(${toothArrSql(Prisma.sql`rfx.content->>'tooth_position'`)}) AS rft
WHERE ${sbScope('rfx')}
AND rfx.type = 'treatment_record' AND rfx.status IN ('active', 'fulfilled')
AND ${refusalSubtypeMatch('rfx')}
AND rfx.content->>'category' = ANY(${resolverCats}::text[])
AND COALESCE(rfx.occurred_at, rfx.planned_for) IS NOT NULL`);
// ⑤ (b) 同牙位以【最新诊断】为准 —— 🔴 严格 >(其余分支都是 >=)
branches.push(Prisma.sql`
SELECT ldx.patient_id, ldt AS tooth, ldx.occurred_at AS gate,
NULL::text AS enc, TRUE AS strict, FALSE AS ndx
FROM patient_facts ldx
CROSS JOIN unnest(${toothArrSql(Prisma.sql`ldx.content->>'tooth_position'`)}) AS ldt
WHERE ${sbScope('ldx')}
AND ldx.type = 'diagnosis_record' AND ldx.status = 'active'
AND ldx.content->>'code' = ANY(${[...STRUCTURAL_DX_CODE_LIST]}::text[])
AND ldx.occurred_at IS NOT NULL`);
// ⑥ (c) 诊断 vs 建议冲突以建议为准 —— 🔴 只对 sig.type='diagnosis_record' 生效(ndx)
branches.push(Prisma.sql`
SELECT rdx.patient_id, rdt AS tooth,
COALESCE(rdx.occurred_at, rdx.planned_for) AS gate,
NULL::text AS enc, FALSE AS strict, TRUE AS ndx
FROM patient_facts rdx
CROSS JOIN unnest(${toothArrSql(Prisma.sql`rdx.content->>'tooth_position'`)}) AS rdt
WHERE ${sbScope('rdx')}
AND rdx.type = 'recommendation_record' AND rdx.status = 'active'
AND rdx.content->>'code' = ANY(${[...STRUCTURAL_DX_CODE_LIST]}::text[])
AND COALESCE(rdx.occurred_at, rdx.planned_for) IS NOT NULL`);
// ⑦ (a''''') 「复查/复诊」= 修复体在位的证据(按类目过滤,不收整类 review)
const reviewRules = REVIEW_IMPLIES_TREATMENT.filter((r) =>
(resolverCats as readonly string[]).includes(r.category),
);
if (reviewRules.length) {
branches.push(Prisma.sql`
SELECT rvx.patient_id, rvt AS tooth, rvx.occurred_at AS gate,
NULL::text AS enc, FALSE AS strict, FALSE AS ndx
FROM patient_facts rvx
CROSS JOIN unnest(${toothArrSql(Prisma.sql`rvx.content->>'tooth_position'`)}) AS rvt
WHERE ${sbScope('rvx')}
AND rvx.type = 'treatment_record' AND rvx.kind = 'actual'
AND rvx.status IN ('active', 'fulfilled')
AND rvx.content->>'category' = 'review'
AND rvx.content->>'subtype' ~ ${reviewRules.map((r) => r.pattern).join('|')}
AND rvx.occurred_at IS NOT NULL`);
}
// ⑧ (a'''''') 缺牙位上的裸「抛光」= 修复体在位(仅 K08 开闸)
if (cfgFlags.polishImpliesRestoration) {
branches.push(Prisma.sql`
SELECT plx.patient_id, plt AS tooth, plx.occurred_at AS gate,
NULL::text AS enc, FALSE AS strict, FALSE AS ndx
FROM patient_facts plx
CROSS JOIN unnest(${toothArrSql(Prisma.sql`plx.content->>'tooth_position'`)}) AS plt
WHERE ${sbScope('plx')}
AND plx.type = 'treatment_record' AND plx.kind = 'actual'
AND plx.status IN ('active', 'fulfilled')
AND plx.content->>'category' = ${MISSING_TOOTH_POLISH_EVIDENCE.category}
AND plx.content->>'subtype' ~ ${MISSING_TOOTH_POLISH_EVIDENCE.subtypePattern}
AND COALESCE(array_length(${toothArrSql(Prisma.sql`plx.content->>'tooth_position'`)}, 1), 0)
BETWEEN 1 AND ${MISSING_TOOTH_POLISH_EVIDENCE.maxTeeth}
AND plx.occurred_at IS NOT NULL`);
}
// ⑨ (a''''''') 病历自由文本自证已治疗(治疗记录 ⋈ 同次病历;与 sig 无关)
const evidenceTerms = TREATED_EVIDENCE_RESTORATION_TERMS.filter((t) =>
(resolverCats as readonly string[]).includes(t.category),
);
if (cfgFlags.treatedEvidenceFromEmrText && evidenceTerms.length) {
const emrTextSql = Prisma.raw(
TREATED_EVIDENCE_EMR_FIELDS.map((f) => `COALESCE(emx.content->>'${f}', '')`).join(" || ' ' || "),
);
branches.push(Prisma.sql`
SELECT tvx.patient_id, tvt AS tooth, tvx.occurred_at AS gate,
NULL::text AS enc, FALSE AS strict, FALSE AS ndx
FROM patient_facts tvx
CROSS JOIN unnest(${toothArrSql(Prisma.sql`tvx.content->>'tooth_position'`)}) AS tvt
JOIN patient_facts emx
ON emx.patient_id = tvx.patient_id
AND emx.type = 'emr_record'
AND emx.status IN ('active', 'fulfilled')
AND emx.content->>'emr_external_id' = tvx.content->>'source_encounter_external_id'
WHERE ${sbScope('tvx')}
AND tvx.type = 'treatment_record' AND tvx.kind = 'actual'
AND tvx.status IN ('active', 'fulfilled')
AND COALESCE(NULLIF(trim(tvx.content->>'tooth_position'), ''), '') != ''
AND tvx.content->>'category' = ANY(${[
...treatedEvidenceTriggersFor(resolverCats),
]}::text[])
AND (
tvx.content->>'category' <> ALL(${[...TREATED_EVIDENCE_BATCH_CATEGORIES]}::text[])
OR COALESCE(array_length(${toothArrSql(Prisma.sql`tvx.content->>'tooth_position'`)}, 1), 0)
<= ${TREATED_EVIDENCE_BATCH_MAX_TEETH}
)
AND (${emrTextSql}) ~ ${evidenceTerms.map((t) => t.pattern).join('|')}
AND (${emrTextSql}) ~ ${TREATED_EVIDENCE_COMPLETION_RE}
AND (${emrTextSql}) !~ ${TREATED_EVIDENCE_INTENT_EXCLUDE_RE}
${
TREATED_EVIDENCE_SINGLE_ARCH_ONLY
? Prisma.sql`AND NOT (
EXISTS (SELECT 1 FROM unnest(${toothArrSql(Prisma.sql`tvx.content->>'tooth_position'`)}) au WHERE au ~ '^[12]')
AND
EXISTS (SELECT 1 FROM unnest(${toothArrSql(Prisma.sql`tvx.content->>'tooth_position'`)}) al WHERE al ~ '^[34]')
)`
: Prisma.empty
}
AND tvx.occurred_at IS NOT NULL`);
}
// ⑩ (a'''''''') 检查所见写着修复体在位(同颌闸 + 失效词一票否决)
if (cfgFlags.restorationInPlaceFromExam) {
branches.push(Prisma.sql`
SELECT rsrc.patient_id, ript AS tooth, rsrc.occurred_at AS gate,
NULL::text AS enc, FALSE AS strict, FALSE AS ndx
FROM (
SELECT rix.patient_id, rix.content->>'exam_findings' AS ef_text, rix.occurred_at
FROM patient_facts rix
WHERE ${sbScope('rix')}
AND rix.type = 'emr_record' AND rix.status IN ('active', 'fulfilled')
AND rix.content->>'exam_findings' ~ '^\\['
AND rix.content->>'exam_findings' ~ ${RESTORATION_IN_PLACE_TERMS_RE}
AND rix.occurred_at IS NOT NULL
) rsrc
CROSS JOIN LATERAL jsonb_array_elements(rsrc.ef_text::jsonb) AS rief
CROSS JOIN unnest(${toothArrSql(Prisma.sql`rief->>'toothPosition'`)}) AS ript
WHERE (rief->>'message') ~ ${RESTORATION_IN_PLACE_TERMS_RE}
AND (rief->>'message') ~ ${RESTORATION_IN_PLACE_STATE_RE}
AND regexp_replace(rief->>'message', ${RESTORATION_IN_PLACE_NEG_STRIP_RE}, '', 'g')
!~ ${RESTORATION_IN_PLACE_FAIL_RE}
AND NOT (
EXISTS (SELECT 1 FROM unnest(${toothArrSql(Prisma.sql`rief->>'toothPosition'`)}) ru WHERE ru ~ '^[12]')
AND
EXISTS (SELECT 1 FROM unnest(${toothArrSql(Prisma.sql`rief->>'toothPosition'`)}) rl WHERE rl ~ '^[34]')
)`);
}
// ⑪ (a''''''''') 整颌活动义齿 —— 🔴 唯一走【病历号等值】相关的分支(enc 列)
// legacy: adx.emr_external_id = sig.source_encounter_external_id(无时间门)
// setbased: enc 进聚合键,gate 恒过
if (cfgFlags.archDentureIsRestored) {
branches.push(Prisma.sql`
SELECT adx.patient_id, adt AS tooth, ${SB_NO_GATE} AS gate,
adx.content->>'emr_external_id' AS enc, FALSE AS strict, FALSE AS ndx
FROM patient_facts adx
CROSS JOIN LATERAL jsonb_array_elements((adx.content->>'exam_findings')::jsonb) AS ade
CROSS JOIN unnest(${toothArrSql(Prisma.sql`ade->>'toothPosition'`)}) AS adt
WHERE ${sbScope('adx')}
AND adx.type = 'emr_record' AND adx.status IN ('active', 'fulfilled')
AND adx.content->>'exam_findings' ~ '^\\['
AND adx.content->>'exam_findings' ~ ${ARCH_DENTURE_PREFILTER_RE}
AND adx.content->>'emr_external_id' IS NOT NULL
AND (ade->>'message') !~ ${ARCH_DENTURE_INTENT_EXCLUDE_RE}
AND (
((ade->>'message') ~ ${ARCH_DENTURE_UPPER_RE} AND adt ~ ${UPPER_ARCH_FIRST_DIGITS_RE})
OR
((ade->>'message') ~ ${ARCH_DENTURE_LOWER_RE} AND adt ~ ${LOWER_ARCH_FIRST_DIGITS_RE})
)`);
}
// ⑫ §E 正畸减数位 —— 与 sig 无关,gate 恒过
if (cfgFlags.excludeOrthoExtractionSites) {
const exTeeth = toothArrSql(Prisma.sql`ex.content->>'tooth_position'`);
branches.push(Prisma.sql`
SELECT ex.patient_id, eet AS tooth, ${SB_NO_GATE} AS gate,
NULL::text AS enc, FALSE AS strict, FALSE AS ndx
FROM patient_facts ex
CROSS JOIN unnest(${exTeeth}) AS eet
WHERE ${sbScope('ex')}
AND ex.type = 'treatment_record' AND ex.kind = 'actual' AND ex.status IN ('active','fulfilled')
AND ex.content->>'category' = 'surgical'
AND eet ~ '^[1-4][45]$'
AND NOT EXISTS (
SELECT 1 FROM unnest(${exTeeth}) AS xt WHERE xt !~ '^[1-4][45]$'
)
AND (${exTeeth} @> ARRAY['14','24']::text[]
OR ${exTeeth} @> ARRAY['34','44']::text[])
AND EXISTS (SELECT 1 FROM patient_facts oc WHERE oc.patient_id = ex.patient_id
AND ((oc.type='diagnosis_record' AND oc.status='active' AND oc.content->>'code'='K07')
OR (oc.type='treatment_record' AND oc.content->>'category'='orthodontic')))`);
}
// ⑬ 「建议拔除」让位同牙编码病种诊断 —— 与 sig 无关,gate 恒过
if (cfgFlags.deferToToothDx) {
branches.push(Prisma.sql`
SELECT dxx.patient_id, ddt AS tooth, ${SB_NO_GATE} AS gate,
NULL::text AS enc, FALSE AS strict, FALSE AS ndx
FROM patient_facts dxx
CROSS JOIN unnest(${toothArrSql(Prisma.sql`dxx.content->>'tooth_position'`)}) AS ddt
WHERE ${sbScope('dxx')}
AND dxx.type = 'diagnosis_record' AND dxx.status = 'active'
AND dxx.content->>'code' = ANY(ARRAY['K00','K01','K02','K03','K04','K06','K08','K09']::text[])`);
}
const branchUnion = Prisma.join(branches, '\n UNION ALL\n');
const postCtes = Prisma.sql`,
gap_scope AS MATERIALIZED (
SELECT DISTINCT gap_patient_id AS patient_id FROM gap_cand
),
gap_resolved AS MATERIALIZED (
-- 按 (患者, 牙位, 病历号, 严格性, 需诊断信号) 聚合出每组的 max(时间门)
SELECT patient_id, tooth, max(gate) AS gate, enc, strict, ndx
FROM ( ${branchUnion} ) sb(${SB_COLS})
GROUP BY patient_id, tooth, enc, strict, ndx
),
gap_rem AS MATERIALIZED (
-- 牙位级反连接。WITH ORDINALITY + ORDER BY ord:保持 legacy 的 unnest 自然顺序,
-- 也保留重复牙位 —— tooth 串会落进 plan_reasons 给客服看,顺序变了就是 diff 噪音。
SELECT c.gap_sig_id AS sig_id,
array_agg(u.x ORDER BY u.ord) AS remaining_teeth
FROM gap_cand c
CROSS JOIN LATERAL unnest(c.gap_sig_teeth) WITH ORDINALITY AS u(x, ord)
WHERE NOT EXISTS (
SELECT 1 FROM gap_resolved r
WHERE r.patient_id = c.gap_patient_id
AND r.tooth = u.x
AND (CASE WHEN r.strict THEN r.gate > c.gap_anchor ELSE r.gate >= c.gap_anchor END)
AND (r.enc IS NULL OR r.enc = c.gap_sig_enc)
AND (NOT r.ndx OR c.gap_sig_type = 'diagnosis_record')
)
GROUP BY c.gap_sig_id
)`;
return {
candExtraCols,
candWhere: externalTreatmentGate,
postCtes,
remJoin: Prisma.sql`LEFT JOIN gap_rem ON gap_rem.sig_id = c.gap_sig_id`,
// 🔴 gap_rem 无行 ≠ 空数组:牙位全被解决的 sig 在 gap_rem 里没有行 → COALESCE 补 {}
toothOutput: Prisma.sql`array_to_string(COALESCE(gap_rem.remaining_teeth, ARRAY[]::text[]), ';')`,
outerWhere: Prisma.sql`AND cardinality(COALESCE(gap_rem.remaining_teeth, ARRAY[]::text[])) > 0`,
};
}
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { lookupDxTreatment, resolverCategoriesFor } from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service';
import {
buildGapCore,
GAP_FLAGS_BY_PRIMARY,
GAP_PRIMARY_GROUPS,
gapVariant,
type GapVariant,
} from './potential-treatment-gap.sql';
/**
......@@ -31,8 +34,11 @@ export class PotentialTreatmentSelector {
patientId: string;
now: Date;
activeCodes: Set<string>;
/// 仅对拍工具用:强制 gap 计算形态。生产路径不传,走 gapVariant() 的环境开关。
variant?: GapVariant;
}): Promise<PotentialGap[]> {
const { hostId, tenantId, patientId, now, activeCodes } = opts;
const variant = opts.variant ?? gapVariant();
const out: PotentialGap[] = [];
for (const [primaryCode, group] of Object.entries(GAP_PRIMARY_GROUPS)) {
......@@ -43,21 +49,23 @@ export class PotentialTreatmentSelector {
if (!rule) continue;
const resolverCats = resolverCategoriesFor(primaryCode) as readonly string[];
const cfgFlags = GAP_FLAGS_BY_PRIMARY[primaryCode] ?? {};
const gap = buildGapCore({ rule, cfgFlags, allCodes, resolverCats });
const gap = buildGapCore({ rule, cfgFlags, allCodes, resolverCats, variant });
const rows = await this.prisma.$queryRaw<RawGapRow[]>`
SELECT
// 投影列(两形态共用;tooth 单列,取法不同)
const projection = Prisma.sql`
sig.id AS fact_id,
sig.content->>'code' AS code,
sig.content->>'name_zh' AS name_zh,
sig.type AS signal_type,
${gap.toothOutput} AS tooth,
sig.content->>'confidence' AS confidence,
EXTRACT(DAY FROM ${now}::timestamptz - COALESCE(sig.occurred_at, sig.planned_for))::int AS days_since,
COALESCE(sig.occurred_at, sig.planned_for) AS anchor_at
COALESCE(sig.occurred_at, sig.planned_for) AS anchor_at`;
// ⚠️ 画像是**逐患者**调用(全量 54.7 万次),这里的 scope 恒为 1 个患者 ——
// gap_scope 只有一行,各分支走 (patient_id, type, status) 索引,形态不会退化成全表扫。
const queryBody = (joinAddon: Prisma.Sql, gapAddon: Prisma.Sql): Prisma.Sql => Prisma.sql`
FROM patients p
JOIN patient_facts sig ON sig.patient_id = p.id
${gap.lateralJoin}
${joinAddon}
WHERE p.host_id = ${hostId}::uuid
AND p.tenant_id = ${tenantId}
AND p.id = ${patientId}::uuid
......@@ -68,8 +76,26 @@ export class PotentialTreatmentSelector {
AND COALESCE(sig.occurred_at, sig.planned_for) IS NOT NULL
${gap.restorationIneligibleFrag}
${gap.congenitalFrag}
${gap.gapWhere}
`;
${gapAddon}`;
const sb = gap.setBased;
const sql = sb
? Prisma.sql`
WITH gap_cand AS MATERIALIZED (
SELECT ${projection}${sb.candExtraCols}
${queryBody(Prisma.empty, sb.candWhere)}
)${sb.postCtes}
SELECT c.fact_id, c.code, c.name_zh, c.signal_type, c.confidence, c.days_since, c.anchor_at,
${sb.toothOutput} AS tooth
FROM gap_cand c
${sb.remJoin}
WHERE TRUE ${sb.outerWhere}`
: Prisma.sql`
SELECT ${projection},
${gap.toothOutput} AS tooth
${queryBody(gap.lateralJoin, gap.gapWhere)}`;
const rows = await this.prisma.$queryRaw<RawGapRow[]>(sql);
for (const r of rows) {
out.push({
primaryCode,
......
......@@ -10,6 +10,7 @@ import {
treatmentCategoryNameZhFor,
recommendedCategoriesForAge,
refineCategoriesForDiagnosis,
type DxTreatmentRule,
} from '@pac/types';
import { PrismaService } from '../../../../prisma/prisma.service';
import type {
......@@ -20,7 +21,14 @@ import type {
import { calcPriority } from '../priority-scorer';
import { toothSet } from '../../../sync/pipeline/parsers/tooth-position.util';
// ⭐ gap 核心单一真理源(召回 + 潜在治疗画像共用;SQL 逻辑搬此,本文件只组装)
import { buildGapCore, GAP_FLAGS_BY_PRIMARY, GAP_PRIMARY_GROUPS } from '../../../clinical-gap/potential-treatment-gap.sql';
import {
buildGapCore,
GAP_FLAGS_BY_PRIMARY,
GAP_PRIMARY_GROUPS,
gapVariant,
type GapVariant,
} from '../../../clinical-gap/potential-treatment-gap.sql';
/**
* 潜在治疗新链召回(treatment_initiation_recall)— v2.1 重写
......@@ -270,35 +278,31 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// 子场景跑 SQL + 算 6 因子分
// ─────────────────────────────────────────────────────────
private async runSubScenario(
/**
* 构造单个子场景的召回 SQL。**抽成独立方法是为了让对拍工具能拿到线上跑的那条 SQL 本身** ——
* verify-gap-equivalence 用同一份代码生成 legacy / setbased 两版再逐行差分,
* 不另写一份查询(另写就变成"验证我抄得对不对",而不是验证线上行为)。
*/
buildScenarioSql(
scope: ScenarioScope,
subKey: string,
cfg: (typeof TreatmentInitiationRecallScenario.SUB_SCENARIOS)[keyof typeof TreatmentInitiationRecallScenario.SUB_SCENARIOS],
): Promise<ScenarioHit[]> {
// 临床窗口 / 类别 / 紧迫临界从 canonical-codes.DiagnosisTreatmentMap 单一真理源读
const rule = lookupDxTreatment(cfg.primaryCode);
if (!rule) {
throw new Error(
`SUB_SCENARIOS[${subKey}].primaryCode=${cfg.primaryCode} 在 DiagnosisTreatmentMap 中找不到 — ` +
`检查 canonical-codes.ts(单一真理源)`,
);
}
const start = rule.cooldownDays;
const goldenRange: [number, number] = [rule.cooldownDays, rule.windowDays];
primaryCode: string,
rule: DxTreatmentRule,
variant: GapVariant,
): Prisma.Sql {
// 码分组 → 单一真理源 GAP_PRIMARY_GROUPS(召回 + 潜在治疗画像共用,不在 SUB_SCENARIOS 内联)
const grp = GAP_PRIMARY_GROUPS[cfg.primaryCode] ?? { dxCodes: [], recCodes: [] };
const grp = GAP_PRIMARY_GROUPS[primaryCode] ?? { dxCodes: [], recCodes: [] };
const dxCodes = grp.dxCodes as readonly string[];
const recCodes = grp.recCodes as readonly string[];
const allCodes = [...dxCodes, ...recCodes];
// §E gap 修正 flag → 单一真理源 GAP_FLAGS_BY_PRIMARY(召回 + 潜在治疗画像共用)
const cfgFlags = GAP_FLAGS_BY_PRIMARY[cfg.primaryCode] ?? {};
const cfgFlags = GAP_FLAGS_BY_PRIMARY[primaryCode] ?? {};
// ⭐ 两个口径分开(单一真理源 canonical-codes):
// expectedCats = rule.categories(窄,主治疗)→ 展示"未启动 X" + 触发预期 + ⑤d 主诉匹配
// resolverCats = resolverCategoriesFor(宽,治疗家族)→ ⑤a "已解决" 判定
// 结构码(K02/K03/K08…)= 任何局部结构治疗都算(充填/根管/冠桥/种植/外科/美学/儿牙);
// 牙周/正畸(K05/K06/K07)沿用各自 categories。见 canonical-codes.resolverCategoriesFor。
const expectedCats = rule.categories as readonly string[];
const resolverCats = resolverCategoriesFor(cfg.primaryCode) as readonly string[];
const resolverCats = resolverCategoriesFor(primaryCode) as readonly string[];
// 收窄(可空,两种粒度):
// - scope.patientId 单患者(详情页"刷新"):O(全租户) → O(1)
......@@ -322,7 +326,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// ⭐ gap 核心(sig 牙位 / resolved / remaining + ⑤a 判定 + 废用牙/先天剔除)抽到共享模块
// potential-treatment-gap.sql —— 召回与潜在治疗画像【单一真理源】,SQL 逻辑零改动只搬家。
// 召回在此基础上再加时间门(④ cooldown / ⑤b 预约 / ⑤d entered / ⑤f 到诊)+ 6 因子打分。
const gap = buildGapCore({ rule, cfgFlags, allCodes, resolverCats });
const gap = buildGapCore({ rule, cfgFlags, allCodes, resolverCats, variant });
// ╔═════════════════════════════════════════════════════════════════════╗
// ║ 召回 SQL 完整解读(initiation = 潜在治疗新链召回) ║
......@@ -389,8 +393,8 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// ║ 输出:每个命中 (patient × sig) 一行,后段 byPatient Map 去重 ║
// ║ 只留 daysSince 最大那条(最早诊断 = 最有召回价值) ║
// ╚═════════════════════════════════════════════════════════════════════╝
const scenarioSql = Prisma.sql`
SELECT
// ── 投影列(legacy / setbased 两形态共用;tooth 单列,两边取法不同)──
const projection = Prisma.sql`
p.id AS patient_id,
p.external_id AS patient_external_id,
-- 建议治疗的年龄适配用(只影响"建议做什么",不参与召回筛选/排除;无生日 → NULL 走默认)
......@@ -401,19 +405,39 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
-- 诊断原文词(K00 主类目细分用:滞留→外科 / 早失→正畸 / 先天缺→修复…)。
-- 纯投影,不进任何 WHERE —— 返回行集与加它之前逐行相同。
sig.content->>'name_zh' AS signal_name_zh,
-- ⭐ 牙位级相减:有牙位信号 → 剩余未治牙位;全口信号 → 原样 NULL(gap 核心,共享模块)
${gap.toothOutput} AS tooth,
sig.content->>'extracted_by' AS extracted_by,
sig.content->>'confidence' AS confidence,
sig.content->>'code_source' AS code_source, -- 置信度因子:std_code/name_map=医生 / image_ai / null
sig.clinic_id AS clinic_id,
COALESCE(sig.occurred_at, sig.planned_for) AS signal_occurred_at,
EXTRACT(DAY FROM ${scope.now}::timestamptz - COALESCE(sig.occurred_at, sig.planned_for))::int AS days_since
EXTRACT(DAY FROM ${scope.now}::timestamptz - COALESCE(sig.occurred_at, sig.planned_for))::int AS days_since`;
// setbased 外层从 gap_cand 取同名列(顺序无关,消费方按列名映射)
const outerProjection = Prisma.raw(
[
'patient_id',
'patient_external_id',
'patient_age',
'signal_fact_id',
'signal_type',
'signal_code',
'signal_name_zh',
'extracted_by',
'confidence',
'code_source',
'clinic_id',
'signal_occurred_at',
'days_since',
]
.map((c) => `c.${c}`)
.join(', '),
);
// ── FROM + 全部闸(①隔离 ②合规 ③信号 ④cooldown ⑤b/⑤f/⑤g)——
// joinAddon = legacy 的 gap lateral(setbased 为空);gapAddon = gap 判定
const queryBody = (joinAddon: Prisma.Sql, gapAddon: Prisma.Sql): Prisma.Sql => Prisma.sql`
FROM patients p
JOIN patient_profiles pp ON pp.patient_id = p.id
JOIN patient_facts sig ON sig.patient_id = p.id
-- ⭐ 按牙相减(sig 牙位 / 已解决 / 剩余未治)— gap 核心,共享模块单一真理源
${gap.lateralJoin}
${joinAddon}
WHERE p.host_id = ${scope.hostId}::uuid -- ① 隔离闸
AND p.tenant_id = ${scope.tenantId} -- ① 隔离闸
${patientFilter} -- 单刷 / 子集收窄(可空)
......@@ -428,12 +452,12 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
AND sig.type IN ('diagnosis_record', 'recommendation_record') -- ③ 信号类型
AND sig.content->>'code' = ANY(${allCodes}::text[]) -- ③ 信号 code 命中
AND COALESCE(sig.occurred_at, sig.planned_for) IS NOT NULL -- ④ 时间不为空
AND COALESCE(sig.occurred_at, sig.planned_for) <= ${this.daysAgo(scope.now, start)}::timestamptz -- ④ 过 cooldown
AND COALESCE(sig.occurred_at, sig.planned_for) <= ${this.daysAgo(scope.now, rule.cooldownDays)}::timestamptz -- ④ 过 cooldown
-- ④' 废用牙/无功能牙剔除 + ④' §E 先天缺失剔除 + ⑤a 牙位级 gap 判定(全口 NOT EXISTS / 有牙位剩余非空)
-- —— 全部抽到 gap 核心(共享模块),召回与潜在治疗画像口径一致
${gap.restorationIneligibleFrag}
${gap.congenitalFrag}
${gap.gapWhere}
${gapAddon}
-- (⑤c 同牙位拔除 已折进 resolved_teeth 的 surgical 分支 — 拔了的牙从 remaining 减掉)
AND NOT EXISTS ( -- ⑤b 排除:患者已有未来预约
-- 召回目的 = 让客服建预约。患者已经有未来预约 → 客服不需要再 push,医生到诊现场处理即可
......@@ -478,8 +502,50 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
SELECT 1 FROM patient_return_visits rv
WHERE rv.patient_id = p.id
AND rv.task_date > ${scope.now}::date
)
)`;
const sb = gap.setBased;
return sb
? // ══ 集合式:候选 → 患者域 → resolved 预聚合 → 牙位反连接 ══
// 见 docs/design/gap-set-based-rewrite-plan.md §4。gap_cand 必须 MATERIALIZED:
// 它被下游扫三次(gap_scope / gap_rem / 主查询),内联会让 ①②③④ 闸重算三遍。
Prisma.sql`
WITH gap_cand AS MATERIALIZED (
SELECT ${projection}${sb.candExtraCols}
${queryBody(Prisma.empty, sb.candWhere)}
)${sb.postCtes}
SELECT ${outerProjection},
${sb.toothOutput} AS tooth
FROM gap_cand c
${sb.remJoin}
WHERE TRUE ${sb.outerWhere}
`
: // ══ 逐行相关子查询(legacy)══
Prisma.sql`
SELECT ${projection},
-- ⭐ 牙位级相减:有牙位信号 → 剩余未治牙位;全口信号 → 原样 NULL(gap 核心,共享模块)
${gap.toothOutput} AS tooth
${queryBody(gap.lateralJoin, gap.gapWhere)}
`;
}
private async runSubScenario(
scope: ScenarioScope,
subKey: string,
cfg: (typeof TreatmentInitiationRecallScenario.SUB_SCENARIOS)[keyof typeof TreatmentInitiationRecallScenario.SUB_SCENARIOS],
): Promise<ScenarioHit[]> {
// 临床窗口 / 类别 / 紧迫临界从 canonical-codes.DiagnosisTreatmentMap 单一真理源读
const rule = lookupDxTreatment(cfg.primaryCode);
if (!rule) {
throw new Error(
`SUB_SCENARIOS[${subKey}].primaryCode=${cfg.primaryCode} 在 DiagnosisTreatmentMap 中找不到 — ` +
`检查 canonical-codes.ts(单一真理源)`,
);
}
const start = rule.cooldownDays;
const goldenRange: [number, number] = [rule.cooldownDays, rule.windowDays];
const expectedCats = rule.categories as readonly string[];
const scenarioSql = this.buildScenarioSql(scope, cfg.primaryCode, rule, gapVariant());
// ══════════════════════════════════════════════════════════════════
// 📊 2026-08-29 全量重算耗时归因(测试服 585K 患者 / 87,661 命中,空闲机)
......@@ -696,7 +762,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// 也不知道是 SQL 还是后处理慢。2026-08-29 生产 plan 段从 12 分钟涨到 159 分钟
// 仍未跑完,是临时加采样器才定位到子场景粒度的 —— 那种事不该再来一次。
this.logger.log(
`[recall] sub=${subKey} code=${cfg.primaryCode} ` +
`[recall] sub=${subKey} code=${cfg.primaryCode} gap=${gapVariant()} ` +
`sql=${sqlMs}ms rows=${rows.length} post=${Date.now() - postStart}ms hits=${hits.length}`,
);
return hits;
......
/**
* gap 集合式形态的**结构对拍**(纯 SQL 文本层,不连库)
*
* 定位:数据层的等价性由 `pnpm verify-gap-equivalence`(逐 患者×信号×牙位 差分)证明,
* 本 spec 只守一件单元测试能守住的事 —— **两种形态的分支集合不许走散**。
* 典型事故:后来人给 legacy 加了第 14 条 resolved 分支,忘了同步 setbased →
* 线上悄悄少销一类证据 → 静默多召 / 少召。那种漏法 tsc 和现有 spec 全都发现不了,
* 但分支计数会当场炸。
*
* ⚠️ 本 spec 断言的是"两边都改了",不是"改对了"。改完仍必须跑 verify-gap-equivalence。
*/
import { lookupDxTreatment, resolverCategoriesFor } from '@pac/types';
import {
buildGapCore,
GAP_FLAGS_BY_PRIMARY,
GAP_PRIMARY_GROUPS,
} from '../src/modules/clinical-gap/potential-treatment-gap.sql';
const PRIMARY_CODES = Object.keys(GAP_PRIMARY_GROUPS);
const WHOLE_MOUTH = ['K05', 'K07'];
function core(primaryCode: string, variant: 'legacy' | 'setbased') {
const rule = lookupDxTreatment(primaryCode);
if (!rule) throw new Error(`no rule for ${primaryCode}`);
const grp = GAP_PRIMARY_GROUPS[primaryCode];
return buildGapCore({
rule,
cfgFlags: GAP_FLAGS_BY_PRIMARY[primaryCode] ?? {},
allCodes: [...grp.dxCodes, ...grp.recCodes],
resolverCats: resolverCategoriesFor(primaryCode) as readonly string[],
variant,
});
}
const count = (hay: string, needle: RegExp): number => (hay.match(needle) ?? []).length;
describe('gap 集合式 ↔ 逐行形态:结构对拍', () => {
it('variant 默认 legacy;只有显式 setbased 才产出集合式拼装件', () => {
const g = core('K08', 'legacy');
expect(g.setBased).toBeUndefined();
expect(core('K08', 'setbased').setBased).toBeDefined();
});
describe.each(PRIMARY_CODES)('%s', (code) => {
const isWhole = WHOLE_MOUTH.includes(code);
it('全口码不进集合式(原样走 legacy),牙位码必须有 resolved 预聚合链', () => {
const g = core(code, 'setbased');
if (isWhole) {
// 全口场景 legacy 的 lateral 本来就会被 PG 的 useless-left-join removal 摘掉 →
// 集合式零收益;实测硬套进来还慢 2~3 倍(见 buildGapSetBased 里的早退注释)。
expect(g.setBased).toBeUndefined();
} else {
const sb = g.setBased!;
expect(sb.postCtes.sql).toContain('gap_scope');
expect(sb.postCtes.sql).toContain('gap_resolved');
expect(sb.postCtes.sql).toContain('gap_rem');
expect(sb.remJoin.sql).toContain('LEFT JOIN gap_rem');
}
});
if (!WHOLE_MOUTH.includes(code)) {
it('两种形态的 resolved 分支条数必须一致(加分支只改一边 = 静默错召)', () => {
const legacy = core(code, 'legacy').lateralJoin.sql;
const setbased = core(code, 'setbased').setBased!.postCtes.sql;
// legacy 分支用裸 UNION 分隔;setbased 用 UNION ALL(先聚合后去重,不需要 UNION 的排序去重)
const legacyBranches = count(legacy, /\bUNION\b(?!\s+ALL)/g) + 1;
const setBranches = count(setbased, /\bUNION ALL\b/g) + 1;
expect(setBranches).toBe(legacyBranches);
});
it('每个分支都挂了患者收窄(漏一个就全表扫 34GB patient_facts)', () => {
const sql = core(code, 'setbased').setBased!.postCtes.sql;
const branches = sql
.slice(sql.indexOf('FROM ('), sql.indexOf(') sb('))
.split(/\bUNION ALL\b/);
expect(branches.length).toBeGreaterThan(1);
for (const b of branches) {
expect(b).toContain('IN (SELECT patient_id FROM gap_scope)');
}
});
it('gate 列永不为 NULL —— 时间门分支带 IS NOT NULL,无门分支写死 infinity', () => {
const sql = core(code, 'setbased').setBased!.postCtes.sql;
const branches = sql
.slice(sql.indexOf('FROM ('), sql.indexOf(') sb('))
.split(/\bUNION ALL\b/);
for (const b of branches) {
const ungated = b.includes(`'infinity'::timestamptz AS gate`);
const gated = /IS NOT NULL/.test(b);
// 二者必居其一:否则全 NULL 组会被 max() 聚成 NULL、当成"无门恒过"→ 误销 → 静默少召
expect(ungated || gated).toBe(true);
}
});
it('严格 > 只出现在「更晚结构诊断」一条分支上', () => {
const sql = core(code, 'setbased').setBased!.postCtes.sql;
expect(count(sql, /TRUE AS strict/g)).toBe(1);
expect(sql).toContain('CASE WHEN r.strict THEN r.gate > c.gap_anchor ELSE r.gate >= c.gap_anchor END');
});
it('牙位顺序与重复原样保留(tooth 串会落进 plan_reasons 给客服看)', () => {
const sql = core(code, 'setbased').setBased!.postCtes.sql;
expect(sql).toContain('WITH ORDINALITY');
expect(sql).toContain('array_agg(u.x ORDER BY u.ord)');
});
it('gap_rem 无行要补空数组,不能留 NULL', () => {
const sb = core(code, 'setbased').setBased!;
expect(sb.toothOutput.sql).toContain("COALESCE(gap_rem.remaining_teeth, ARRAY[]::text[])");
expect(sb.outerWhere.sql).toContain("COALESCE(gap_rem.remaining_teeth, ARRAY[]::text[])");
});
}
});
it('病历号等值相关(整颌活动义齿)只在 K08 出现,且走 enc 列而非时间门', () => {
const k08 = core('K08', 'setbased').setBased!.postCtes.sql;
expect(k08).toContain("adx.content->>'emr_external_id' AS enc");
expect(k08).toContain("r.enc IS NULL OR r.enc = c.gap_sig_enc");
const k02 = core('K02', 'setbased').setBased!.postCtes.sql;
expect(k02).not.toContain("AS enc,\n FALSE AS strict");
expect(count(k02, /adx\./g)).toBe(0);
});
it('「建议优先于诊断」分支只对 diagnosis_record 信号生效(ndx 标志)', () => {
const sql = core('K08', 'setbased').setBased!.postCtes.sql;
expect(count(sql, /TRUE AS ndx/g)).toBe(1);
expect(sql).toContain("NOT r.ndx OR c.gap_sig_type = 'diagnosis_record'");
});
it('牙位级规则若被加上 excludeIfEverTreated,集合式必须直接炸而不是静默算错', () => {
const rule = { ...lookupDxTreatment('K08')!, excludeIfEverTreated: true };
expect(() =>
buildGapCore({
rule,
cfgFlags: GAP_FLAGS_BY_PRIMARY.K08,
allCodes: ['K08'],
resolverCats: resolverCategoriesFor('K08') as readonly string[],
variant: 'setbased',
}),
).toThrow(/excludeIfEverTreated/);
});
});
# 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)是零风险的第一步,值得单独试。
---
## 10. 实施记录 · 本地验收(2026-08-30)
分支 `perf/gap-set-based`。默认仍走 legacy(`PAC_GAP_VARIANT` 未设),集合式靠环境变量切换。
### 与方案的两处偏离(都已改回)
- 曾把全口码(K05/K07)也统一进 `gap_cand` 形态 —— **实测慢 2~3 倍**
(ortho 1309→4439ms ×0.29;perio 1388→3065ms ×0.45),已按 §8 改回原样走 legacy。
`buildGapSetBased``rule.wholeMouth` 直接 `return null`
### 本地验收结果(30K 患者库)
| 项 | 结果 |
|---|---|
| 单元测试 | 102 套 / 1859 测试全绿(含新增 `gap-setbased-parity.spec.ts` 69 例) |
| SQL 层对拍 | 11 个子场景**逐 (患者×信号×牙位) 零差异**,行数逐个相同 |
| 端到端对拍 | `plan_reasons` 39,182 条**行级 diff = 0**(含 priority_score) |
| 幂等 | 两版互相复跑都 `plansCreated=0 plansClosed=0` |
| 场景段耗时 | legacy 34.2s → setbased 29.2s(×1.17) |
⚠️ 本地库小且全热、去重比低,**×1.17 不作为收益判据** —— 以测试机 585K 患者的实测为准。
### 对拍工具自检
`--self`(legacy 对 legacy)先跑过,零差异 —— 证明工具本身不会把相同当不同。
## 11. 测试机验收协议(2026-08-30 夜)
脚本 `~/gapverify.sh`(测试机),产物落 `~/gapbench/`
| 步 | 内容 | 门 |
|---|---|---|
| 0 | 工具自检:`--self --sub=jaw_cyst`(legacy 对 legacy) | 必须零差异,否则后面的"零差异"不可信 |
| 1 | 画像消费方:`--persona=2000` | 零差异 + 单患者 p95 不劣化 >10% |
| 2 | 召回 11 子场景:逐 (患者×信号×牙位) 双向 EXCEPT ALL | **零差异**(不是"差异很少") |
| 3 | 交替全量重算 A(legacy) → B(setbased) → C(legacy) → D(setbased) | 行级 diff 全 0;取 C vs D(都是热态第二轮)比墙钟 |
**为什么要交替四轮**:第一轮把页读进缓存,第二轮才代表稳态。只跑 legacy→setbased
会让 setbased 白蹭前一轮的缓存,把收益量虚高 —— 这条分支上已经有两次"理论漂亮实测为零"
的前科,不能再拿有偏的数字下结论。
### ⛔ 对拍工具打出的 ×N **不能当收益判据**
工具总是先跑 legacy 再跑 setbased,第二条白蹭第一条预热出来的缓存。
2026-08-30 测试机自检实测(`--self`,同一条 legacy SQL 跑两遍):
```
jaw_cyst legacy=36,797ms legacy=4,861ms ×7.57
```
**缓存效应 7.6 倍,比任何真实形态差异大一个数量级。** 所以:
- `verify-gap-equivalence` 的 ×N 只用来看"有没有离谱地变慢",不用来量收益
- 收益只认交替四轮里的 **C(legacy,第二轮) vs D(setbased,第二轮)** —— 两边都是热态
**噪音源**:测试机 `PAC_STALE_SCAN_CRON=0 2 * * *`(画像 stale 扫描)会落在验收中段。
C/D 相邻,长时间后台负载对两者影响相近,不单边偏袒;若数字明显抖动,重跑 C/D 一对。
`PAC_INCREMENTAL_CRON=15 8 * * *` 在窗口之外。
### 分支覆盖自检(本地 30K 库,2026-08-30)
「零差异」只有在**每条分支都真的产出过行**时才有意义 —— 一条从没触发的分支两边都返回空,
差分当然为零,但什么也没证明。本地各分支的源行数:
| 分支 | 源行数 | | 分支 | 源行数 |
|---|---|---|---|---|
| ① 治疗家族 | 56,552 | | ⑧ 裸抛光 | 66 |
| ② 桥类修复 | 8,681 | | ⑨ 病历文本自证 | 4,157 |
| ③ 缺牙间隙关闭 | 117 | | ⑩ 检查所见修复在位 | 4,628 |
| ④ 患者拒绝 | 375 | | ⑪ 整颌活动义齿 | 584 |
| ⑤ 更晚结构诊断 | 153,509 | | ⑫ 正畸减数位(外科拔牙) | 8,344 |
| ⑥ 建议记录 | 15,440 | | ⑬ 让位编码病种 | 77,083 |
| ⑦ 复查即治疗 | 1,805 | | | |
13 条全部有料,本地对拍不是空转。测试机数据量 ~20 倍,覆盖只会更足。
### 测试机结果 · 步 0~1(2026-08-30 01:05~)
| 步 | 结果 |
|---|---|
| 0 工具自检 | ✅ 零差异(并测出缓存效应 ×7.57,见上) |
| 1 画像对拍 | ✅ **零差异**(2000 位,1290 位有 gap);耗时/患者 legacy p50=13ms p95=47ms 合计 34.7s → setbased p50=9ms p95=27ms 合计 22.1s |
画像的门是「p95 不劣化 >10%」—— 通过。
⚠️ 但每位患者也是先 legacy 后 setbased,**顺序同样偏袒 setbased**,
所以只能下「不劣化」的结论,**不能说快 1.57 倍**。要量画像的真实收益,得改成交替顺序再跑。
### 测试机结果 · 步 2:召回 11 子场景对拍(2026-08-30 01:07~)
**✅ 11/11 零差异,行数逐个相同**(585K 患者,逐 患者×信号×牙位 双向 EXCEPT ALL)。
| 子场景 | 行数 | legacy | setbased | 比值* |
|---|---|---|---|---|
| missing_tooth | 14,970 | 230.4s | 151.7s | ×1.52 |
| caries_no_filling | 45,339 | 183.3s | 71.3s | ×2.57 |
| hard_tissue_damage | 31,758 | 102.8s | 47.3s | ×2.17 |
| endo_no_rct | 10,165 | 84.2s | 39.3s | ×2.14 |
| impacted_tooth | 38,003 | 71.0s | 39.1s | ×1.82 |
| perio_no_srp *(全口对照)* | 15,751 | 41.7s | 39.1s | ×1.07 |
| ortho_no_consult *(全口对照)* | 21,674 | 29.1s | 13.2s | ×2.20 |
| development_eruption | 2,882 | 19.4s | 13.9s | ×1.39 |
| extraction_recommended | 1,235 | 15.2s | 15.8s | ×0.96 |
| gum_alveolar_lesion | 875 | 7.3s | 9.2s | ×0.79 |
| jaw_cyst | 284 | 5.2s | 6.8s | ×0.77 |
\***这一列不是收益**。两个全口子场景两边跑的是**同一条 SQL**(setbased 对 wholeMouth 早退),
它们的 ×1.07 与 ×2.20 就是纯缓存噪音的量级 —— 噪音跨度比要测的信号还大。
这一列只用来看「有没有离谱地变慢」。收益看步 3 的 C vs D。
**新发现:小场景一致变慢。** ≤3000 行的四条(extraction ×0.96 / gum ×0.79 / jaw_cyst ×0.77,
本地 extraction ×0.60)都在带缓存优势的情况下仍然更慢 —— 集合式的 CTE 物化是**固定开销**,
结果集太小就摊不掉。绝对值都在 2 秒以内,对总墙钟无影响,不值得为它加"按规模切形态"的分叉
(那会让单一真理源裂成两条路径,得不偿失)。
### 测试机结果 · 步 3:交替四轮全量重算(2026-08-30 01:45~03:14)
| 轮 | 形态 | 场景段 | 整轮墙钟 | 命中患者 | reason 行数 |
|---|---|---|---|---|---|
| A | legacy | 670s | 1,584s | 87,807 | 323,781 |
| B | setbased | **1,334s** | 1,675s | 87,811 | 323,791 |
| C | legacy | 794s | 1,181s | 87,813 | 323,794 |
| D | setbased | **545s** | 923s | 87,814 | 323,796 |
#### ⛔ B 轮是离群值 —— 我据它下过一个错误结论,记在这里免得后来人重犯
B 轮场景段 1,334s(比 legacy 慢一倍),两个**跑同一条 SQL** 的全口子场景也慢了 7~12 倍
(ortho 38→444s / perio 105→797s)。我当场写下「集合式把内存打穿、溢盘、连没改的查询一起拖垮」,
还配了机器状态佐证(14G 内存吃光 + 9G swap + `temp_bytes=187GB`)。
**全错。** D 轮复现不出来 —— 545s,四轮里最快。真相:
- B 轮跑在 **02:11–02:39**,整段压在测试机 `PAC_STALE_SCAN_CRON=0 2 * * *`(画像 stale 扫描)上。
A 轮尾巴也蹭到 11 分钟,所以 A 也偏慢。C/D 在扫描结束后跑,才是干净的。
- `temp_bytes=187GB`**统计累计值,不是今晚产生的**。实测每轮增量:
C(legacy)4 个临时文件 / D(setbased)4 个,总增量 62MB —— **根本没有溢盘**
- 那个"内存打穿"的因果链是我照着一个数字编出来的,没有任何一步是量出来的。
**教训**:交替四轮的设计救了这次。只跑 A→B 就下结论的话,会把一个正确的优化否掉,
而且带着一套听起来很像回事的错误归因。⛔ 一个离群点 + 一套讲得通的机制 ≠ 结论;
**结论要能复现**。同 [[verify-before-prohibiting]]。
#### 真实收益:C(legacy) vs D(setbased),相邻两轮、同等条件
| 子场景 | C legacy | D setbased | 倍数 |
|---|---|---|---|
| impacted_tooth | 269.6s | 119.6s | **×2.25** |
| caries_no_filling | 358.2s | 171.5s | **×2.09** |
| hard_tissue_damage | 209.7s | 132.3s | ×1.59 |
| endo_no_rct | 243.2s | 169.7s | ×1.43 |
| development_eruption | 58.9s | 43.9s | ×1.34 |
| ortho_no_consult *(全口对照,同一条 SQL)* | 74.7s | 59.1s | *×1.26* |
| missing_tooth | 286.6s | 245.5s | ×1.17 |
| extraction_recommended | 51.0s | 45.6s | ×1.12 |
| gum_alveolar_lesion | 40.2s | 38.7s | ×1.04 |
| perio_no_srp *(全口对照,同一条 SQL)* | 128.2s | 130.1s | *×0.99* |
| jaw_cyst | 16.2s | 18.2s | ×0.89 |
两个对照组(逐字节相同的 SQL)给出 ×1.26 / ×0.99 → **环境噪音 ±25%**
超出噪音的真实收益集中在 impacted / caries / hard / endo 四条。
- **场景段 794s → 545s(×1.46,−31%)**
- **整轮 1,181s → 923s(×1.28,−22%)**
#### 端到端正确性
reason 行级 diff(323,796 条):A↔B 10 行 / B↔C 3 行 / C↔D 2 行 / A↔D 15 行。
差异**全部是新增(`>`),无一删除**,成簇按患者出现,且包含 `perio_no_srp@whole`
这种全口场景的行(两版跑同一条 SQL)—— 是患者跨过 ⑤f 到诊冷静期重新进池的**时间漂移**,
与形态无关。命中患者数 87,807 → 87,811 → 87,813 → 87,814 单调递增也印证这点。
**零删除 = setbased 从未丢掉 legacy 有的任何一行**,静默少召这个最怕的方向没有发生。
#### 结论
| 门 | 结果 |
|---|---|
| 正确性(SQL 层,固定 now 同一快照) | ✅ 11/11 零差异,行数逐个相同 |
| 正确性(画像消费方) | ✅ 2000 位零差异,p95 不劣化 |
| 正确性(端到端) | ✅ 差异仅时间漂移,零删除 |
| 收益 | ⚠️ 场景段 −31% / 整轮 −22%,**低于方案 §7 定的 ≥3× 试点门** |
方案 §0.1 预估「全轮改善 35~40%,不足以单独装进 2 小时」——**实测 22~31%,预估方向对、幅度偏乐观**
生产场景段 3h11m 按此推算 → 约 2h11m,整轮 3h41m → 约 2h50m,**仍然超 2 小时**
要进窗口还得叠加 §9(全口码那条独立线)或别的手段。
## 12. 测试机切到集合式(2026-08-30 03:2x)
`apps/pac-service/.env``PAC_GAP_VARIANT=setbased`,重启 pac-service。
**生产未动,仍是 legacy**(该变量在生产 .env 里不存在 → `gapVariant()` 返回 legacy)。
回退:把该行改回 `legacy`(或删掉),`bash deploy/deploy-prod.sh --no-pull`**不需要改代码、不需要发版。**
### ⛔ 切换时踩的坑:别手搓 docker compose
我为了省一次构建,手动跑了
`docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --no-deps --force-recreate pac-service`,
结果:
1. **compose 文件组合错了** —— deploy-prod.sh 用的是 `-f docker-compose.prod.yml`(+ managed override),
我多带了一个 `docker-compose.yml`,容器名从 `pac-pac-service-1` 变成 `pac-service`,端口冲突起不来
2. 删掉重来后又撞上**已知的 DB 口令坑**([[pac-prod-compose-db-cred-landmine]]):
手动 compose 不 source `.env`,`DATABASE_URL` 里的口令回落 → **P1000 认证失败 → crash-loop**
3. 测试服务挂了约 5 分钟,靠 `bash deploy/deploy-prod.sh --no-pull` 恢复(三项验证全过,数据完好)
deploy-prod.sh 的文件头注释本来就写着"不信 compose 的 diff 启发式"、整套逻辑就是为了避开这些。
**改环境变量也要走部署脚本** —— 它多花的那几分钟构建时间,买的是不出这种事。
## 13. 交互路径(详情页「刷新」)—— 用户当场感受得到的那条
`plan.controller``recomputeForPatient`**HTTP 端点**,走的就是本套召回 SQL 的
单患者路径(`scope.patientId`)。之前只验了批量和画像,漏了它。
批量慢是运维问题,**这条慢是体验问题**
测试机(585K 患者,200 位样本 × 11 子场景 = 2,200 次比对):
| | legacy | setbased |
|---|---|---|
| 单子场景查询 p50 | 5ms | 4ms |
| 单子场景查询 p95 | 20ms | 17ms |
| **一次「刷新」(11 条合计)** | **89ms** | **75ms** |
| 结果 | — | ✅ **零差异** |
本地(30K)同向:一次刷新 103ms → 71ms,零差异。
结论:**交互路径不劣化**。⚠️ 每条查询仍是先 legacy 后 setbased,顺序偏袒后者,
所以只下「不劣化」的结论,不宣称快 15%。
## 14. 生产与测试机的配置差异(推算收益时必须带上)
| | 生产(friday) | 测试机 |
|---|---|---|
| `PAC_RECALL_SUBSCENARIO_CONCURRENCY` | **未设 → 默认 1(串行)** | **4** |
| `PAC_GAP_VARIANT` | 未设 → legacy | setbased(2026-08-30 起) |
| `PAC_PLAN_BATCH_CONCURRENCY` | 未设 → 默认 8 | 8 |
⚠️ **−31% 是在并发 4 上量的,生产是串行。** 倍数能否原样搬过去**没有生产实测**
两个旁证指向同一区间:按「逐查询耗时求和」(更接近串行形态)算,C→D 是 1736.5s → 1174.2s = **−32%**;
整轮墙钟 −22%。所以 **−20~32% 是一个有依据的区间,不是一个点估计**
上生产前应先跑**只读对拍**(`verify-gap-equivalence --host=jvs-dw`,不写库、不改配置),
确认生产数据分布上同样零差异 —— 生产与测试机的数据快照不是同一份。
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