feat(门禁): 栈判据定稿——静态 margin 唯一拦提交、运行时水位只告警、采集不健康整轮作废
task-32 裁定落成可执行件(道F 交付)。不是二选一而是**分权**:静态最坏链 margin 仍是唯一拦提交项(由 `stack_report.py` 守,它本就把运行时峰值取进 max);新增 `stack_gate.py` 管台架侧——趋势只出 WARN, **绝对底线**(free_min < 25% cfg)是运行时唯一有 FAIL 权限的一档,**采集健康门不过 = VOID / exit 2**, 不红不绿、不进台账。把「daemon 死了 status 仍报正常、rx_lines=0」那类事故机械化:门不过与设备故障 exit 1 分开记,按码聚合的人不会把端口没开当成又崩了一次栈。 容差是推导的:tol = 上取整到 16 B 的 max(128 B, 2×实测摆动),实测 80 B ⇒ 统一 160 B,登记进 `scripts/stack_tolerance.tsv` 带来源列;**改数权限写死**——只有台架重测后才允许回填,禁止为让某轮变绿 而调大容差(被这把尺考核的人不得当场决定尺子的松紧),且这句话**紧贴数值写**。 `--selftest` 14 条能红,开发中抓到作者三个真错(身份戳正则把 __DATE__ 月份写成三位数字、一度把跨轮戳 不同当 VOID 会让判据永不可用、文档 §4.0.2 被 doc-links 抓跨文档串号)。**不接 pre-commit**:它的输入 是台架串口采集,提交时既无设备也无当轮日志,挂上去要么永远 SKIP、要么拿上一轮日志判这一轮的码(与 体积门禁那条假凭证同族)。
This commit is contained in:
@@ -100,12 +100,39 @@
|
|||||||
判据写"崩溃遗言一整行、恰好一次、`dropped=0`",并按 `boot_log_data/README.md` §2「故障注入日志」
|
判据写"崩溃遗言一整行、恰好一次、`dropped=0`",并按 `boot_log_data/README.md` §2「故障注入日志」
|
||||||
运行时放宽 `@test`(`--forbidden` 去掉 `[ERR]`、`--max-boot-count 2`)——**不改仓库配置**。
|
运行时放宽 `@test`(`--forbidden` 去掉 `[ERR]`、`--max-boot-count 2`)——**不改仓库配置**。
|
||||||
|
|
||||||
## 5. 栈与堆的比对口径
|
## 5. 栈判据(task-32 定稿:三档分权,不再是"待定")
|
||||||
|
|
||||||
同一份像连跑 4 次仅复位启动,uiTask 峰值实测 4116~4188 B(**自身摆动 72 B**),heap 计数逐字节稳定。
|
**一句话裁定:静态最坏链是唯一拦提交项(`scripts/stack_report.py` 每任务 verdict);运行时
|
||||||
因此:**不要**用"栈水位不劣于上一轮"这种单次比较当判据。要么取 ≥3 次的最大值对最大值,
|
水位只出告警(`scripts/stack_gate.py`,超容差 = WARN);采集健康门不过则整轮作废(VOID,
|
||||||
要么把 `scripts/stack_report.py` 的静态最坏链当硬线、运行时峰值只作趋势(定稿见 task-32)。
|
既不红也不绿、也不得写进台账)。**
|
||||||
|
|
||||||
|
| 档 | 判据 | 等级与权限 | 由谁跑 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 硬线 | `margin = cfg − max(静态最坏链, 运行时峰值) ≥ max(1024 B ISR, cfg×30%)` | **FAIL = 拦提交** | `scripts/stack_report.py`(看它退出码。**Lead 裁定 2026-10-27:提交时守这条的一直是 stack_report 本身;`stack_gate.py` 定为台架侧门禁、不接 pre-commit**——提交时既无设备也无当轮日志,挂上去只会「永远 SKIP」或「拿上一轮日志判这一轮的码」,后者是假凭证同族) |
|
||||||
|
| 趋势 | 同镜像 ≥3 圈取最大值 vs 基线轮 ≥3 圈最大值,`Δ > tol(task)` | **WARN = 记录 + 归因,永不单独拦提交** | `scripts/stack_gate.py` |
|
||||||
|
| 绝对底线 | 任一任务 `free_min < 25% × cfg` | **FAIL**(运行时唯一有 FAIL 权限的一条,不需要跨轮比较) | `scripts/stack_gate.py` |
|
||||||
|
| 采集健康门 | 锚点 `reset cause:` 恰 1 次 / 身份戳存在且**同轮唯一** / 每块 ≥20 行 / 每块都有 `[STACK]` 行 / ≥3 圈(基线轮同样要过门,否则本轮只跳趋势比较) | **VOID,exit 2** | `scripts/stack_gate.py` |
|
||||||
|
|
||||||
|
**容差是推导值,不是拍脑袋**:`tol(task) = 向上取整到 16 B 格的 max(128 B, 2 × 同镜像实测最大摆动)`。
|
||||||
|
实测来源:同一份像 4 圈仅复位里 uiTask 摆 72 B、adcTask 摆 80 B(task-30),2026-10-27 那份像 4 圈零摆动
|
||||||
|
(task-42)→ 最大摆动 80 B ⇒ **全任务统一 160 B**,登记在 `scripts/stack_tolerance.tsv`(含来源列)。
|
||||||
|
**谁能改这个数**:只有台架在「改栈配置 / 抬栈 / 新增 `DBG_*` 调用点」之后重测同镜像摆动才允许回填;
|
||||||
|
**禁止为了让某一轮变绿而调大容差**——被这把尺考核的人不得当场决定尺子的松紧(与 §7 里
|
||||||
|
"Forbidden 令牌不得落在英文单词内部"同源:判据的松紧与写法都要由测量侧一次性定死)。
|
||||||
|
|
||||||
|
**身份戳与归因**:跨轮比较本来就是两份镜像,正常;但 Δ 只有在**附上两次构建之间的源码 diff**
|
||||||
|
时才可归因到某笔改动。台账行必须写**两次的 `fw build` 时间戳(含 `suite=`/`dstr=`)**,
|
||||||
|
SHA 只当坐标(2026-10-26 那天 SHA 被 amend 挪了三轮)。同镜像比较里出现"零摆动"不代表判据失效,
|
||||||
|
只代表这次没噪声。
|
||||||
|
|
||||||
|
**为什么必须留 VOID 这一档**(设计输入=当天两次自纠):常驻 daemon 死掉后 `status` 会自动拉起
|
||||||
|
一个新 daemon 并"正常"应答,`rx_lines=0` 也照样返回成功 JSON;而 Forbidden 令牌 `Fault` 会命中
|
||||||
|
`de`+`fault` 制造假红。所以判据**不许依赖采集健康的自我感觉**:门不过 → exit 2 与 exit 1 分开,
|
||||||
|
按码聚合的人不会把"端口没开"记成"又崩了一次栈"。
|
||||||
|
|
||||||
|
**判据自己必须能红**:`& '.venv\Scripts\python.exe' scripts/stack_gate.py --selftest`(14 条,含
|
||||||
|
"72 B 实测摆动不算红"、"超容差只 WARN"、"单圈基线拒比"、"死采集器 VOID"、"同轮混两份身份戳 VOID"、
|
||||||
|
"绝对底线 FAIL"、"峰值取最坏一圈而非最后一圈")。**改完判据不跑这条,就等于没改。**
|
||||||
## 6. 锁与脚手架
|
## 6. 锁与脚手架
|
||||||
|
|
||||||
- bench 锁是**会话级**,必须手工 acquire/release,且**用同一个名字**释放。
|
- bench 锁是**会话级**,必须手工 acquire/release,且**用同一个名字**释放。
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""stack_gate.py - 栈水位趋势判据(task-32 定稿的可执行形态,纯标准库)。
|
||||||
|
|
||||||
|
模块名称:台架栈水位判据器(趋势 + 采集健康门)
|
||||||
|
模块功能:把 N 段启动日志里的 [STACK] 水位汇成"本轮最大值",与基线轮最大值按每任务
|
||||||
|
容差比较,输出四档结论;**先过采集健康门,再谈比较**。
|
||||||
|
创建日期:2026-10-27(裁定见 docs/内存管理/RAM使用分析与优化方案.md §4.0.6 第③条)
|
||||||
|
|
||||||
|
为什么要有它:旧判据「栈水位不劣于上一轮」拿单次运行时峰值跨轮比较,而同一份镜像自己
|
||||||
|
就会摆(实测 uiTask 4116~4188 = 72 B、adcTask 920~1000 = 80 B),于是既假红又漏报。
|
||||||
|
本脚本把那条判据拆成三件事,等级不同、互不越权:
|
||||||
|
1) 硬线(唯一拦提交项)= scripts/stack_report.py 的每任务 verdict:
|
||||||
|
margin = cfg - max(静态最坏链, 运行时峰值) >= max(isr_margin, cfg*30%)
|
||||||
|
本脚本不重算也不替代它,只在末尾提示其退出码(接不接 pre-commit 由 Lead 定)。
|
||||||
|
2) 趋势(告警级,永不单独拦提交)= 同一身份戳、>=3 圈取最大值,与基线轮最大值比,
|
||||||
|
超每任务容差 -> WARN。容差按字节,来源见 scripts/stack_tolerance.tsv。
|
||||||
|
3) 作废(VOID,优先于前两者)= 采集健康门不过:本轮既不判过也不判不过,也不得写进台账
|
||||||
|
结论。判据不许依赖"采集看起来很健康"这种自我感觉——2026-10-27 实证:常驻 daemon
|
||||||
|
死后 status 会自动拉起一个新 daemon,仍报"正常"而 rx_lines=0。
|
||||||
|
|
||||||
|
退出码(与 verify_log.py 同族口径:测量问题绝不伪装成设备故障):
|
||||||
|
0 = PASS(含通过但有趋势 WARN)
|
||||||
|
1 = FAIL(运行时绝对底线破线:free_min < 25% of cfg —— 设备侧真消息)
|
||||||
|
2 = VOID_MEASUREMENT(采集健康门未过:本轮不可用,也不算设备的账)
|
||||||
|
3 = 用法错误
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
RUNTIME_FLOOR_PCT = 25 # 运行时绝对底线(与静态 30% 硬线错开一档:峰值只是下界证据)
|
||||||
|
MIN_CIRCLES = 3 # 少于 3 圈 = 噪声样本,不做跨轮比较
|
||||||
|
DEFAULT_TOL = 160 # 未登记任务的兜底容差(字节),推导见 scripts/stack_tolerance.tsv 头注
|
||||||
|
|
||||||
|
STACK_RE = re.compile(r"\[STACK\]\s+(\w+)\s+peak_used=(\d+)/(\d+)\s+free_min=(\d+)\s*\((\d+)%\)")
|
||||||
|
# __DATE__ 的月份是**英文名**("Oct 26 2026 22:52:09"),不是数字——早先写成 \d{3} 会让
|
||||||
|
# 每一份真实日志都判成"没有身份戳"而整轮作废(判据全红比判据漏更响,但也别拿假红当纪律)。
|
||||||
|
STAMP_RE = re.compile(r"fw build:\s*([A-Za-z]{3}\s+\d{1,2}\s+\d{4}\s+\d{2}:\d{2}:\d{2})\s+suite=(\S+)\s+dstr=(\S+)")
|
||||||
|
ANCHOR = "reset cause:"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_block(text):
|
||||||
|
"""取一段日志的 (每任务峰值, 身份戳, 锚点数, 有效行数)。
|
||||||
|
|
||||||
|
同一任务在一块里出现多次时取最大值——栈峰值的语义就是"最坏那一次",不做平均。
|
||||||
|
"""
|
||||||
|
payload = [l for l in text.splitlines() if l.strip() and not l.lstrip().startswith("#")]
|
||||||
|
peaks = {}
|
||||||
|
for ln in payload:
|
||||||
|
m = STACK_RE.search(ln)
|
||||||
|
if m:
|
||||||
|
name = m.group(1)
|
||||||
|
used, cfg, free = int(m.group(2)), int(m.group(3)), int(m.group(4))
|
||||||
|
prev = peaks.get(name)
|
||||||
|
if prev is None or used > prev["used"]:
|
||||||
|
peaks[name] = {"used": used, "cfg": cfg, "free": free}
|
||||||
|
stamps = STAMP_RE.findall("\n".join(payload))
|
||||||
|
uniq = sorted(set(stamps))
|
||||||
|
stamp = None
|
||||||
|
if uniq:
|
||||||
|
stamp = uniq[0] if len(uniq) == 1 else ("MIXED:" + str(len(uniq)))
|
||||||
|
return {"peaks": peaks, "anchors": sum(1 for l in payload if ANCHOR in l),
|
||||||
|
"stamp": stamp, "lines": len(payload)}
|
||||||
|
|
||||||
|
|
||||||
|
def measurement_gate(blocks, label):
|
||||||
|
"""(ok, reasons)。任一条不满足就整轮作废:宁可不出结论,也不出错结论。"""
|
||||||
|
reasons = []
|
||||||
|
if len(blocks) < MIN_CIRCLES:
|
||||||
|
reasons.append("%s: only %d circle(s), need >= %d for a peak-vs-peak comparison"
|
||||||
|
% (label, len(blocks), MIN_CIRCLES))
|
||||||
|
stamps = set()
|
||||||
|
for i, b in enumerate(blocks):
|
||||||
|
n = i + 1
|
||||||
|
if b["anchors"] != 1:
|
||||||
|
reasons.append("%s block#%d: boot anchor count=%d (want exactly 1) -> capture did "
|
||||||
|
"not start at a reset, or the windows overlap" % (label, n, b["anchors"]))
|
||||||
|
if b["lines"] < 20:
|
||||||
|
reasons.append("%s block#%d: only %d payload line(s) -> the collector was not "
|
||||||
|
"carrying this boot (a respawned daemon still answers 'ok')"
|
||||||
|
% (label, n, b["lines"]))
|
||||||
|
if not b["peaks"]:
|
||||||
|
reasons.append("%s block#%d: zero [STACK] lines (a missing line is not zero used)"
|
||||||
|
% (label, n))
|
||||||
|
if not b["stamp"]:
|
||||||
|
reasons.append("%s block#%d: no 'fw build:' identity stamp -> cannot prove which "
|
||||||
|
"image these numbers belong to" % (label, n))
|
||||||
|
elif str(b["stamp"]).startswith("MIXED"):
|
||||||
|
reasons.append("%s block#%d: %s identity stamps inside one block -> the capture "
|
||||||
|
"crossed two images" % (label, n, b["stamp"]))
|
||||||
|
else:
|
||||||
|
stamps.add(b["stamp"])
|
||||||
|
# 同一轮内部出现多个身份戳 = 采集窗跨了一次重烧,这轮的数字不属于任何一份镜像 -> 作废。
|
||||||
|
# 注意:**跨轮**戳不同是正常的(那正是要比的两份镜像),不能按作废处理,见 evaluate()。
|
||||||
|
if len(stamps) > 1:
|
||||||
|
reasons.append("%s: this round mixes %d identity stamps %s -> one circle was reflashed "
|
||||||
|
"mid-round, these peaks belong to no single image"
|
||||||
|
% (label, len(stamps), sorted(stamps)))
|
||||||
|
return (not reasons), reasons
|
||||||
|
|
||||||
|
|
||||||
|
def load_tolerances(path):
|
||||||
|
"""容差表:每行 task<TAB>bytes[<TAB>来源],# 开头为注释。缺文件则全用默认值。"""
|
||||||
|
tol = {}
|
||||||
|
if not path or not os.path.isfile(path):
|
||||||
|
return tol, "no tolerance file -> default %d B for every task" % DEFAULT_TOL
|
||||||
|
for raw in open(path, "r", encoding="utf-8").read().splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
parts = line.split("\t")
|
||||||
|
if len(parts) >= 2 and parts[1].isdigit():
|
||||||
|
tol[parts[0]] = int(parts[1])
|
||||||
|
return tol, "loaded %d task tolerance(s) from %s" % (len(tol), os.path.basename(path))
|
||||||
|
|
||||||
|
|
||||||
|
def worst_of(blocks):
|
||||||
|
"""把本轮各圈压成"每任务最大值"(峰值就该取最坏一次)。"""
|
||||||
|
agg = {}
|
||||||
|
for b in blocks:
|
||||||
|
for name, info in b["peaks"].items():
|
||||||
|
prev = agg.get(name)
|
||||||
|
if prev is None or info["used"] > prev["used"]:
|
||||||
|
agg[name] = dict(info)
|
||||||
|
return agg
|
||||||
|
|
||||||
|
|
||||||
|
def decide(cur, base, tol_map):
|
||||||
|
"""跨轮趋势:只出 WARN。FAIL 的权限只给绝对底线,硬线归 stack_report.py。"""
|
||||||
|
rows, warns = [], []
|
||||||
|
for name in sorted(cur):
|
||||||
|
info = cur[name]
|
||||||
|
bl = base.get(name)
|
||||||
|
tol = tol_map.get(name, DEFAULT_TOL)
|
||||||
|
pct = (100 * info["free"] // info["cfg"]) if info["cfg"] else 0
|
||||||
|
row = {"task": name, "cur_max": info["used"], "cfg": info["cfg"], "free_min": info["free"],
|
||||||
|
"floor_pct": pct, "tol": tol, "base_max": (bl or {}).get("used"),
|
||||||
|
"delta": (info["used"] - bl["used"]) if bl else None, "level": "PASS"}
|
||||||
|
if pct < RUNTIME_FLOOR_PCT:
|
||||||
|
row["level"] = "FAIL"
|
||||||
|
warns.append("%s free_min=%d/%d (%d%%) below the %d%% runtime floor"
|
||||||
|
% (name, info["free"], info["cfg"], pct, RUNTIME_FLOOR_PCT))
|
||||||
|
elif bl is not None and (info["used"] - bl["used"]) > tol:
|
||||||
|
row["level"] = "WARN"
|
||||||
|
warns.append("%s peak rose %d B over baseline max (%d -> %d, tol=%d B): trend only, "
|
||||||
|
"the blocking decision is stack_report.py's margin verdict"
|
||||||
|
% (name, info["used"] - bl["used"], bl["used"], info["used"], tol))
|
||||||
|
rows.append(row)
|
||||||
|
return rows, warns
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate(cur_blocks, base_blocks, tol_map):
|
||||||
|
"""(exit_code, verdict, 报告行)。顺序:先门,再绝对底线,最后才是趋势告警。"""
|
||||||
|
out = []
|
||||||
|
ok_cur, why_cur = measurement_gate(cur_blocks, "current")
|
||||||
|
if not ok_cur:
|
||||||
|
out.append("[VOID_MEASUREMENT] this round is not usable:")
|
||||||
|
out.extend(" - " + r for r in why_cur)
|
||||||
|
out.append(" -> fix the measurement first (open the port and watch rx_lines grow,")
|
||||||
|
out.append(" capture from a reset, prove the identity stamp). A dead collector must")
|
||||||
|
out.append(" not become a stack FAIL, and this round must NOT enter the ledger.")
|
||||||
|
return 2, "VOID_MEASUREMENT", out
|
||||||
|
if base_blocks:
|
||||||
|
ok_base, why_base = measurement_gate(base_blocks, "baseline")
|
||||||
|
if not ok_base:
|
||||||
|
out.append("[WARN] baseline round failed its own gate -> trend skipped this run:")
|
||||||
|
out.extend(" - " + r for r in why_base)
|
||||||
|
out.append(" (we refuse to compare against numbers we cannot trust; the current")
|
||||||
|
out.append(" round still gets its absolute-floor check)")
|
||||||
|
base_blocks = []
|
||||||
|
cur = worst_of(cur_blocks)
|
||||||
|
base = worst_of(base_blocks) if base_blocks else {}
|
||||||
|
rows, warns = decide(cur, base, tol_map)
|
||||||
|
cur_stamp = cur_blocks[0]["stamp"]
|
||||||
|
base_stamp = base_blocks[0]["stamp"] if base_blocks else None
|
||||||
|
cross_image = bool(base_blocks) and cur_stamp != base_stamp
|
||||||
|
out.append("%-9s %8s %9s %6s %7s %6s %5s %s"
|
||||||
|
% ("task", "cur_max", "base_max", "delta", "cfg", "free%", "tol", "level"))
|
||||||
|
for r in rows:
|
||||||
|
out.append("%-9s %8d %9s %6s %7d %5d%% %5d %s" % (
|
||||||
|
r["task"], r["cur_max"],
|
||||||
|
"-" if r["base_max"] is None else r["base_max"],
|
||||||
|
"-" if r["delta"] is None else ("%+d" % r["delta"]),
|
||||||
|
r["cfg"], r["floor_pct"], r["tol"], r["level"]))
|
||||||
|
fails = [w for w in warns if "floor" in w]
|
||||||
|
trend = [w for w in warns if "floor" not in w]
|
||||||
|
for w in fails:
|
||||||
|
out.append("[FAIL] " + w)
|
||||||
|
for w in trend:
|
||||||
|
out.append("[WARN] " + w)
|
||||||
|
if not base_blocks:
|
||||||
|
out.append("note: no usable baseline -> trend comparison off this run (levels come from "
|
||||||
|
"the absolute runtime floor only)")
|
||||||
|
elif cross_image:
|
||||||
|
out.append("note: CROSS-IMAGE comparison (current=%s vs baseline=%s). Different stamps "
|
||||||
|
"are normal here -- but a delta is only attributable to a given change if the "
|
||||||
|
"source diff between those two builds is attached; write BOTH stamps into the "
|
||||||
|
"ledger row, never the commit SHA alone (SHAs moved three times on 2026-10-26)."
|
||||||
|
% (cur_stamp[0], base_stamp[0]))
|
||||||
|
else:
|
||||||
|
out.append("note: same-image comparison (stamps match: %s) -> a delta over tolerance is "
|
||||||
|
"measurement noise or an unexplained regression; re-measure before writing it up."
|
||||||
|
% (cur_stamp[0],))
|
||||||
|
out.append("hard line unchanged: run scripts/stack_report.py; its per-task verdict "
|
||||||
|
"(margin >= max(isr_margin, 30% of cfg), computed from the larger of the static "
|
||||||
|
"chain and the runtime peak) is the only stack criterion that blocks a commit.")
|
||||||
|
if fails:
|
||||||
|
return 1, "FAIL", out
|
||||||
|
if trend:
|
||||||
|
return 0, "PASS_WITH_WARN", out
|
||||||
|
return 0, "PASS", out
|
||||||
|
|
||||||
|
|
||||||
|
def synth(stamp, anchors, entries, filler=24):
|
||||||
|
"""造一段日志文本(selftest 用)。entries = [(task, used, cfg, free)]。"""
|
||||||
|
L = ["[RX] 2026-10-27T10:00:%02d.000+08:00 [PROF] filler=%d" % (i % 60, i) for i in range(filler)]
|
||||||
|
if stamp:
|
||||||
|
L.append("[RX] 2026-10-27T10:00:00.000+08:00 [2026-10-27 02:00:00][APP] fw build: "
|
||||||
|
+ stamp + " suite=NONE dstr=off")
|
||||||
|
for _ in range(anchors):
|
||||||
|
L.append("[RX] 2026-10-27T10:00:00.100+08:00 [2026-10-27 02:00:00][APP] reset cause: "
|
||||||
|
"software (NVIC_SystemReset) (CSR=0x14000000)")
|
||||||
|
for task, used, cfg, free in entries:
|
||||||
|
L.append("[RX] 2026-10-27T10:00:01.000+08:00 [2026-10-27 02:00:01][STACK] %s "
|
||||||
|
"peak_used=%d/%d free_min=%d (%d%%)" % (task, used, cfg, free, 100 * free // cfg))
|
||||||
|
return "\n".join(L) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def selftest(out=print):
|
||||||
|
"""证明四档都能真的亮——不能失败的判据等于没有判据。"""
|
||||||
|
cases = []
|
||||||
|
|
||||||
|
def chk(name, got, want):
|
||||||
|
cases.append((name, got == want, "got=%r want=%r" % (got, want)))
|
||||||
|
|
||||||
|
tasks = ("default", "net", "ftp", "ui", "timesync", "adc", "iec104", "protect", "test")
|
||||||
|
clean = [(t, 1000, 4096, 3096) for t in tasks]
|
||||||
|
S = "Oct 26 2026 22:52:09"
|
||||||
|
b1 = parse_block(synth(S, 1, clean))
|
||||||
|
b2 = parse_block(synth(S, 1, [(t, 1040, 4096, 3056) for t in tasks]))
|
||||||
|
b3 = parse_block(synth(S, 1, [(t, 990, 4096, 3106) for t in tasks]))
|
||||||
|
chk("three clean circles -> PASS", evaluate([b1, b2, b3], [], {})[1], "PASS")
|
||||||
|
base72 = [parse_block(synth("Oct 26 2026 19:43:23", 1, [("ui", 4116, 16384, 12268)]))]
|
||||||
|
cur72 = [parse_block(synth("Oct 26 2026 19:43:23", 1, [("ui", 4188, 16384, 12196)]))] * 3
|
||||||
|
chk("the measured 72 B swing is not a red flag", evaluate(cur72, base72, {"ui": 160})[1], "PASS")
|
||||||
|
cur_big = [parse_block(synth("Oct 26 2026 19:43:23", 1, [("ui", 4388, 16384, 11996)]))] * 3
|
||||||
|
base72r = base72 * 3 # 基线轮也要 >=3 圈:单圈基线是噪声样本,不许拿它比
|
||||||
|
chk("delta over tolerance = WARN not FAIL", evaluate(cur_big, base72r, {"ui": 160})[1], "PASS_WITH_WARN")
|
||||||
|
chk("single-circle baseline is refused (trend skipped, no false red)",
|
||||||
|
evaluate(cur_big, base72, {"ui": 160})[1], "PASS")
|
||||||
|
chk("cross-round different stamps still compares (with attribution note)",
|
||||||
|
0, evaluate([parse_block(synth("Oct 26 2026 22:52:09", 1, [("ui", 4400, 16384, 11984)]))] * 3,
|
||||||
|
base72r, {"ui": 160})[0])
|
||||||
|
low = [parse_block(synth("Oct 26 2026 19:43:23", 1, [("ui", 13000, 16384, 3384)]))] * 3
|
||||||
|
chk("runtime floor breach = FAIL", evaluate(low, [], {})[1], "FAIL")
|
||||||
|
dead = parse_block(synth(S, 0, []))
|
||||||
|
chk("dead collector -> VOID (not FAIL)", evaluate([dead] * 3, [], {})[1], "VOID_MEASUREMENT")
|
||||||
|
two = parse_block(synth(S, 2, clean))
|
||||||
|
chk("two boot anchors -> VOID", evaluate([two, two, two], [], {})[1], "VOID_MEASUREMENT")
|
||||||
|
nostamp = parse_block(synth("", 1, clean))
|
||||||
|
chk("missing identity stamp -> VOID", evaluate([nostamp] * 3, [], {})[1], "VOID_MEASUREMENT")
|
||||||
|
mixed = parse_block(synth(S, 1, clean) + synth("Oct 25 2026 09:00:00", 1, clean))
|
||||||
|
chk("mixed stamps in one block -> VOID", evaluate([mixed] * 3, [], {})[1], "VOID_MEASUREMENT")
|
||||||
|
cross = parse_block(synth("Oct 25 2026 09:00:00", 1, clean))
|
||||||
|
chk("round that mixes two stamps -> VOID", evaluate([b1, b2, cross], [], {})[1], "VOID_MEASUREMENT")
|
||||||
|
chk("only two circles -> VOID", evaluate([b1, b2], [], {})[1], "VOID_MEASUREMENT")
|
||||||
|
chk("unusable baseline -> trend skipped, floor still enforced",
|
||||||
|
evaluate([b1, b2, b3], [dead], {})[1], "PASS")
|
||||||
|
chk("peak takes the worst circle, not the last", worst_of([b1, b3, b2])["ui"]["used"], 1040)
|
||||||
|
|
||||||
|
bad = 0
|
||||||
|
for name, ok, detail in cases:
|
||||||
|
out("%-5s %s%s" % ("[OK]" if ok else "[FAIL]", name, "" if ok else " " + detail))
|
||||||
|
if not ok:
|
||||||
|
bad += 1
|
||||||
|
out("SELFTEST: %d case(s), %d failure(s) -> %s" % (len(cases), bad, "PASS" if bad == 0 else "FAIL"))
|
||||||
|
return 0 if bad == 0 else 1
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
ap = argparse.ArgumentParser(description="bench stack water-level trend gate (task-32 ruling)")
|
||||||
|
ap.add_argument("--block", action="append", default=[],
|
||||||
|
help="current-round boot log block, repeatable (need >= %d)" % MIN_CIRCLES)
|
||||||
|
ap.add_argument("--baseline-block", action="append", default=[],
|
||||||
|
help="previous-round boot log block, repeatable; omit = trend comparison off")
|
||||||
|
ap.add_argument("--tolerance-tsv", default="scripts/stack_tolerance.tsv",
|
||||||
|
help="per-task tolerance table (task TAB bytes); missing task -> %d B" % DEFAULT_TOL)
|
||||||
|
ap.add_argument("--selftest", action="store_true", help="run built-in classification cases and exit")
|
||||||
|
a = ap.parse_args(argv)
|
||||||
|
if a.selftest:
|
||||||
|
return selftest()
|
||||||
|
if not a.block:
|
||||||
|
print("usage error: give at least one --block (or --selftest)")
|
||||||
|
return 3
|
||||||
|
tol_map, note = load_tolerances(a.tolerance_tsv)
|
||||||
|
blocks = [parse_block(open(p, "r", encoding="utf-8", errors="replace").read()) for p in a.block]
|
||||||
|
base = [parse_block(open(p, "r", encoding="utf-8", errors="replace").read()) for p in a.baseline_block]
|
||||||
|
print("stack trend gate -- %s (floor=%d%% of cfg, min circles=%d)" % (note, RUNTIME_FLOOR_PCT, MIN_CIRCLES))
|
||||||
|
code, verdict, lines = evaluate(blocks, base, tol_map)
|
||||||
|
for ln in lines:
|
||||||
|
print(ln)
|
||||||
|
print("VERDICT: %s" % verdict)
|
||||||
|
return code
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# scripts/stack_tolerance.tsv -- 栈水位跨轮容差(字节)。判据裁定见
|
||||||
|
# docs/内存管理/RAM使用分析与优化方案.md §4.0.6 第③条;用法:scripts/stack_gate.py。
|
||||||
|
#
|
||||||
|
# 容差是**推导值不是拍的**:tol(task) = 向上取整到 16 B 格的 max(128 B, 2 x 同镜像实测最大摆动)。
|
||||||
|
# 实测来源(台架原始日志在 docs/测试说明/boot_log_data/):
|
||||||
|
# 2026-10-26 task-30(同一份 1d4b6a17 像、4 圈仅复位):uiTask 4116~4188 = 72 B、adcTask 80 B
|
||||||
|
# 2026-10-27 task-42(HEAD 像、4 圈仅复位):全部任务零摆动
|
||||||
|
# -> 最大同镜像摆动 80 B => tol = max(128, 160) = **160 B**,全任务统一。
|
||||||
|
# 谁改这个数:**只允许台架**在「改栈配置 / 抬栈 / 新增 DBG 日志调用点」后重测同镜像摆动并回填,
|
||||||
|
# 并在下面那列写上来源;**禁止为了让某一轮变绿而调大容差**(与 Forbidden 令牌那条同源:
|
||||||
|
# 判据的松紧不能由被它考核的人当场决定)。
|
||||||
|
# 格式:task<TAB>bytes<TAB>来源
|
||||||
|
default 160 task-30/42 实测同镜像最大摆动 80 B x2(2026-10-27)
|
||||||
|
net 160 同上
|
||||||
|
ftp 160 同上
|
||||||
|
ui 160 同上
|
||||||
|
timesync 160 同上
|
||||||
|
adc 160 同上
|
||||||
|
iec104 160 同上
|
||||||
|
protect 160 同上
|
||||||
|
test 160 同上(仅测试构建创建)
|
||||||
|
Reference in New Issue
Block a user