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

一、现象(用户报告)
加载 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

+19
View File
@@ -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<float> localCurve; /**< 本地均滤波取值(长度 = localEnd - localBegin)。 */
std::size_t localBegin = 0; /**< 本地区间起点(源采样下标,含)。 */
std::size_t localEnd = 0; /**< 本地区间终点(源采样下标,不含)。 */
bool localValid = false; /**< 本地区间是否可用。 */
};
mutable std::vector<EdgeScanState> 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 下降沿数量。
+132 -34
View File
@@ -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<std::ptrdiff_t>(victim));
}
EdgeScanState created;
created.file = &file;
@@ -3083,35 +3110,84 @@ namespace jdi
std::fill(state->blockFalling.begin(), state->blockFalling.end(), 0);
}
// ---- 中心轴:需要整条信号对应的均值滤波曲线 -----------------------------
// 取值必须与绘制逐位一致,因此要求**键完全相同**的条目(不能像绘制那样用旧参数的曲线顶替)。
// 拿不到就交给后台算、本帧不推进;**绝不在这里同步算**(千万级采样会卡住界面数秒)。
const std::vector<float>* 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<int>(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<std::size_t>(safePoints) * static_cast<std::size_t>(safeIterations) + 2u;
localReach = std::min(localReach, kDerivedCurveGuardMax);
}
const std::vector<double>& 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<float> local =
ComputeDerivedWindow(signal, windowBegin, windowEnd,
static_cast<int>(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<std::size_t>(safeIterations);
m_edgeScanBudget -= std::min(m_edgeScanBudget, cost);
return true;
};
// 判定基准:**绘制后**的值相对阈值 / 中心曲线的偏差(区间之外 / 缺失值 → NaN)。
const std::vector<double>& values = signal.values;
const bool filterByTime = hasRange && !monotonic;
const auto deltaAt = [&](std::size_t index) -> double
{
const double nan = std::numeric_limits<double>::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)
{
+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 离屏验证方法学(本机偏好,强烈建议沿用)