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:
2026-10-27 11:31:53 +08:00
parent 728d60675f
commit 13001f2ae4
3 changed files with 362 additions and 4 deletions
+310
View File
@@ -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())