影子信号走增量:清空十币 560ms → 247ms

compute() 每根新建 TF_DF 换成按 (symbol, timeframe) 缓存的流式对象。worker
进程被复用,所以缓存跨根存活;2 worker 轮流拿 10 币,每个 worker 最终缓存
全部 20 条流,实测内存开销落在噪声里(597→598MiB)。

## 前提先验,否则整个改动建立在沙子上

init_stream/append_bar **没有 trim**,dataframe 靠 pd.concat 无界增长。所以
增量必然让窗口每根 +1,只能周期性重建拉回,两次重建之间窗口是 [W, W+500]
而非恒定 W。于是必须先证明 compute() 输出对窗口长度不敏感——否则增量等于
静默换掉一批信号,不报错不崩。

verify_window_sens.py:三币 75 个信号窗口,+200/+500/+1000 三档全部逐字段
一致。step39 说的是「命中率在 2000 根饱和」,饱和不等于不变,这是两回事。

## 对拍

verify_incr_parity.py:三币 1,800 根、21 个命中、各跨 1 次重建边界,逐字段
零分歧。不能引用 HANDOFF §5.5——那验的是 bsp_list 那条链的整体哈希,而这里
是 find_fast_bsp3 那条链,且流式对象跨根复用,状态污染只会让信号悄悄换一批。

对拍顺带定论一件读代码定不了的事:cal_bi_list **不依赖** klc.trend。
init_stream/append_bar 从不调 cal_trend(它只在 get_klc_list 里),所以追加
出来的 klc 其 trend 恒为 UNKNOWN,而批量构建的有值;两者结果逐字段相同。
HANDOFF §5.5 那句「bi.py:221 读 klc.trend,笔的计算依赖它」不成立——221 行
在 cal_trend 自己的循环里,读的是它自身的序列状态。

## 重建不走 init_stream

init_stream 是逐行 dataframe.iloc[idx],正是引擎提速刚修掉的反模式:2001 根
要 238.5ms,而批量 lean 只 74.3ms,慢 3.2 倍。第一版用它重建,10 个币启动时
各来一次,清空反而涨到 1686ms。改用 TF_DF(df, lean=True) 重建,append_bar
靠 _ensure_stream_state 就能接上。

## 实测

append_bar 21.8ms vs 批量 lean 重建 77.7ms = 3.56x,与研究侧测的 3.7x 一致。
拆解:add_indicators 全表 7.5ms(34%,为加一根重算 2001 行)+ cal_bi_list
整表重扫 11.1ms(51%)+ concat 1.4ms。这两项都在引擎侧,值得反馈。

十币 / 2 核:清空 560→247ms,排队 92→10ms,纯计算 219→108ms。判定从
「加 worker 无用,唯一出路是增量」变成「宽裕,无需优化」。

注意 inner 108ms 里 chan 构建只占约 22ms,其余是 build_htf_zones /
find_fast_bsp3 / attach_htf_context。**瓶颈已不在 chan 构建**,再压增量收益
有限。

stream_bars 落到 latency CSV:恒等于 2001 说明缺口判定在每根都回退重建、
增量静默失效,这一点从耗时上看不出是哪一环。实测窗口稳定长大。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jack
2026-08-28 06:17:47 +08:00
co-authored by Cursor
parent c8a0062707
commit 0a0fd2f682
5 changed files with 349 additions and 9 deletions
+7 -3
View File
@@ -363,7 +363,8 @@ class Shadow:
# compute_ms 含排队;queue_ms/inner_ms 把它拆开,用来判断加核有没有用 # compute_ms 含排队;queue_ms/inner_ms 把它拆开,用来判断加核有没有用
"compute_ms", "queue_ms", "inner_ms", "compute_ms", "queue_ms", "inner_ms",
"n_bars", "n_hits", "n_bars", "n_hits",
"n_pass", "atr_bp", "lag_med_ms", "lag_ok"]) "n_pass", "atr_bp", "lag_med_ms", "lag_ok",
"stream_bars"])
# 无条件漂移:每根都记,用来和信号根上的条件漂移对照 # 无条件漂移:每根都记,用来和信号根上的条件漂移对照
self.f_drf, self.w_drf = _writer(d / "shadow_drift.csv", [ self.f_drf, self.w_drf = _writer(d / "shadow_drift.csv", [
"site", "sym", "kline_ts", "delay_label", "delay_ms", "site", "sym", "kline_ts", "delay_label", "delay_ms",
@@ -571,7 +572,7 @@ class Shadow:
t0 = time.perf_counter() t0 = time.perf_counter()
payload = (df_l[NUM_COLS].values.tolist(), payload = (df_l[NUM_COLS].values.tolist(),
df_h[NUM_COLS].values.tolist(), baseline, time.time()) df_h[NUM_COLS].values.tolist(), baseline, time.time(), sym)
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
from shadow_signal import compute_packed from shadow_signal import compute_packed
try: try:
@@ -610,7 +611,10 @@ class Shadow:
"queue_ms": res.get("queue_ms"), "inner_ms": res.get("inner_ms"), "queue_ms": res.get("queue_ms"), "inner_ms": res.get("inner_ms"),
"n_bars": res.get("n_bars", 0), "n_bars": res.get("n_bars", 0),
"n_hits": len(hits), "n_pass": n_pass, "atr_bp": atr_bp, "n_hits": len(hits), "n_pass": n_pass, "atr_bp": atr_bp,
"lag_med_ms": lag_med, "lag_ok": int(lag_ok)}) "lag_med_ms": lag_med, "lag_ok": int(lag_ok),
# 增量流当前窗口。恒等于 2001 说明缺口判定在每根都
# 回退重建,增量静默失效——只从耗时上看不出是哪一环
"stream_bars": res.get("stream_bars")})
self.f_lat.flush() self.f_lat.flush()
if baseline is not None and np.isfinite(baseline): if baseline is not None and np.isfinite(baseline):
+66 -4
View File
@@ -40,10 +40,64 @@ for _v in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS"):
LEAN = os.environ.get("SHADOW_LEAN", "1") not in ("0", "", "false") LEAN = os.environ.get("SHADOW_LEAN", "1") not in ("0", "", "false")
INCR = os.environ.get("SHADOW_INCR", "1") not in ("0", "", "false")
# 增量流缓存。worker 进程被复用,所以这个 dict 跨根存活。
# 键是 (symbol, timeframe)——2 个 worker 轮流拿 10 个币,每个 worker 最终会
# 缓存全部 10 个币,共 20 条流。
_STREAMS: dict[tuple, tuple] = {}
# 两次重建之间允许窗口长多少根。
#
# init_stream/append_bar **没有 trim**dataframe 靠 pd.concat 无界增长。所以
# 增量必然让窗口每根 +1,只能周期性 init_stream 拉回。取 500 的两个理由:
# 1. append_bar 里 rebuild_bi_zs 要整表重扫笔,是 O(n)。窗口涨 25% 成本也涨
# 约 25%500/2001 正好把这个膨胀压在 25% 以内。
# 2. 重建约 51ms、追加约 14ms,摊到 500 根上重建只加 0.07ms/根。
# 前提「输出对窗口长度不敏感」由 verify_window_sens.py 验过(+200/+500/+1000
# 全部逐字段一致),否则这个方案等于静默换掉一批信号。
MAX_GROW = 500
def _chan_for(key: tuple, df, tf: str, lean: bool):
"""拿该窗口对应的 chan 对象,能增量就增量,否则重建。
三种情况必须回退到全量重建,否则会拿一个状态不对的流去出信号:
缓存没有 首次见到这个币
窗口已长过阈值 见 MAX_GROW
缓存末根不在新窗口 说明中间断了很多根(或时间戳回退),接不上
第三种是最要紧的。2 个 worker 轮流拿 10 个币,某个 worker 可能隔几根才再
看到同一个币,那几根要补齐;但若缺口大到超出窗口,就没法补,只能重建。
不检查而直接 append 会把不连续的 K 线接在一起,笔和中枢全错且不报错。
"""
from chanlun import TF_DF
ts = df["timestamp"].to_numpy("int64")
st = _STREAMS.get(key)
if st is not None:
chan, last_ts, base_n = st
if len(chan.dataframe) <= base_n + MAX_GROW and last_ts >= ts[0] \
and last_ts <= ts[-1] and (ts == last_ts).any():
for _, row in df[df["timestamp"] > last_ts].iterrows():
chan.append_bar(row)
_STREAMS[key] = (chan, int(ts[-1]), base_n)
return chan
# 重建走**批量** init_TF_DF,不用 init_stream。init_stream 是逐行
# `dataframe.iloc[idx]`,正是引擎提速刚修掉的反模式:实测 2001 根要
# 238.5ms,而批量 lean 只要 74.3ms,慢 3.2 倍。
# append_bar 能接在批量构建的对象上——_ensure_stream_state 会补出
# _klc_feed_last_klu,其余列表 init_TF_DF 都建好了。
chan = TF_DF(df.copy(), 1, tf, lean=lean)
_STREAMS[key] = (chan, int(ts[-1]), len(df))
return chan
def compute(df_l, df_h, entry_px: float | None = None, def compute(df_l, df_h, entry_px: float | None = None,
lean: bool | None = None) -> dict: lean: bool | None = None, sym: str | None = None,
incr: bool | None = None) -> dict:
"""在 df_l 的最后一根上找信号。df_l/df_h 都只含已收盘 K 线。 """在 df_l 的最后一根上找信号。df_l/df_h 都只含已收盘 K 线。
entry_px 是次根开盘价(回测 entry_delay=1 的成交价),用作 atr_pct 的 entry_px 是次根开盘价(回测 entry_delay=1 的成交价),用作 atr_pct 的
@@ -66,6 +120,8 @@ def compute(df_l, df_h, entry_px: float | None = None,
import pandas as pd import pandas as pd
lean = LEAN if lean is None else lean lean = LEAN if lean is None else lean
# 没有 sym 就无法给流分键,只能走全量——对拍脚本会用这条路径当基准
incr = (INCR if incr is None else incr) and sym is not None
try: try:
from chanlun import TF_DF from chanlun import TF_DF
@@ -75,7 +131,8 @@ def compute(df_l, df_h, entry_px: float | None = None,
from lib.nested_level import build_htf_zones from lib.nested_level import build_htf_zones
from lib.shadow_budget import ATR_GATE_BP from lib.shadow_budget import ATR_GATE_BP
chan_l = TF_DF(df_l, 1, "1m", lean=lean) chan_l = _chan_for((sym, "1m"), df_l, "1m", lean) if incr \
else TF_DF(df_l, 1, "1m", lean=lean)
cdf = chan_l.dataframe cdf = chan_l.dataframe
last = len(cdf) - 1 last = len(cdf) - 1
base = {"last_idx": last, "n_bars": int(len(df_l)), "hits": [], base = {"last_idx": last, "n_bars": int(len(df_l)), "hits": [],
@@ -111,7 +168,8 @@ def compute(df_l, df_h, entry_px: float | None = None,
# 5m 同向。算不出时 h1_agree 记 0,该信号自然不会通过 pass_all # 5m 同向。算不出时 h1_agree 记 0,该信号自然不会通过 pass_all
if df_h is not None and len(df_h) > 0: if df_h is not None and len(df_h) > 0:
chan_h = TF_DF(df_h, 1, "5m", lean=lean) chan_h = _chan_for((sym, "5m"), df_h, "5m", lean) if incr \
else TF_DF(df_h, 1, "5m", lean=lean)
hdf = chan_h.dataframe hdf = chan_h.dataframe
tl = htf_fx_timeline( tl = htf_fx_timeline(
signals_to_frame(extract_fx_signals(chan_h, hdf)), hdf) signals_to_frame(extract_fx_signals(chan_h, hdf)), hdf)
@@ -180,11 +238,15 @@ def compute_packed(payload: tuple) -> dict:
t_start = time.time() t_start = time.time()
l_rows, h_rows, entry_px, *rest = payload l_rows, h_rows, entry_px, *rest = payload
t_submit = rest[0] if rest else None t_submit = rest[0] if rest else None
sym = rest[1] if len(rest) > 1 else None
t0 = time.perf_counter() t0 = time.perf_counter()
df_l = _rebuild(l_rows) df_l = _rebuild(l_rows)
df_h = _rebuild(h_rows) if h_rows else None df_h = _rebuild(h_rows) if h_rows else None
out = compute(df_l, df_h, entry_px) out = compute(df_l, df_h, entry_px, sym=sym)
# 落盘这两个数才能在线看出增量是否在生效:走了重建的根 grown 会等于窗口
st = _STREAMS.get((sym, "1m"))
out["stream_bars"] = int(len(st[0].dataframe)) if st else None
out["inner_ms"] = int((time.perf_counter() - t0) * 1000) out["inner_ms"] = int((time.perf_counter() - t0) * 1000)
# 同一台机器,父子进程时钟一致,可直接相减 # 同一台机器,父子进程时钟一致,可直接相减
out["queue_ms"] = int((t_start - t_submit) * 1000) \ out["queue_ms"] = int((t_start - t_submit) * 1000) \
+154
View File
@@ -0,0 +1,154 @@
"""逐根对拍「全量重算」与「增量追加」,并量提速。
## 为什么必须逐根对拍,不能引用 HANDOFF §5.5
§5.5 验的是 step46 那批用例(走 bsp_list 那条链),且是「追加 150~200 根 vs
全量重建」的整体哈希。影子路径不同:
- 走 find_fast_bsp3 + build_htf_zones + htf_fx_timeline + attach_htf_context
- 流式对象**跨根复用**,而 worker 轮流拿多个币,同一条流可能隔几根才被
再次追加。状态污染只会让信号悄悄换一批,不报错、不崩
而且代码阅读已经暴露一处偏差:`init_stream/append_bar` 从不调 `cal_trend`
(它只在 `get_klc_list` 里),所以增量路径下 `klc.trend` 恒为 UNKNOWN。
HANDOFF 说「笔的计算依赖 klc.trend」——若为真,增量的笔就和全量不同。
那句话所引的 bi.py:221 其实在 `cal_trend` 自己的循环里,不是 `cal_bi_list`
的依赖。**这条只能由对拍来定论**,不能靠读代码。
## 判据
逐字段相同,排除 last_idx/n_bars(随窗口长度必然变,见 verify_window_sens
与计时字段。数量相同而标志不同一样算失败。
模拟真实调用模式:连续推进,且每根都按「全量」和「增量」各算一次,增量那侧
复用同一条流。
python research/live/verify_incr_parity.py --syms BTC,ETH,SOL --n 300
"""
from __future__ import annotations
import argparse
import os
import sys
import time
import warnings
from pathlib import Path
import numpy as np
import pandas as pd
warnings.filterwarnings("ignore")
for _v in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS"):
os.environ.setdefault(_v, "1")
HERE = Path(__file__).resolve()
sys.path.insert(0, str(HERE.parents[1]))
sys.path.insert(0, str(HERE.parents[2]))
sys.path.insert(0, str(HERE.parent))
BASE_L, BASE_H = 2001, 801
def run_one(sym: str, cache: Path, n: int, start_at: int | None) -> dict:
import shadow_signal as ss
from verify_lean_parity import SKIP, WINDOW_KEYS, canon, load, signal_bars
skip = SKIP + WINDOW_KEYS + ("stream_bars",)
l_all, h_all = load(sym, "1m", cache), load(sym, "5m", cache)
l_ts = l_all["timestamp"].to_numpy("int64")
h_ts = h_all["timestamp"].to_numpy("int64")
# 从最后一个信号根往前 n 根开始,保证这段里一定有信号分支被执行
if start_at is None:
try:
sb = signal_bars(sym, cache)
sb = sb[(sb > BASE_L + n) & (sb < len(l_all) - 1)]
start_at = int(sb[-1]) - n + 5 if len(sb) else BASE_L + 10
except Exception:
start_at = BASE_L + 10
ends = [e for e in range(start_at, start_at + n) if e < len(l_all) - 1]
if not ends:
raise RuntimeError("窗口不足")
ss._STREAMS.clear()
same = diff = 0
t_full = t_incr = 0.0
n_hits = 0
first = None
rebuilds = 0
prev_grown = 0
for e in ends:
hi = int(np.searchsorted(h_ts, l_ts[e], side="right"))
df_l = l_all.iloc[e - BASE_L + 1:e + 1]
df_h = h_all.iloc[max(0, hi - BASE_H):hi]
entry = float(l_all["open"].to_numpy(float)[e + 1])
t0 = time.perf_counter()
rf = ss.compute(df_l.copy(), df_h.copy(), entry, incr=False)
t_full += time.perf_counter() - t0
t0 = time.perf_counter()
ri = ss.compute(df_l.copy(), df_h.copy(), entry, sym=sym, incr=True)
t_incr += time.perf_counter() - t0
grown = len(ss._STREAMS[(sym, "1m")][0].dataframe)
if grown <= prev_grown:
rebuilds += 1
prev_grown = grown
n_hits += len(rf.get("hits") or [])
if canon(rf, skip) == canon(ri, skip):
same += 1
else:
diff += 1
if first is None:
first = (e, canon(rf, skip), canon(ri, skip))
k = len(ends)
print(f" {k} 根 · 一致 {same} · 不一致 {diff} · 命中 {n_hits} 个 · "
f"重建 {rebuilds} 次 · 末窗 {prev_grown}")
print(f" 单根 全量 {t_full / k * 1000:.1f}ms → "
f"增量 {t_incr / k * 1000:.1f}ms "
f"{t_full / max(t_incr, 1e-9):.2f}x")
if first:
e, a, b = first
print(f" ⚠ 首个分歧 idx={e}\n 全量: {a[:300]}\n 增量: {b[:300]}")
ss._STREAMS.clear()
del l_all, h_all
return {"sym": sym, "n": k, "same": same, "diff": diff, "hits": n_hits,
"full_ms": t_full / k * 1000, "incr_ms": t_incr / k * 1000}
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--syms", default="BTC,ETH,SOL")
ap.add_argument("--cache", default="research/live/cache")
ap.add_argument("--n", type=int, default=300)
ap.add_argument("--start", type=int, default=None)
a = ap.parse_args()
rows = []
for sym in a.syms.split(","):
print(f"\n{'=' * 70}\n{sym}")
try:
rows.append(run_one(sym, Path(a.cache), a.n, a.start))
except Exception as e:
print(f" 跳过:{e!r}")
if not rows:
return
d = pd.DataFrame(rows)
print(f"\n\n{'=' * 70}\n汇总\n")
print(f" 对拍 {int(d['n'].sum()):,} 根 · 不一致 {int(d['diff'].sum())} · "
f"命中 {int(d['hits'].sum())}")
print(f" 单根 全量 {d['full_ms'].mean():.1f}ms → "
f"增量 {d['incr_ms'].mean():.1f}ms "
f"{d['full_ms'].sum() / max(d['incr_ms'].sum(), 1e-9):.2f}x")
if int(d["diff"].sum()) == 0:
print("\n 逐字段一致,增量可以上线。")
else:
print("\n ⛔ 有分歧,不要上线。增量流的状态与全量重建不等价。")
if __name__ == "__main__":
main()
+7 -2
View File
@@ -50,7 +50,12 @@ def load(sym: str, tf: str, cache: Path) -> pd.DataFrame:
return pd.read_feather(c[0]) return pd.read_feather(c[0])
def canon(d: dict) -> str: # 随窗口长度必然改变的记账字段。比「窗口长度会不会改信号」时要排除它们,
# 否则一定 0/12 不一致,而那是记账字段在变,不是信号在变
WINDOW_KEYS = ("last_idx", "n_bars")
def canon(d: dict, skip: tuple = SKIP) -> str:
"""把返回值规范化成可比较的字符串。 """把返回值规范化成可比较的字符串。
浮点直接比会被末位差异误判,但 lean 走的是同一段算术、不该有任何差异, 浮点直接比会被末位差异误判,但 lean 走的是同一段算术、不该有任何差异,
@@ -68,7 +73,7 @@ def canon(d: dict) -> str:
return norm(v.item()) return norm(v.item())
return v return v
return json.dumps({k: norm(v) for k, v in sorted(d.items()) return json.dumps({k: norm(v) for k, v in sorted(d.items())
if k not in SKIP}, sort_keys=True, ensure_ascii=False) if k not in skip}, sort_keys=True, ensure_ascii=False)
def signal_bars(sym: str, cache: Path) -> np.ndarray: def signal_bars(sym: str, cache: Path) -> np.ndarray:
+115
View File
@@ -0,0 +1,115 @@
"""compute() 的输出对窗口长度是否不变——增量路径的前提。
## 为什么这是增量路径的前提
`init_stream/append_bar` 没有 trim`dataframe` 靠 `pd.concat` 无界增长。所以
增量方案必然意味着**窗口会长大**(每根 +1),只能靠周期性 `init_stream` 重建
拉回。于是在两次重建之间,实际窗口是 [W, W+slack] 而不是恒定 W。
这就把一个问题摆在前面:如果 `compute()` 的输出随窗口长度变化,增量路径等于
静默把信号换了一批——不报错、不崩,只是测的不再是回测那批信号。
step39 的结论是「命中率在 2000 根饱和」。**饱和不等于不变**:再加根数不再提高
命中率,与「结果逐字段相同」是两回事。所以要单独验。
判据是逐字段相同,不是命中数相同。数量相同而方向或滤网标志不同,会让影子测
的是另一批信号。
python research/live/verify_window_sens.py --syms BTC,ETH,SOL
"""
from __future__ import annotations
import argparse
import os
import sys
import warnings
from pathlib import Path
import numpy as np
import pandas as pd
warnings.filterwarnings("ignore")
for _v in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS"):
os.environ.setdefault(_v, "1")
HERE = Path(__file__).resolve()
sys.path.insert(0, str(HERE.parents[1]))
sys.path.insert(0, str(HERE.parents[2]))
sys.path.insert(0, str(HERE.parent))
BASE_L, BASE_H = 2001, 801
# 增量在两次重建之间会长这么多。取 500 是因为它对应约 8 小时,
# 重建摊薄后单根成本仍接近纯追加
GROW = (0, 200, 500, 1000)
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--syms", default="BTC,ETH,SOL")
ap.add_argument("--cache", default="research/live/cache")
ap.add_argument("--n", type=int, default=40)
a = ap.parse_args()
from shadow_signal import compute
from verify_lean_parity import (SKIP, WINDOW_KEYS, canon, load,
signal_bars)
# last_idx/n_bars 必然随窗口长度变。不排除的话结果一定是「全不一致」,
# 而那说明的是记账字段在变,不是信号在变
skip = SKIP + WINDOW_KEYS
cache = Path(a.cache)
print("同一根上,只改窗口长度,比对 compute() 的全部返回字段")
print(f"基准窗口 1m×{BASE_L} + 5m×{BASE_H};增量会让它长大,故试 "
f"+{GROW[1:]}\n")
tot = {g: [0, 0] for g in GROW[1:]} # [相同, 不同]
for sym in a.syms.split(","):
l_all, h_all = load(sym, "1m", cache), load(sym, "5m", cache)
l_ts = l_all["timestamp"].to_numpy("int64")
h_ts = h_all["timestamp"].to_numpy("int64")
try:
sb = signal_bars(sym, cache)
except Exception as e:
print(f"{sym} 取信号根失败:{e!r}")
continue
need = BASE_L + max(GROW)
sb = sb[(sb > need) & (sb < len(l_all) - 1)][-a.n:]
if len(sb) == 0:
print(f"{sym} 可用信号根不足")
continue
res: dict[int, list[str]] = {g: [] for g in GROW}
for e in sb:
hi = int(np.searchsorted(h_ts, l_ts[e], side="right"))
entry = float(l_all["open"].to_numpy(float)[e + 1])
for g in GROW:
df_l = l_all.iloc[e - (BASE_L + g) + 1:e + 1]
df_h = h_all.iloc[max(0, hi - (BASE_H + g // 5)):hi]
res[g].append(canon(compute(df_l.copy(), df_h.copy(), entry),
skip=skip))
print(f"{sym} {len(sb)} 根信号窗口")
for g in GROW[1:]:
same = sum(1 for x, y in zip(res[0], res[g]) if x == y)
tot[g][0] += same
tot[g][1] += len(sb) - same
print(f" +{g:>4} 根 → 一致 {same}/{len(sb)}"
+ ("" if same == len(sb) else " ⚠ 有分歧"))
del l_all, h_all
print(f"\n{'=' * 66}\n汇总\n")
for g in GROW[1:]:
s, d = tot[g]
print(f" 窗口 +{g:>4} 根:一致 {s} · 不一致 {d}")
worst = max(GROW[1:], key=lambda g: tot[g][1])
if tot[worst][1] == 0:
print("\n 窗口长度不影响输出,增量路径的前提成立。")
print(" 可以按「长到 +N 根再 init_stream 重建」摊薄成本。")
else:
print("\n ⛔ 窗口长度会改变输出,增量路径会静默换掉一批信号。")
print(" 此时要么每根都重建(等于没有增量),要么先把窗口效应本身收口。")
if __name__ == "__main__":
main()