修复「偶发的改一个信号的中心轴参数、其它信号也重新统计」

一、现象(用户报告)
加载 VERY_BIG_DATA.CSV、所有信号显示在同一网格、同时启用普通边沿计数与中心轴边沿计数
并勾选全部信号;改变某一个信号的中心轴参数时,**偶发**地让其它几个信号的统计也重新计算
(不是必现,多测几次才出现)。

二、根因(用间歇性探针实测定位,不是推测)
EdgeScanFor 的中心轴分支原先靠 AcquireCoveringCurve + ScheduleCurveJob 拿**整条**均值滤波曲线:
- 千万级信号(2580 万采样)一条整段曲线 ≈ 103MB;每多试一个参数值就多一条(参数变化产生新键),
  绘制侧按可见窗口还会再发布一份;
- 这些条目共享同一个曲线缓存,预算为 kCurveSpanCacheMaxBytes = 768MB;
- 一旦超限,PruneCurveEntriesLocked() 从**队首**淘汰 —— 最早发布的往往正是**其它信号**的整段曲线;
- 于是那些信号被判为「曲线未就绪」→ 界面显示「统计中…」并在后台重新排队计算 →
  表现出来就是「其它几个信号重新统计」,而且**偶发**(取决于当时缓存里积了多少条目)。

⇒ 统计数值本身是正确的,错的是「几条信号的统计通过一块共享缓存互相牵连」,
而这块缓存的压力取决于用户试过多少个参数值。这才是要根除的问题。

三、修法
1. EdgeScanState 新增 localCurve / localBegin / localEnd / localValid:
   中心轴扫描时**就地计算**自己需要的一小段均值滤波曲线(ensureLocalCurve):
   - 半径 = points * iterations(迭代 N 次 X 点均值滤波最多向两侧传播 X*N 个采样),
     再夹到 kDerivedCurveGuardMax;
   - 窗口至少覆盖 8 个扫描块(kEdgeBlockSamples * 8),把「窗口 × 迭代次数」的重复计算摊薄;
   - 窗口只有几十 KB~几百 KB,与曲线缓存、与其它信号**彻底解耦**;
   - 计算成本按「采样数 × 迭代次数」计入扫描预算(不会一帧算太多);
   - **扫完后立即释放** localCurve(区间查询需要时按需重算)→ 多信号网格下常驻内存很小。
2. 删除原「中心轴曲线未就绪就把整 tick 扫描预算清零」的门控:
   它会把**同一帧里排在后面的其它信号的统计一起饿死**(测试里表现为全部卡在「统计中」)。
   要「让路」只应跳过自己。
3. 加固状态上限:kEdgeScanStateLimit 16 → 64,且超限时**优先淘汰「已扫完」的最旧状态**,
   绝不淘汰正在推进的。上限太小会淘汰其它信号的已扫完状态 → 它们从头重扫(同一类症状);
   实测 10 个信号 × (普通 + 中心轴)= 20 个状态,原来的 16 就会踩到。
4. 附带收益:统计不再等后台算整条曲线,超大文件下第一个统计值出现得更快。

四、验证(全部离屏,harness 与探针均为 BELOW_NORMAL 优先级,不占用鼠标/键盘/屏幕)
1. uitest 新增 [AU](间歇性复现探针):5 个信号合并在同一网格、普通 + 中心轴全部启用,
   用真实编辑路径**连续改 25 次**某个信号的中心轴参数,每轮 14 帧逐帧检查**未被改动的**统计:
   CASE A = 状态被删除;CASE B = 状态还在但其曲线消失(显示 0 / "统计中");
   CASE C = 状态被重置或截断。
   - 修复前:baseline **0/10 完成**(全部卡在「统计中」,此时 curve entries 0);
   - 修复后:baseline **10/10 完成**,25 次编辑 **CASE A/B/C 全 0**,且 curve entries 0
     (证明统计已完全不需要后台曲线缓存)。
