1. 31 Aug, 2026 3 commits
    • merge: main → test(反向合) · a267668f
      luoqi committed
    • docs(sync): 增量回看窗分析 —— 不能缩,根因是游标跟的量与到达顺序无关 · e503e8ae
      生产只读实测(7 天 / 281,760 条增量记录):
        滞后 p50 2.39h / p99.9 15.39h / max 47.43h;>24h 31 条、>48h 0 条
        按表拆:refund max 47.4h(>24h 有 27 条)、临床表 max 15.5h(>24h 全 0)
      
      根因:游标跟 updated_date(源 HIS 改动时刻,≈事件时间),而数据到达取决于 DW 自己的
      ETL —— 两者无关。所以必然有"updated_date 比游标旧、此刻才到达"的行,游标追不上。
      退费那几条是铁证:源系统 13:05 建、13:13 改完,PAC 47 小时后才看到。
      
       不能缩:缩 24h 丢 31 条、36h 丢 19 条,且游标推过去是**永久**漏拉。
         且 max=47.43h 恰好顶在 48h 边界、>48h 为 0 —— 这是**截断的指纹**,
         真实尾巴可能更长。本测量方向单边:能证"够用"不能证"不够"。
      
      根治不在我们这边:DW 十张表一个入仓时间字段都没有(rq 是业务日期已排除)。
      若 DW 加一列 ETL 写入的时间戳,游标即可在到达顺序上单调 → 漏拉结构上不可能,
      fetched 从 24.8 万降到约 1.6 万,摄入 31m → 预计 5~10m。
      
       另记一个取样陷阱:第一版按 received_at 时间窗取样,混进 5 次 full: 全量补摄的
      历史数据,得出"滞后 5 年"的荒谬结果。既有增量又有补摄的系统,取样必须按事件来源限定。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
  2. 30 Aug, 2026 37 commits
    • merge: main → test(反向合) · 734f693f
      luoqi committed
    • merge: 预取消 O(患者数) 项 + 三查询并行 → main · 48ca36cb
      测试机验证(87,885 命中患者 / 44 chunk):
        预取 288,003ms / 247,250ms(旧基线) → 183,810ms(新)  −26~31%
        命中患者 87,885,在 87,849~87,853 漂移带内 —— 口径没变
      本地(19,476 患者 / 10 chunk):预取 8,383 → 4,899ms;plan_reasons 行级 diff=0
      
      推算生产:预取 20m09s → 省 5~6 分钟,整轮 2h02m → 约 1h56m,回到 2 小时线内。
      luoqi committed
    • perf(plan): 预取消掉一个 O(患者数) 项 + 三条查询并行 · 66c54b46
      ① snooze 抑制集提到 chunk 循环外一次查完。
         它的代价跟「终态且冷静期未到期的计划数」走,跟患者数无关 ——
         2026-08-30 生产实测全库符合条件只有 **106 行**,而按 chunk 查要跑 274 次
         (54.8 万患者 / 2000),**273 次在查空**。
         🔴 这不是省常数,是消掉一个 O(患者数) 项:到 200 万患者原写法是 1000 次往返,
            新写法仍是 1 次。生产百万级且在涨,这类项要按规模判断而不是按当下耗时。
         索引 (status,…) 前导 status,completed/abandoned 是稀有态 → Bitmap 扫 42 buffers/0.6ms。
      
      ② 余下三条(latest plan / persona / 末次到诊诊所)彼此独立,改 Promise.all 并行。
         每 chunk 墙钟从「三条之和」降到「最慢那条」。并发度恒为 3,不随患者数涨,
         不会挤爆 Prisma 池(默认 核数×2+1)。
      
      口径零变化(三条都是只读、无共享状态;snooze map 只会被本批患者查到)。
      本地实测:预取 8,383ms → 4,899ms;**plan_reasons 逐行 diff = 0**(39,225 行)。
      ️ 预取是纯性能路径,单测覆盖不到,所以靠真实数据端到端行级对拍来验。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • merge: main → test(反向合) · 28b30afc
      luoqi committed
    • docs(gap): 生产实测集合式全量更慢,已回退 —— 子集掩盖了性能的规模拐点 · dc760962
      生产 10 万患者子集对拍 11/11 零差异 → 开启 → 20:15 轮第 1 批判据失败 → 回退。
        missing_tooth 8m13s → 30m42s(慢 3.7×)
        endo_no_rct   8m12s → 18m21s(慢 2.2×)
        第 1 批墙钟   17m03s → 30m42s(+80%)
      命中数仍在漂移带内 —— **正确性没问题,是性能不达标**。
      临时文件零增长、swap 为 0 —— 也不是内存问题(这次没再编错误归因)。
      
      归因:集合式代价跟**患者域**走(gap_scope 要预聚合全域),legacy 跟**候选行数**走。
      规模一变结论就翻转:30K/585K/10万子集 都快 1.5×,113 万全量慢 3.7×。
      
       教训:方案里写过「子集零差异 ≠ 全量零差异」,但只想着正确性覆盖。
         真正被掩盖的是**性能**,而且方向都反了。子集测正确性有效,测性能无效 ——
         除非能先证明代价与规模线性,而集合式恰恰不是。
      
      生产维持 legacy + 并发4(场景段 50m25s / 整轮 1h17m)。代码留 main,默认关闭。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • merge: main → test(反向合) · f26f0dde
      luoqi committed
    • test(gap): 对拍差分支持 --subset=N —— 让生产真实数据上的零差异证据变得便宜 · 9a042f45
      生产 113 万患者全量差分要几小时,还会跟 2 小时批次抢 RDS;收窄到几十万后
      几分钟就能在**生产真实数据**上拿到证据。抽样偏向"有 active 诊断/建议信号"的患者,
      否则大多数抽中的人两版都返回空,验了个寂寞。
      
      ️ 收窄降低的是**覆盖**不是可信度:差异一旦出现仍是真差异;但「子集零差异」
         ≠「全量零差异」,所以报告行里带上患者域,逼自己写清跑的是多少人。
      
      本地 3000 位子集实测:11/11 零差异。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • perf(gap): resolvedTeeth 集合式形态 + 对拍工具(默认 legacy,生产不启用) · 1bde4764
      把 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>
      luoqi committed
    • docs(gap): 精简误召个案表 —— 只留结论和证据,删推理过程 · 61ba24de
      删掉四类冗余:
      · 「该计划共 N 条理由,另 X 条判得对」的旁注(#8/#10/#14/#15/#17)
      · 算法机制的重复解释(#9「算法逻辑没错,是事实没进结构化数据」等)
      · 「该修复上线晚于本单建单,故当时未生效」这类时序补注(#19/#22)
      · 举证细节的收尾展开(#8 总院回访那段收成一句)
      
      个案表是给人看结论的,不是复述推理的地方。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • perf(recall): 启用子场景并发 —— 生产全量 ×2.31,2 小时窗口进得去 · 0c391bd1
      生产全量实测(113 万患者 / 54.8 万命中,同一台 RDS,相邻两轮):
        串行(=1): 场景段 7,200,096ms(2h00m)  整轮 8,654,105ms(2h24m)
        并发(=4): 场景段 3,115,928ms(51m56s) 整轮 4,806,869ms(1h20m)
        → 场景段 ×2.31,整轮 ×1.80,省 64 分钟
      先在生产一个 39,148 患者的诊所交替标定(×2.18),再由全量复现(×2.31)。
      
      🔴 推翻原有的「 别指望靠这个旋钮提速」结论 —— 它让生产一年多没开这根杠杆。
      原依据是「并发3=24.1分 vs 串行23.3分」,两处都错:
      ① 3% 差距落在 ±25% 的环境噪音里(用「两边跑同一条 SQL」的对照组量过),那次比较
         什么也没证明;
      ② 机制归因也错:真瓶颈是**延迟**(逐次索引探查等 page,CPU 与磁盘都闲着)不是吞吐,
         所以并发 2 就超线性(生产 ×1.50 / 测试机 ×2.88)。若真是吞吐受限,墙钟应约等于
         各查询耗时之和;实测墙钟只有求和的 43%。
      
      同时把「集合式重写是唯一出路」那段改成现状:并发已解决窗口问题,集合式转为可选。
      .env.example 补文档,含「判据只能看阶段墙钟、不能看单条 sql=」的读数陷阱。
      
      本提交只改注释与 .env.example,不改任何运行逻辑(生产开关已于 2026-08-30 09:16 生效)。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • merge: CLI 启动误杀正在跑的同步锁 → main · 6f0915f7
      生产事故修复(2026-08-30):在 pac-service 容器里 docker exec 跑任何 CLI,
      都会 createApplicationContext(AppModule) → 跑一遍 SyncIncrementalScheduler.onModuleInit
      → reapStaleRunningLocks 把 service 里**正在跑**的那轮同步当僵尸锁清掉。
      实测 08:17:11 起 recompute-plans → 08:17:13 正常跑着的 08:15 那轮被标 failed
      (前 19 轮全 success)。数据未丢(cursor_after=null,下轮同水位 catchup),但白丢一轮。
      
      两道防线:年龄阈值 REAP_MIN_AGE_MS=3h + 16 个 CLI 建上下文前设 PAC_SCHEDULER_DISABLED=1。
      不带任何开关,上线即生效。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(sync): CLI 启动会清掉正在跑的同步锁 —— 生产实测夭折一轮增量 · 824554a9
      🔴 事故(2026-08-30 生产):在 pac-service 容器里 docker exec 跑 recompute-plans,
         08:17:11 起进程 → 08:17:13 正在跑的 08:15 那轮同步被标 failed。前 19 轮全 success,
         只死了撞上的这一轮。数据未丢(cursor_after=null,下轮同水位 catchup),但白丢一轮。
      
      根因:每个 CLI 都 createApplicationContext(AppModule) → 跑一遍
      SyncIncrementalScheduler.onModuleInit → reapStaleRunningLocks。
      原判据是「startedAt < 本进程启动 = 僵尸锁」,注释里写着"两者的进程都不可能比本进程
      启动得更早还活着"—— 但**长驻的 pac-service 恰恰就是那个更早启动还活着的进程**。
      理由写反了方向,而且只在"回收逻辑跑在长驻服务里"时才成立。
      
      两道防线:
      ① scheduler 加年龄阈值 REAP_MIN_AGE_MS=3h —— 真僵尸锁必然躺很久,正在跑的不会。
         用年龄区分,不靠猜进程身份。(生产单轮摄入实测 28~52 分钟)
      ② 新增 src/cli/bootstrap-flags.ts,16 个 CLI 在建上下文**之前**设
         PAC_SCHEDULER_DISABLED=1(该总闸本就会跳过回收,只是没人用)。
         豁免 sync-incremental.cli(它就是要触发同步),由 ① 兜底。
      
      回归测试 tests/cli-scheduler-guard.spec.ts:遍历所有会建上下文的 CLI,
      断言调用存在**且位置早于** createApplicationContext;并锁住年龄阈值 ≥2h。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(sync): CLI 启动会清掉正在跑的同步锁 —— 生产实测夭折一轮增量 · 8d6664c8
      🔴 事故(2026-08-30 生产):在 pac-service 容器里 docker exec 跑 recompute-plans,
         08:17:11 起进程 → 08:17:13 正在跑的 08:15 那轮同步被标 failed。前 19 轮全 success,
         只死了撞上的这一轮。数据未丢(cursor_after=null,下轮同水位 catchup),但白丢一轮。
      
      根因:每个 CLI 都 createApplicationContext(AppModule) → 跑一遍
      SyncIncrementalScheduler.onModuleInit → reapStaleRunningLocks。
      原判据是「startedAt < 本进程启动 = 僵尸锁」,注释里写着"两者的进程都不可能比本进程
      启动得更早还活着"—— 但**长驻的 pac-service 恰恰就是那个更早启动还活着的进程**。
      理由写反了方向,而且只在"回收逻辑跑在长驻服务里"时才成立。
      
      两道防线:
      ① scheduler 加年龄阈值 REAP_MIN_AGE_MS=3h —— 真僵尸锁必然躺很久,正在跑的不会。
         用年龄区分,不靠猜进程身份。(生产单轮摄入实测 28~52 分钟)
      ② 新增 src/cli/bootstrap-flags.ts,16 个 CLI 在建上下文**之前**设
         PAC_SCHEDULER_DISABLED=1(该总闸本就会跳过回收,只是没人用)。
         豁免 sync-incremental.cli(它就是要触发同步),由 ① 兜底。
      
      回归测试 tests/cli-scheduler-guard.spec.ts:遍历所有会建上下文的 CLI,
      断言调用存在**且位置早于** createApplicationContext;并锁住年龄阈值 ≥2h。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • test(gap): 对拍工具补批量并发标定(--conc=N --subset=M) · 0c76093a
      只读:每条子场景 SQL 外套 count(*),不写库。判据用**墙钟**,不看各查询耗时之和
      (并发下单条会被争抢拉长,和不可比 —— 这正是 2026-08-29 那次误判的成因之一)。
      
      背景:代码注释里「 别指望靠并发提速」的依据是「并发3=24.1分 vs 串行23.3分」,
      3% 的差距落在 ±25% 的环境噪音里,什么也没证明,却成了生产不开并发的理由。
      今晚测试机 C 轮(legacy,并发4)各子场景耗时之和 1,736s 而场景段墙钟仅 794s
      —— 墙钟远小于求和 = 并发确实在重叠,与「瓶颈是共享I/O、并行无用」的旧归因矛盾。
      
      本地(30K,8000 患者子集,两对交替):
        conc=4  4.8s / 5.2s
        conc=1 10.5s / 10.0s     → 稳定 ×2.0,可复现
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • test(gap): 对拍工具补交互路径(--single=N)—— 详情页「刷新」直接面向用户 · 8a58bb72
      plan.controller recomputeForPatient 是 HTTP 端点,走的就是这套召回 SQL 的
      单患者路径(scope.patientId)。批量慢是运维问题,这条慢是用户当场感受得到的问题,
      之前只测了批量和画像,漏了它。
      
      按「一位患者跑完 11 个子场景」= 一次刷新的真实代价来计时,同时逐 signal×tooth 比结果。
      本地(30K):零差异;单查询 p95 26→17ms;一次刷新 103→71ms。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • docs(gap): 纠正 B 轮误判 + 落测试机最终结论 · 41472220
       我上一版据 B 轮单点写下「集合式内存打穿、溢盘、拖垮全口查询」——全错。
         D 轮复现不出来(545s,四轮最快);实测每轮临时文件 C=4 个 / D=4 个、增量 62MB,
         根本没溢盘;187GB 是统计累计值不是今晚产生的。B 轮整段压在 02:00 的
         stale-scan cron 上,是外部负载。那套「内存打穿」的因果链是照着一个数字编的。
      
      真实结论(C vs D,相邻两轮、两个同SQL全口对照组给出 ±25% 噪音基准):
        场景段 794s → 545s(−31%)   整轮 1,181s → 923s(−22%)
        超噪音的收益集中在 impacted ×2.25 / caries ×2.09 / hard ×1.59 / endo ×1.43
      正确性三道门全过;端到端差异全是新增(时间漂移),零删除。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • test(gap): 对拍工具补画像消费方(--persona=N) · d091ed21
      画像是**逐患者**调用(全量 54.7 万次),SQL 形态与召回不同(scope 恒 1 行),
      单次开销会被放大 54.7 万倍 —— 必须单独验,而且要比耗时分布不只比结果。
      只读不写库:直接调 selectForPatient 两次比 gap 列表。
      
      selectForPatient 加可选 variant 入参(只给对拍用;生产路径不传,走环境开关)。
      
      本地实测(2000 位有 active 信号的患者):
        零差异(其中 1441 位有 gap)
        耗时/患者 legacy p50=7ms p95=18ms 合计=16.1s
                  setbased p50=6ms p95=16ms 合计=13.9s  ← 不劣化
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • perf(gap): resolvedTeeth 集合式形态 + 对拍工具(默认仍走 legacy) · 8930c3b6
      形态:cand → scope → resolved 预聚合 → 牙位反连接。核心恒等式
        ∃x∈G: t(x) ⋛ a  ⟺  max{t(x)} ⋛ a,分组 G=(患者,牙位)。
      13 个分支对 sig 的相关性只有三类:时间门(10)/ 病历号等值(1)/ 无相关(2),
      另有「建议优先」一条多个 sig.type 标量谓词(ndx 标志)。
      
      ️ 集合式是**独立重写一份**,刻意不与 legacy 共用片段 —— 共用则重构写错的地方
         两边一起错、对拍互相抵消。等价性靠 verify-gap-equivalence 逐行差分来证。
      
      全口码(K05/K07)不进集合式:它们的 lateral PG 本来就会摘掉(零收益),
      实测硬套进 gap_cand 还慢 2~3 倍(1309→4439ms / 1388→3065ms)。
      
      新增:
        · src/cli/verify-gap-equivalence.cli.ts —— 两版同一 REPEATABLE READ 快照,
          双向 EXCEPT ALL 差分到 (患者,信号,牙位);--self 自对拍先证工具可信
        · tests/gap-setbased-parity.spec.ts —— 结构对拍,守「分支集合不许走散」
          (加分支只改一边 = 静默错召,tsc 和现有 spec 都发现不了)
        · scenario.buildScenarioSql() 抽成独立方法,让对拍拿到线上跑的那条 SQL 本身
        · PAC_GAP_VARIANT=setbased 切换;默认 legacy
      
      本地实测(30K 库):11 个子场景全部零差异,行数逐个相同;
      牙位级 ×1.05~1.73(库小全热,不作为收益判据,以测试机 585K 为准)。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed