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",
......
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,
......
/**
* 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/);
});
});
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