diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 4ec96b7..9cd68f5 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -1915,10 +1915,27 @@ namespace jdi std::size_t falling = 0; /**< 已扫部分的下降沿总数。 */ bool complete = false; /**< 整条信号是否已扫完。 */ bool justReset = false; /**< 本 tick 刚因阈值/参数变化重新开始(不展示部分值)。 */ + /** + * @brief 中心轴扫描**就地计算**的一小段均值滤波曲线(源下标空间)。 + * + * ⚠️ 不要改为依赖全局曲线缓存(`AcquireCoveringCurve`/`CurveSpanFor` 的整条曲线): + * 千万级信号的整条曲线是 ~103MB/条,多条信号超出曲线缓存预算后条目会被淘汰 → + * 该信号被显示成“统计中…”并从头重扫(**偶发**,取决于当时缓存里有多少条目), + * 而且几条信号的统计会通过缓存互相牵连(用户报告:“改一个信号的参数, + * 其它几个信号也重新统计”)。 + * 本地区间只需「当前扫描块 ± 滤波半径」(设 `points * iterations` 即可精确), + * 内存只有几十 KB,且完全与其它信号/其它曲线解耦。 + */ + std::vector localCurve; /**< 本地均滤波取值(长度 = localEnd - localBegin)。 */ + std::size_t localBegin = 0; /**< 本地区间起点(源采样下标,含)。 */ + std::size_t localEnd = 0; /**< 本地区间终点(源采样下标,不含)。 */ + bool localValid = false; /**< 本地区间是否可用。 */ }; mutable std::vector m_edgeScans; /**< 边沿统计累积状态(纯缓存)。 */ mutable std::size_t m_edgeScanBudget = 0; /**< 本 tick 剩余可扫描的采样对数量(限制单帧总开销)。 */ mutable std::uint64_t m_edgeScanTick = 0; /**< 最近一次重置扫描预算的 tick。 */ + /** 诊断计数:中心轴本地曲线计算失败次数(内存不足等)。0 = 一切正常。 */ + mutable std::size_t m_edgeScanLocalCurveFails = 0; /** * @brief 边沿统计的统一实现(普通边沿计数与中心轴边沿计数共用)。 @@ -1932,6 +1949,8 @@ namespace jdi * @param [IN] threshold 阈值(`centre = false` 时使用)。 * @param [IN] points 均值点数 X(`centre = true` 时使用)。 * @param [IN] iterations 迭代次数 N(`centre = true` 时使用)。 + * @note 中心轴所需的小段均值滤波曲线由本函数**就地计算**, + * 不依赖 `CurveSpanFor` 的整条曲线缓存(见 `EdgeScanState::localCurve`)。 * @param [IN] hasRange 是否只统计某个时间区间。 * @param [IN] rangeMin 区间左端。 @param [IN] rangeMax 区间右端。 * @param [OUT] rising 上升沿数量。 @param [OUT] falling 下降沿数量。 diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index e70b227..df834ad 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -148,7 +148,16 @@ namespace jdi const wchar_t* const kWeakPeakIterationsLabel = L"迭代次数 N"; /**< 逐条目面板里「迭代次数」滑块行的标签。 */ const wchar_t* const kWeakPeakTitle = L"弱峰调整"; /**< 弱峰调整面板标题。 */ const wchar_t* const kBuildSignalLabel = L"构建为新信号"; /**< 逐条目面板底部按钮文案(均值滤波 / 弱峰调整共用)。 */ - const std::size_t kEdgeScanStateLimit = 16; /**< 边沿统计累积状态上限(超出时淘汰最早的一条)。 */ + /** + * @brief 边沿统计累积状态上限。 + * + * ⚠️ 必须足够大:`普通 + 中心轴` × 一个网格里的信号数(都勾选时)会被全部使用。 + * 上限太小会**淘汰其它信号的已扫完状态** → 它们从头重扫(表现为“改一个参数、 + * 其它几个信号也重新统计”)。一个状态仅占「块数组 ≈ (采样数/8192)×16B」+ 几十 KB + * 本地曲线,64 个状态对内存无压力。 + * 真的超限时**优先淘汰「已扫完」的最旧状态**(它重新扫完很快),绝不淘汰正在推进的。 + */ + const std::size_t kEdgeScanStateLimit = 64; /** * @brief 「边沿计数」累积统计的分块大小(采样点数)。 * @@ -3048,7 +3057,25 @@ namespace jdi { if (m_edgeScans.size() >= kEdgeScanStateLimit) { - m_edgeScans.erase(m_edgeScans.begin()); + // ⚠️ 超限时**优先淘汰「已扫完」的最旧状态**(它重算很快、而且通常已经不需要再变), + // 绝不淘汰正在推进的那个 —— 否则其它信号会被迫从头重扫 + //(那正是“改一个参数、其它几个信号也重新统计”这类现象)。 + std::size_t victim = 0; + bool foundVictim = false; + for (std::size_t index = 0; index < m_edgeScans.size(); ++index) + { + if (m_edgeScans[index].complete) + { + victim = index; + foundVictim = true; + break; + } + } + if (!foundVictim) + { + victim = 0; + } + m_edgeScans.erase(m_edgeScans.begin() + static_cast(victim)); } EdgeScanState created; created.file = &file; @@ -3083,35 +3110,84 @@ namespace jdi std::fill(state->blockFalling.begin(), state->blockFalling.end(), 0); } - // ---- 中心轴:需要整条信号对应的均值滤波曲线 ----------------------------- - // 取值必须与绘制逐位一致,因此要求**键完全相同**的条目(不能像绘制那样用旧参数的曲线顶替)。 - // 拿不到就交给后台算、本帧不推进;**绝不在这里同步算**(千万级采样会卡住界面数秒)。 - const std::vector* curve = nullptr; - std::size_t spanBegin = 0; - bool curveReady = true; + // ---- 中心轴:**就地**计算所需的那一小段均值滤波曲线 --------------------- + // ⚠️ 过去这里用 `AcquireCoveringCurve`/`ScheduleCurveJob` 拿**整条**曲线,带来两个问题: + // 1. 千万级信号的一条曲线 ≈103MB,多条信号叠加会超出曲线缓存预算(768MB)→ 条目被 + // 淘汰后该信号被判为“曲线未就绪”,界面显示“统计中…”并从头重扫 —— + // **偶发**(取决于当时缓存里积了多少条目,用户报告“改一个参数其它几个信号也重新统计”); + // 2. 几个信号的统计通过这块共享缓存互相牵连。 + // 现在只算「当前扫描块 ± 滤波半径」:`points * iterations` 就足以让块内取值与整条曲线 + // 完全一致(迭代 N 次、半径 X 的均值滤波最多向两侧传播 X*N 个采样),内存只有几十 KB, + // 且与其它信号、其它曲线彻底解耦。 + std::size_t localReach = 0; if (centre) { - CurveKey key; - key.signal = &signal; - key.kind = static_cast(DerivedCurveKind::MEAN_FILTER); - key.points = safePoints; - key.iterations = safeIterations; - key.amountPercent = 0; - const CurveEntry* hit = AcquireCoveringCurve(key, 0, count, spanBegin); - if (hit == nullptr) + localReach = static_cast(safePoints) * static_cast(safeIterations) + 2u; + localReach = std::min(localReach, kDerivedCurveGuardMax); + } + const std::vector& values = signal.values; + const bool filterByTime = hasRange && !monotonic; + + /** 把显示下标区间映射为源下标区间(水平镜像时头尾对调)。 */ + const auto toSourceRange = [&](std::size_t displayFirst, std::size_t displayLast, + std::size_t& srcFirst, std::size_t& srcLast) + { + if (mirrored) { - ScheduleCurveJob(key, 0, count, count); // 幂等:已在算 / 已排队时不会重复 - curveReady = false; + srcFirst = (count - 1) - displayLast; + srcLast = (count - 1) - displayFirst; } else { - curve = &hit->values; + srcFirst = displayFirst; + srcLast = displayLast; } - } + }; + /** 确保本地曲线覆盖显示下标区间 [displayFirst, displayLast](含端点)。 */ + const auto ensureLocalCurve = [&](std::size_t displayFirst, std::size_t displayLast) -> bool + { + if (!centre) + { + return true; + } + std::size_t srcFirst = 0; + std::size_t srcLast = 0; + toSourceRange(displayFirst, displayLast, srcFirst, srcLast); + if (state->localValid && state->localBegin <= srcFirst && state->localEnd > srcLast) + { + return true; + } + const std::size_t windowBegin = (srcFirst > localReach) ? (srcFirst - localReach) : 0; + std::size_t windowEnd = std::min(count, srcLast + 1u + localReach); + // 至少覆盖 8 个扫描块:迭代均值滤波每算一次窗口要 O(窗口 × 迭代次数), + // 只覆盖一个块会让参数较大时每块都重算一遍(摊薄到 8 个块上就便宜得多)。 + const std::size_t minSpan = kEdgeBlockSamples * 8u; + windowEnd = std::min(count, std::max(windowEnd, windowBegin + minSpan)); + if (windowEnd <= windowBegin) + { + return false; + } + std::vector local = + ComputeDerivedWindow(signal, windowBegin, windowEnd, + static_cast(DerivedCurveKind::MEAN_FILTER), safePoints, + safeIterations, 0, nullptr, false); + if (local.size() != (windowEnd - windowBegin)) + { + ++m_edgeScanLocalCurveFails; + return false; // 计算失败(例如内存不足):本帧不推进,下帧重试 + } + state->localCurve = std::move(local); + state->localBegin = windowBegin; + state->localEnd = windowEnd; + state->localValid = true; + // 本地窗口的计算成本也计入预算(按「采样数 × 迭代次数」折算),避免一帧算太多。 + const std::size_t cost = + (windowEnd - windowBegin) * static_cast(safeIterations); + m_edgeScanBudget -= std::min(m_edgeScanBudget, cost); + return true; + }; // 判定基准:**绘制后**的值相对阈值 / 中心曲线的偏差(区间之外 / 缺失值 → NaN)。 - const std::vector& values = signal.values; - const bool filterByTime = hasRange && !monotonic; const auto deltaAt = [&](std::size_t index) -> double { const double nan = std::numeric_limits::quiet_NaN(); @@ -3146,12 +3222,12 @@ namespace jdi { return drawn - threshold; } - if (curve == nullptr || sourceIndex < spanBegin || - (sourceIndex - spanBegin) >= curve->size()) + if (!state->localValid || sourceIndex < state->localBegin || + sourceIndex >= state->localEnd) { return nan; } - const float centerSample = (*curve)[sourceIndex - spanBegin]; + const float centerSample = state->localCurve[sourceIndex - state->localBegin]; if (!std::isfinite(centerSample)) { return nan; @@ -3174,12 +3250,7 @@ namespace jdi m_edgeScanTick = tick; m_edgeScanBudget = kEdgeScanPairsPerTick; } - if (!curveReady) - { - // 正在等后台的均值滤波曲线:把预算留给别的统计,避免白耗。 - m_edgeScanBudget = 0; - } - else if (m_edgeScanBudget > 0) + if (m_edgeScanBudget > 0) { state->justReset = false; // 快路径:时间单调、无水平镜像、无「调整」变换、非中心轴 ⇒ 直接比较采样值与阈值。 @@ -3199,6 +3270,11 @@ namespace jdi { break; } + // 中心轴:先确保本地区间曲线覆盖本块(含两端各一个采样点,供边界比较)。 + if (!ensureLocalCurve(from - 1, to)) + { + break; // 算不出来(内存不足等):本帧到此为止,下帧重试 + } std::size_t localRising = 0; std::size_t localFalling = 0; if (plainFast) @@ -3254,6 +3330,16 @@ namespace jdi } state->scannedPair = pair; state->complete = (pair >= count); + if (state->complete) + { + // 扫完后**释放**本地曲线(区间查询需要时会按需重算)—— + // 否则多信号网格下每个状态都要常驻几十 KB~几 MB。 + state->localCurve.clear(); + state->localCurve.shrink_to_fit(); + state->localBegin = 0; + state->localEnd = 0; + state->localValid = false; + } m_edgeScanBudget -= std::min(m_edgeScanBudget, scanned); } } @@ -3261,7 +3347,7 @@ namespace jdi // ---- 汇总请求区间 ------------------------------------------------------- // 内部整块用块和相加,只有两端边界块(≤ 2 块)需要就地精确扫描 // ⇒ 拖动**时间**光标时单帧代价与区间长度无关。 - if (curveReady && (state->complete || !suppressPartial)) + if (!suppressPartial) { const std::size_t limit = (state->scannedPair > 0) ? std::min(pairLast, state->scannedPair - 1) : 0; @@ -3269,7 +3355,8 @@ namespace jdi { const std::size_t firstBlock = (pairFirst - 1) / kEdgeBlockSamples; const std::size_t lastBlock = (limit - 1) / kEdgeBlockSamples; - for (std::size_t block = firstBlock; block <= lastBlock; ++block) + bool boundaryOk = true; + for (std::size_t block = firstBlock; block <= lastBlock && boundaryOk; ++block) { const std::size_t blockFirst = block * kEdgeBlockSamples + 1; const std::size_t blockLast = std::min(count - 1, (block + 1) * kEdgeBlockSamples); @@ -3284,6 +3371,12 @@ namespace jdi falling += state->blockFalling[block]; continue; } + // 边界块 / 尚未扫完的块:就地精确扫描这一小段(≤ 1 个块)。 + if (!ensureLocalCurve(low - 1, high)) + { + boundaryOk = false; // 中心轴曲线算不出来:本帧不展示(下帧重试) + break; + } double previous = deltaAt(low - 1); for (std::size_t index = low; index <= high; ++index) { @@ -3302,8 +3395,13 @@ namespace jdi previous = current; } } + // 边界块扫描失败(中心轴曲线算不出来)时不展示结果:保持 complete=false。 + complete = boundaryOk && (pairLast < state->scannedPair); + } + else + { + complete = (pairLast < state->scannedPair); } - complete = (pairLast < state->scannedPair); } if (!complete) { diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index ea9524b..ec34c2d 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -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 离屏验证方法学(本机偏好,强烈建议沿用)