2. 新增「本地窗口 vs 整条曲线」正确性断言:points ∈ {5,17,64,300} × iterations ∈ {1,2,4}
   共 12 组,中心轴结果与「整条信号均值滤波后逐点判定」的参考实现**逐位一致**
   (证明 X*N 半径的保护带足够)。
3. 合计 789/789 通过(连跑 3 次:2 次全绿,1 次命中既有的菜单点击类 harness 抖动——
   [AP] reset 图标与面板菜单,重跑即好,与本轮改动无关);make release 零警告。

规则同步:Documents/agent_mem.md 新增 §9.25,§11.2 新增第 40–42 条硬约束
(派生曲线不得依赖共享整条曲线缓存 / 预算不得因等待而清零 / 状态上限必须大于 种类×信号数),
变更日志补一行。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-10-14 17:19:09 +08:00
1 parent 48bc534bc1
commit 65f7625dce
3 files changed
+222 -34

No files matched your search

+71
View File
@@ -1218,6 +1218,63 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸(
---
### 9.25 偶发的「改一个参数、其它信号也重新统计」根因:中心轴统计依赖整条曲线缓存
> 用户报告:与 §9.24 同样的操作**偶发**地让其它几个信号重新统计(不是必现,多测几次才出现)。
#### 一、根因(实测定位,不是猜的)
`EdgeScanFor` 的中心轴分支原先靠 **`AcquireCoveringCurve` + `ScheduleCurveJob`** 拿**整条**均值滤波曲线:
- 千万级信号(`VERY_BIG_DATA` 2580 万采样)一条整段曲线 = **≈103 MB**;
每多试一个参数值就多一条(扫描与绘制用同一个键,但**参数变化会产生新键**);
再加上「绘制侧按可见窗口取的那一份」,
极易把曲线缓存推到预算上限(`kCurveSpanCacheMaxBytes = 768MB`);
- 一旦超限,`PruneCurveEntriesLocked()` **从队首淘汰**(最早发布的往往就是**其它信号**的整段曲线)→
那些信号被判为「曲线未就绪」→ 界面显示「统计中…」并在后台重新排队计算 →
**看起来就是"其它几个信号重新统计"**,而且**偶发**(取决于当时缓存里积了多少条目)。
⇒ 统计结果**正确性**没有错,错的是**耦合**:几条信号的统计通过一块共享缓存互相牵连,
而且这块缓存的大小取决于用户试过多少个参数值。**这是本轮真正要根除的问题。**
#### 二、修法:中心轴统计**就地计算**自己需要的一小段曲线
- `EdgeScanState` 新增 `localCurve/localBegin/localEnd/localValid`:扫描时只算
**「当前扫描块 ± 滤波半径」**的均值滤波取值(`ensureLocalCurve`)。
* 半径 = `points * iterations`(迭代 N 次 X 点均值滤波最多向两侧传播 X·N 个采样),
再夹到 `kDerivedCurveGuardMax`;
* 窗口至少覆盖 **8 个扫描块**(`kEdgeBlockSamples * 8`),把「窗口 × 迭代次数」的重复计算摊薄;
* 窗口规模只有几十 KB~几百 KB,**与曲线缓存、与其它信号彻底解耦**;
* 窗口计算成本按 `采样数 × 迭代次数` **计入扫描预算**(不会一帧算太多);
* **扫完后立刻释放** `localCurve`(区间查询需要时按需重算)→ 多信号网格下的常驻内存很小。
- 删除了原来「曲线未就绪就把整 tick 预算清零」的门控(那会让**同一帧里排在后面的其它统计饿死**)。
- 附带收益:**统计不再等后台算整条曲线**,超大文件下第一个统计值出现得更快。
- ⚠️ **验证**:`points ∈ {5,17,64,300} × iterations ∈ {1,2,4}` 共 12 组,
中心轴结果与「整条信号均值滤波后逐点判定」的参考实现**逐位一致**(证明
`X·N` 半径的保护带足够)。
#### 三、同时加固:状态上限与淘汰策略(同类"偶发重算"的另一个来源)
- `kEdgeScanStateLimit` **16 → 64**:`普通 + 中心轴` × 网格内信号数(全部勾选)都会被用到;
上限太小会**淘汰其它信号的已扫完状态** → 它们从头重扫(同样的症状)。
实测 10 个信号(`Problem_Data`)会出现 20 个状态,**原来的 16 就会踩到**。
- 真超限时**优先淘汰「已扫完」的最旧状态**(重算很快),**绝不淘汰正在推进的**。
#### 四、验证(离屏,harness 与探针均为 `BELOW_NORMAL` 优先级)
- `uitest` 新增 `[AU]`(**间歇性复现探针**):5 个信号合并在同一网格、普通 + 中心轴全部启用,
用真实编辑路径**连续改 25 次**某个信号的中心轴参数,每轮 14 帧逐帧检查**未被改动的**统计:
* CASE A = 状态被删除;CASE B = 状态还在但其曲线消失(显示 0/"统计中");
* CASE C = 状态被重置/截断。
修复前:**baseline 0/10 完成**(全部卡在"统计中",因为 `curve entries 0` 时被
「未就绪就清零预算」饿死);修复后:**baseline 10/10 完成,25 次编辑 CASE A/B/C 全 0**,
且 `curve entries 0`(统计已完全不需要后台曲线)。
- 新增「本地窗口 vs 整条曲线」12 组参数的正确性断言(见上)。
- 合计 **789/789 通过**(连跑 3 次:2 次全绿,1 次命中既有的菜单点击类 harness 抖动 ——
`[AP]` reset 图标、面板菜单,重跑即好,与本轮改动无关);`make release` 零警告。
---
## 10. 变更日志(本文件维护记录)
| 日期 | 设备/模型 | 摘要 |
@@ -1253,6 +1310,7 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸(
| 2026-10-14 | DeepSeek-V4.1-Flash | **修复远端「调整」功能引入的性能回归 + 根治首帧卡顿 + 图例交互/托底重做(详见 §9.19)**。① **8 倍回归**:远端把 `DataSet::Style(ref)` 写进了 `AutoRangeLaneToView` 的逐采样循环(亿级解析调用)→ 工具栏「Y 轴自适应」在全视图下 **1200ms**(旧提交 147ms);修法:样式指针提循环外 + **整段自适应取已缓存 min/max(O(1),结果逐位一致)** + 无变换走紧凑循环 → **0.02ms**。② **首帧卡顿根治**:加载后宽基底图层从未分配,用户第一次平移/缩放时 `Paint` 在那一帧**同步重建全部 5 个网格图层**(首帧 **64.3 → 13.0ms**);修法:新增 `WarmLaneLayersStep()`,在**命中静态场景缓存**(视界未变、非拖拽)时用预算机制**每帧至多补 1 个网格**,把整批重建摊到静止帧。③ **派生信号把「调整」烘进数据**(不再继承样式指纹);⚠️ 当时写的“仅 `offsetTime` 因共用文件时间轴仍需继承”**已被 §9.21 推翻**(现在 `offsetTime` 也按 `time[i]-offsetTime` 重采样烘进采样值)。④ **GX 拖拽让位判据改为鼠标指针**(鼠标越过相邻网格中心水平线即让位),并给 GX 徽标加悬停高亮(叠加层 + `IDC_HAND`,与信号列表 GX 徽标一致)。⑤ **图例命中矩形在装饰缓存路径下必须重建**(原先命中缓存时提前 return → `m_legendHits` 被清空且不重建 → 中键平移后图例悬停/点选全失效)。⑥ **每个图例条目加圆角托底 + 装饰矩形改为逐块 + 托底不透明铺满外接矩形** → 消除「GX/图例周边旧波形像素残留」(逐像素验证装饰矩形内 **0 差异**);悬停高亮为不透明托底并**重绘色块与文字**(否则内容被盖住)。验证:离屏 Application harness **40/40 通过**,`zoomcheck` mode 20–26 量化,`make release` 零警告。 |
| 2026-10-14 | DeepSeek-V4.1-Flash(**起源设备**) | **派生曲线内存治理 + 边沿统计统一预算状态机(含中心轴)(详见 §9.23)**。① **弱峰调整内存**:`PeakExtremum` 字段重排 24→**16 字节**(密集极值信号省 ≈1/3 极值表);`ComputeDerivedWindow` 整段窗口**不再复制采样**(省 8 B/采样 ≈ 206 MB/条);**大任务并发上限**(内存充足 2 / 紧张 1,实测 4 并发 3M 采样 412→324 MiB);**内存严重不足时直接拒绝计算并只告警一次**(`kMessagePlotLowMemory`,不再 `bad_alloc` 崩溃);**低内存节流**(阶间 `Sleep(1)`、并发 1,"少量多次");**取消标志贯通到算法**(每阶 / 每 1M 采样检查)⇒ **过时任务立即退出并归还缓冲**;`AvailablePhysicalBytes()` 按 200ms 缓存。② **边沿统计**:统一为 `EdgeScanState` + `EdgeScanFor`(普通/中心轴共用),阈值/参数变化就地把状态重置、重置当 tick 不展示部分值(修掉"错误的且不更新的数字");**未完成时主动 `PostMessage(kMessagePlotDerivedReady)` 请求下一帧**(修掉"鼠标不动就一直统计中、移上去才好");中心轴所需曲线**只在后台算**(原先 `CurveSpanFor` 未传 `allowAsyncCompute` ⇒ 同步算整条均值滤波曲线 ⇒ 界面停顿数秒),`MeanFilterValueAt` 同步整条计算的路径也已移除;⚠️ `ScheduleCurveJob` 改为**幂等**(否则统计侧每帧请求会造成"请求→作废→重请求"死循环)。实测:6M 采样首帧 1.4ms、拖动中 10 次查询 1.07ms、**同帧 5 条信号合计 1.3ms**、保持不动收敛到与参考实现逐位一致。③ **离屏 harness/探针**:`main` 开头设置 `BELOW_NORMAL_PRIORITY_CLASS` + 线程低于正常(再长的测试也不抢用户前台 CPU),保持隐藏窗口 / 只注入合成消息 / 注入后立即 `ReleaseCapture`。验证:`uitest` **751/751 通过**(连跑 3 次稳定),`make release` 零警告。 |
| 2026-10-14 | DeepSeek-V4.1-Flash(**起源设备**) | **边沿统计改为“定向失效”(详见 §9.24)**:改变**一个**信号的中心轴参数时,过去用 `m_edgeScans.clear()` 把**两个浮动面板、所有信号**的累积状态全部丢掉 → 所有统计从头重算。新增 `InvalidateEdgeScansFor(const SignalData*, bool centreOnly)`,只擦除该信号(中心轴参数变化时只限它的中心轴状态);均值滤波参数变化与统计无关(不作废);勾选切换只改变“是否显示”(三个 `clear()` 全部删除,不再作废任何缓存)。验证:`uitest` 新增 `[AT]`——3 信号合并同一网格,用**真实编辑路径**改第 2 个信号的中心轴参数,断言被改信号中心轴已作废、另两个信号 `scannedPair/rising` 逐位不变、被改信号的**普通**边沿计数逐位不变、未改动信号仍 `complete` 瞬时返回;**774/774 通过**(连跑 3 次稳定),`make release` 零警告。 |
| 2026-10-14 | DeepSeek-V4.1-Flash(**起源设备**) | **修复“偶发的改一个参数、其它信号也重新统计”(详见 §9.25)**:根因是中心轴统计靠 `AcquireCoveringCurve`/`ScheduleCurveJob` 拿**整条**曲线 — 千万级信号一条 ≈103MB,多试几个参数值就把曲线缓存推到 768MB 上限 → `PruneCurveEntriesLocked` 从队首淘汰(往往是**其它信号**的曲线)→ 那些信号显示“统计中…”并在后台重新排队(**偶发**,取决于缓存里积了多少条目)。修法:`EdgeScanState` 新增 `localCurve/localBegin/localEnd/localValid`,扫描时**就地计算「当前扫描块 ± 滤波半径」**(半径 = `points*iterations`,窗口 ≥ 8 个块,成本计入扫描预算,扫完即释放)⇒ 与曲线缓存、与其它信号**彻底解耦**(实测统计时 `curve entries 0`);删除“曲线未就绪就把整 tick 预算清零”的门控(它会把同帧后面排队的其它统计**饿死**);`kEdgeScanStateLimit` **16→64** 且超限时优先淘汰「已扫完」的状态。验证:新增间歇性探针 `[AU]`(5 信号同网格、真实编辑路径连改 25 次、逐帧分类 CASE A/B/C)——修复前 baseline **0/10 完成**,修复后 **10/10 完成且 25 次编辑 CASE A/B/C 全 0**;新增「本地窗口 vs 整条曲线」12 组参数(points 5/17/64/300 × iterations 1/2/4)**逐位一致**断言;合计 **789/789 通过**,`make release` 零警告。 |
| 2026-10-14 | DeepSeek-V4.1-Flash(**起源设备**) | **新标签页分组默认折起 + GX 徽标悬停不改光标 + 标题栏 RAM 改用「私有工作集」+ 边沿计数改为「逐块累积 + 每 tick 预算推进」(详见 §9.22)**。① `NewTab(bool collapseGroups)`(首个标签页 false、用户新建 true)+ `TabConfig::SetGroupsCollapsedByDefault/SetAllCollapsed`;`SyncToDataSet` 用该默认值初始化**新出现的**分组。② `QueryCursor` 的 GX 徽标分支由 `IDC_HAND` 改为 `nullptr`(悬停不改形状,仅拖拽时 `IDC_SIZENS`)。③ **RAM 口径修正**:原取 `WorkingSetSize`(总工作集,含映射 DLL/共享页与已释放未回收的驻留页)→ 改为 `PROCESS_MEMORY_COUNTERS_EX2::PrivateWorkingSetSize`(与任务管理器「内存」列一致;失败退回 `PrivateUsage` → `WorkingSetSize`)。实测加载后 117.55/109.96 MiB、关闭分组后 15.44/7.62 MiB —— **无泄漏,内存完整归还**。④ **边沿计数性能**:改前 25.8M 采样 ≈**115ms/条/帧**(5 条同网格 = 575ms/帧,用户报告"开启即卡顿、之后拖不动");改为按 `kEdgeBlockSamples=8192` 分块累积、每 tick 预算 `kEdgeScanPairsPerTick=1.2M`、区间用块和相加(区间不进键)、拖动横向光标期间不推进并显示上一个完整值 + `complete=false`(`EdgeCountsText` 显示"统计中…/…");实测首帧 1.7ms、单次最坏 1.9ms、拖动中 10 次查询 0.11ms、最终结果与参考实现逐位一致。⑤ **内存排查报告**(用户要求,第 4 项只排查未改动):弱峰调整工作缓冲实测 **≈34 B/采样**(极值表 `PeakExtremum` 24 B/极值为主导),**4 条工作线程并发 +412 MiB / 3M 采样** ⇒ 25.8M 采样峰值 ≈**3.5GB**(+数据 1.03GB +曲线缓存 ≤768MB ⇒ 4~5GB,与用户 4.4GB 观测吻合);**全仓无 `try`/`catch`** ⇒ 低内存时 `bad_alloc` 逸出线程入口 ⇒ `std::terminate()` 崩溃(无优雅降级机制)。验证:离屏 `uitest` **738/738 通过**(新增 `[AR]` 边沿计数预算/精确性/拖动期 O(1)、`[AS]` 标题栏 RAM 口径),`make release` 零警告。 |
| 2026-10-14 | DeepSeek-V4.1-Flash(**起源设备**) | **边沿计数改用「绘制值」+ 派生信号烘入时间偏移 + 图例托底统一为 GX 徽标款 + 调整二级面板向上展开(详见 §9.21)**。① **边沿计数恒为 0**:`EdgeCountsFor`/`EdgeCenterCountsFor` 只比原始采样值,而阈值是屏幕上的值 → 「系数 50」的信号统计恒为 0;修法:判定值改为 `ApplyDrawTransform(style, values[SourceIndexFor(...)])`(中心轴两侧同套变换;水平镜像时曲线窗口也要先映射下标);缓存新增 **`styleKey`**(否则改完「调整」仍命中旧计数)。② **区间边界点被误计**:`EdgeIndexRange` 左端“多取一个采样点”的外扩**已删除**,收紧为“**两个端点都在区间内**才计入”(光标恰落在越过阈值的点 B 上时 B 不再被算作上升沿)。③ **`offsetTime` 也烘进派生采样值**(**推翻 §9.19 的“唯一例外”**):按 `time[i]-offsetTime` 取最近采样点重采样(单调时间轴用只前进的游标,O(n);越界置 NaN),新信号样式**全默认**;非单调时间轴退回到把 `offsetTime` 带在样式上。④ **图例托底 = GX 徽标同款**(同高 / 圆角 = 高度一半 / 同配色 / 水平内边距 = 胶囊半径),文本改用新增字体 **`uiTiny`(10 逻辑 px)**;`kLegendPlateRadius` 已删。⑤ **`PopupMenu::Open(..., expandUp=true)`**(锤点为**左下角**、向上展开,`m_buildExpandUp` 供 `RebuildLayout` 复用)+ 「系数/偏移」「镜像」面板锤在按钮左上角再上移 `kAdjustMenuGap`。验证:`.preflight/uitest` 新增 `[AR]`(五组断言全部与**独立参考实现**逐位比对,含 A/B:“按原始采样值算会得到 7/6 个边沿”);**并同步修正 harness 里两处旧规则断言**(`[AP]` 派生信号继承 → 默认样式 + 烘值;`[AN]` 图例带无托底 → 装饰矩形逐块且暴露列可见底层内容);合计 **722/722 通过**,`make release` 零警告。 |
@@ -1378,6 +1436,19 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸(
那会让所有信号、所有面板的统计一起重算(用户报告“改一个信号的参数,其它统计也重新计算了”)。
只有**数据层结构变化**(增删文件/信号、重排网格)才允许整体作废(走 `InvalidateDerivedCaches`)。
同理:**勾选/取消勾选**只改变“是否显示”,不应作废任何统计或曲线缓存。
40. ⚠️ **统计/判定所需的派生曲线不得依赖共享的“整条曲线”缓存**:
千万级信号一条整段曲线 ≈103MB,多条信号叠加会超出 768MB 预算 →
`PruneCurveEntriesLocked` 从队首淘汰(往往是**其它信号**的曲线)→
那些信号被判为“曲线未就绪”、显示“统计中…”并重新排队 → **偶发地看起来像“其它信号重新统计”**。
✓ 正确做法(`EdgeScanFor` 中心轴):**就地计算「当前块 ± 滤波半径」的一小段**
(半径 = `points * iterations`,窗口至少 8 个块,成本计入扫描预算,扫完即释放)。
41. ⚠️ **扫描预算绝不能因为在等某个条件就清零**:曾经“中心轴曲线未就绪 → `m_edgeScanBudget = 0`”
会把**同一帧里排在后面的其它信号的统计一起饿死**(测试里表现为全部卡在“统计中”)。
要“让路”就只跳过自己。
42. ⚠️ **累积状态上限必须大于「种类数 × 网格内信号数」**(`kEdgeScanStateLimit = 64`),
且超限时**优先淘汰「已扫完」的最旧状态**,绝不淘汰正在推进的。
上限太小会淘汰其它信号的已扫完状态 → 它们从头重扫(与第 39 条同一类症状)。
实测 10 个信号 × (普通+中心轴)= 20 个状态,原来的 16 就会踩到。
### 11.3 离屏验证方法学(本机偏好,强烈建议沿用)