From 5b1d295fd8c4374200de9d7e0f599034f93c1393 Mon Sep 17 00:00:00 2001 From: shaofl <2899218482@qq.com> Date: Thu, 30 Jul 2026 08:51:37 -0700 Subject: [PATCH 01/11] fix(factors): write the bookclose artifacts instead of renaming them after The close book mode used to write the with-book reports under the SHARED `{stem}_exec_with_book.*` names and `os.replace` them to `_bookclose` afterwards. Running the two book modes in the order decision -> close therefore destroyed the decision run's three with-book artifacts: the close run overwrote them, then moved them aside. All eleven factors lost their `exec_with_book.*` in the D5 C5 full run, and the reconcile's reports leg died on a bare FileNotFoundError naming a file that had existed minutes earlier. The suffix now travels INTO the write-out layer (`run_exec_basis_evaluation(..., with_book_suffix=)`, default "" so every existing caller keeps its byte-identical paths), so each book mode only ever writes its own file names and the run order stops mattering. Two tests, at the two levels the behaviour spans: the writer's own (two real runs into one report dir, first run's bytes must survive) and the runner's (decision then close, with the stub applying the suffix at write time exactly as the real writer does, so a wrong suffix writes wrong names). --- qt/exec_basis_eval.py | 20 ++++++++-- qt/factor_eval_runner.py | 42 ++++++++++----------- tests/test_exec_basis_eval.py | 45 ++++++++++++++++++++++ tests/test_factor_eval_runner.py | 64 ++++++++++++++++++++++++++++++-- 4 files changed, 141 insertions(+), 30 deletions(-) diff --git a/qt/exec_basis_eval.py b/qt/exec_basis_eval.py index 3802f91..88bc673 100644 --- a/qt/exec_basis_eval.py +++ b/qt/exec_basis_eval.py @@ -281,6 +281,7 @@ def run_exec_basis_evaluation( stem: str, force_rebuild: bool = False, book_view: str = View.CLOSE.value, + with_book_suffix: str = "", extra_sections: Sequence[SectionLike] | None = None, ) -> ExecBasisEvaluation: """Build the exec-to-exec returns, sanity-check them, evaluate twice, report. @@ -296,6 +297,18 @@ def run_exec_basis_evaluation( ``decision`` explicitly. Only the caller knows this, so it is a parameter rather than something guessed here. + ``with_book_suffix`` (default ``""`` -> the legacy paths, byte-for-byte) + is appended to the WITH-BOOK artifact stem only, so a caller that runs the + same factor under two book views writes them to two different files IN ONE + STEP. It is a write-time parameter and never a post-hoc move: the D5 C5 F5 + defect was exactly that — the close-book run wrote the shared + ``{stem}_exec_with_book.*`` names first and renamed them afterwards, so the + decision-book artifacts written by an earlier run were destroyed between + the write and the rename, and the reconcile's reports leg then failed on a + file that had existed minutes before. The no-book artifacts carry no book + at all and the sanity report is book-independent, so both keep the shared + stem regardless of this suffix. + ``extra_sections`` (default None -> the legacy behavior, byte-for-byte) are add-Sections appended to BOTH exec reports before they are written — the contract's §3.6 extension point ("may ADD sections but never drop a @@ -425,15 +438,14 @@ def run_exec_basis_evaluation( report_no_book = _with_extra_sections(report_no_book, extra_sections) report_with_book = _with_extra_sections(report_with_book, extra_sections) + with_book_stem = f"{stem}_exec_with_book{with_book_suffix}" nb_md, nb_json = _write_report(report_no_book, report_dir, f"{stem}_exec_no_book") - wb_md, wb_json = _write_report( - report_with_book, report_dir, f"{stem}_exec_with_book" - ) + wb_md, wb_json = _write_report(report_with_book, report_dir, with_book_stem) nb_png = render_factor_dashboard( report_no_book, ir_no_book, report_dir / f"{stem}_exec_no_book_dashboard.png" ) wb_png = render_factor_dashboard( - report_with_book, ir_with_book, report_dir / f"{stem}_exec_with_book_dashboard.png" + report_with_book, ir_with_book, report_dir / f"{with_book_stem}_dashboard.png" ) return ExecBasisEvaluation( spec=exec_spec, diff --git a/qt/factor_eval_runner.py b/qt/factor_eval_runner.py index edb1002..5501221 100644 --- a/qt/factor_eval_runner.py +++ b/qt/factor_eval_runner.py @@ -69,9 +69,8 @@ from __future__ import annotations -import os import time -from dataclasses import dataclass, replace +from dataclasses import dataclass from pathlib import Path import pandas as pd @@ -348,23 +347,25 @@ def _coverage_split( return tuple(sorted(covered)), tuple(empty) -def _apply_bookclose_suffix( - exec_basis: ExecBasisEvaluation, report_dir: Path, stem: str -) -> ExecBasisEvaluation: - """``book_mode='close'`` artifact isolation: ``..._exec_with_book_bookclose.*``. +#: ``book_mode='close'`` artifact isolation: the with-book artifacts are +#: written straight to ``..._exec_with_book_bookclose.*``. The no-book +#: artifacts carry no book at all, so they keep the shared stem; the sanity +#: report is book-independent for the same reason. +#: +#: D5 C5 F5: this used to be a POST-HOC ``os.replace`` of files first written +#: under the shared ``_exec_with_book`` stem — which silently destroyed the +#: DECISION-book artifacts of any earlier run, because the close run wrote +#: over them before moving them aside. Running the two book modes in the order +#: decision -> close therefore left no decision artifacts at all, and the +#: reconcile's reports leg died on a bare FileNotFoundError. The suffix now +#: travels INTO the write-out layer, so each book mode only ever writes its +#: own file names and the run order stops mattering. +_BOOKCLOSE_SUFFIX = "_bookclose" - The no-book artifacts carry no book at all, so they keep the shared stem; - the sanity report is book-independent for the same reason. - """ - mapping = { - "with_book_md": report_dir / f"{stem}_exec_with_book_bookclose.md", - "with_book_json": report_dir / f"{stem}_exec_with_book_bookclose.json", - "with_book_dashboard": report_dir - / f"{stem}_exec_with_book_bookclose_dashboard.png", - } - for field_name, dst in mapping.items(): - os.replace(getattr(exec_basis, field_name), dst) - return replace(exec_basis, **mapping) + +def _with_book_suffix(book_mode: str) -> str: + """The with-book artifact suffix for a book mode (see ``_BOOKCLOSE_SUFFIX``).""" + return _BOOKCLOSE_SUFFIX if book_mode == View.CLOSE.value else "" # --------------------------------------------------------------------------- # @@ -477,12 +478,9 @@ def run_factor_eval( report_dir=Path(cfg.output.report_dir), stem=stem, book_view=book_mode, + with_book_suffix=_with_book_suffix(book_mode), extra_sections=extra_sections or None, ) - if book_mode == View.CLOSE.value: - exec_basis = _apply_bookclose_suffix( - exec_basis, Path(cfg.output.report_dir), stem - ) logger.info( "verdict exec no-book: %s (predictive=%s); with-book: %s (incremental=%s)", diff --git a/tests/test_exec_basis_eval.py b/tests/test_exec_basis_eval.py index c9e9aa8..29c4a22 100644 --- a/tests/test_exec_basis_eval.py +++ b/tests/test_exec_basis_eval.py @@ -317,3 +317,48 @@ def test_exec_basis_cli_line_survives_an_absent_metric(tmp_path): # the measured facts are still reported assert "stk_mins_live_calls=0" in line assert "no_bar=" in line + + +def test_with_book_suffix_isolates_the_two_book_views_at_WRITE_time(tmp_path): + """D5 C5 F5: a second book view must not destroy the first one's artifacts. + + The suffix used to be applied AFTER the write, by renaming files that had + already been written under the shared ``_exec_with_book`` stem — so a + close-book run silently overwrote and then moved away the decision-book + run's three artifacts, and the reconcile's reports leg died on files that + had existed minutes earlier. Two runs into ONE report dir, in the order + that used to be destructive; the first run's bytes must survive. + """ + cfg, panel, factor, book, spec, eval_cfg, report_dir = _fixture(tmp_path) + first = run_exec_basis_evaluation( + factor, spec, eval_cfg, book, + cfg=cfg, panel=panel, symbols=list(SYMBOLS), logger=LOGGER, + report_dir=report_dir, stem="demo", book_view="decision", + ) + assert first.with_book_md.name == "demo_exec_with_book.md" + before = { + p: p.read_bytes() + for p in (first.with_book_md, first.with_book_json, first.with_book_dashboard) + } + + second = run_exec_basis_evaluation( + factor, spec, eval_cfg, book, + cfg=cfg, panel=panel, symbols=list(SYMBOLS), logger=LOGGER, + report_dir=report_dir, stem="demo", book_view="close", + with_book_suffix="_bookclose", + ) + + assert second.with_book_md.name == "demo_exec_with_book_bookclose.md" + assert second.with_book_json.name == "demo_exec_with_book_bookclose.json" + assert second.with_book_dashboard.name == "demo_exec_with_book_bookclose_dashboard.png" + for path in (second.with_book_md, second.with_book_json, second.with_book_dashboard): + assert path.exists() + # ... and the first run's artifacts are untouched, byte for byte. + for path, blob in before.items(): + assert path.exists(), f"{path.name} was destroyed by the second run" + assert path.read_bytes() == blob + # The no-book artifacts and the sanity report carry no book and keep the + # shared stem in BOTH runs (they are rewritten, not renamed). + assert second.no_book_md.name == "demo_exec_no_book.md" + assert second.sanity_report_path.name == "demo_exec_basis_sanity.md" + assert second.no_book_md == first.no_book_md diff --git a/tests/test_factor_eval_runner.py b/tests/test_factor_eval_runner.py index 506de3e..40ea529 100644 --- a/tests/test_factor_eval_runner.py +++ b/tests/test_factor_eval_runner.py @@ -382,13 +382,26 @@ def earliest_available(self, symbols): return DATES[0] -def _stub_exec_basis(report_dir, stem): +def _stub_exec_basis(report_dir, stem, with_book_suffix="", book_view=""): + """Stand-in for the write-out layer, honouring its ``with_book_suffix``. + + The suffix is applied HERE, at write time, exactly as + ``qt.exec_basis_eval.run_exec_basis_evaluation`` applies it — so a runner + that passes the wrong suffix writes the wrong file names and the callers' + assertions fail. The written content names the book view, so a test can + tell whose artifact a given file is. + """ report_dir.mkdir(parents=True, exist_ok=True) paths = {} for kind in ("no_book", "with_book"): + stem_for_kind = ( + f"{stem}_exec_with_book{with_book_suffix}" + if kind == "with_book" + else f"{stem}_exec_no_book" + ) for suffix, key in ((".md", "md"), (".json", "json"), ("_dashboard.png", "dashboard")): - p = report_dir / f"{stem}_exec_{kind}{suffix}" - p.write_text("stub", encoding="utf-8") + p = report_dir / f"{stem_for_kind}{suffix}" + p.write_text(f"stub {kind} book_view={book_view}", encoding="utf-8") paths[f"{kind}_{key}"] = p return ExecBasisEvaluation( spec=None, params=None, artifact_path=report_dir / "a.parquet", @@ -424,7 +437,11 @@ def fake_exec_eval(factor_panel, spec, eval_cfg, book, **kwargs): captured.update( factor_panel=factor_panel, spec=spec, eval_cfg=eval_cfg, book=book, **kwargs ) - return _stub_exec_basis(kwargs["report_dir"], kwargs["stem"]) + return _stub_exec_basis( + kwargs["report_dir"], kwargs["stem"], + with_book_suffix=kwargs.get("with_book_suffix", ""), + book_view=kwargs.get("book_view", ""), + ) monkeypatch.setattr( "qt.factor_eval_runner.run_exec_basis_evaluation", fake_exec_eval @@ -515,3 +532,42 @@ def fake_panel(factor_ids, universe, decisions, **kwargs): # every requested symbol had a finite value in the fake panel assert result.covered_symbols == 2 assert result.empty_symbols == 0 + + +def test_running_decision_then_close_leaves_the_decision_artifacts_intact( + monkeypatch, tmp_path +): + """D5 C5 F5: the run ORDER must not decide which artifacts survive. + + The close-book run used to write the shared ``_exec_with_book`` names and + rename them afterwards, so this exact order destroyed the decision run's + three with-book artifacts — every one of the eleven factors lost them in + the C5 full run, and the reconcile's reports leg then died on a bare + FileNotFoundError. The suffix now travels into the write-out layer, so + each book mode only ever writes its own names. + """ + captured: dict = {} + _wire(monkeypatch, tmp_path, captured) + + decision = run_factor_eval( + "ignored.yaml", "jump_amount_corr_20", book_mode="decision" + ) + assert captured["with_book_suffix"] == "" + kept = { + p: p.read_bytes() + for p in ( + decision.exec_basis.with_book_md, + decision.exec_basis.with_book_json, + decision.exec_basis.with_book_dashboard, + ) + } + assert all(b"book_view=decision" in blob for blob in kept.values()) + + close = run_factor_eval("ignored.yaml", "jump_amount_corr_20", book_mode="close") + assert captured["with_book_suffix"] == "_bookclose" + + for path, blob in kept.items(): + assert path.exists(), f"the close run destroyed {path.name}" + assert path.read_bytes() == blob + assert close.exec_basis.with_book_md.name.endswith("_exec_with_book_bookclose.md") + assert b"book_view=close" in close.exec_basis.with_book_md.read_bytes() From ed93c9d1083b4cd4cdd005fae6af11229a528cfd Mon Sep 17 00:00:00 2001 From: shaofl <2899218482@qq.com> Date: Thu, 30 Jul 2026 08:52:01 -0700 Subject: [PATCH 02/11] feat(factors): register the C5 F2/F3/F4 difference classes in the reconcile Three judgement gaps the D5 C5 full reconciliation exposed. Each one is bounded on every axis it can be bounded on, and each bound has a reverse test that fails one step outside it. F2 - `threshold_flip_contamination` gains a BARS-ONLY arm: the same 600623.SH critical-bar flip already registered for the cross-sectional factors, on factors that have no per-date OLS to spread it. Only the directly affected symbol qualifies; the bound is RELATIVE and per factor (peak_interval_kurtosis 5e-3, valley_relative_vwap 1e-5) because the two live on unrelated value scales - measured |diff| 1.07e-2 vs 1.53e-6, so one absolute bound cannot serve both. At most 25 cells; an unregistered factor never gets the class. F3 - two more surfaces of the anchor truncation. (1) frozen-finite -> new-NaN joins the warmup direction set for VALID-DAY POOLED factors inside the early region only: their emission is gated by a count of valid days, so the two geometries can accumulate a different number of them at the anchor edge. A bounded factor has no such gate and keeps failing. (2) A new class `warmup_sparse_valid_day_tail` for the sparse names that carry that early difference past the early window: window [2021-11-01, 2021-11-12], a symbol whitelist, at most 20 cells, pooled factors only. Every (factor, symbol) pair in the class sits below that factor's own median emission density - including 600906.SH, which is below it for peak_ridge and above it for ridge, and appears only in the former's cluster. F4 - the ABSOLUTE float-dust predicate moves ahead of every region branch, so a cell is classified by its mechanism rather than by where it lands. Machine-precision dust is uniform across the grid; letting the region decide first made a handful of dust cells count as warmup cells, and their monthly counts then failed the pooled non-increasing gate on noise (intraday_amp_cut: 2021-08/09/10 = 8/3/9 cells, every one |diff| <= 1e-12). Measured on the same served panels, before -> after: amp_cut's months {07:8198, 08:11, 09:3, 10:9} -> {07:8198} and its float tail 758 -> 798 of 1000; vpq 883 -> 925 of 1000; jump, which sits exactly ON its cap of 101, gains nothing. The relative arm stays where it was. Also prints the per-class max relative difference in the panels summary (review NIT-1): the headline max is taken before any bucketing, so a 9.03e-01 that lies entirely inside a registered class was being printed next to `unclassified=0` and reading like an ungated tolerance. One-sided cells report inf, never 0.0. --- .../factors/d5_runner_difference_catalogue.md | 103 +++++ qt/cli.py | 13 + qt/factor_eval_reconcile.py | 280 +++++++++++- tests/test_factor_eval_reconcile.py | 401 ++++++++++++++++++ 4 files changed, 775 insertions(+), 22 deletions(-) diff --git a/docs/factors/d5_runner_difference_catalogue.md b/docs/factors/d5_runner_difference_catalogue.md index dda052d..27b6586 100644 --- a/docs/factors/d5_runner_difference_catalogue.md +++ b/docs/factors/d5_runner_difference_catalogue.md @@ -580,3 +580,106 @@ NW-t −16.1546 → −16.1323、periods 1199 → 1209;增量 ICIR:`_bookclo **0/1,213 日不同**、max rel **0.0**。评审独立复测(另写脚本、用 `git show main:` 把修复 前后两个模块载进同一进程喂逐字节相同输入)得 max rel **1.457e-01**、且**整日几何 5/5 逐位相等**——"已发布值不移动"由此**直接证得,不靠推理**。 + +### 七之七、C5 全量对账(11 因子 × 三模式)暴露的五类失败 —— 具名类扩展登记(2026-07-30) + +C5 全量跑(`artifacts/logs/factor_eval_reconcile_*.log`,2026-07-28 那批)暴露五类失败 +F1–F5。F1 是**真引擎缺陷**、已由独立 correctness PR 消除(见 §七之六,**永不作为具名类**); +F5 是 harness/runner 的 artifact 销毁缺陷(修码,见本节末)。**只有 F2/F3/F4 是判据缺口**, +本节登记它们的边界与越界反例。 + +**方法**:先把 11 个因子的 served 面板一次性 dump 下来(`tmp/context/cc_c5_audit/`, +cache-only、`live_calls=0`),再离线对**同一批 served 值**跑改动前后的分类器——同一份输入、 +两套判据,差异才可归因到判据本身而不是两次引擎运行。 + +#### F2 —— `threshold_flip_contamination` 的 bars-only 臂(新增) + +与 §七之五裁定 2 的截面臂**同一物理事件**:600623.SH 2023-06-15 13:07 一根 +`volume=13300.0` 整数 bar 压在 same-slot μ+σ 阈值上,pandas rolling 浮点累加**路径依赖 +加载起点**(thr 差 ~4e-13)⇒ `vol > thr` 翻转。bars-only 因子**没有逐日 OLS 去传播它**, +所以只有**直接受影响的那只 symbol** 能动;窗口内任何**别的** symbol 仍然掉进后面的通用尾部, +量级不够就照样 unclassified 失败。 + +| 参数 | 值 | 实测 | +|---|---|---| +| 窗口 | [2023-06-01, 2023-07-14](**与截面臂同窗,未改**) | 实际落点 2023-06-15..07-14 | +| symbol | 仅 600623.SH(**与截面臂同 symbol,未改**) | 两因子的 20 格全在这一只 | +| 判据 | **相对**(截面臂是绝对) | 见下 | +| `peak_interval_kurtosis_20` | rel ≤ **5e-3** | 实测 max rel **2.904e-3**(max abs 1.07e-2) | +| `valley_relative_vwap_20` | rel ≤ **1e-5** | 实测 max rel **1.530e-6**(恒定绝对偏移) | +| cell 数 | ≤ **25**/因子 | 实测各 **20** 格 | + +**为什么 bars-only 臂用相对判据而截面臂用绝对**:这两个因子的值尺度互不相干——kurtosis 的 +20 格 |diff| 到 **1.07e-2**(若沿用截面臂 2e-04 的绝对界会全数越界),relative_vwap 的 +|diff| 只有 1.53e-6。在其中一个上标定的绝对界对另一个没有意义,故按因子登记**相对**界。 +**未登记的因子拿不到这个类**(jump 在同窗同量级仍 unclassified,有反向测试)。 + +#### F3 —— anchor 截断的两个新表象 + +① **`warmup_left_extension` 方向集新增 `frozen_finite_new_nan`**,**仅** valid-day pooled +因子、**仅**早区窗内。机制:这些因子只在有效日发行、且需要 `min_valid_days` 个有效日, +anchor 边缘两种几何**积累到的有效日个数**可以不同(实证 688276.SH 2021-08-20:旧锚 10 个 +有效日 → 出值,饱和加载 8 个 → NaN)。bounded 因子没有这个计数闸门,其 finite→NaN +**仍然**是 unclassified(反向测试锁定);pooled 因子在早区窗**之外**的 finite→NaN 同样仍失败。 +实测:ridge/valley_ridge 各 7 格、peak_ridge 11 格,全在 2021-07-28..08-23(早区窗内)。 + +② **新具名类 `warmup_sparse_valid_day_tail`**:同一 anchor 截断在**稀疏发行**的 valid-day +pooled 因子上的长尾——有效日少的票把早区差异**带出早区窗**,落在 2021 年 11 月上旬一小簇。 + +| 参数 | 值 | 实测 | +|---|---|---| +| 因子形态 | 仅 valid-day pooled | bounded 因子拿不到(反向测试) | +| 窗口 | [2021-11-01, **2021-11-12**] | ridge / valley_ridge 落点即此 | +| symbol 白名单 | 000034.SZ / 000402.SZ / 000999.SZ / 002375.SZ / 002653.SZ / 688183.SH | 两因子各 5 只,并集 6 只 | +| 幅度 | **不设界**(与 warmup 同理由:两种加载几何在该处本就合法地不同) | ridge 最大 rel **1.96**(符号翻转) | +| cell 数 | ≤ **20**/因子 | 实测各 **13** 格 | + +**机制的可证伪印证**:该类涉及的 (因子, symbol) 对**全部 18/18** 落在该因子自身发行密度的 +中位数**以下**(窗口 [2021-07-01, 2021-11-30],中位 40–41 个发行日)。更强的一条: +600906.SH 在 peak_ridge 上发行 29 日(低于中位)而在 ridge 上 42 日(**高于**中位)—— +它也**只**出现在 peak_ridge 的簇里。稀疏性是逐 (因子, symbol) 的,受影响集合随之而变。 +对照组 `volume_peak_count_20`(非稀疏 pooled,发行 90% 的日子):同样这 9 只票**一格不差**。 + +#### F4 —— 绝对 float-dust 谓词前移到所有区域分支之前 + +`|diff| ≤ 1e-12`(§七之五裁定 3 的绝对臂)原先排在 `_in_warmup` **之后**,于是恰好落在 +warmup 区的几格机器精度尘埃被计成 warmup cell,其**按月计数**把 pooled 非递增闸门顶翻—— +实测 `intraday_amp_cut_10` 月计数 `{07: 8198, 08: 11, 09: 3, 10: 9}`,3→9 判非单调,而 +8/9/10 月那 23 格**全部** `abs ≤ 1e-12`,与 2021-11 至 2026-06 每月都有的稳态尾部同一总体。 + +裁定:**按机制识别,不按位置**——绝对臂前移到梯子最前(紧跟 1e-12 相对容差之后)。 +它同时先于截面污染窗口,这是有意的:实测 `intraday_amp_cut_10` 那 17 格"污染"**全部**是尘埃, +现在如实归入 float 尾。相对臂(rel ≤ 5e-12)**仍在原位**(区域分支之后),未动。 + +**回归实测(同一批 served 值,改动前 → 改动后)**: + +| 因子 | warmup | 月计数 | float_tail(cap) | contamination | ok | +|---|---|---|---|---|---| +| `intraday_amp_cut_10` | 8221 → **8198** | `{07:8198, 08:11, 09:3, 10:9}` → **`{07:8198}`** | 758 → **798**(1000) | 17 → **0** | False → **True** | +| `valley_price_quantile_20` | 24568 → **24531** | `{07:902, 08:19980, 09:3667, 10:19}` → `{07:902, 08:19980, 09:3649}` | 883 → **925**(1000) | 19177 → 19172 | True → True | +| `jump_amount_corr_20` | 17289(不变) | — | **101 → 101**(cap 101,**恰在界上、未被顶破**) | 0 → 0 | True → True | +| 其余 8 个因子 | 不变 | 不变 | 0 → 0 | 不变 | 不变 | + +尘埃只在两个因子身上落进过区域分支;两者改动后都**远在** float cap 之内。**恰在 cap 上的 +jump 一格未增**(它的 101 格全在 warmup 区外)。 + +#### F5 —— runner 的 artifact 销毁(修码,不是具名类) + +`qt/factor_eval_runner.py::_apply_bookclose_suffix` 原先**先**用共享 stem 写 +`{stem}_exec_with_book.*`、**再** `os.replace` 移到 `_bookclose` ⇒ 按 decision→close 顺序跑完, +decision 的 with-book 三件套被**覆盖后移走**,全 11 因子的 `exec_with_book.*` 在 C5 全量跑里 +全部消失,reconcile reports 腿死在裸 `FileNotFoundError`。修法:后缀**传进写出层** +(`run_exec_basis_evaluation(..., with_book_suffix=)`,默认 `""` ⇒ 既有调用方逐字节不变), +**不再事后移动**;reconcile reports 模式增加前置检查,缺文件时给出"先跑 +`run-factor-eval --book-mode decision`"的可读错误并列出缺了哪几个。半写的 `_bookclose` +配对(只有 json 或只有 md)同样是可读错误——那说明 close 模式跑到一半死了。 + +#### 本节未覆盖、需 lead 另行裁定的一处 + +`peak_ridge_amount_ratio_20` 的 panels 腿**在 C5 全量跑里就是 FAIL(unclassified=30)**, +而交接文档 §2 的结果总表把它记成 panels=0(通过)——**文档记错,日志为准** +(`artifacts/logs/factor_eval_reconcile_peak_ridge_amount_ratio_20.log`, +`unclassified=30 ... ok=False`)。它属于 F3 同族的**第三个**因子:11 格 ffnn(已由 ① 吸收) ++ 19 格 11 月簇,其中 13 格落在 ② 的窗口/白名单内、**剩 6 格在界外**(3 只未登记的 +symbol 共 5 格 + 688183.SH 在 2021-11-15,晚窗口末日一个交易日)。按 ② 现行参数它仍 FAIL。 +**未自行放宽**——见 C5 审计报告与交接记录。 diff --git a/qt/cli.py b/qt/cli.py index 6a1e44d..e8f59da 100644 --- a/qt/cli.py +++ b/qt/cli.py @@ -632,6 +632,7 @@ def _cmd_run_factor_eval_reconcile(args: argparse.Namespace) -> int: f"frozen={result.rows_frozen} new={result.rows_new} " f"equal={result.equal} within_tol={result.within_tolerance} " f"warmup={len(result.by_class('warmup_left_extension'))} " + f"sparse_tail={len(result.by_class('warmup_sparse_valid_day_tail'))} " f"float_tail={len(result.by_class('float_reordering_tail'))} " f"threshold_flip={len(result.by_class('threshold_flip_tail'))} " f"flip_contamination={len(result.by_class('threshold_flip_contamination'))} " @@ -639,6 +640,18 @@ def _cmd_run_factor_eval_reconcile(args: argparse.Namespace) -> int: f"unclassified={len(unclassified)} " f"max_rel_diff={result.max_rel_diff:.3e}" ) + # The headline max is taken before any bucketing, so print it per + # class too: a big number that sits entirely inside a registered + # class must not read as an ungated tolerance (review NIT-1). + per_class = result.max_rel_by_class() + print( + " max_rel by class: " + + ( + ", ".join(f"{k}={v:.3e}" for k, v in sorted(per_class.items())) + if per_class + else "(no differing cells)" + ) + ) if result.warmup_by_direction: print(f" warmup by direction: {result.warmup_by_direction}") if result.warmup_by_month: diff --git a/qt/factor_eval_reconcile.py b/qt/factor_eval_reconcile.py index 770bb9d..bbdd47e 100644 --- a/qt/factor_eval_reconcile.py +++ b/qt/factor_eval_reconcile.py @@ -49,21 +49,37 @@ class is CEILED at ``(lookback_depth - 1) x |frozen symbols|`` cells shape still FAILS. If the frozen panel has no finite value at all there is no exemption (the conservative direction). Months after the exempt month stay strictly gated. + 1b. ``warmup_sparse_valid_day_tail`` — the SAME anchor-truncation family on + valid-day POOLED factors with a sparse emission grid, whose early-region + difference is carried forward past the early window instead of averaging + out inside it. Registered window [2021-11-01, 2021-11-12], a symbol + whitelist, at most 20 cells per factor, pooled factors only; amplitude + unbounded inside (the geometries legitimately differ, as in the early + region). 2. ``float_reordering_tail`` — scattered finite-vs-finite cells with rel diff <= 5e-12 (rolling-correlation summation order; the JC1 1e-12 gate is the attributable floor, this is the measured tail above it), OR with abs diff <= 1e-12 regardless of rel (lead ruling 3: on near-zero cross-sectional OLS residuals the rel criterion is meaningless — the - measured dust is ~1e-16 machine precision on ~1e-5 residuals). The - class is CAPPED — 101 cells for bars-only factors (the measured jump - count), 1000 for cross-sectional ones (measured vpq 707 + headroom); - more fails. + measured dust is ~1e-16 machine precision on ~1e-5 residuals). The ABS + arm is evaluated FIRST in the ladder, ahead of every region branch + (C5 F4): dust is uniform across the grid, and letting the region decide + first made a handful of dust cells count as warmup cells and fail the + pooled monthly gate on noise. The class is CAPPED — 101 cells for + bars-only factors (the measured jump count), 1000 for cross-sectional + ones (measured vpq 707 + headroom); more fails. 3. ``threshold_flip_tail`` — count factors (volume_peak): rolling-sigma float noise (~4e-10) times an integer volume sitting on the peak threshold flips the count by EXACTLY +/-1 on a sparse (symbol, day) cluster (measured: 20 cells, 600623.SH 2023-06-15..07-14). Bounds: |delta| == 1 exactly, rel <= 1e-2, at most 25 cells; more fails. - 4. ``threshold_flip_contamination`` — CROSS-SECTIONAL factors only (lead + 4. ``threshold_flip_contamination`` — two arms of one physical event. + BARS-ONLY arm (C5 F2): only the directly affected symbol (600623.SH) + inside the same window, bounded per factor by a RELATIVE tolerance + (``BARS_ONLY_FLIP_CONTAMINATION_REL_TOL``, at most 25 cells) — a + bars-only factor has no per-date OLS to spread the flip, so any other + symbol moving there is a new fact. + CROSS-SECTIONAL arm — (lead ruling 2, catalogue §七之五): the same PRV critical-bar flip family as (3), observed on vpq as a CONTINUOUS value change (valley VWAP -> q_day -> qbar) plus second-order contamination of the whole cross @@ -80,9 +96,15 @@ class is CAPPED — 101 cells for bars-only factors (the measured jump served one by the migrated primitives. 5. the jump cutoff reference — handled by the reference-path selection above. - Anything else (finite->NaN, finite-vs-finite beyond every named tail, a - finite value on a row the frozen panel does not have outside the warmup - boundary) is UNCLASSIFIED and fails the mode. Extra all-NaN rows in the + Anything else (finite->NaN outside the one registered direction below, + finite-vs-finite beyond every named tail, a finite value on a row the frozen + panel does not have outside the warmup boundary) is UNCLASSIFIED and fails + the mode. The one registered finite->NaN direction is + ``warmup_left_extension``'s ``frozen_finite_new_nan`` (C5 F3), available to + VALID-DAY POOLED factors inside the early region only: their emission is + gated by a count of valid days, so the two geometries can accumulate a + different number of them at the anchor edge. A bounded factor has no such + gate and its finite->NaN stays unclassified. Extra all-NaN rows in the served panel are the D4c NaN footprint (registered drift #3) and are counted, not failed. @@ -242,9 +264,55 @@ class is CAPPED — 101 cells for bars-only factors (the measured jump #: measured 19,143 cells -> cap 20,000 THRESHOLD_FLIP_CONTAMINATION_MAX_CELLS = 20_000 +#: ``threshold_flip_contamination``, BARS-ONLY arm (C5 F2, lead ruling): the +#: SAME physical event as the cross-sectional arm — the 600623.SH 2023-06-15 +#: 13:07 ``volume=13300.0`` integer bar sitting on the same-slot mu+sigma +#: threshold, where pandas' rolling float accumulation is path-dependent on the +#: load start (threshold differs by ~4e-13) and flips ``vol > thr``. On a +#: bars-only factor there is no per-date OLS to propagate it, so ONLY the +#: directly affected symbol can move; every other symbol inside the window +#: keeps falling through to the generic tails and fails there if it is a real +#: change. The bound is RELATIVE here (the cross-sectional arm's bound is +#: absolute) because these two factors live on unrelated value scales: measured +#: kurtosis rel 8.2e-5..2.9e-3 with |diff| up to 1.07e-02, relative_vwap a +#: constant rel ~1.53e-06. An absolute bound calibrated on one is meaningless +#: for the other. Per factor, so a factor that is not in this map gets NO +#: bars-only contamination class at all. +BARS_ONLY_FLIP_CONTAMINATION_REL_TOL: dict[str, float] = { + #: measured max rel 2.90e-03 -> bound 5e-03 + "peak_interval_kurtosis_20": 5e-03, + #: measured max rel 1.53e-06 -> bound 1e-05 + "valley_relative_vwap_20": 1e-05, +} +#: measured 20 cells per factor (the same 20 trading days as +#: ``threshold_flip_tail``'s volume_peak cluster) -> cap 25 +BARS_ONLY_FLIP_CONTAMINATION_MAX_CELLS = 25 + EARLY_REGION_LO = pd.Timestamp("2021-07-01") EARLY_REGION_HI = pd.Timestamp("2021-10-31") +#: ``warmup_sparse_valid_day_tail`` (C5 F3 ②, lead ruling): the SECOND surface +#: of the same anchor-truncation family as ``warmup_left_extension``, on +#: valid-day POOLED factors whose emission grid is sparse (they publish a value +#: only on valid days, so a stock with few valid days carries an early-region +#: difference forward for months instead of averaging it out within the early +#: window). The cells therefore land OUTSIDE the early region, in a short +#: cluster in early November 2021 on a handful of sparse names — which is why +#: the early-region class cannot absorb them and a separate, tightly bounded +#: class is needed. AMPLITUDE IS NOT BOUNDED inside the class (the measured +#: ridge cells include a sign flip, rel 1.96): the two loading geometries +#: legitimately disagree there for the same reason they do inside the early +#: region. The teeth are the window, the symbol whitelist and the cell cap — +#: all three are checked, and the class is only available to pooled factors. +SPARSE_VALID_DAY_TAIL_LO = pd.Timestamp("2021-11-01") +SPARSE_VALID_DAY_TAIL_HI = pd.Timestamp("2021-11-12") +#: The measured sparse names (union over the affected factors). +SPARSE_VALID_DAY_TAIL_SYMBOLS: frozenset[str] = frozenset({ + "000034.SZ", "000402.SZ", "000999.SZ", "002375.SZ", "002653.SZ", "688183.SH", +}) +#: measured 13 cells per factor -> cap 20 +SPARSE_VALID_DAY_TAIL_MAX_CELLS = 20 + #: factor_id -> the frozen exec artifact's report name (qt.exec_baseline_freeze #: FACTORS). Closed map: an unknown factor id is a readable error, never a guess. _FACTOR_TO_REPORT_NAME: dict[str, str] = { @@ -752,6 +820,32 @@ class PanelDiff: def by_class(self, classification: str) -> list[PanelCellDiff]: return [d for d in self.diffs if d.classification == classification] + def max_rel_by_class(self) -> dict[str, float]: + """Per-class max relative difference, for the run summary. + + ``max_rel_diff`` above is the headline over the WHOLE grid and is + updated before any bucketing, so it is routinely dominated by a + registered class (measured: a headline ``9.03e-01`` that sits entirely + inside ``warmup_left_extension``) while printed next to + ``unclassified=0``. Read alone that invites "the tolerance has no + teeth"; per class it is unambiguous, and since ``diffs`` is never + persisted it would otherwise have to be recomputed from a rerun + (D5 C4a review NIT-1). + + A cell with a missing side (``nan_to_finite`` / ``new_only_finite`` / + ``frozen_finite_new_nan``) has no defined ratio and reports ``inf`` — + never 0.0, which would read as "these cells agree". + """ + out: dict[str, float] = {} + for d in self.diffs: + if d.frozen is None or d.new is None: + rel = float("inf") + else: + denom = max(abs(d.frozen), abs(d.new)) + rel = abs(d.frozen - d.new) / denom if denom > 0 else 0.0 + out[d.classification] = max(out.get(d.classification, 0.0), rel) + return out + def classify_panel_differences( new_values: pd.Series, @@ -768,6 +862,10 @@ def classify_panel_differences( float_tail_max: int | None = None, flip_rel_tol: float = THRESHOLD_FLIP_REL_TOL, flip_max: int = THRESHOLD_FLIP_MAX_CELLS, + sparse_tail_lo: pd.Timestamp = SPARSE_VALID_DAY_TAIL_LO, + sparse_tail_hi: pd.Timestamp = SPARSE_VALID_DAY_TAIL_HI, + sparse_tail_symbols: frozenset[str] = SPARSE_VALID_DAY_TAIL_SYMBOLS, + sparse_tail_max: int = SPARSE_VALID_DAY_TAIL_MAX_CELLS, ) -> PanelDiff: """Classify every cell difference between the served and the frozen panel. @@ -817,6 +915,17 @@ def classify_panel_differences( if is_cross_sectional else FLOAT_TAIL_MAX_CELLS ) + # The bars-only contamination arm is per factor and NEVER available to a + # cross-sectional factor (that one has its own arm, with absolute bounds). + bars_only_flip_tol = ( + None if is_cross_sectional + else BARS_ONLY_FLIP_CONTAMINATION_REL_TOL.get(factor_id) + ) + contamination_max = ( + THRESHOLD_FLIP_CONTAMINATION_MAX_CELLS + if is_cross_sectional + else BARS_ONLY_FLIP_CONTAMINATION_MAX_CELLS + ) result = PanelDiff(factor_id=factor_id) frozen = frozen.copy() frozen["date"] = pd.to_datetime(frozen["date"]) @@ -902,8 +1011,39 @@ def _warmup_cell(date, symbol, frozen_v, new_v, direction: str) -> None: result.max_rel_diff = max(result.max_rel_diff, rel) if rel <= tol: result.within_tolerance += 1 + elif abs_diff <= FLOAT_TAIL_ABS_TOL: + # C5 F4 (lead ruling): the absolute float-dust predicate is + # evaluated BEFORE any REGION branch, so a cell is classified + # by its MECHANISM rather than by where it happens to sit. + # Machine-precision dust exists uniformly across the whole + # grid (measured: every month from 2021-11 to 2026-06 carries + # 6..28 such cells); when the region branches ran first, the + # handful that landed in the warmup region were counted as + # warmup cells, and their month counts (measured + # intraday_amp_cut: 8, 3, 9 in 2021-08/09/10, all |diff| <= + # 1e-12) then failed the pooled non-increasing gate on pure + # noise. Moving the predicate up removes the dust from the + # warmup counts instead of weakening the gate. It also means + # the class caps below now bound the dust wherever it lands. + result.diffs.append( + PanelCellDiff(str(date.date()), str(symbol), float(frozen_v), + float(new_v), "float_reordering_tail") + ) elif _in_warmup(date, symbol): _warmup_cell(date, symbol, frozen_v, new_v, "finite_to_finite") + elif ( + is_pooled + and symbol in sparse_tail_symbols + and sparse_tail_lo <= date <= sparse_tail_hi + ): + # C5 F3 (2) — the sparse valid-day tail of the same anchor + # truncation. Pooled factors only, inside the registered + # window, on a registered sparse name; amplitude unbounded + # inside (see the constant's docstring), cell count capped. + result.diffs.append( + PanelCellDiff(str(date.date()), str(symbol), float(frozen_v), + float(new_v), "warmup_sparse_valid_day_tail") + ) elif ( is_cross_sectional and THRESHOLD_FLIP_CONTAMINATION_LO @@ -930,10 +1070,34 @@ def _warmup_cell(date, symbol, frozen_v, new_v, direction: str) -> None: PanelCellDiff(str(date.date()), str(symbol), float(frozen_v), float(new_v), cls) ) - elif abs_diff <= FLOAT_TAIL_ABS_TOL or rel <= float_tail_tol: - # Lead ruling 3: |diff| <= 1e-12 is float dust REGARDLESS of - # rel (the rel criterion is meaningless on near-zero - # cross-sectional OLS residuals). + elif ( + bars_only_flip_tol is not None + and symbol == THRESHOLD_FLIP_CONTAMINATION_SYMBOL + and THRESHOLD_FLIP_CONTAMINATION_LO + <= date + <= THRESHOLD_FLIP_CONTAMINATION_HI + ): + # C5 F2 (lead ruling): the bars-only arm of the same class. + # Only the DIRECTLY affected symbol qualifies — a bars-only + # factor has no per-date OLS to contaminate the rest of the + # cross section, so any other symbol moving inside this window + # would be a new fact and keeps falling through below. An + # overshoot of the per-factor bound is UNCLASSIFIED here and + # never falls through to the generic tails, exactly as in the + # cross-sectional arm. + cls = ( + "threshold_flip_contamination" + if rel <= bars_only_flip_tol + else "unclassified_finite_vs_finite" + ) + result.diffs.append( + PanelCellDiff(str(date.date()), str(symbol), float(frozen_v), + float(new_v), cls) + ) + elif rel <= float_tail_tol: + # The REL arm of the float tail. (Lead ruling 3's ABS arm now + # runs at the top of the ladder — see the C5 F4 branch — so a + # dust cell is classified the same way wherever it lands.) result.diffs.append( PanelCellDiff(str(date.date()), str(symbol), float(frozen_v), float(new_v), "float_reordering_tail") @@ -949,11 +1113,26 @@ def _warmup_cell(date, symbol, frozen_v, new_v, direction: str) -> None: float(new_v), "unclassified_finite_vs_finite") ) continue - if pd.notna(frozen_v): # finite -> NaN: never in an allowed class - result.diffs.append( - PanelCellDiff(str(date.date()), str(symbol), float(frozen_v), None, - "unclassified_frozen_finite_new_nan") - ) + if pd.notna(frozen_v): + # frozen finite -> new NaN. C5 F3 (1) (lead ruling): this joins the + # warmup direction set for VALID-DAY POOLED factors INSIDE the + # early region, and nowhere else. Mechanism: those factors publish + # only on valid days and need ``min_valid_days`` of them, so at the + # anchor edge the two loading geometries can accumulate a different + # NUMBER of valid days (measured 688276.SH 2021-08-20: 10 under the + # old anchor -> a value, 8 under the saturated load -> NaN). It is + # the same left-extension geometry difference as the other three + # directions, in the one direction that can only appear on a + # factor whose emission is gated by a count. For a BOUNDED factor, + # or outside the early region, finite -> NaN remains unclassified: + # there is no counting gate that could legitimately drop a value. + if is_pooled and _in_warmup(date, symbol): + _warmup_cell(date, symbol, frozen_v, new_v, "frozen_finite_new_nan") + else: + result.diffs.append( + PanelCellDiff(str(date.date()), str(symbol), float(frozen_v), None, + "unclassified_frozen_finite_new_nan") + ) continue # frozen NaN -> new finite: the warmup class or unclassified. if _in_warmup(date, symbol): @@ -997,8 +1176,8 @@ def _warmup_cell(date, symbol, frozen_v, new_v, direction: str) -> None: ) and len(result.by_class("float_reordering_tail")) <= float_tail_max and len(result.by_class("threshold_flip_tail")) <= flip_max - and len(result.by_class("threshold_flip_contamination")) - <= THRESHOLD_FLIP_CONTAMINATION_MAX_CELLS + and len(result.by_class("threshold_flip_contamination")) <= contamination_max + and len(result.by_class("warmup_sparse_valid_day_tail")) <= sparse_tail_max ) return result @@ -1171,24 +1350,73 @@ def run_panels_mode(config_path: str, factor_id: str, repo_root: Path) -> PanelD ) logger.info( "panels %s: rows frozen=%d new=%d equal=%d tol=%d warmup=%d(%s) " - "warmup_ceiling=%s exempt_month=%s " + "warmup_ceiling=%s exempt_month=%s sparse_tail=%d " "float_tail=%d threshold_flip=%d flip_contamination=%d footprint=%d " - "unclassified=%d max_rel=%.3e live_calls=%d ok=%s", + "unclassified=%d max_rel=%.3e max_rel_by_class=%s live_calls=%d ok=%s", factor_id, result.rows_frozen, result.rows_new, result.equal, result.within_tolerance, len(result.by_class("warmup_left_extension")), result.warmup_by_direction, result.warmup_max_cells, result.warmup_exempt_month, + len(result.by_class("warmup_sparse_valid_day_tail")), len(result.by_class("float_reordering_tail")), len(result.by_class("threshold_flip_tail")), len(result.by_class("threshold_flip_contamination")), result.nan_footprint_rows, len([d for d in result.diffs if d.classification.startswith("un")]), - result.max_rel_diff, live_calls, result.ok, + result.max_rel_diff, + {k: f"{v:.3e}" for k, v in sorted(result.max_rel_by_class().items())}, + live_calls, result.ok, ) return result +def require_report_inputs(report_dir: Path, factor_id: str, config_path: str) -> None: + """Fail readably if the reports leg's inputs are not all on disk. + + The four DECISION-book-mode artifacts are required. Before the D5 C5 F5 + fix, running the two book modes in the order decision -> close DESTROYED + the decision with-book artifacts (the close run wrote the shared stem and + renamed it afterwards), and this leg then died on a bare + ``FileNotFoundError`` naming one file — which reads like a run that was + never done, not like a run whose output was deleted after the fact. Naming + every missing file and the command that produces it turns "which of the + six do I have?" into something the message answers. + + The ``_bookclose`` trio stays OPTIONAL (that is the existing contract: a + decision-only run reconciles two grids instead of three), but a HALF + present pair is not — that state means a close-mode run died mid-write. + """ + stem = f"factor_eval_{factor_id}" + required = [ + report_dir / f"{stem}_exec_no_book.json", + report_dir / f"{stem}_exec_no_book.md", + report_dir / f"{stem}_exec_with_book.json", + report_dir / f"{stem}_exec_with_book.md", + ] + missing = [p for p in required if not p.exists()] + if missing: + raise ReconciliationError( + f"reports mode needs the decision-book artifacts of {factor_id!r}, " + f"but {len(missing)} of {len(required)} are absent: " + f"{[p.name for p in missing]}. Produce them first with: " + f"run-factor-eval --config {config_path} --factor {factor_id} " + "--book-mode decision" + ) + bookclose = [ + report_dir / f"{stem}_exec_with_book_bookclose.json", + report_dir / f"{stem}_exec_with_book_bookclose.md", + ] + present = [p for p in bookclose if p.exists()] + if len(present) == 1: + raise ReconciliationError( + f"{factor_id!r} has a HALF-written _bookclose pair " + f"({present[0].name} exists, its counterpart does not). Re-run: " + f"run-factor-eval --config {config_path} --factor {factor_id} " + "--book-mode close" + ) + + def run_reports_mode( config_path: str, factor_id: str, repo_root: Path, *, report_dir: Path | None = None ) -> list[ReportDiff]: @@ -1198,6 +1426,7 @@ def run_reports_mode( cfg = load_config(config_path) report_dir = report_dir or Path(cfg.output.report_dir) report_name = _report_name(factor_id) + require_report_inputs(report_dir, factor_id, config_path) correction_expected = factor_id == "jump_amount_corr_20" registered_sections = REGISTERED_EXTRA_SECTIONS.get(factor_id, ()) section_md_prefixes = _registered_section_md_prefixes(registered_sections) @@ -1338,6 +1567,8 @@ def run_anchors_mode(config_path: str, factor_id: str, repo_root: Path) -> Ancho "ALLOWED_ADDED_JSON_PATHS", "ALLOWED_ADDED_MD_PREFIXES", "AnchorsDiff", + "BARS_ONLY_FLIP_CONTAMINATION_MAX_CELLS", + "BARS_ONLY_FLIP_CONTAMINATION_REL_TOL", "EARLY_REGION_HI", "EARLY_REGION_LO", "FLOAT_TAIL_ABS_TOL", @@ -1351,6 +1582,10 @@ def run_anchors_mode(config_path: str, factor_id: str, repo_root: Path) -> Ancho "REGISTERED_EXTRA_SECTIONS", "ReconciliationError", "ReportDiff", + "SPARSE_VALID_DAY_TAIL_HI", + "SPARSE_VALID_DAY_TAIL_LO", + "SPARSE_VALID_DAY_TAIL_MAX_CELLS", + "SPARSE_VALID_DAY_TAIL_SYMBOLS", "THRESHOLD_FLIP_CONTAMINATION_CROSS_ABS_TOL", "THRESHOLD_FLIP_CONTAMINATION_DIRECT_ABS_TOL", "THRESHOLD_FLIP_CONTAMINATION_HI", @@ -1368,6 +1603,7 @@ def run_anchors_mode(config_path: str, factor_id: str, repo_root: Path) -> Ancho "load_anchor_rows", "load_verified_baseline", "require_baseline_verified", + "require_report_inputs", "run_anchors_mode", "run_panels_mode", "run_reports_mode", diff --git a/tests/test_factor_eval_reconcile.py b/tests/test_factor_eval_reconcile.py index 714edeb..c0b8429 100644 --- a/tests/test_factor_eval_reconcile.py +++ b/tests/test_factor_eval_reconcile.py @@ -1236,3 +1236,404 @@ def test_anchor_jump_mismatch_inside_warmup_is_warmup_not_definition_failure(): hand=0.46, service=0.35, is_pooled=False, tol=1e-12, warmup_dates=warmup, ) assert row.classification == "warmup_left_extension" + + +# --------------------------------------------------------------------------- # +# D5 C5 F4 — the ABSOLUTE float-dust predicate runs BEFORE every region branch. +# +# Machine-precision dust exists uniformly across the grid; when the region +# branches ran first, the few dust cells that happened to land in the warmup +# region were counted as warmup cells and their monthly counts failed the +# pooled non-increasing gate on pure noise (measured intraday_amp_cut: +# 2021-08/09/10 = 8/3/9 cells, every one |diff| <= 1e-12). +# --------------------------------------------------------------------------- # +def test_panels_float_dust_inside_the_warmup_boundary_is_dust_not_warmup(): + # A 1e-13 absolute move on the grid's first w-1 dates: dust, whatever + # region it sits in. + frozen = _frozen_panel( + [("2021-07-01", "A", 1e-5), ("2021-07-02", "A", 1.0), ("2021-07-03", "A", 1.0)], + "f", + ) + new = _new_series( + [("2021-07-01", "A", 1e-5 + 1e-13), ("2021-07-02", "A", 1.0), ("2021-07-03", "A", 1.0)] + ) + result = classify_panel_differences( + new, frozen, factor_id="f", is_pooled=False, lookback_depth=3 + ) + assert result.ok, result.diffs + assert len(result.by_class("float_reordering_tail")) == 1 + assert result.by_class("warmup_left_extension") == [] + + +def test_panels_a_real_move_inside_the_warmup_boundary_is_still_warmup(): + # REVERSE of the above: raise the SAME cell's move above the dust floor + # and it goes back to the warmup class. The reorder must not have turned + # the warmup class off. + frozen = _frozen_panel( + [("2021-07-01", "A", 1.0), ("2021-07-02", "A", 1.0), ("2021-07-03", "A", 1.0)], + "f", + ) + new = _new_series( + [("2021-07-01", "A", 1.05), ("2021-07-02", "A", 1.0), ("2021-07-03", "A", 1.0)] + ) + result = classify_panel_differences( + new, frozen, factor_id="f", is_pooled=False, lookback_depth=3 + ) + assert result.ok, result.diffs + assert len(result.by_class("warmup_left_extension")) == 1 + assert result.by_class("float_reordering_tail") == [] + + +def test_panels_pooled_monthly_gate_is_not_failed_by_float_dust(): + # The measured F4 shape: a big genuine warmup month, then later months + # carrying ONLY dust. Under the old ordering those dust cells were warmup + # cells and 1 -> 2 across months failed the non-increasing gate. + rows_f = [("2021-07-01", "A", 1.0), ("2021-07-02", "A", 1.0)] + rows_f += [(f"2021-08-{d:02d}", "A", 1e-5) for d in (2, 3)] + rows_f += [(f"2021-09-{d:02d}", "A", 1e-5) for d in (1, 2, 3)] + rows_f += [("2021-12-01", "A", 1.0)] + frozen = _frozen_panel(rows_f, "f") + # July: the structurally exempt month (first finite value) + a real warmup + # move on 07-02; Aug/Sep: dust only, in a RISING count (2 then 3). + rows_n = [("2021-07-01", "A", 1.0), ("2021-07-02", "A", 1.4)] + rows_n += [(f"2021-08-{d:02d}", "A", 1e-5 + 1e-13) for d in (2, 3)] + rows_n += [(f"2021-09-{d:02d}", "A", 1e-5 + 1e-13) for d in (1, 2, 3)] + rows_n += [("2021-12-01", "A", 1.0)] + new = _new_series(rows_n) + result = classify_panel_differences( + new, frozen, factor_id="f", is_pooled=True, lookback_depth=40 + ) + assert result.ok, result.diffs + assert result.warmup_by_month == {"2021-07": 1} + assert result.warmup_monotonic is True + assert len(result.by_class("float_reordering_tail")) == 5 + + +def test_panels_pooled_monthly_gate_still_fails_on_real_rising_counts(): + # REVERSE: the same shape with the Aug/Sep cells moved ABOVE the dust + # floor. The gate must still fire — the reorder removed noise from the + # counts, it did not weaken the gate. + rows_f = [("2021-07-01", "A", 1.0), ("2021-07-02", "A", 1.0)] + rows_f += [(f"2021-08-{d:02d}", "A", 1.0) for d in (2, 3)] + rows_f += [(f"2021-09-{d:02d}", "A", 1.0) for d in (1, 2, 3)] + rows_f += [("2021-12-01", "A", 1.0)] + frozen = _frozen_panel(rows_f, "f") + rows_n = [("2021-07-01", "A", 1.0), ("2021-07-02", "A", 1.4)] + rows_n += [(f"2021-08-{d:02d}", "A", 1.2) for d in (2, 3)] + rows_n += [(f"2021-09-{d:02d}", "A", 1.2) for d in (1, 2, 3)] + rows_n += [("2021-12-01", "A", 1.0)] + new = _new_series(rows_n) + result = classify_panel_differences( + new, frozen, factor_id="f", is_pooled=True, lookback_depth=40 + ) + assert not result.ok + assert result.warmup_by_month == {"2021-07": 1, "2021-08": 2, "2021-09": 3} + assert result.warmup_monotonic is False + + +def test_panels_float_dust_inside_the_contamination_window_is_dust(): + # The reorder also precedes the contamination window (measured: all 17 of + # intraday_amp_cut's "contamination" cells were dust and now say so). + frozen = _frozen_panel( + [("2023-06-15", "600623.SH", 1e-5), ("2023-06-16", "600623.SH", 1.0)], "f" + ) + new = _new_series( + [("2023-06-15", "600623.SH", 1e-5 + 1e-13), ("2023-06-16", "600623.SH", 1.0)] + ) + result = classify_panel_differences( + new, frozen, factor_id="f", is_pooled=False, lookback_depth=1, + is_cross_sectional=True, + ) + assert result.ok, result.diffs + assert len(result.by_class("float_reordering_tail")) == 1 + assert result.by_class("threshold_flip_contamination") == [] + + +# --------------------------------------------------------------------------- # +# D5 C5 F2 — threshold_flip_contamination, BARS-ONLY arm (per-factor REL bound) +# --------------------------------------------------------------------------- # +def _flip_cells(factor_id: str, rel: float, n: int, *, symbol="600623.SH"): + """n cells on ``symbol`` inside the registered flip window, at ~``rel``.""" + dates = pd.date_range("2023-06-15", periods=n, freq="D").strftime("%Y-%m-%d") + frozen = _frozen_panel([(d, symbol, 1.0) for d in dates], factor_id) + new = _new_series([(d, symbol, 1.0 / (1.0 - rel)) for d in dates]) + return new, frozen + + +def test_panels_bars_only_flip_contamination_within_the_per_factor_bound(): + new, frozen = _flip_cells("peak_interval_kurtosis_20", 2.9e-03, 3) + result = classify_panel_differences( + new, frozen, factor_id="peak_interval_kurtosis_20", is_pooled=True, + lookback_depth=1, + ) + assert result.ok, result.diffs + assert len(result.by_class("threshold_flip_contamination")) == 3 + + +def test_panels_bars_only_flip_above_the_per_factor_bound_fails(): + # REVERSE: one order of magnitude past kurtosis's 5e-3 bound. + new, frozen = _flip_cells("peak_interval_kurtosis_20", 5e-02, 3) + result = classify_panel_differences( + new, frozen, factor_id="peak_interval_kurtosis_20", is_pooled=True, + lookback_depth=1, + ) + assert not result.ok + assert len(result.by_class("unclassified_finite_vs_finite")) == 3 + + +def test_panels_bars_only_flip_bound_is_PER_FACTOR(): + # The same 1e-3 cell passes for kurtosis (bound 5e-3) and FAILS for + # valley_relative_vwap (bound 1e-5) — a single shared bound calibrated on + # either factor would be wrong about the other. + kurt_new, kurt_frozen = _flip_cells("peak_interval_kurtosis_20", 1e-03, 2) + kurt = classify_panel_differences( + kurt_new, kurt_frozen, factor_id="peak_interval_kurtosis_20", + is_pooled=True, lookback_depth=1, + ) + vwap_new, vwap_frozen = _flip_cells("valley_relative_vwap_20", 1e-03, 2) + vwap = classify_panel_differences( + vwap_new, vwap_frozen, factor_id="valley_relative_vwap_20", + is_pooled=True, lookback_depth=1, + ) + assert kurt.ok and len(kurt.by_class("threshold_flip_contamination")) == 2 + assert not vwap.ok + assert len(vwap.by_class("unclassified_finite_vs_finite")) == 2 + + +def test_panels_bars_only_flip_is_not_available_to_an_unregistered_factor(): + # REVERSE: jump lives in the same window with the same magnitude and is + # NOT in the registry -> it never gets the class. + new, frozen = _flip_cells("jump_amount_corr_20", 2.9e-03, 3) + result = classify_panel_differences( + new, frozen, factor_id="jump_amount_corr_20", is_pooled=False, + lookback_depth=1, + ) + assert not result.ok + assert result.by_class("threshold_flip_contamination") == [] + assert len(result.by_class("unclassified_finite_vs_finite")) == 3 + + +def test_panels_bars_only_flip_is_only_the_directly_affected_symbol(): + # REVERSE: a bars-only factor has no per-date OLS to spread the flip, so + # another symbol moving inside the window is a NEW FACT, not contamination. + new, frozen = _flip_cells("peak_interval_kurtosis_20", 2.9e-03, 3, symbol="600000.SH") + result = classify_panel_differences( + new, frozen, factor_id="peak_interval_kurtosis_20", is_pooled=True, + lookback_depth=1, + ) + assert not result.ok + assert result.by_class("threshold_flip_contamination") == [] + + +def test_panels_bars_only_flip_outside_the_window_fails(): + # REVERSE: same symbol, same magnitude, one day past the window's end. + frozen = _frozen_panel([("2023-07-17", "600623.SH", 1.0)], "peak_interval_kurtosis_20") + new = _new_series([("2023-07-17", "600623.SH", 1.0029)]) + result = classify_panel_differences( + new, frozen, factor_id="peak_interval_kurtosis_20", is_pooled=True, + lookback_depth=1, + ) + assert not result.ok + assert len(result.by_class("unclassified_finite_vs_finite")) == 1 + + +def test_panels_bars_only_flip_cell_cap(): + at_cap_new, at_cap_frozen = _flip_cells("peak_interval_kurtosis_20", 2.9e-03, 25) + at_cap = classify_panel_differences( + at_cap_new, at_cap_frozen, factor_id="peak_interval_kurtosis_20", + is_pooled=True, lookback_depth=1, + ) + over_new, over_frozen = _flip_cells("peak_interval_kurtosis_20", 2.9e-03, 26) + over = classify_panel_differences( + over_new, over_frozen, factor_id="peak_interval_kurtosis_20", + is_pooled=True, lookback_depth=1, + ) + assert at_cap.ok and len(at_cap.by_class("threshold_flip_contamination")) == 25 + # every cell still CLASSIFIES; the cap is a count gate on the class + assert len(over.by_class("threshold_flip_contamination")) == 26 + assert not over.ok + + +def test_panels_bars_only_bound_never_applies_to_a_cross_sectional_factor(): + # Exclusivity: a cross-sectional factor uses the ABSOLUTE arm even if its + # id is in the bars-only registry. rel 2.9e-3 on a value of 1.0 is + # |diff| 2.9e-3, past the direct-symbol 2e-4 absolute bound -> FAIL. + new, frozen = _flip_cells("peak_interval_kurtosis_20", 2.9e-03, 3) + result = classify_panel_differences( + new, frozen, factor_id="peak_interval_kurtosis_20", is_pooled=True, + lookback_depth=1, is_cross_sectional=True, + ) + assert not result.ok + assert len(result.by_class("unclassified_finite_vs_finite")) == 3 + + +# --------------------------------------------------------------------------- # +# D5 C5 F3 (1) — frozen-finite -> new-NaN joins the warmup direction set, for +# VALID-DAY POOLED factors inside the early region only. +# --------------------------------------------------------------------------- # +def test_panels_pooled_frozen_finite_new_nan_in_the_early_region_is_warmup(): + frozen = _frozen_panel( + [("2021-08-20", "688276.SH", 0.42), ("2021-12-01", "688276.SH", 0.5)], "f" + ) + new = _new_series( + [("2021-08-20", "688276.SH", NAN), ("2021-12-01", "688276.SH", 0.5)] + ) + result = classify_panel_differences( + new, frozen, factor_id="f", is_pooled=True, lookback_depth=40 + ) + assert result.ok, result.diffs + assert result.warmup_by_direction == {"frozen_finite_new_nan": 1} + assert result.warmup_by_month == {"2021-08": 1} + + +def test_panels_bounded_frozen_finite_new_nan_is_never_warmup(): + # REVERSE: a bounded factor has no valid-day counting gate that could + # legitimately drop a value — inside its own warmup boundary or not. + frozen = _frozen_panel( + [("2021-08-20", "688276.SH", 0.42), ("2021-12-01", "688276.SH", 0.5)], "f" + ) + new = _new_series( + [("2021-08-20", "688276.SH", NAN), ("2021-12-01", "688276.SH", 0.5)] + ) + result = classify_panel_differences( + new, frozen, factor_id="f", is_pooled=False, lookback_depth=40 + ) + assert not result.ok + assert len(result.by_class("unclassified_frozen_finite_new_nan")) == 1 + + +def test_panels_pooled_frozen_finite_new_nan_outside_the_early_region_fails(): + # REVERSE: the same disappearance one month past the early region. + frozen = _frozen_panel( + [("2021-11-20", "688276.SH", 0.42), ("2021-12-01", "688276.SH", 0.5)], "f" + ) + new = _new_series( + [("2021-11-20", "688276.SH", NAN), ("2021-12-01", "688276.SH", 0.5)] + ) + result = classify_panel_differences( + new, frozen, factor_id="f", is_pooled=True, lookback_depth=40 + ) + assert not result.ok + assert len(result.by_class("unclassified_frozen_finite_new_nan")) == 1 + + +# --------------------------------------------------------------------------- # +# D5 C5 F3 (2) — warmup_sparse_valid_day_tail +# --------------------------------------------------------------------------- # +def _sparse_tail_cells(n: int, *, symbol="000402.SZ", start="2021-11-01", value=-0.5): + dates = pd.bdate_range(start, periods=n).strftime("%Y-%m-%d") + frozen = _frozen_panel([(d, symbol, 0.5) for d in dates], "f") + new = _new_series([(d, symbol, value) for d in dates]) + return new, frozen + + +def test_panels_sparse_valid_day_tail_is_registered_for_pooled_factors(): + # Amplitude is deliberately unbounded inside the class: the measured + # ridge_minute_return cells include a sign flip (rel 1.96). + new, frozen = _sparse_tail_cells(3) + result = classify_panel_differences( + new, frozen, factor_id="f", is_pooled=True, lookback_depth=40 + ) + assert result.ok, result.diffs + assert len(result.by_class("warmup_sparse_valid_day_tail")) == 3 + + +def test_panels_sparse_valid_day_tail_is_not_available_to_bounded_factors(): + new, frozen = _sparse_tail_cells(3) + result = classify_panel_differences( + # lookback_depth=1 -> no warmup dates at all, so the only class that + # could take these cells is the sparse tail, and a bounded factor + # must not get it. + new, frozen, factor_id="f", is_pooled=False, lookback_depth=1 + ) + assert not result.ok + assert result.by_class("warmup_sparse_valid_day_tail") == [] + assert len(result.by_class("unclassified_finite_vs_finite")) == 3 + + +def test_panels_sparse_valid_day_tail_symbol_whitelist_has_teeth(): + # REVERSE: a name that is not on the sparse whitelist, same window. + new, frozen = _sparse_tail_cells(3, symbol="600519.SH") + result = classify_panel_differences( + new, frozen, factor_id="f", is_pooled=True, lookback_depth=40 + ) + assert not result.ok + assert len(result.by_class("unclassified_finite_vs_finite")) == 3 + + +def test_panels_sparse_valid_day_tail_window_has_teeth(): + # REVERSE: one trading day past the window's end (2021-11-12 is a Friday, + # so the next trading day is 2021-11-15). + frozen = _frozen_panel([("2021-11-15", "000402.SZ", 0.5)], "f") + new = _new_series([("2021-11-15", "000402.SZ", -0.5)]) + result = classify_panel_differences( + new, frozen, factor_id="f", is_pooled=True, lookback_depth=40 + ) + assert not result.ok + assert len(result.by_class("unclassified_finite_vs_finite")) == 1 + + +def test_panels_sparse_valid_day_tail_cell_cap(): + at_cap_new, at_cap_frozen = _sparse_tail_cells(20) + over_new, over_frozen = _sparse_tail_cells(21) + at_cap = classify_panel_differences( + at_cap_new, at_cap_frozen, factor_id="f", is_pooled=True, lookback_depth=40, + sparse_tail_hi=pd.Timestamp("2021-12-31"), + ) + over = classify_panel_differences( + over_new, over_frozen, factor_id="f", is_pooled=True, lookback_depth=40, + sparse_tail_hi=pd.Timestamp("2021-12-31"), + ) + assert at_cap.ok and len(at_cap.by_class("warmup_sparse_valid_day_tail")) == 20 + assert len(over.by_class("warmup_sparse_valid_day_tail")) == 21 + assert not over.ok + + +def test_panels_sparse_valid_day_tail_does_not_enter_the_monthly_warmup_counts(): + # It sits OUTSIDE the early region by construction; feeding it into the + # monthly non-increasing machinery would fail that gate by definition. + new, frozen = _sparse_tail_cells(3) + result = classify_panel_differences( + new, frozen, factor_id="f", is_pooled=True, lookback_depth=40 + ) + assert result.warmup_by_month == {} + assert result.warmup_by_direction == {} + + +# --------------------------------------------------------------------------- # +# D5 C4a review NIT-1 — per-class max rel in the run summary +# --------------------------------------------------------------------------- # +def test_max_rel_by_class_separates_a_registered_headline_from_the_gate(): + # The headline max_rel_diff is taken before any bucketing; here it is + # 0.5 and belongs ENTIRELY to the warmup class, while the only other + # differing cell is dust. Printed alone the headline reads like an + # ungated tolerance. + frozen = _frozen_panel( + [("2021-07-01", "A", 1.0), ("2021-07-02", "A", 1.0), ("2021-09-01", "A", 1e-5)], + "f", + ) + new = _new_series( + [("2021-07-01", "A", 2.0), ("2021-07-02", "A", 1.0), ("2021-09-01", "A", 1e-5 + 1e-13)] + ) + result = classify_panel_differences( + new, frozen, factor_id="f", is_pooled=False, lookback_depth=3 + ) + assert result.ok, result.diffs + assert result.max_rel_diff == pytest.approx(0.5) + by_class = result.max_rel_by_class() + assert by_class["warmup_left_extension"] == pytest.approx(0.5) + # ... while the other differing cell is seven orders of magnitude smaller + # (its RATIO is 1e-8 — it qualifies on the absolute arm, which is exactly + # why reading the headline as "the tolerance" is wrong). + assert by_class["float_reordering_tail"] < 1e-6 + + +def test_max_rel_by_class_reports_inf_for_one_sided_cells(): + # A NaN -> finite cell has no ratio; reporting 0.0 would read as "these + # cells agree". + frozen = _frozen_panel([("2021-07-01", "A", NAN), ("2021-07-02", "A", 1.0)], "f") + new = _new_series([("2021-07-01", "A", 0.7), ("2021-07-02", "A", 1.0)]) + result = classify_panel_differences( + new, frozen, factor_id="f", is_pooled=False, lookback_depth=3 + ) + assert result.max_rel_by_class()["warmup_left_extension"] == float("inf") From 4f57980fe0029a963d884aa0531e90e1a09c2b24 Mon Sep 17 00:00:00 2001 From: shaofl <2899218482@qq.com> Date: Thu, 30 Jul 2026 08:54:45 -0700 Subject: [PATCH 03/11] test(factors): pin the reports leg's input preflight Names every missing decision-mode artifact and the command that produces them, instead of dying on whichever file it opened first; a half-written _bookclose pair is its own readable error. --- tests/test_factor_eval_reconcile.py | 51 +++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/test_factor_eval_reconcile.py b/tests/test_factor_eval_reconcile.py index c0b8429..562b2af 100644 --- a/tests/test_factor_eval_reconcile.py +++ b/tests/test_factor_eval_reconcile.py @@ -23,6 +23,7 @@ diff_report_md, frozen_panel_path, require_baseline_verified, + require_report_inputs, ) # --------------------------------------------------------------------------- # @@ -1637,3 +1638,53 @@ def test_max_rel_by_class_reports_inf_for_one_sided_cells(): new, frozen, factor_id="f", is_pooled=False, lookback_depth=3 ) assert result.max_rel_by_class()["warmup_left_extension"] == float("inf") + + +# --------------------------------------------------------------------------- # +# D5 C5 F5 — the reports leg names its missing inputs instead of dying on the +# first one it happens to open. +# --------------------------------------------------------------------------- # +def _write_report_set(report_dir: Path, factor_id: str, *, bookclose: bool): + report_dir.mkdir(parents=True, exist_ok=True) + stem = f"factor_eval_{factor_id}" + names = [ + f"{stem}_exec_no_book.json", f"{stem}_exec_no_book.md", + f"{stem}_exec_with_book.json", f"{stem}_exec_with_book.md", + ] + if bookclose: + names += [ + f"{stem}_exec_with_book_bookclose.json", + f"{stem}_exec_with_book_bookclose.md", + ] + for name in names: + (report_dir / name).write_text("{}", encoding="utf-8") + return stem + + +def test_report_inputs_complete_decision_set_passes_with_or_without_bookclose(tmp_path): + for bookclose in (False, True): + d = tmp_path / f"bc_{bookclose}" + _write_report_set(d, "jump_amount_corr_20", bookclose=bookclose) + require_report_inputs(d, "jump_amount_corr_20", "config/x.yaml") + + +def test_report_inputs_names_every_missing_decision_artifact(tmp_path): + stem = _write_report_set(tmp_path, "jump_amount_corr_20", bookclose=True) + (tmp_path / f"{stem}_exec_with_book.json").unlink() + (tmp_path / f"{stem}_exec_with_book.md").unlink() + with pytest.raises(ReconciliationError) as excinfo: + require_report_inputs(tmp_path, "jump_amount_corr_20", "config/x.yaml") + message = str(excinfo.value) + # BOTH missing files are named (the bare FileNotFoundError named one) ... + assert f"{stem}_exec_with_book.json" in message + assert f"{stem}_exec_with_book.md" in message + # ... and the message says what to run, with the config it was given. + assert "--book-mode decision" in message + assert "config/x.yaml" in message + + +def test_report_inputs_reject_a_half_written_bookclose_pair(tmp_path): + stem = _write_report_set(tmp_path, "jump_amount_corr_20", bookclose=True) + (tmp_path / f"{stem}_exec_with_book_bookclose.md").unlink() + with pytest.raises(ReconciliationError, match="HALF-written"): + require_report_inputs(tmp_path, "jump_amount_corr_20", "config/x.yaml") From 963f510d1584c39959ba63617b66373ad8045e67 Mon Sep 17 00:00:00 2001 From: shaofl <2899218482@qq.com> Date: Thu, 30 Jul 2026 09:02:00 -0700 Subject: [PATCH 04/11] docs(factors): state the measured steady-state dust distribution The F4 argument is that the three disputed months are indistinguishable from every other month, so it has to be backed by the distribution rather than by the three counts alone: 798 cells over all 59 months, 3-28 per month (median 12), 100% of them on the absolute arm, with the disputed 11/3/9 inside that spread. Also records that the two representative factors were re-run through the real harness, not only the offline classifier. --- .../factors/d5_runner_difference_catalogue.md | 12 ++++++++++- qt/factor_eval_reconcile.py | 21 ++++++++++--------- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/docs/factors/d5_runner_difference_catalogue.md b/docs/factors/d5_runner_difference_catalogue.md index 27b6586..9cc08a5 100644 --- a/docs/factors/d5_runner_difference_catalogue.md +++ b/docs/factors/d5_runner_difference_catalogue.md @@ -645,7 +645,9 @@ pooled 因子上的长尾——有效日少的票把早区差异**带出早区 `|diff| ≤ 1e-12`(§七之五裁定 3 的绝对臂)原先排在 `_in_warmup` **之后**,于是恰好落在 warmup 区的几格机器精度尘埃被计成 warmup cell,其**按月计数**把 pooled 非递增闸门顶翻—— 实测 `intraday_amp_cut_10` 月计数 `{07: 8198, 08: 11, 09: 3, 10: 9}`,3→9 判非单调,而 -8/9/10 月那 23 格**全部** `abs ≤ 1e-12`,与 2021-11 至 2026-06 每月都有的稳态尾部同一总体。 +8/9/10 月那 23 格**全部** `abs ≤ 1e-12`。**独立复算的稳态对照**:该因子改动后的 798 格 +float 尾铺满窗口**全部 59 个月**,每月 **3–28 格(中位 12)**、**100% 走绝对臂**——被争议的 +8/9/10 三个月(11 / 3 / 9 格)**正落在这个分布之内**,与任何别的月份区分不开。 裁定:**按机制识别,不按位置**——绝对臂前移到梯子最前(紧跟 1e-12 相对容差之后)。 它同时先于截面污染窗口,这是有意的:实测 `intraday_amp_cut_10` 那 17 格"污染"**全部**是尘埃, @@ -683,3 +685,11 @@ decision 的 with-book 三件套被**覆盖后移走**,全 11 因子的 `exec_ + 19 格 11 月簇,其中 13 格落在 ② 的窗口/白名单内、**剩 6 格在界外**(3 只未登记的 symbol 共 5 格 + 688183.SH 在 2021-11-15,晚窗口末日一个交易日)。按 ② 现行参数它仍 FAIL。 **未自行放宽**——见 C5 审计报告与交接记录。 + +**判据改动经真实 harness 复核(非只跑离线分类器)**:`run-factor-eval-reconcile --mode panels` +在真实缓存上重跑两个代表因子,数字与离线分析逐项一致—— +`intraday_amp_cut_10` **rc=0**(`warmup=8198 float_tail=798 flip_contamination=0 +unclassified=0 monotonic=True`)、`peak_interval_kurtosis_20` **rc=0** +(`warmup=35152 flip_contamination=20 unclassified=0`)。新的逐类 max 行同时给出 +`max_rel by class: float_reordering_tail=2.946e-09, warmup_left_extension=inf`—— +headline 的 `1.976e+00` 完全属于 warmup 类,闸门类只有 2.9e-09,这正是 NIT-1 要消除的误读。 diff --git a/qt/factor_eval_reconcile.py b/qt/factor_eval_reconcile.py index bbdd47e..9312b3b 100644 --- a/qt/factor_eval_reconcile.py +++ b/qt/factor_eval_reconcile.py @@ -1015,16 +1015,17 @@ def _warmup_cell(date, symbol, frozen_v, new_v, direction: str) -> None: # C5 F4 (lead ruling): the absolute float-dust predicate is # evaluated BEFORE any REGION branch, so a cell is classified # by its MECHANISM rather than by where it happens to sit. - # Machine-precision dust exists uniformly across the whole - # grid (measured: every month from 2021-11 to 2026-06 carries - # 6..28 such cells); when the region branches ran first, the - # handful that landed in the warmup region were counted as - # warmup cells, and their month counts (measured - # intraday_amp_cut: 8, 3, 9 in 2021-08/09/10, all |diff| <= - # 1e-12) then failed the pooled non-increasing gate on pure - # noise. Moving the predicate up removes the dust from the - # warmup counts instead of weakening the gate. It also means - # the class caps below now bound the dust wherever it lands. + # Machine-precision dust is uniform across the whole grid — + # measured on intraday_amp_cut: 798 cells spread over all 59 + # months of the window, 3..28 per month (median 12), 100% of + # them on this absolute arm. When the region branches ran + # first, the cells that happened to land in the warmup region + # were counted as WARMUP cells, and their month counts (11, 3, + # 9 for 2021-08/09/10 — squarely inside that 3..28 spread) + # then failed the pooled non-increasing gate on pure noise. + # Moving the predicate up removes the dust from the warmup + # counts instead of weakening the gate, and puts it under the + # float-tail cap wherever it lands. result.diffs.append( PanelCellDiff(str(date.date()), str(symbol), float(frozen_v), float(new_v), "float_reordering_tail") From b241f926c4f049c4a5513aa811ef9606472adf8c Mon Sep 17 00:00:00 2001 From: shaofl <2899218482@qq.com> Date: Thu, 30 Jul 2026 09:15:21 -0700 Subject: [PATCH 05/11] docs(factors): transcribe the pre-fix C5 numbers into the committed report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The C5 first-run results existed only in artifacts/logs/, which is gitignored and lives on one machine — and `_make_logger` opens in truncate mode, so the phase-B rerun will overwrite every one of those files. This repo has already lost a full run's log once to a /tmp cleanup. The report is the durable carrier; the logs are not. Adds the per-factor x three-mode pre-fix table with each leg's counters, the unclassified count and the ok flag, and states the provenance of every row: eight transcribed from surviving log text, two from a verbatim capture taken before my own validation reruns overwrote them (cross-checked against an offline re-derivation on the same served panels under main's classifier), and one from catalogue section 7.6. The offline re-derivation reproduces the table cell for cell on the nine non-amp_marginal factors, so it stands as an evidence base without having to trust the logs. Corrects section 1.2's characterisation of the bounded warmup cell ceiling. It is (w-1) x |symbols|, a STRUCTURAL upper bound that cannot be exceeded unless the date-boundary predicate itself is broken — a backstop that only fires once another guard has already failed, not a magnitude or density gate. The measured 91.5% occupancy is not "8.5% of headroom"; it is the fraction of symbols with a differing warmup cell, which is structurally near one. And it does NOT close C4a review LOW-1: the class still has no magnitude bound at all, with a measured max rel of 1.996 (a sign flip) inside it. Registers three holes in the evidence base: the first C5 run executed on a classifier predating PR #109 so the ceiling never ran (impact settled — all three bounded factors clear it on current HEAD); the reports leg produced zero judgements for all eleven factors because every rc=1 was F5, making phase B its first observation and leaving the frozen baseline as the A/B/C decomposition's only reference object; and `_make_logger`'s truncate mode as a known operational risk, same failure family as D5a's "one run destroys the only frozen baseline" — not fixed here, that is a behaviour change. --- docs/factors/d5_c5_audit_report.md | 91 ++++++++++++++++++++++++++++-- 1 file changed, 87 insertions(+), 4 deletions(-) diff --git a/docs/factors/d5_c5_audit_report.md b/docs/factors/d5_c5_audit_report.md index f373443..a2f2188 100644 --- a/docs/factors/d5_c5_audit_report.md +++ b/docs/factors/d5_c5_audit_report.md @@ -29,10 +29,18 @@ sha256 manifest 入 git),**绝不读 live 路径**——新 runner 写同名文件,跑一次就毁掉 唯一基线。 - **cache-only**:每次 service 取数后断言 `stk_mins_live_calls == 0`,非零即 ABORT。 -- **harness 加固(本准备步)**:bounded `warmup_left_extension` 类加**结构性 cell 数 - ceiling** =(w−1 个 warmup 交易日)×(冻结面板 symbol 数)——该类按定义不可能超过 - 此界,越界即 FAIL(C4a 评审 LOW-1;类内**幅度仍不设限**的既有裁定不变,理由:两种 - 加载几何在该区本来就合法地不同,区外仍由 1e-12 把门)。 +- **bounded `warmup_left_extension` 的 cell ceiling 是 backstop,不是主闸门(措辞已修正)**: + ceiling =(w−1 个 warmup 交易日)×(冻结面板 symbol 数)是该判据下 warmup 格数的 + **结构上限**——只要 `_in_warmup` 的**日期边界判据本身**没坏,它在**数学上就不可能被 + 超过**。所以它**既不是量级闸门也不是密度闸门**,而是"日期边界判据自身有没有坏"的 + **兜底**;**只有在另一个守卫已经失效时它才会响**。 + 实测占用率 91.4%–91.5%(8198/8955、17289/18905、17272/18905)**不是"还有 8.5% 余量" + 的意思**:占用率 =(warmup 区内真有差异的票数)/(全部票数),结构上本就该接近 1, + 它既不是警报也不是头寸。 + ⚠️ **C4a 评审 LOW-1 的量级缺口仍然敞着**:bounded `warmup_left_extension` 类**至今 + 没有任何量级上限**,本轮实测落在 warmup 类里的 max rel 高达 **1.996**(符号翻转)。 + 区外仍由 1e-12 把门,区内不设限是既有裁定(两种加载几何在该区本来就合法地不同)—— + **但读者不许把上面那个 ceiling 当成填补了这个缺口**。 ## 二、预登记漂移清单(对账白名单) @@ -58,6 +66,56 @@ unclassified → FAIL。** | 13 | `book_view_effect`(with_book(decision) 格) | decision 书 vs 冻结 close 书的 Incremental 轴数值差:**全量报告、不设闸**;闸在 `_bookclose` 格(见 §四) | harness 设计(a)/(b) 分解;交接 §3 D5 C4 ⚠️ | | 14 | `hand_anchors_d2.json` 报 `all_ok_frozen14: False` | 70 行 5 处失配**全部且仅有 jump**(手算已截断 vs 冻结 `panels_d2` 未截断)——正确信号非回归 | 交接 §3 #5 | +## 二之二、C5 首轮全量跑的原始结果(pre-fix)—— 誊自日志,**耐久化** + +> **为什么誊在这里**:这些数字原本只存在于 `artifacts/logs/`(gitignored、只在本机), +> 而 C5 的重跑会把同名日志**全部覆盖**——`qt.pipeline._make_logger` 以 truncate 模式打开。 +> 本项目已经因为 `/tmp` 被清理丢过一次全量跑的总日志。**报告是耐久载体,日志不是。** +> 下表是 F1/F2/F3/F4/F5 全部修复**之前**的状态,B 阶段的结果表(§三)与它对照阅读。 + +**运行环境**:11 因子 × {panels, reports, anchors},2026-07-28 01:50→05:52 一轮 sweep, +cache-only(每行 `live_calls=0`)。**⚠️ 该轮跑在 PR #109 之前的判据上**(见 §六.6)。 + +### panels 腿(`unclassified` 与 `ok` 为判定值,其余为分类计数) + +| 因子 | frozen / new | warmup | float_tail | flip | contam | footprint | **unclassified** | max_rel | **ok** | +|---|---|---|---|---|---|---|---|---|---| +| minute_ideal_amp_10 | 1159263 / 1205160 | 8198 | 0 | 0 | 0 | 45897 | **0** | 1.996e+00 | **True** | +| jump_amount_corr_20 | 1159263 / 1205160 | 17289 | 101 | 0 | 0 | 45897 | **0** | 1.787e+00 | **True** | +| volume_peak_count_20 | 1149313 / 1205160 | 34441 | 0 | 20 | 0 | 46788 | **0** | 8.453e-01 | **True** | +| valley_price_quantile_20 | 1146878 / 1205160 | 24568 | 883 | 0 | 19177 | 58282 | **0** | 1.999e+00 | **True** | +| amp_marginal_anomaly_vol_20 | 1159263 / — | — | — | — | — | — | **729029** | — | **False** (F1) | +| peak_interval_kurtosis_20 | 1149313 / 1205160 | 35152 | 0 | 0 | 0 | 46790 | **20** | 1.233e+00 | **False** (F2) | +| valley_relative_vwap_20 | 1146878 / 1205160 | 35205 | 0 | 0 | 0 | 49235 | **20** | 8.041e-03 | **False** (F2) | +| valley_ridge_vwap_ratio_20 | 591524 / 1205160 | 28161 | 0 | 0 | 0 | 608309 | **20** | 1.293e-02 | **False** (F3) | +| ridge_minute_return_20 | 588536 / 1205160 | 27811 | 0 | 0 | 0 | 611318 | **20** | 1.999e+00 | **False** (F3) | +| intraday_amp_cut_10 | 1154288 / 1158842 | 8221 | 758 | 0 | 17 | 0 | **0** | 1.976e+00 | **False** (F4,按月计数非单调) | +| peak_ridge_amount_ratio_20 | 579030 / 1205160 | 27896 | 0 | 0 | 0 | 620916 | **30** | 7.360e-01 | **False** (F3 第三个因子) | + +**出处(逐条)**:8 行誊自幸存日志原文(快照 `tmp/context/cc_c5_audit/c5_run_logs_preserved/`, +mtime 保留);`peak_interval_kurtosis_20` 与 `intraday_amp_cut_10` 两行的日志**已被本轮 +判据验证重跑覆盖**,其值出自 ① 覆盖前的逐字抓取与 ② 用同一批 served 面板在 **main 判据** +下的离线独立重算——两者一致;`amp_marginal_anomaly_vol_20` 一行的日志已被 F1 修复重跑 +覆盖,其值出自编目 §七之六(该行本就是 F1 的证据,非本轮新测)。 + +**交叉印证**:离线独立重算(不读日志,直接对 dump 的 served 面板跑 main 判据)在 **9 个 +非 amp_marginal 因子上逐格复现上表**——这使离线重算可以独立当证据基用,而不必信任日志。 + +### anchors 腿 + +| 因子 | rows | ok | warmup | failed | 判定 | +|---|---|---|---|---|---| +| minute_ideal_amp_10 / jump / volume_peak / ridge / valley_ridge / peak_ridge | 5 | 3 | 2 | 0 | **True** | +| valley_price_quantile_20 / valley_relative_vwap_20 / intraday_amp_cut_10 | 5 | 4 | 1 | 0 | **True** | +| peak_interval_kurtosis_20 | 5 | 2 | 3 | 0 | **True** | +| amp_marginal_anomaly_vol_20 | 5 | — | — | **3** | **False** (F1;出处编目 §七之六) | + +### reports 腿 + +**11/11 全部 rc=1,且每一个都是 F5**——`{stem}_exec_with_book.json` 被 close 模式的 +事后改名销毁,`run_reports_mode` 抛 `FileNotFoundError`。**这不是判定结果**:该腿在 C5 +首轮**没有对任何因子产生过一个判定**(详见 §六.7)。 + ## 三、每因子结果表(TBD) 填写口径:panels = 具名类 cell 计数 + unclassified 计数(必须为 0);reports = @@ -91,6 +149,11 @@ unregistered 计数(strict 格必须为 0);anchors = ok/warmup/failed 行 - **C−B = 书视图修正**:`with_book(decision)` 格的 `book_view_effect` 叶子(decision 书 vs close 书的预期差异,全量列出不设闸)——即 §1.1 活缺陷(close(d) 书)的修正幅度。 +> ⚠️ **参照物只有冻结 exec 基线,没有"上一轮 C5"可比**。A/B/C 三列全部是**新 artifact +> vs `artifacts/refactor_baseline/exec_baseline/` 的 77 份冻结产物**的比较。C5 首轮的 +> reports 腿对 11 个因子**一个判定都没产生过**(全是 F5 抛异常,见 §二之二与 §六.7), +> 所以本表**不是**与前一轮 C5 的对比——**读者不许这么读**。 + | 因子 | A(no_book 各类计数) | B(bookclose 各类计数) | C−B(book_view_effect 叶子数 / 涉及轴) | 分解闭合?(B 超 A 的部分是否全部具名) | |---|---|---|---|---| | jump_amount_corr_20 | TBD | TBD | TBD | TBD | @@ -129,3 +192,23 @@ unregistered 计数(strict 格必须为 0);anchors = ok/warmup/failed 行 (限行 + 省略标记已落地,C5 重渲染后残留形态需目视确认)。 5. **census 守卫 cwd 陷阱**:读真实语料的 census 测试在无语料的 worktree 里静默 skip——全量对账的 gate 行必须报「语料可达/不可达」两组数字,别只看总数。 +6. **C5 首轮全量跑执行在 PR #109 之前的判据上 ⇒ cell ceiling 一次都没上过场**(登记, + 不补跑)。`warmup_ceiling=` 字段由 `35de346`(2026-07-28 **02:08**)引入,而 sweep 跑在 + **01:50→05:52**;**幸存的 8 条日志行没有一条带这个字段**(带的三条全是 07-30 的重跑) + ⇒ 整轮 sweep 用的是 #109 之前的分类器。**影响已结清**:ceiling 只对 bounded 因子有效, + 在当前 HEAD(ceiling 生效)上重算,三个 bounded 因子全过——8198/8955、17289/18905、 + 17272/18905。**没有藏住任何失败。** 不补跑的理由:B 阶段本来就在当前 HEAD 上重跑全部 + 11 个因子,ceiling 届时真正上场。但**首轮那一列 panels 结果是另一套判据产出的**, + §二之二 的表要这么读。 +7. **reports 腿在 C5 首轮零观测**(登记):11 个 `rc=1` **全部是 F5**(缺输入文件抛异常), + 没有一个是判定结果。⇒ 该腿至今只被真正观测过**一个**因子(`amp_marginal_anomaly_vol_20`, + F1 修复后重跑 rc=0,六格差异全落在已登记类内)。**B 阶段是这一腿的首次观测**;§四的 + A/B/C 分解因此**只有冻结基线一个参照物**(见 §四的告示)。B 中若 reports 出现类外差异: + **停下上报,不扩类**。 +8. **`_make_logger` 是 truncate 模式 —— 重跑会静默销毁同名日志的唯一副本**(登记为已知 + 运维风险,**本 PR 不改**:那是行为改动,属另一个 PR)。本轮已被它咬过一次:为验证判据 + 改动而重跑 `intraday_amp_cut_10` / `peak_interval_kurtosis_20` 的 panels 腿,覆盖掉了这 + 两条 C5 原始日志行(值经"覆盖前逐字抓取 + 离线独立重算"双路复原,见 §二之二 出处)。 + 与 D5a 抓到的"跑一次就毁掉唯一冻结基线"是**同一族失效模式**:**产物是唯一副本,而写它 + 的动作没有意识到这一点**。缓解:`tmp/context/cc_c5_audit/c5_run_logs_preserved/` 存了 + B 之前的快照,且 §二之二 已把数字誊进**被提交的**报告。 From 6beb8d8adab6b79cb44f9e52c06660f9f7a34a62 Mon Sep 17 00:00:00 2001 From: shaofl <2899218482@qq.com> Date: Thu, 30 Jul 2026 09:28:07 -0700 Subject: [PATCH 06/11] feat(factors): re-derive the sparse valid-day tail from all three factors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The window and whitelist were derived from TWO factors. The third, peak_ridge_amount_ratio_20, was failing at the time with unclassified=30 and had been recorded as passing in the handoff's results table — so it never entered the evidence base. This is not "widening the class to cover it": the old parameters were fitted to an incomplete sample, and these are derived from all three. How the omission happened is recorded in the catalogue: the reports leg was rc=1 for all eleven factors at once (F5 destroying its inputs), one whole column was uniformly dead, and attention went to which factors carried an F1-F4 label rather than back to the logs for that row. Membership is now stated as the MECHANISM it claims — the symbol's valid-day emission density on that factor is below that factor's own median — with the whitelist as that criterion's enumeration in this window. The claim was falsified rather than assumed: all 18 in-class (factor, symbol) pairs sit below their factor's median, 0 violate it, and no whitelisted name is dense on every affected factor. The sharpest confirmation is an asymmetry the mechanism predicts: 600906.SH is below its median on peak_ridge and above it on ridge, and appears in the former's cluster and not the latter's. Control group: the dense pooled factor volume_peak_count_20 has zero cells on those same names. Two committed tests re-run the check whenever the frozen panels are on disk. The catalogue records an ANTI-RATCHET constraint: this class must not be widened again. Keep relaxing and unclassified can always be driven to zero, at which point the class describes the residual instead of a mechanism and "an uncatalogued difference is a failure" has been repealed by degrees. A future factor needing it is evidence of exactly that -> stop and report. Boundaries: window [2021-11-01, 2021-11-15], nine names, cap unchanged at 20 (measured 13/13/19). Reverse tests re-cut to the new edge from both sides, and the whitelist test now reads an INDEPENDENTLY written expectation - deriving the parametrization from the constant under test could not detect a name being dropped (measured: removing one took the run from 9 passed to 8 passed, green both times). --- .../factors/d5_runner_difference_catalogue.md | 76 ++++++++--- qt/factor_eval_reconcile.py | 73 ++++++++--- tests/test_factor_eval_reconcile.py | 123 +++++++++++++++++- 3 files changed, 233 insertions(+), 39 deletions(-) diff --git a/docs/factors/d5_runner_difference_catalogue.md b/docs/factors/d5_runner_difference_catalogue.md index 9cc08a5..f61dd01 100644 --- a/docs/factors/d5_runner_difference_catalogue.md +++ b/docs/factors/d5_runner_difference_catalogue.md @@ -626,19 +626,45 @@ anchor 边缘两种几何**积累到的有效日个数**可以不同(实证 68 ② **新具名类 `warmup_sparse_valid_day_tail`**:同一 anchor 截断在**稀疏发行**的 valid-day pooled 因子上的长尾——有效日少的票把早区差异**带出早区窗**,落在 2021 年 11 月上旬一小簇。 +**成员判据是机制,白名单只是它在本窗口的枚举**——这一类的**可证伪内容**是: + +> 一格属于此类,是因为**该票在该因子上的有效日发行密度,低于该因子自身的中位数**。 + +这样的票发行日太少,早区的差异来不及在早区窗内摊平,于是被**带出**早区窗——这正是早区类 +吸收不了它们、需要单独一类的原因。 + | 参数 | 值 | 实测 | |---|---|---| | 因子形态 | 仅 valid-day pooled | bounded 因子拿不到(反向测试) | -| 窗口 | [2021-11-01, **2021-11-12**] | ridge / valley_ridge 落点即此 | -| symbol 白名单 | 000034.SZ / 000402.SZ / 000999.SZ / 002375.SZ / 002653.SZ / 688183.SH | 两因子各 5 只,并集 6 只 | +| 窗口 | [2021-11-01, **2021-11-15**] | 三因子落点的并集 | +| symbol 白名单 | 000034.SZ / 000402.SZ / 000999.SZ / **002281.SZ** / 002375.SZ / 002653.SZ / **300857.SZ** / **600906.SH** / 688183.SH(**9 只**) | 三因子各 5 / 5 / 8 只,并集 9 只 | | 幅度 | **不设界**(与 warmup 同理由:两种加载几何在该处本就合法地不同) | ridge 最大 rel **1.96**(符号翻转) | -| cell 数 | ≤ **20**/因子 | 实测各 **13** 格 | - -**机制的可证伪印证**:该类涉及的 (因子, symbol) 对**全部 18/18** 落在该因子自身发行密度的 -中位数**以下**(窗口 [2021-07-01, 2021-11-30],中位 40–41 个发行日)。更强的一条: -600906.SH 在 peak_ridge 上发行 29 日(低于中位)而在 ridge 上 42 日(**高于**中位)—— -它也**只**出现在 peak_ridge 的簇里。稀疏性是逐 (因子, symbol) 的,受影响集合随之而变。 -对照组 `volume_peak_count_20`(非稀疏 pooled,发行 90% 的日子):同样这 9 只票**一格不差**。 +| cell 数 | ≤ **20**/因子 | 实测 13 / 13 / **19** 格 | + +**证伪检查(做了,不是假设)**:该类涉及的 **18/18** 个 (因子, symbol) 对全部低于该因子自身 +的发行密度中位数(窗口 [2021-07-01, 2021-11-30],中位 40–41 个发行日),**0 处违反**;9 只 +白名单票**没有一只**在所有受影响因子上都是稠密的。**最锋利的一条是机制做出的不对称预言**: +600906.SH 在 peak_ridge 上发行 29 日(低于中位)、在 ridge 上 42 日(**高于**中位)——它也 +**只**出现在 peak_ridge 的簇里、**不**出现在 ridge 的。稀疏性是逐 (因子, symbol) 的,受影响 +集合随之而变。对照组 `volume_peak_count_20`(稠密 pooled,92 天里发行 83 天):同样这 9 只票 +**一格不差**。该检查已落成 committed 测试,真实冻结面板在盘上时**每次都跑**(mutation 实证: +往白名单里塞一只三个因子上都稠密的票 `000009.SZ`(48/41、49/41、47/40)→ 测试转红)。 + +**⚠️ 反棘轮约束(lead 裁定):本类不得再次放宽。** 一直放宽下去,`unclassified` 总能归零, +而那时具名类就不再是关于机制的主张、只是**残差的描述**,「未编目的差异算失败」(设计 §六.5) +也就被悄悄废掉了。**若日后又有因子或格需要它,那件事本身就是"此类在描述残差而非机制"的证据 +⇒ 停下上报,不要再打补丁。** + +**这组参数是怎么被推错、又怎么重新推的(记录在案)**:初版是 6 只票 / 窗口收在 **11-12**, +**只从两个因子推出来**——因为第三个因子 `peak_ridge_amount_ratio_20` 当时**正在 FAIL +(`unclassified=30`)却被交接文档的结果总表记成通过**(表记 panels=0,而 +`artifacts/logs/factor_eval_reconcile_peak_ridge_amount_ratio_20.log` 白纸黑字 +`unclassified=30 ... ok=False`)。**漏记是怎么发生的**:reports 腿当时对全部 11 个因子都 +rc=1(F5 的 artifact 销毁),三列里有一列整体失效,逐因子核对的注意力被"哪些因子有 F1–F4 +标签"牵走,`peak_ridge` 这一行的 panels 值没有回到日志复核。**所以现在这组参数不是"为了 +覆盖 peak_ridge 而放宽的",而是"原参数推自一个残缺的证据基、现在从三个因子重新推的"。** +后续为防同类问题,11 因子 × 3 模式的「表所记 vs 日志实测」全量对照已做(33 格中仅此 1 格 +不符)并誊进审计报告 §二之二。 #### F4 —— 绝对 float-dust 谓词前移到所有区域分支之前 @@ -678,13 +704,31 @@ decision 的 with-book 三件套被**覆盖后移走**,全 11 因子的 `exec_ #### 本节未覆盖、需 lead 另行裁定的一处 -`peak_ridge_amount_ratio_20` 的 panels 腿**在 C5 全量跑里就是 FAIL(unclassified=30)**, -而交接文档 §2 的结果总表把它记成 panels=0(通过)——**文档记错,日志为准** -(`artifacts/logs/factor_eval_reconcile_peak_ridge_amount_ratio_20.log`, -`unclassified=30 ... ok=False`)。它属于 F3 同族的**第三个**因子:11 格 ffnn(已由 ① 吸收) -+ 19 格 11 月簇,其中 13 格落在 ② 的窗口/白名单内、**剩 6 格在界外**(3 只未登记的 -symbol 共 5 格 + 688183.SH 在 2021-11-15,晚窗口末日一个交易日)。按 ② 现行参数它仍 FAIL。 -**未自行放宽**——见 C5 审计报告与交接记录。 +(本节初稿在此留过一处待裁定项:`peak_ridge_amount_ratio_20` 是 F3 同族的第三个因子, +按当时的窄参数仍有 6 格类外。已由 lead 裁定为**重新推导**而非扩张,处置见上文的 +「成员判据是机制」与「这组参数是怎么被推错、又怎么重新推的」两段。) + +#### 落地后的实测(同一批 served 面板,最终判据) + +**11/11 因子 panels 腿 `unclassified=0`、全部 `ok=True`**: + +| 因子 | warmup | sparse_tail | float_tail (cap) | flip | contam | 月单调 | +|---|---|---|---|---|---|---| +| minute_ideal_amp_10 | 8198 | 0 | 0 (101) | 0 | 0 | — | +| jump_amount_corr_20 | 17289 | 0 | **101 (101)** | 0 | 0 | — | +| amp_marginal_anomaly_vol_20 | 17272 | 0 | 0 (101) | 0 | 0 | — | +| volume_peak_count_20 | 34441 | 0 | 0 (101) | 20 | 0 | True | +| valley_price_quantile_20 | 24531 | 0 | 925 (1000) | 0 | 19172 | True | +| intraday_amp_cut_10 | 8198 | 0 | 798 (1000) | 0 | 0 | True | +| peak_interval_kurtosis_20 | 35152 | 0 | 0 (101) | 0 | **20** | True | +| valley_relative_vwap_20 | 35205 | 0 | 0 (101) | 0 | **20** | True | +| valley_ridge_vwap_ratio_20 | 28168 | **13** | 0 (101) | 0 | 0 | True | +| ridge_minute_return_20 | 27818 | **13** | 0 (101) | 0 | 0 | True | +| peak_ridge_amount_ratio_20 | 27907 | **19** | 0 (101) | 0 | 0 | True | + +⚠️ **`jump_amount_corr_20` 的 float_tail 恰好等于它的 cap(101/101),零余量**——这个 cap +当初就是按该因子的观测值定死的,**再多一格就 FAIL**。本轮的判据改动没有往里加格(它那 101 +格全在 warmup 区外),但**这个零余量本身是登记在案的脆弱点**,别等它某天自己炸。 **判据改动经真实 harness 复核(非只跑离线分类器)**:`run-factor-eval-reconcile --mode panels` 在真实缓存上重跑两个代表因子,数字与离线分析逐项一致—— diff --git a/qt/factor_eval_reconcile.py b/qt/factor_eval_reconcile.py index 9312b3b..a192583 100644 --- a/qt/factor_eval_reconcile.py +++ b/qt/factor_eval_reconcile.py @@ -52,10 +52,14 @@ class is CEILED at ``(lookback_depth - 1) x |frozen symbols|`` cells 1b. ``warmup_sparse_valid_day_tail`` — the SAME anchor-truncation family on valid-day POOLED factors with a sparse emission grid, whose early-region difference is carried forward past the early window instead of averaging - out inside it. Registered window [2021-11-01, 2021-11-12], a symbol - whitelist, at most 20 cells per factor, pooled factors only; amplitude - unbounded inside (the geometries legitimately differ, as in the early - region). + out inside it. Membership is a MECHANISM — the symbol's valid-day + emission density on that factor is below that factor's own median — and + the whitelist is that criterion's enumeration in this window (checked, + not assumed: 18/18 in-class pairs satisfy it). Registered window + [2021-11-01, 2021-11-15], a symbol whitelist, at most 20 cells per + factor, pooled factors only; amplitude unbounded inside (the geometries + legitimately differ, as in the early region). MUST NOT BE WIDENED AGAIN + — see the constants' anti-ratchet note. 2. ``float_reordering_tail`` — scattered finite-vs-finite cells with rel diff <= 5e-12 (rolling-correlation summation order; the JC1 1e-12 gate is the attributable floor, this is the measured tail above it), OR with @@ -293,24 +297,55 @@ class is CEILED at ``(lookback_depth - 1) x |frozen symbols|`` cells #: ``warmup_sparse_valid_day_tail`` (C5 F3 ②, lead ruling): the SECOND surface #: of the same anchor-truncation family as ``warmup_left_extension``, on -#: valid-day POOLED factors whose emission grid is sparse (they publish a value -#: only on valid days, so a stock with few valid days carries an early-region -#: difference forward for months instead of averaging it out within the early -#: window). The cells therefore land OUTSIDE the early region, in a short -#: cluster in early November 2021 on a handful of sparse names — which is why -#: the early-region class cannot absorb them and a separate, tightly bounded -#: class is needed. AMPLITUDE IS NOT BOUNDED inside the class (the measured -#: ridge cells include a sign flip, rel 1.96): the two loading geometries -#: legitimately disagree there for the same reason they do inside the early -#: region. The teeth are the window, the symbol whitelist and the cell cap — -#: all three are checked, and the class is only available to pooled factors. +#: valid-day POOLED factors whose emission grid is sparse. +#: +#: THE CLASS'S FALSIFIABLE CONTENT — the membership criterion is a MECHANISM, +#: and the whitelist below is only its enumeration in this window: +#: +#: a cell belongs here because THAT SYMBOL'S valid-day emission density +#: ON THAT FACTOR is below THAT FACTOR'S OWN median. +#: +#: Such a name publishes on too few days for an early-region difference to +#: average out inside the early window, so it carries the difference forward +#: past it — which is why these cells land OUTSIDE the early region and the +#: early-region class cannot absorb them. +#: +#: The mechanism is falsifiable and was checked, not assumed. Measured over +#: [2021-07-01, 2021-11-30] on the frozen panels: all 18 in-class +#: (factor, symbol) pairs sit below their factor's own median emission (medians +#: 40-41 days), and 0 violate it. The sharpest confirmation is an ASYMMETRY the +#: mechanism predicts: 600906.SH emits 29 days on peak_ridge (below its median) +#: and 42 on ridge (ABOVE its median) — and it appears in the former's cluster +#: and NOT in the latter's. Control group: volume_peak_count_20, a DENSE pooled +#: factor (83 of 92 days), has zero cells on these same nine names. +#: ``tests/test_factor_eval_reconcile.py`` re-runs this check against the real +#: frozen panels whenever the corpus is on disk. +#: +#: AMPLITUDE IS NOT BOUNDED inside the class (the measured ridge cells include a +#: sign flip, rel 1.96): the two loading geometries legitimately disagree there +#: for the same reason they do inside the early region. The teeth are the +#: window, the symbol whitelist and the cell cap — all three are checked, and +#: the class is only available to pooled factors. +#: +#: ⚠️ ANTI-RATCHET (lead ruling, C5): THIS CLASS MUST NOT BE WIDENED AGAIN. +#: Keep relaxing a class and ``unclassified`` can always be driven to zero, at +#: which point the class stops being a claim about a mechanism and becomes a +#: description of the residual — and "an uncatalogued difference is a failure" +#: (design §六.5) has been quietly repealed. If a future factor or cell needs +#: this class, that need is ITSELF evidence that the class is describing +#: residuals rather than a mechanism: STOP AND REPORT, do not patch the bounds. SPARSE_VALID_DAY_TAIL_LO = pd.Timestamp("2021-11-01") -SPARSE_VALID_DAY_TAIL_HI = pd.Timestamp("2021-11-12") -#: The measured sparse names (union over the affected factors). +SPARSE_VALID_DAY_TAIL_HI = pd.Timestamp("2021-11-15") +#: The enumeration of the criterion above across the THREE affected factors +#: (ridge_minute_return / valley_ridge_vwap_ratio / peak_ridge_amount_ratio). +#: ⚠️ An earlier version of this constant held SIX names and ran to 11-12, +#: derived from only TWO of those factors — because the third was FAILING at +#: the time and had been recorded as passing (see catalogue §七之七). SPARSE_VALID_DAY_TAIL_SYMBOLS: frozenset[str] = frozenset({ - "000034.SZ", "000402.SZ", "000999.SZ", "002375.SZ", "002653.SZ", "688183.SH", + "000034.SZ", "000402.SZ", "000999.SZ", "002281.SZ", "002375.SZ", + "002653.SZ", "300857.SZ", "600906.SH", "688183.SH", }) -#: measured 13 cells per factor -> cap 20 +#: measured 13 / 13 / 19 cells across the three factors -> cap 20 SPARSE_VALID_DAY_TAIL_MAX_CELLS = 20 #: factor_id -> the frozen exec artifact's report name (qt.exec_baseline_freeze diff --git a/tests/test_factor_eval_reconcile.py b/tests/test_factor_eval_reconcile.py index 562b2af..8464ac4 100644 --- a/tests/test_factor_eval_reconcile.py +++ b/tests/test_factor_eval_reconcile.py @@ -15,6 +15,7 @@ import pytest from qt.factor_eval_reconcile import ( + SPARSE_VALID_DAY_TAIL_SYMBOLS, ReconciliationError, check_new_pair_consistency, classify_anchor_row, @@ -1563,10 +1564,10 @@ def test_panels_sparse_valid_day_tail_symbol_whitelist_has_teeth(): def test_panels_sparse_valid_day_tail_window_has_teeth(): - # REVERSE: one trading day past the window's end (2021-11-12 is a Friday, - # so the next trading day is 2021-11-15). - frozen = _frozen_panel([("2021-11-15", "000402.SZ", 0.5)], "f") - new = _new_series([("2021-11-15", "000402.SZ", -0.5)]) + # REVERSE, re-cut for the re-derived boundary: the window now ends + # 2021-11-15 (a Monday), so the first date OUTSIDE it is 2021-11-16. + frozen = _frozen_panel([("2021-11-16", "000402.SZ", 0.5)], "f") + new = _new_series([("2021-11-16", "000402.SZ", -0.5)]) result = classify_panel_differences( new, frozen, factor_id="f", is_pooled=True, lookback_depth=40 ) @@ -1688,3 +1689,117 @@ def test_report_inputs_reject_a_half_written_bookclose_pair(tmp_path): (tmp_path / f"{stem}_exec_with_book_bookclose.md").unlink() with pytest.raises(ReconciliationError, match="HALF-written"): require_report_inputs(tmp_path, "jump_amount_corr_20", "config/x.yaml") + + +def test_panels_sparse_valid_day_tail_last_registered_day_is_inside(): + # The re-derived window's LAST day must be inside it (the boundary moved + # to 2021-11-15 because one real cell sat there); paired with the reverse + # test above, the two pin the edge from both sides. + frozen = _frozen_panel([("2021-11-15", "000402.SZ", 0.5)], "f") + new = _new_series([("2021-11-15", "000402.SZ", -0.5)]) + result = classify_panel_differences( + new, frozen, factor_id="f", is_pooled=True, lookback_depth=40 + ) + assert result.ok, result.diffs + assert len(result.by_class("warmup_sparse_valid_day_tail")) == 1 + + +#: The re-derived membership, written out INDEPENDENTLY of the constant. +#: Parametrizing over ``SPARSE_VALID_DAY_TAIL_SYMBOLS`` itself cannot detect a +#: name being dropped — the case simply disappears and the suite still passes +#: (measured: removing 600906.SH took the run from 9 passed to 8 passed, green +#: both times). A test whose expectations are read from the thing under test +#: is the shape this repo has been bitten by repeatedly. +_EXPECTED_SPARSE_TAIL_SYMBOLS = ( + "000034.SZ", "000402.SZ", "000999.SZ", "002281.SZ", "002375.SZ", + "002653.SZ", "300857.SZ", "600906.SH", "688183.SH", +) + + +def test_the_sparse_tail_whitelist_is_exactly_the_re_derived_membership(): + assert SPARSE_VALID_DAY_TAIL_SYMBOLS == frozenset(_EXPECTED_SPARSE_TAIL_SYMBOLS) + assert len(_EXPECTED_SPARSE_TAIL_SYMBOLS) == 9 + + +@pytest.mark.parametrize("symbol", _EXPECTED_SPARSE_TAIL_SYMBOLS) +def test_panels_sparse_tail_whitelist_members_are_each_accepted(symbol): + # Every registered name is really usable (a typo'd ticker would sit in the + # constant looking authoritative while never matching anything). + frozen = _frozen_panel([("2021-11-02", symbol, 0.5)], "f") + new = _new_series([("2021-11-02", symbol, -0.5)]) + result = classify_panel_differences( + new, frozen, factor_id="f", is_pooled=True, lookback_depth=40 + ) + assert result.ok, result.diffs + assert len(result.by_class("warmup_sparse_valid_day_tail")) == 1 + + +# --------------------------------------------------------------------------- # +# The sparse-tail class's FALSIFIABLE CONTENT, checked against the real frozen +# panels: membership is a MECHANISM (the symbol's valid-day emission density on +# that factor is below that factor's own median), and the whitelist is only +# that criterion's enumeration. A name that is dense everywhere would falsify +# the mechanism story — which is the whole reason the class is allowed to exist. +# --------------------------------------------------------------------------- # +_SPARSE_TAIL_FACTORS = ( + "ridge_minute_return_20", + "valley_ridge_vwap_ratio_20", + "peak_ridge_amount_ratio_20", +) +#: The emission window the medians are taken over: the early region plus the +#: November cluster the class covers. +_DENSITY_LO, _DENSITY_HI = "2021-07-01", "2021-11-30" +_FROZEN_PANELS = Path("artifacts/refactor_baseline/panels") + +requires_frozen_panels = pytest.mark.skipif( + not all((_FROZEN_PANELS / f"{f}.parquet").exists() for f in _SPARSE_TAIL_FACTORS), + reason="frozen D1 panels not on disk (artifacts/ is gitignored)", +) + + +def _emission_density(factor_id: str) -> tuple[dict[str, int], float]: + """(symbol -> emitted days, the factor's median) over the density window.""" + frame = pd.read_parquet(_FROZEN_PANELS / f"{factor_id}.parquet") + frame["date"] = pd.to_datetime(frame["date"]) + window = frame[(frame["date"] >= _DENSITY_LO) & (frame["date"] <= _DENSITY_HI)] + emitted = window.groupby("symbol")[factor_id].apply(lambda s: int(s.notna().sum())) + return emitted.to_dict(), float(emitted.median()) + + +@requires_frozen_panels +def test_every_sparse_tail_name_is_sparse_on_some_affected_factor(): + """Each whitelisted name must satisfy the density criterion SOMEWHERE. + + A name that is at or above the median on all three affected factors would + mean the class is admitting it for some other reason than the mechanism the + class claims — i.e. the whitelist would be a residual description. The + ruling on that is explicit: STOP, do not widen. + """ + density = {f: _emission_density(f) for f in _SPARSE_TAIL_FACTORS} + offenders = {} + for symbol in sorted(SPARSE_VALID_DAY_TAIL_SYMBOLS): + seen = { + f: (emitted.get(symbol), median) + for f, (emitted, median) in density.items() + if symbol in emitted + } + if not any(e < m for e, m in seen.values()): + offenders[symbol] = seen + assert not offenders, ( + "whitelisted name(s) are NOT sparse on any affected factor, so the " + f"class is not describing the mechanism it claims: {offenders}" + ) + + +@requires_frozen_panels +def test_the_density_criterion_predicts_where_600906_appears_and_where_it_does_not(): + """The asymmetry that makes the criterion a prediction rather than a label. + + 600906.SH is below its median on peak_ridge and ABOVE it on ridge — and it + shows up in the former's November cluster and not in the latter's. If the + criterion were decorative, this pair would not split. + """ + peak_emitted, peak_median = _emission_density("peak_ridge_amount_ratio_20") + ridge_emitted, ridge_median = _emission_density("ridge_minute_return_20") + assert peak_emitted["600906.SH"] < peak_median + assert ridge_emitted["600906.SH"] >= ridge_median From edebb4a7ee6ebda1b52193711637f1da32d3799e Mon Sep 17 00:00:00 2001 From: shaofl <2899218482@qq.com> Date: Thu, 30 Jul 2026 12:28:18 -0700 Subject: [PATCH 07/11] feat(factors): register the two drifts the reports leg had never shown anyone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until phase B this leg had produced ZERO judgements for ten of the eleven factors — every rc=1 was F5 destroying its inputs — so neither drift below had ever been seen. Same shape as the peak_ridge case: the registrations were fitted to a sample of one, not widened after the fact. A. The diagnostics-sink coverage disclosures arrive as an appended section. REGISTERED_EXTRA_SECTIONS held only vpq's, because vpq was the only factor whose reports leg had ever run. Re-verified per factor and per grid rather than assumed: every leaf under the appended index is old=None with no value rewrite; the verdict labels are unchanged; and there is NO INDEX DISPLACEMENT — sections 8 -> 9 with the first eight names pairwise identical and the disclosure appended last. That third check matters because leaves are compared position by position, so a section inserted mid-list would shift every later one and "the rest match" would be an illusion. It is detected rather than assumed, and a test pins the mechanism. B. spec.description value rewrites, registered as per-factor EXACT (old, new) pairs. This is a DIFFERENT class from the section 7 spec drift, which covers added keys; this is an existing key whose value changed. No predicate: a blanket "description rewrites are fine" rule would wave through the day a factor's stated meaning really changes, and spec.description is the field a reader uses to learn what the factor computes. Cause is the D2 taxonomy move, and D2 was measured bitwise identical (PR #89, 14/14 at max_rel_diff=0.0), so this is provenance, not semantics — no version bump, no correction carrier, by the same criterion as F1. Both carry anti-ratchet notes: a further member is evidence the registration describes residuals rather than a mechanism -> stop and report. Two defects found while doing it, both by observation rather than by a test: the displacement test passed for the WRONG REASON (a trailing extra index satisfied its weaker assertion; registering name changes left it green), now asserting the displaced sections[k].name directly; and the Markdown prefixes, derived from dataclasses.fields alone, missed the two COMPUTED properties to_section adds on top of asdict, leaving three rendered lines unregistered with the JSON side already green. That tuple is now author-once in qt.factor_eval_disclosures and a test compares the prefixes against the keys a real section payload actually carries. Reports leg: 11/11 rc=0, zero unregistered leaves. Scope of the rerun was established mechanically, not asserted: an AST diff against the phase-B commit shows the changed functions are exactly _classify_value_change, diff_report_json and run_reports_mode, with every panels/anchors entry point byte-identical. --- .../factors/d5_runner_difference_catalogue.md | 75 +++++ qt/factor_eval_disclosures.py | 14 +- qt/factor_eval_reconcile.py | 176 +++++++++- tests/test_factor_eval_reconcile.py | 316 +++++++++++++++++- 4 files changed, 559 insertions(+), 22 deletions(-) diff --git a/docs/factors/d5_runner_difference_catalogue.md b/docs/factors/d5_runner_difference_catalogue.md index f61dd01..ca62e8b 100644 --- a/docs/factors/d5_runner_difference_catalogue.md +++ b/docs/factors/d5_runner_difference_catalogue.md @@ -737,3 +737,78 @@ unclassified=0 monotonic=True`)、`peak_interval_kurtosis_20` **rc=0** (`warmup=35152 flip_contamination=20 unclassified=0`)。新的逐类 max 行同时给出 `max_rel by class: float_reordering_tail=2.946e-09, warmup_left_extension=inf`—— headline 的 `1.976e+00` 完全属于 warmup 类,闸门类只有 2.9e-09,这正是 NIT-1 要消除的误读。 + +### 七之八、C5 phase B 首次观测 reports 腿暴露的两类漂移 —— 登记(2026-07-30) + +**为什么到现在才出现**:C5 首轮 reports 腿对**全部 11 个因子**都 rc=1,而每一个都是 F5 +(artifact 被 close 模式的事后改名销毁 → 抛 `FileNotFoundError`),**没有一个是判定结果**。 +也就是说这一腿在 phase B 之前**从未对这 10 个因子产生过任何观测**。下面两类漂移不是"新出现 +的",是**第一次被人看见**——与 peak_ridge 同一形状:**登记表原本就不完整,因为它是在一个 +样本量为 1 的证据基上建的**。 + +#### 成因 A:诊断 sink 的覆盖率披露作为**追加节**出现(`REGISTERED_EXTRA_SECTIONS` 补全) + +| 因子 | 新增节 | +|---|---| +| `valley_ridge_vwap_ratio_20` | `ridge_scarcity_coverage` | +| `ridge_minute_return_20` | `ridge_scarcity_coverage` | +| `peak_ridge_amount_ratio_20` | `peak_scarcity_coverage` | +| (已登记的先例)`valley_price_quantile_20` | `neutralization_coverage` | + +与 §七之四 #1 的 vpq 完全同形:统一 runner 经 §3.6 扩展点把机制 A 的披露装配进报告,冻结 +artifact 早于它。**vpq 之所以是唯一一条,正因为 vpq 是唯一跑过 reports 腿的因子。** + +**逐因子 × 逐 grid 重新验证(裁定要求,实测非假定)**: + +| 检查 | 结果 | +|---|---| +| ① 追加下标的每个叶子都是 `old=None` 纯新增、无值改写 | **8/8 grid 全过,non_pure=0** | +| ② verdict 标签与冻结基线一致 | **8/8 全过,label_diff=none** | +| ③ **无索引位移** | **8/8:sections `8 → 9`,前 8 个名字逐位对齐,披露节追加在最后** | + +③ 的重要性:叶子路径是**按下标**展平比对的,中途插入一节会让其后每一节错位,"其余节全同" +就成了假象。**位移是被检出而非被吸收的**——错位后 `sections[k].name` 会成为 unregistered +**change** 而失败,有专测钉住(并有 mutation:把 `.name` 改动登记掉 → 该测试转红)。 +⚠️ 该测试初版只断言"有 unregistered 差异",**mutation 实测它是靠尾部多出的下标过的、不是靠 +名字错位**——即**为错误的理由通过**;已改成断言存在 `sections[k].name` 的 unregistered +change。这是本轮第 3 次"测试绿但理由不对",同样不是被测试抓住的,是被 mutation 抓住的。 + +#### 成因 B:`spec.description` 值改写(**逐对精确枚举,不用谓词**) + +**这与 §七 登记的 spec 漂移是不同的类**:那条覆盖**新增键**(16→20),这条是**已有键的值 +改写**,两者不合并进同一节。 + +涉及 3 个因子(`peak_interval_kurtosis_20` / `valley_relative_vwap_20` / +`valley_ridge_vwap_ratio_20`),登记方式是**按因子逐条写死确切的 (old, new) 字符串对** +(`REGISTERED_SPEC_DESCRIPTION_REWRITES`)。**任何其它 description 改写——第四个因子,或这 +三个上的另一段新文本——一律不登记、照样 FAIL**(有正反向测试 + mutation:把精确对改成 +"description 改写一律放行"的谓词 → 反向测试转红)。*理由*:`spec.description` 正是读者用来 +知道这个因子算什么的字段;一个宽泛谓词会把**真正改了因子定义陈述**的那一天一起放行。 +**精确枚举在这里是优点,不是笨拙。** + +**语义判定:provenance 改写,不是语义改写** ⇒ 不触发 `spec.version` bump、不触发更正承载 +(与 §七之六 F1 同一判据)。成因是 **D2 迁移**把峰/谷/岭分类从 +`data.clean.intraday_volume_prv` 挪到 `factors.compute.minute.primitives`,三个因子的 +description 相应改名(`REUSED from ...` → `SHARED taxonomy in ...`);而 **D2 本身实测逐位 +一致**(PR #89:14/14 面板 `max_rel_diff=0.0`),因子算的东西一个字节没变。 + +登记的字符串是 artifact 里**已被报告写出层截断**的值(两侧各 214 字符、以截断标记收尾)—— +**故意如此**:登记必须匹配消费者真正打开的东西。截断上限若变,这些对就不再匹配、本腿**响亮 +失败**,这是正确方向。另有一条测试拿**盘上的冻结 artifact** 逐字符核对登记的 old 串 +(mutation:改一个字符 → 转红)。 + +#### 两条共同约束 + +- **正反向测试齐备**:登记项恰好命中 → 归类;未登记的 section 名 / 未登记的 description 对 + / 别的因子的 section → **全部 FAIL 成类外**。 +- ⚠️ **反棘轮**:这两条**都不得再次放宽**。再有新成员出现,说明它在描述残差而非机制 + (成因 A 的机制 = 诊断 sink 的纯新增节;成因 B 的机制 = D2 的一次性 provenance 改写) + ⇒ **停下上报,不要再打补丁。** + +#### 重跑范围(裁定要求:先确认是否触及共用代码) + +改动**只在 reports 腿**:AST 逐函数比对 phase B 那个 commit(`6beb8d8`)与本次 HEAD,变化的 +函数**恰为** `_classify_value_change` / `diff_report_json` / `run_reports_mode` 三个; +`classify_panel_differences` / `classify_anchor_row` / `run_panels_mode` / `run_anchors_mode` +/ `load_anchor_rows` / `frozen_panel_path` / `_build_bundle` **一个字节未变** ⇒ panels 与 +anchors 的结论不受影响,**只重跑 reports 腿**。 diff --git a/qt/factor_eval_disclosures.py b/qt/factor_eval_disclosures.py index 561685c..698380d 100644 --- a/qt/factor_eval_disclosures.py +++ b/qt/factor_eval_disclosures.py @@ -579,6 +579,17 @@ def summarize_neutralization( # --------------------------------------------------------------------------- # # The add-Section bridge (contract §3.6: may ADD, never drop a mandatory one) # --------------------------------------------------------------------------- # +#: Payload keys that are computed PROPERTIES rather than dataclass fields, so +#: ``asdict`` alone does not produce them. Author-once: the reconcile harness +#: derives an add-Section's Markdown line prefixes from this same tuple, and a +#: property listed in only one of the two places would silently unregister a +#: rendered line (measured: three such lines failed the reports leg). +DERIVED_PAYLOAD_PROPERTIES: tuple[str, ...] = ( + "validity_rate", + "return_guard_attrition", +) + + def to_section(name: str, coverage) -> Section: """Pack a coverage disclosure dataclass into an add-Section. @@ -590,7 +601,7 @@ def to_section(name: str, coverage) -> Section: section added this way cannot move a verdict — pinned by test. """ payload: dict[str, object] = dict(asdict(coverage)) - for prop in ("validity_rate", "return_guard_attrition"): + for prop in DERIVED_PAYLOAD_PROPERTIES: if hasattr(coverage, prop): payload[prop] = getattr(coverage, prop) return Section(name=name, payload=payload, note=coverage.render()) @@ -652,6 +663,7 @@ def publishes_neutralization_disclosure(factor) -> bool: __all__ = [ + "DERIVED_PAYLOAD_PROPERTIES", "DisclosureBinding", "NEUTRALIZATION_SECTION_NAME", "NeutralizationCoverage", diff --git a/qt/factor_eval_reconcile.py b/qt/factor_eval_reconcile.py index a192583..4c091f7 100644 --- a/qt/factor_eval_reconcile.py +++ b/qt/factor_eval_reconcile.py @@ -406,45 +406,166 @@ class is CEILED at ``(lookback_depth - 1) x |frozen symbols|`` cells #: mechanism B — never an add-Section), so the unified runner's new section is #: an ADDITION against the frozen artifact, registered here per factor; any #: other added section stays unregistered and fails. +#: ⚠️ THE ENUMERATION WAS INCOMPLETE, and for a reason worth stating: until the +#: D5 C5 phase-B run, this leg had NEVER produced a judgement for ten of the +#: eleven factors (every rc=1 was the F5 artifact destruction), so the four +#: OTHER disclosure sections had simply never been seen by anyone. vpq was the +#: only entry because vpq was the only factor whose reports leg had ever run. +#: Registering them is not "widening": the map was fitted to a sample of one. +#: +#: Each addition below was RE-VERIFIED per factor and per grid, not assumed +#: (lead ruling): (1) every leaf under the appended index is ``old=None``, a +#: pure addition with no value rewrite; (2) the verdict labels are unchanged; +#: (3) NO INDEX DISPLACEMENT — measured sections 8 -> 9 with the first eight +#: names pairwise identical and the disclosure appended LAST. (3) matters +#: because the leaf paths are indexed: a section inserted mid-list would shift +#: every later one and "the rest of the sections match" would be an illusion. +#: It is detected rather than assumed — a shifted ``sections[k].name`` shows up +#: as an unregistered CHANGE and fails — and there is a test for that. +#: +#: ⚠️ ANTI-RATCHET (lead ruling): do NOT widen this map again. A further +#: factor needing an entry is evidence that the registration is describing +#: residuals rather than the diagnostics-sink mechanism: STOP AND REPORT. REGISTERED_EXTRA_SECTIONS: dict[str, tuple[str, ...]] = { "valley_price_quantile_20": ("neutralization_coverage",), + "valley_ridge_vwap_ratio_20": ("ridge_scarcity_coverage",), + "ridge_minute_return_20": ("ridge_scarcity_coverage",), + "peak_ridge_amount_ratio_20": ("peak_scarcity_coverage",), } -#: The MD note-line prefix of ``NeutralizationCoverage.render()`` — the -#: disclosure's one-line summary, rendered as the section's note. Kept as a -#: constant with a pinning test (the alternative — re-rendering a zero -#: instance — would couple the reconcile gate to the renderer's format by -#: construction instead of by assertion). -_NEUTRALIZATION_NOTE_PREFIX = "neutralization (T-1 rev20):" +#: ``spec.description`` value rewrites — a DIFFERENT class from the §七 +#: registered spec drift, which covers ADDED KEYS only. This is an existing +#: key whose VALUE changed, and `spec.description` is the field a reader uses +#: to learn what the factor computes, so the registration is a per-factor +#: EXACT PAIR rather than a predicate (lead ruling). A blanket "description +#: rewrites are acceptable" rule would wave through the day someone really +#: changes what a factor claims to compute; exact enumeration is the point, +#: not clumsiness. ANY other rewrite — a fourth factor, or a different new +#: text on these three — stays unregistered and fails. +#: +#: Cause: the D2 migration moved the peak/valley/ridge taxonomy out of +#: ``data.clean.intraday_volume_prv`` into +#: ``factors.compute.minute.primitives``, and each description was updated to +#: name its new home. D2 itself was measured BITWISE identical (PR #89: 14/14 +#: panels at ``max_rel_diff=0.0``), so this is a PROVENANCE rewrite, not a +#: semantic one — which is why it triggers no version bump and no correction +#: carrier, by the same criterion applied to F1. +#: +#: The strings are the artifacts' stored values, i.e. already truncated by the +#: report writer's value cap (both sides 214 chars, ending in the truncation +#: marker). That is deliberate: the registration must match what a consumer +#: opens. If the cap ever changes these pairs stop matching and this leg fails +#: loudly, which is the correct direction. +#: +#: ⚠️ ANTI-RATCHET: do NOT add a fourth entry. See above. +REGISTERED_SPEC_DESCRIPTION_REWRITES: dict[str, tuple[str, str]] = { + "peak_interval_kurtosis_20": ( + "Volume-peak interval kurtosis (Kaiyuan microstructure series #27, " + "SECOND factor 量峰间隔峰度). SAME peak identification as PR-F " + "volume_peak_count (REUSED from data.clean.intraday_volume_prv, not " + "re-implemen...[truncated]", + "Volume-peak interval kurtosis (Kaiyuan microstructure series #27, " + "SECOND factor 量峰间隔峰度). SAME peak identification as PR-F " + "volume_peak_count (SHARED taxonomy in factors.compute.minute." + "primitives, not r...[truncated]", + ), + "valley_relative_vwap_20": ( + "Valley-relative VWAP (Kaiyuan microstructure series #27, THIRD factor " + "量谷相对加权价格). SAME minute classification as PR-F volume_peak_count and " + "PR-H peak_interval_kurtosis (REUSED from data.clean.intraday_v..." + "[truncated]", + "Valley-relative VWAP (Kaiyuan microstructure series #27, THIRD factor " + "量谷相对加权价格). SAME minute classification as PR-F volume_peak_count and " + "PR-H peak_interval_kurtosis (SHARED taxonomy in factors.comput..." + "[truncated]", + ), + "valley_ridge_vwap_ratio_20": ( + "Valley/ridge VWAP ratio (Kaiyuan microstructure series #27, FOURTH " + "factor 谷岭加权价格比). SAME minute classification as PR-F " + "volume_peak_count / PR-H peak_interval_kurtosis / PR-I " + "valley_relative_vwap (REUS...[truncated]", + "Valley/ridge VWAP ratio (Kaiyuan microstructure series #27, FOURTH " + "factor 谷岭加权价格比). SAME minute classification as PR-F " + "volume_peak_count / PR-H peak_interval_kurtosis / PR-I " + "valley_relative_vwap (SHAR...[truncated]", + ), +} + +#: The MD note-line prefix of each registered disclosure's ``render()`` — the +#: one-line summary rendered as the section's note. Kept as constants with a +#: pinning test (the alternative — re-rendering a zero instance — would couple +#: the reconcile gate to the renderer's format by construction instead of by +#: assertion). An unlisted section name is a readable error, never a guess. +_SECTION_NOTE_PREFIXES: dict[str, str] = { + "neutralization_coverage": "neutralization (T-1 rev20):", + "ridge_scarcity_coverage": "ridge scarcity:", + "peak_scarcity_coverage": "peak scarcity:", +} -def _registered_section_md_prefixes(section_names: tuple[str, ...]) -> tuple[str, ...]: +def _registered_section_md_prefixes( + factor_id: str, section_names: tuple[str, ...] +) -> tuple[str, ...]: """The MD line prefixes belonging to a registered add-Section's rendering. An extra section renders (``analytics/eval/render.py._render_section``) as an unnumbered ``## + `` heading, the note line, and one - ``- :`` payload line per dataclass field (derived from the - dataclass, so a field rename breaks the pinning test instead of silently - unregistering). An unknown section name is a readable error — registering - a section whose MD rendering is not catalogued would be a guess. + ``- :`` payload line per dataclass field. The payload dataclass is + DERIVED, not listed again here: mechanism B is + ``NeutralizationCoverage``; mechanism A comes from the factor's own entry + in ``qt.factor_eval_disclosures._DISCLOSURE_BY_CLASS`` via its summarizer's + return annotation. That matters because ``ridge_scarcity_coverage`` is + published by TWO factors with DIFFERENT payloads (``RidgeCoverage`` vs + ``RidgeReturnCoverage``), so the prefixes are per FACTOR and a + section-name-only lookup would be wrong for one of them. A renamed field + or a summarizer returning something else breaks the pinning test instead + of silently unregistering. + + An unknown section name is a readable error — registering a section whose + MD rendering is not catalogued would be a guess. """ - from dataclasses import fields as dc_fields + from dataclasses import fields as dc_fields, is_dataclass + from typing import get_type_hints from qt.factor_eval_disclosures import ( + DERIVED_PAYLOAD_PROPERTIES, NEUTRALIZATION_SECTION_NAME, NeutralizationCoverage, + disclosure_binding_for, ) prefixes: list[str] = [] for name in section_names: - if name == NEUTRALIZATION_SECTION_NAME: - prefixes.append(f"## + {NEUTRALIZATION_SECTION_NAME}") - prefixes.append(_NEUTRALIZATION_NOTE_PREFIX) - prefixes.extend(f"- {f.name}:" for f in dc_fields(NeutralizationCoverage)) - else: + note = _SECTION_NOTE_PREFIXES.get(name) + if note is None: raise ValueError( f"no MD rendering is registered for added section {name!r}." ) + if name == NEUTRALIZATION_SECTION_NAME: + payload: type = NeutralizationCoverage + else: + binding = disclosure_binding_for(factor_registry.build(factor_id)) + if binding is None or binding.section_name != name: + raise ValueError( + f"{factor_id!r} does not publish the add-Section {name!r}; " + "the registration and the disclosure table disagree." + ) + payload = get_type_hints(binding.summarize)["return"] + if not is_dataclass(payload): + raise ValueError( + f"the summarizer for {name!r} does not return a dataclass " + f"({payload!r}), so its MD fields cannot be derived." + ) + prefixes.append(f"## + {name}") + prefixes.append(note) + prefixes.extend(f"- {f.name}:" for f in dc_fields(payload)) + # ... plus the payload's DERIVED properties, which asdict does not + # produce and which therefore have no dataclass field to be read from. + prefixes.extend( + f"- {prop}:" + for prop in DERIVED_PAYLOAD_PROPERTIES + if hasattr(payload, prop) + ) return tuple(prefixes) @@ -570,6 +691,7 @@ def _classify_value_change( correction_expected: bool, corrections_present: bool, strict: bool, + description_rewrite: tuple[str, str] | None = None, ) -> str: """Classify one changed leaf (both sides present, values differ). @@ -601,6 +723,15 @@ def _classify_value_change( return "registered_sanity_stem_rename" if path.endswith(".exec_price_artifact_reused") and old_v is False and new_v is True: return "registered_run_order_artifact" + if ( + path == "spec.description" + and description_rewrite is not None + and (old_v, new_v) == description_rewrite + ): + # The D2 provenance rewrite, matched as an EXACT PAIR. A different old + # or a different new on the same factor is a different fact and stays + # unregistered — which is the whole reason this is not a predicate. + return "registered_d2_provenance_rewrite" if correction_expected and corrections_present: return "registered_correction_effect" if not strict: @@ -620,6 +751,7 @@ def diff_report_json( strict: bool, correction_expected: bool, registered_sections: tuple[str, ...] = (), + description_rewrite: tuple[str, str] | None = None, ) -> ReportDiff: """Diff one (frozen, new) JSON pair leaf by leaf against the registered list. @@ -631,6 +763,8 @@ def diff_report_json( correction — accepted ONLY if the new JSON carries a ``corrections`` block. ``registered_sections``: add-Section names whose whole subtree is a registered addition (§七之四 — the frozen artifact predates the section). + ``description_rewrite``: this factor's registered EXACT (old, new) + ``spec.description`` pair (§七之八). Anything else on that path fails. """ result = ReportDiff(name=name, strict=strict) old_flat, new_flat = _flatten(old), _flatten(new) @@ -651,6 +785,7 @@ def diff_report_json( correction_expected=correction_expected, corrections_present=corrections_present, strict=strict, + description_rewrite=description_rewrite, ) result.diffs.append(LeafDiff(path, old_v, new_v, cls)) elif in_new: @@ -1465,7 +1600,10 @@ def run_reports_mode( require_report_inputs(report_dir, factor_id, config_path) correction_expected = factor_id == "jump_amount_corr_20" registered_sections = REGISTERED_EXTRA_SECTIONS.get(factor_id, ()) - section_md_prefixes = _registered_section_md_prefixes(registered_sections) + section_md_prefixes = _registered_section_md_prefixes( + factor_id, registered_sections + ) + description_rewrite = REGISTERED_SPEC_DESCRIPTION_REWRITES.get(factor_id) stem = f"factor_eval_{factor_id}" results: list[ReportDiff] = [] @@ -1490,6 +1628,7 @@ def run_reports_mode( frozen_json, new_json, name=f"{stem}_exec_{book}.json[{label}]", strict=strict, correction_expected=correction_expected, registered_sections=registered_sections, + description_rewrite=description_rewrite, ) ) new_md = (report_dir / f"{stem}_exec_{book}{'_bookclose' if 'bookclose' in label else ''}.md").read_text() @@ -1616,6 +1755,7 @@ def run_anchors_mode(config_path: str, factor_id: str, repo_root: Path) -> Ancho "PANEL_REL_TOL", "PanelDiff", "REGISTERED_EXTRA_SECTIONS", + "REGISTERED_SPEC_DESCRIPTION_REWRITES", "ReconciliationError", "ReportDiff", "SPARSE_VALID_DAY_TAIL_HI", diff --git a/tests/test_factor_eval_reconcile.py b/tests/test_factor_eval_reconcile.py index 8464ac4..b347251 100644 --- a/tests/test_factor_eval_reconcile.py +++ b/tests/test_factor_eval_reconcile.py @@ -15,6 +15,8 @@ import pytest from qt.factor_eval_reconcile import ( + REGISTERED_EXTRA_SECTIONS, + REGISTERED_SPEC_DESCRIPTION_REWRITES, SPARSE_VALID_DAY_TAIL_SYMBOLS, ReconciliationError, check_new_pair_consistency, @@ -483,7 +485,9 @@ def test_json_section_addition_is_matched_by_NAME_not_index(): def test_md_registered_section_lines_pass_for_vpq(): from qt.factor_eval_reconcile import _registered_section_md_prefixes - prefixes = _registered_section_md_prefixes(("neutralization_coverage",)) + prefixes = _registered_section_md_prefixes( + "valley_price_quantile_20", ("neutralization_coverage",) + ) result = diff_report_md( _MD_OLD, _MD_OLD + _NEUTRALIZATION_MD, name="t", correction_expected=False, registered_section_lines=prefixes, @@ -507,7 +511,9 @@ def test_md_section_prefixes_are_derived_from_the_dataclass_fields(): from qt.factor_eval_disclosures import NeutralizationCoverage from qt.factor_eval_reconcile import _registered_section_md_prefixes - prefixes = _registered_section_md_prefixes(("neutralization_coverage",)) + prefixes = _registered_section_md_prefixes( + "valley_price_quantile_20", ("neutralization_coverage",) + ) for f in dc_fields(NeutralizationCoverage): assert f"- {f.name}:" in prefixes # the note prefix really is the render() format's head (not a stale copy) @@ -523,7 +529,9 @@ def test_md_section_prefixes_reject_an_unknown_section(): from qt.factor_eval_reconcile import _registered_section_md_prefixes with pytest.raises(ValueError, match="no MD rendering is registered"): - _registered_section_md_prefixes(("bogus_coverage",)) + _registered_section_md_prefixes( + "valley_price_quantile_20", ("bogus_coverage",) + ) # --------------------------------------------------------------------------- # @@ -1803,3 +1811,305 @@ def test_the_density_criterion_predicts_where_600906_appears_and_where_it_does_n ridge_emitted, ridge_median = _emission_density("ridge_minute_return_20") assert peak_emitted["600906.SH"] < peak_median assert ridge_emitted["600906.SH"] >= ridge_median + + +# --------------------------------------------------------------------------- # +# D5 C5 phase B — the two drifts the reports leg had never shown anyone, +# because until phase B it had never produced a judgement for these factors. +# +# A: the diagnostics-sink coverage disclosures arriving as an appended section. +# B: the D2 provenance rewrite of spec.description, registered as EXACT PAIRS. +# --------------------------------------------------------------------------- # +def _json_with_sections(names: list[str]) -> dict: + doc = _frozen_like() + doc["sections"] = [{"name": n, "status": "ok", "payload": {"v": 1}} for n in names] + return doc + + +def test_registered_disclosure_sections_are_additions_for_each_factor(): + for factor_id, section in ( + ("valley_ridge_vwap_ratio_20", "ridge_scarcity_coverage"), + ("ridge_minute_return_20", "ridge_scarcity_coverage"), + ("peak_ridge_amount_ratio_20", "peak_scarcity_coverage"), + ("valley_price_quantile_20", "neutralization_coverage"), + ): + old = _json_with_sections(["ic", "quantiles"]) + new = _with_registered_additions(_json_with_sections(["ic", "quantiles", section])) + result = diff_report_json( + old, new, name=factor_id, strict=True, correction_expected=False, + registered_sections=REGISTERED_EXTRA_SECTIONS[factor_id], + ) + assert result.ok, (factor_id, [d for d in result.diffs if "unregistered" in d.classification]) + assert result.by_class("registered_section_addition") + + +def test_an_unregistered_disclosure_section_still_fails(): + # REVERSE: a section that is not in this factor's registered tuple. + old = _json_with_sections(["ic", "quantiles"]) + new = _with_registered_additions( + _json_with_sections(["ic", "quantiles", "some_other_coverage"]) + ) + result = diff_report_json( + old, new, name="ridge_minute_return_20", strict=True, correction_expected=False, + registered_sections=REGISTERED_EXTRA_SECTIONS["ridge_minute_return_20"], + ) + assert not result.ok + assert result.by_class("unregistered_addition") + + +def test_a_section_registered_for_ANOTHER_factor_is_not_accepted_here(): + # The map is per factor: ridge's disclosure must not pass for a factor + # whose registered tuple names a different section. + old = _json_with_sections(["ic", "quantiles"]) + new = _with_registered_additions( + _json_with_sections(["ic", "quantiles", "ridge_scarcity_coverage"]) + ) + result = diff_report_json( + old, new, name="valley_price_quantile_20", strict=True, + correction_expected=False, + registered_sections=REGISTERED_EXTRA_SECTIONS["valley_price_quantile_20"], + ) + assert not result.ok + + +def test_a_section_INSERTED_mid_list_is_detected_not_absorbed(): + """The check the lead asked for: index displacement must not pass silently. + + Leaf paths are indexed, so a section inserted before the end shifts every + later one. If that were absorbed, "the other sections all match" would be + an illusion. Measured on the real artifacts the disclosures are APPENDED + (sections 8 -> 9, first eight names pairwise identical), and this test pins + what happens if that ever stops being true. + """ + old = _json_with_sections(["ic", "quantiles", "verdict_inputs"]) + new = _with_registered_additions( + _json_with_sections(["ic", "ridge_scarcity_coverage", "quantiles", "verdict_inputs"]) + ) + result = diff_report_json( + old, new, name="ridge_minute_return_20", strict=True, correction_expected=False, + registered_sections=REGISTERED_EXTRA_SECTIONS["ridge_minute_return_20"], + ) + assert not result.ok + # Pin the MECHANISM, not just the failure: leaves are compared position by + # position, so a displaced section shows up as a changed `sections[k].name`. + # Asserting only "something was unregistered" passes for the wrong reason — + # the trailing extra index alone would satisfy it (measured: registering + # name changes left that weaker assertion green). + displaced_names = [ + d for d in result.diffs + if d.path.endswith(".name") + and d.classification.startswith("unregistered") + and d.old is not None + and d.new is not None + ] + assert displaced_names, ( + "a section inserted mid-list must surface as a changed sections[k].name; " + f"got {[(d.path, d.classification) for d in result.diffs]}" + ) + + +def test_registered_d2_description_rewrite_is_accepted_as_an_exact_pair(): + for factor_id, (old_text, new_text) in REGISTERED_SPEC_DESCRIPTION_REWRITES.items(): + old = _frozen_like() + old["spec"]["description"] = old_text + new = _with_registered_additions(_frozen_like()) + new["spec"]["description"] = new_text + result = diff_report_json( + old, new, name=factor_id, strict=True, correction_expected=False, + description_rewrite=REGISTERED_SPEC_DESCRIPTION_REWRITES[factor_id], + ) + assert result.ok, (factor_id, result.diffs) + assert len(result.by_class("registered_d2_provenance_rewrite")) == 1 + + +def test_a_DIFFERENT_description_rewrite_on_a_registered_factor_fails(): + # REVERSE: the registered OLD text but some other new text. This is the + # case the exact pair exists for — the day a factor's stated meaning + # really changes must not ride in on a provenance registration. + factor_id = "peak_interval_kurtosis_20" + old_text, _ = REGISTERED_SPEC_DESCRIPTION_REWRITES[factor_id] + old = _frozen_like() + old["spec"]["description"] = old_text + new = _with_registered_additions(_frozen_like()) + new["spec"]["description"] = "Now computes something else entirely." + result = diff_report_json( + old, new, name=factor_id, strict=True, correction_expected=False, + description_rewrite=REGISTERED_SPEC_DESCRIPTION_REWRITES[factor_id], + ) + assert not result.ok + assert len(result.by_class("unregistered_change")) == 1 + + +def test_an_unregistered_factors_description_rewrite_fails(): + # REVERSE: a factor with no registered pair at all (the default None). + old = _frozen_like() + old["spec"]["description"] = "A" + new = _with_registered_additions(_frozen_like()) + new["spec"]["description"] = "B" + result = diff_report_json( + old, new, name="jump_amount_corr_20", strict=True, correction_expected=False, + ) + assert not result.ok + assert len(result.by_class("unregistered_change")) == 1 + + +@requires_frozen_panels +def test_the_registered_description_pairs_match_the_frozen_artifacts_exactly(): + """The transcribed constants must equal the bytes on disk, character for + character. A pair that is one character off would leave the leg failing + while the registration looked right in review.""" + from qt.exec_baseline_freeze import ( + DEFAULT_FROZEN_ROOT, + DEFAULT_MANIFEST, + FrozenExecBaseline, + ) + from qt.factor_eval_reconcile import _FACTOR_TO_REPORT_NAME + + repo = Path(".").resolve() + baseline = FrozenExecBaseline(repo / DEFAULT_FROZEN_ROOT, repo / DEFAULT_MANIFEST) + for factor_id, (old_text, _new_text) in REGISTERED_SPEC_DESCRIPTION_REWRITES.items(): + frozen = baseline.report_json(_FACTOR_TO_REPORT_NAME[factor_id], "no_book") + assert frozen["spec"]["description"] == old_text, factor_id + + +def test_md_prefixes_are_derived_per_FACTOR_not_per_section_name(): + """`ridge_scarcity_coverage` is published by two factors with DIFFERENT + payloads (RidgeCoverage vs RidgeReturnCoverage), so a section-name-only + lookup would hand one of them the other's field list.""" + from qt.factor_eval_reconcile import _registered_section_md_prefixes + + a = _registered_section_md_prefixes( + "valley_ridge_vwap_ratio_20", ("ridge_scarcity_coverage",) + ) + b = _registered_section_md_prefixes( + "ridge_minute_return_20", ("ridge_scarcity_coverage",) + ) + assert a[:2] == b[:2] == ("## + ridge_scarcity_coverage", "ridge scarcity:") + assert set(a) != set(b), "the two payloads must not produce the same fields" + assert "- valley_median:" in a and "- valley_median:" not in b + assert "- ridge_return_mean:" in b and "- ridge_return_mean:" not in a + + +def test_md_prefixes_reject_a_factor_that_does_not_publish_that_section(): + from qt.factor_eval_reconcile import _registered_section_md_prefixes + + with pytest.raises(ValueError, match="does not publish the add-Section"): + _registered_section_md_prefixes( + "jump_amount_corr_20", ("ridge_scarcity_coverage",) + ) + + +@pytest.mark.parametrize( + "section_name, payload_factory", + [ + ("ridge_scarcity_coverage", "ridge"), + ("peak_scarcity_coverage", "peak"), + ("neutralization_coverage", "neutralization"), + ], +) +def test_the_registered_note_prefix_matches_what_render_actually_emits( + section_name, payload_factory +): + """Pin the note prefixes by ASSERTION against a real rendering. + + The constants exist so the gate is not coupled to the renderer's format by + construction; that only works if something checks they still agree. + """ + from qt.factor_eval_reconcile import _SECTION_NOTE_PREFIXES + + frame = pd.DataFrame( + { + "classifiable_bars": [240, 240], + "valley_bars": [200, 200], + "ridge_bars": [25, 30], + "peak_bars": [12, 14], + "ridge_return_bars": [25, 30], + "valid": [True, True], + }, + index=pd.MultiIndex.from_tuples( + [(pd.Timestamp("2021-07-01"), "A"), (pd.Timestamp("2021-07-02"), "A")], + names=["date", "symbol"], + ), + ) + if payload_factory == "ridge": + from qt.factor_eval_disclosures import summarize_ridge_coverage as fn + + coverage = fn([frame]) + elif payload_factory == "peak": + from qt.factor_eval_disclosures import summarize_peak_coverage as fn + + coverage = fn([frame]) + else: + from qt.factor_eval_disclosures import summarize_neutralization as fn + + idx = pd.MultiIndex.from_tuples( + [(pd.Timestamp("2021-07-01"), f"S{i}") for i in range(12)], + names=["date", "symbol"], + ) + series = pd.Series(range(12), index=idx, dtype=float) + coverage = fn(series, series, series, min_cross_section=10) + assert coverage.render().startswith(_SECTION_NOTE_PREFIXES[section_name]) + + +@pytest.mark.parametrize( + "factor_id, section_name", + [ + ("valley_ridge_vwap_ratio_20", "ridge_scarcity_coverage"), + ("ridge_minute_return_20", "ridge_scarcity_coverage"), + ("peak_ridge_amount_ratio_20", "peak_scarcity_coverage"), + ("valley_price_quantile_20", "neutralization_coverage"), + ], +) +def test_md_prefixes_cover_EVERY_key_the_real_section_payload_carries( + factor_id, section_name +): + """The registration must cover the payload a real run writes, key for key. + + Deriving the prefixes from ``dataclasses.fields`` alone missed the two + COMPUTED properties ``to_section`` adds on top of ``asdict``, so three + rendered Markdown lines stayed unregistered and the reports leg kept + failing with the JSON side already green. Comparing against the actual + payload keys is what closes that gap — a future payload key with no + dataclass field to be read from fails here instead of on a two-hour run. + """ + from qt.factor_eval_disclosures import ( + NEUTRALIZATION_SECTION_NAME, + disclosure_binding_for, + summarize_neutralization, + to_section, + ) + from qt.factor_eval_reconcile import _registered_section_md_prefixes + from factors import registry as factor_registry + + frame = pd.DataFrame( + { + "classifiable_bars": [240, 240], + "valley_bars": [200, 200], + "ridge_bars": [25, 30], + "peak_bars": [12, 14], + "ridge_return_bars": [25, 30], + "valid": [True, True], + }, + index=pd.MultiIndex.from_tuples( + [(pd.Timestamp("2021-07-01"), "A"), (pd.Timestamp("2021-07-02"), "A")], + names=["date", "symbol"], + ), + ) + if section_name == NEUTRALIZATION_SECTION_NAME: + idx = pd.MultiIndex.from_tuples( + [(pd.Timestamp("2021-07-01"), f"S{i}") for i in range(12)], + names=["date", "symbol"], + ) + series = pd.Series(range(12), index=idx, dtype=float) + coverage = summarize_neutralization(series, series, series, min_cross_section=10) + else: + binding = disclosure_binding_for(factor_registry.build(factor_id)) + coverage = binding.summarize([frame]) + + payload_keys = set(to_section(section_name, coverage).payload) + prefixes = set(_registered_section_md_prefixes(factor_id, (section_name,))) + missing = {k for k in payload_keys if f"- {k}:" not in prefixes} + assert not missing, ( + f"{factor_id}/{section_name}: payload keys with no registered Markdown " + f"prefix -> their rendered lines would fail the reports leg: {sorted(missing)}" + ) From 71c455a377661fb309aa3a3f13d4c25d7a6cbb19 Mon Sep 17 00:00:00 2001 From: shaofl <2899218482@qq.com> Date: Thu, 30 Jul 2026 12:33:13 -0700 Subject: [PATCH 08/11] docs(factors): fill the C5 audit report with the measured four-leg results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sections 3/4/5 now carry the real numbers instead of TBD: per-factor results for all three legs (33 cells, every one rc=0, unclassified and failed both 0), the with_book decomposition, and the eleven-row verdict table. The decomposition is PER METRIC, as required — an aggregate "with_book moved because the book view changed" would let an engine regression hide behind an intended change. Split out, it says something stronger: A and B carry identical deltas on IC / ICIR / NW-t / N_eff (a book does not enter those), and C-B is non-zero on exactly 11 of 57 metric rows, every one of them the incremental ICIR — the only metric a book can reach. Verdicts and all three axis labels are unchanged for 11/11 factors in every grid. Also records jump_amount_corr_20's float tail sitting at 101/101 with ZERO headroom: the cap was calibrated on that factor's own observed count, so one more cell fails the leg, and the failure would read as "cap exceeded" rather than pointing at whatever moved. Header no longer claims to be a skeleton. --- docs/factors/d5_c5_audit_report.md | 265 ++++++++++++++++++++++------- 1 file changed, 205 insertions(+), 60 deletions(-) diff --git a/docs/factors/d5_c5_audit_report.md b/docs/factors/d5_c5_audit_report.md index a2f2188..c32985b 100644 --- a/docs/factors/d5_c5_audit_report.md +++ b/docs/factors/d5_c5_audit_report.md @@ -1,9 +1,11 @@ # D5 C5 —— 四腿全量对账审计报告 -> **状态**:**骨架**。结果区全部 TBD,由全量对账(11 因子 × panels/reports/anchors) -> 跑完后填充。填充纪律:每处差异必须落在 §二 预登记清单的具名条目内;**未编目的 -> 差异 = 验收不通过**(设计 v3.2 §五腿 3:「允许差异,但每处差异必须归因到具名原因; -> 变好的差异优先怀疑泄漏」)。 +> **状态**:**已填充**(2026-07-30)。11 因子 × panels/reports/anchors 全量对账已跑完, +> 结果在 §三/§四/§五。填充纪律(已执行):每处差异必须落在 §二 预登记清单或 §二之三 的 +> 本轮新增登记内;**未编目的差异 = 验收不通过**(设计 v3.2 §五腿 3:「允许差异,但每处 +> 差异必须归因到具名原因;变好的差异优先怀疑泄漏」)。**类外差异为 0。** +> ⚠️ 本轮的每一条新增登记都不是"放宽",而是**原登记表建立在一个不完整的证据基上**—— +> 详见 §六.6/6.7 与编目 §七之七/§七之八。 > **执行环境**:11 因子 × {no_book, with_book} 统一 exec-only runner > (`qt/factor_eval_runner.py`)+ 对账 harness(`qt/factor_eval_reconcile.py` 三模式)。 @@ -66,6 +68,21 @@ unclassified → FAIL。** | 13 | `book_view_effect`(with_book(decision) 格) | decision 书 vs 冻结 close 书的 Incremental 轴数值差:**全量报告、不设闸**;闸在 `_bookclose` 格(见 §四) | harness 设计(a)/(b) 分解;交接 §3 D5 C4 ⚠️ | | 14 | `hand_anchors_d2.json` 报 `all_ok_frozen14: False` | 70 行 5 处失配**全部且仅有 jump**(手算已截断 vs 冻结 `panels_d2` 未截断)——正确信号非回归 | 交接 §3 #5 | +## 二之三、本轮新增的登记项(§二 清单的增补) + +以下六条是 C5 修复轮新增的具名登记。**每一条都配有正反向测试(越界必 FAIL)与 mutation +证据**,出处见编目 §七之七 / §七之八。**#17 / #19 / #20 带反棘轮约束:不得再次放宽—— +再有新成员出现即说明该类在描述残差而非机制,停下上报。** + +| # | 名称 | 边界 | 出处 | +|---|---|---|---| +| 15 | `threshold_flip_contamination` bars-only 臂 | 同窗同 symbol;**相对**界按因子(kurtosis ≤5e-3 / relative_vwap ≤1e-5);≤25 cell/因子 | 编目 §七之七 F2 | +| 16 | `warmup_left_extension` 新方向 `frozen_finite_new_nan` | 仅 valid-day pooled 因子 + 早区窗内 | 编目 §七之七 F3① | +| 17 | `warmup_sparse_valid_day_tail` | 仅 pooled;[2021-11-01, 2021-11-15];9 票白名单(成员判据=发行密度低于该因子中位数,**已证伪检查 18/18**);≤20 cell/因子;**反棘轮** | 编目 §七之七 F3② | +| 18 | 绝对 float-dust 前移到区域分支之前 | `abs ≤ 1e-12` 按机制分类,不按位置 | 编目 §七之七 F4 | +| 19 | 诊断 sink 披露节(三个因子) | `old=None` 纯新增 + verdict 标签不变 + **无索引位移**,逐 grid 复验 8/8;**反棘轮** | 编目 §七之八 A | +| 20 | `spec.description` 的 D2 provenance 改写 | **逐对精确枚举**(3 因子);其它一律 FAIL;不 bump 版本、不触发更正承载 | 编目 §七之八 B | + ## 二之二、C5 首轮全量跑的原始结果(pre-fix)—— 誊自日志,**耐久化** > **为什么誊在这里**:这些数字原本只存在于 `artifacts/logs/`(gitignored、只在本机), @@ -116,66 +133,194 @@ mtime 保留);`peak_interval_kurtosis_20` 与 `intraday_amp_cut_10` 两行 事后改名销毁,`run_reports_mode` 抛 `FileNotFoundError`。**这不是判定结果**:该腿在 C5 首轮**没有对任何因子产生过一个判定**(详见 §六.7)。 -## 三、每因子结果表(TBD) - -填写口径:panels = 具名类 cell 计数 + unclassified 计数(必须为 0);reports = -no_book / with_book(decision) / with_book(bookclose) 三格各自的登记类计数与 -unregistered 计数(strict 格必须为 0);anchors = ok/warmup/failed 行数(failed 必须 -为 0)。`stk_mins_live_calls` 每因子必须为 0。 - -| 因子 | panels: warmup / float_tail / flip_tail / flip_contam / footprint / unclassified / ceiling | reports: no_book | reports: with_book(decision) | reports: with_book(bookclose) | anchors: ok / warmup / failed | live_calls | -|---|---|---|---|---|---|---| -| jump_amount_corr_20 | TBD | TBD | TBD | TBD | TBD | TBD | -| minute_ideal_amp_10 | TBD | TBD | TBD | TBD | TBD | TBD | -| amp_marginal_anomaly_vol_20 | TBD | TBD | TBD | TBD | TBD | TBD | -| volume_peak_count_20 | TBD | TBD | TBD | TBD | TBD | TBD | -| intraday_amp_cut_10 | TBD | TBD | TBD | TBD | TBD | TBD | -| peak_interval_kurtosis_20 | TBD | TBD | TBD | TBD | TBD | TBD | -| valley_relative_vwap_20 | TBD | TBD | TBD | TBD | TBD | TBD | -| valley_ridge_vwap_ratio_20 | TBD | TBD | TBD | TBD | TBD | TBD | -| ridge_minute_return_20 | TBD | TBD | TBD | TBD | TBD | TBD | -| valley_price_quantile_20 | TBD | TBD | TBD | TBD | TBD | TBD | -| peak_ridge_amount_ratio_20 | TBD | TBD | TBD | TBD | TBD | TBD | - -## 四、with_book 差异分解表(TBD) - -交接 §3 的硬要求:with_book 的差异必须可分解、**整体归因不可接受**(「with_book 变了 -是因为书视图改了」会把引擎回归藏在 intended change 后面)。三分量: - -- **A = no_book 引擎效应**:no_book 格的登记类差异(干净的「有没有弄坏东西」测试, - 先对干净)。 -- **B = close 书引擎效应**:`with_book(bookclose)` 格(legacy-faithful close 书,strict) - 的登记类差异——引擎在带书条件下的效应,与 A 同口径可比。 -- **C−B = 书视图修正**:`with_book(decision)` 格的 `book_view_effect` 叶子(decision 书 - vs close 书的预期差异,全量列出不设闸)——即 §1.1 活缺陷(close(d) 书)的修正幅度。 - -> ⚠️ **参照物只有冻结 exec 基线,没有"上一轮 C5"可比**。A/B/C 三列全部是**新 artifact -> vs `artifacts/refactor_baseline/exec_baseline/` 的 77 份冻结产物**的比较。C5 首轮的 -> reports 腿对 11 个因子**一个判定都没产生过**(全是 F5 抛异常,见 §二之二与 §六.7), -> 所以本表**不是**与前一轮 C5 的对比——**读者不许这么读**。 - -| 因子 | A(no_book 各类计数) | B(bookclose 各类计数) | C−B(book_view_effect 叶子数 / 涉及轴) | 分解闭合?(B 超 A 的部分是否全部具名) | +## 三、每因子结果表 + +**运行环境**:11 因子 × {no_book, with_book} × {decision, close} eval(22 次 rc 全 0)+ +三模式对账,cache-only(`stk_mins_live_calls` 非零命中 **0**)。panels 与 anchors 出自 phase B +全量跑;reports 出自其后的 reports-only 重跑(改动经 AST 逐函数比对**只触及 reports 腿**, +panels/anchors 的入口函数逐字节未变——见 §六.9)。 + +### 三之1 panels 腿(全部 `unclassified=0`) + +| 因子 | warmup | sparse_tail | float_tail (cap) | flip | contam | footprint | unclassified | +|---|---|---|---|---|---|---|---| +| jump_amount_corr_20 | 17289 | 0 | 101 (101) | 0 | 0 | 45897 | **0** | +| minute_ideal_amp_10 | 8198 | 0 | 0 (101) | 0 | 0 | 45897 | **0** | +| amp_marginal_anomaly_vol_20 | 17272 | 0 | 0 (101) | 0 | 0 | 45897 | **0** | +| volume_peak_count_20 | 34441 | 0 | 0 (101) | 20 | 0 | 46788 | **0** | +| intraday_amp_cut_10 | 8198 | 0 | 798 (1000) | 0 | 0 | 0 | **0** | +| peak_interval_kurtosis_20 | 35152 | 0 | 0 (101) | 0 | 20 | 46790 | **0** | +| valley_relative_vwap_20 | 35205 | 0 | 0 (101) | 0 | 20 | 49235 | **0** | +| valley_ridge_vwap_ratio_20 | 28168 | 13 | 0 (101) | 0 | 0 | 608309 | **0** | +| ridge_minute_return_20 | 27818 | 13 | 0 (101) | 0 | 0 | 611318 | **0** | +| valley_price_quantile_20 | 24531 | 0 | 925 (1000) | 0 | 19172 | 58282 | **0** | +| peak_ridge_amount_ratio_20 | 27907 | 19 | 0 (101) | 0 | 0 | 620916 | **0** | + +### 三之2 anchors 腿(全部 `failed=0`) + +| 因子 | rows | ok | warmup | failed | |---|---|---|---|---| -| jump_amount_corr_20 | TBD | TBD | TBD | TBD | -| minute_ideal_amp_10 | TBD | TBD | TBD | TBD | -| amp_marginal_anomaly_vol_20 | TBD | TBD | TBD | TBD | -| volume_peak_count_20 | TBD | TBD | TBD | TBD | -| intraday_amp_cut_10 | TBD | TBD | TBD | TBD | -| peak_interval_kurtosis_20 | TBD | TBD | TBD | TBD | -| valley_relative_vwap_20 | TBD | TBD | TBD | TBD | -| valley_ridge_vwap_ratio_20 | TBD | TBD | TBD | TBD | -| ridge_minute_return_20 | TBD | TBD | TBD | TBD | -| valley_price_quantile_20 | TBD | TBD | TBD | TBD | -| peak_ridge_amount_ratio_20 | TBD | TBD | TBD | TBD | - -## 五、判定(TBD) +| jump_amount_corr_20 | 5 | 3 | 2 | **0** | +| minute_ideal_amp_10 | 5 | 3 | 2 | **0** | +| amp_marginal_anomaly_vol_20 | 5 | 4 | 1 | **0** | +| volume_peak_count_20 | 5 | 3 | 2 | **0** | +| intraday_amp_cut_10 | 5 | 4 | 1 | **0** | +| peak_interval_kurtosis_20 | 5 | 2 | 3 | **0** | +| valley_relative_vwap_20 | 5 | 4 | 1 | **0** | +| valley_ridge_vwap_ratio_20 | 5 | 3 | 2 | **0** | +| ridge_minute_return_20 | 5 | 3 | 2 | **0** | +| valley_price_quantile_20 | 5 | 4 | 1 | **0** | +| peak_ridge_amount_ratio_20 | 5 | 3 | 2 | **0** | + +### 三之3 reports 腿(三格;全部 rc=0、零类外叶子) + +| 因子 | rc | 登记类计数(no_book / bookclose / decision) | +|---|---|---| +| jump_amount_corr_20 | **0** | 108 diffs / 124 diffs / 124 diffs | +| minute_ideal_amp_10 | **0** | 121 diffs / 122 diffs / 137 diffs | +| amp_marginal_anomaly_vol_20 | **0** | 121 diffs / 122 diffs / 137 diffs | +| volume_peak_count_20 | **0** | 122 diffs / 137 diffs / 138 diffs | +| intraday_amp_cut_10 | **0** | 126 diffs / 127 diffs / 142 diffs | +| peak_interval_kurtosis_20 | **0** | 127 diffs / 142 diffs / 143 diffs | +| valley_relative_vwap_20 | **0** | 128 diffs / 144 diffs / 144 diffs | +| valley_ridge_vwap_ratio_20 | **0** | 163 diffs / 179 diffs / 179 diffs | +| ridge_minute_return_20 | **0** | 158 diffs / 172 diffs / 174 diffs | +| valley_price_quantile_20 | **0** | 105 diffs / 121 diffs / 121 diffs | +| peak_ridge_amount_ratio_20 | **0** | 163 diffs / 179 diffs / 179 diffs | + +**总计:33 格(11 因子 × 三腿)全部 rc=0**;panels 的 `unclassified` 与 anchors 的 `failed` +全为 0,reports 三格零类外叶子。 + +⚠️ **`jump_amount_corr_20` 的 float_tail 是 101/101 —— 零余量**。该 cap 当初按这个因子的 +观测值标定,**再多一格就 FAIL**。本轮判据改动没有往里加格(它那 101 格全在 warmup 区外, +F4 前移一格未动),但这个零余量是**登记在案的脆弱点**:下次任何触及 rolling 求和顺序的改动 +都可能顶破它,而那时失败信息会是"cap 超了",不是"哪里错了"。 + +## 四、with_book 差异分解(逐指标) + +分解口径:**A** = `no_book`(引擎效应,先对干净的)· **B** = `_bookclose`(legacy-faithful +close 书 + 新引擎,与 A 同口径可比)· **C** = decision 书 · **C−B = 书视图修正**。 +Δ 一律相对**冻结基线的同名格**。 + +> ⚠️ **参照物只有冻结 exec 基线**:C5 首轮 reports 腿对 11 个因子一个判定都没产生过 +> (全是 F5),所以本表**不是**与上一轮 C5 的对比。见 §六.7。 + +| 因子 | 指标 | 冻结 no_book | **A** 新 no_book (Δ) | 冻结 with_book | **B** bookclose (Δ) | **C** decision (Δ) | **C−B**(书视图修正) | +|---|---|---|---|---|---|---|---| +| jump_amount_corr_20 | IC mean | -0.030840 | -0.030387 (+0.000453) | -0.030840 | -0.030387 (+0.000453) | -0.030387 (+0.000453) | **+0.000000** | +| jump_amount_corr_20 | ICIR | -0.425348 | -0.423262 (+0.002086) | -0.425348 | -0.423262 (+0.002086) | -0.423262 (+0.002086) | **+0.000000** | +| jump_amount_corr_20 | NW-t | -15.191082 | -15.123855 (+0.067227) | -15.191082 | -15.123855 (+0.067227) | -15.123855 (+0.067227) | **+0.000000** | +| jump_amount_corr_20 | N_eff | 1162.206183 | 1164.076757 (+1.870574) | 1162.206183 | 1164.076757 (+1.870574) | 1164.076757 (+1.870574) | **+0.000000** | +| jump_amount_corr_20 | incr. ICIR | n/a | n/a (—) | -0.300601 | -0.299010 (+0.001591) | -0.295001 (+0.005600) | **+0.004009** | +| jump_amount_corr_20 | **verdict** | Watch | Watch (same) | Watch | Watch (same) | Watch (same) | 同 | +| jump_amount_corr_20 | **三轴标签** | PASS/NOT_/NOT_ | PASS/NOT_/NOT_ (same) | PASS/PASS/NOT_ | PASS/PASS/NOT_ (same) | PASS/PASS/NOT_ (same) | 同 | +| minute_ideal_amp_10 | IC mean | -0.029644 | -0.029508 (+0.000136) | -0.029644 | -0.029508 (+0.000136) | -0.029508 (+0.000136) | **+0.000000** | +| minute_ideal_amp_10 | ICIR | -0.473361 | -0.471797 (+0.001564) | -0.473361 | -0.471797 (+0.001564) | -0.471797 (+0.001564) | **+0.000000** | +| minute_ideal_amp_10 | NW-t | -16.476304 | -16.393063 (+0.083241) | -16.476304 | -16.393063 (+0.083241) | -16.393063 (+0.083241) | **+0.000000** | +| minute_ideal_amp_10 | N_eff | 1205.000000 | 1209.000000 (+4.000000) | 1205.000000 | 1209.000000 (+4.000000) | 1209.000000 (+4.000000) | **+0.000000** | +| minute_ideal_amp_10 | incr. ICIR | n/a | n/a (—) | -0.260375 | -0.260375 (+0.000000) | -0.257123 (+0.003252) | **+0.003252** | +| minute_ideal_amp_10 | **verdict** | Watch | Watch (same) | Watch | Watch (same) | Watch (same) | 同 | +| minute_ideal_amp_10 | **三轴标签** | PASS/NOT_/NOT_ | PASS/NOT_/NOT_ (same) | PASS/PASS/NOT_ | PASS/PASS/NOT_ (same) | PASS/PASS/NOT_ (same) | 同 | +| amp_marginal_anomaly_vol_20 | IC mean | -0.042601 | -0.042341 (+0.000260) | -0.042601 | -0.042341 (+0.000260) | -0.042341 (+0.000260) | **+0.000000** | +| amp_marginal_anomaly_vol_20 | ICIR | -0.446484 | -0.443614 (+0.002870) | -0.446484 | -0.443614 (+0.002870) | -0.443614 (+0.002870) | **+0.000000** | +| amp_marginal_anomaly_vol_20 | NW-t | -16.154643 | -16.132296 (+0.022347) | -16.154643 | -16.132296 (+0.022347) | -16.132296 (+0.022347) | **+0.000000** | +| amp_marginal_anomaly_vol_20 | N_eff | 1116.017042 | 1115.866033 (-0.151009) | 1116.017042 | 1115.866033 (-0.151009) | 1115.866033 (-0.151009) | **+0.000000** | +| amp_marginal_anomaly_vol_20 | incr. ICIR | n/a | n/a (—) | -0.311985 | -0.311985 (+0.000000) | -0.304787 (+0.007198) | **+0.007198** | +| amp_marginal_anomaly_vol_20 | **verdict** | Reject | Reject (same) | Reject | Reject (same) | Reject (same) | 同 | +| amp_marginal_anomaly_vol_20 | **三轴标签** | FAIL/NOT_/NOT_ | FAIL/NOT_/NOT_ (same) | FAIL/FAIL/NOT_ | FAIL/FAIL/NOT_ (same) | FAIL/FAIL/NOT_ (same) | 同 | +| volume_peak_count_20 | IC mean | 0.018587 | 0.018475 (-0.000112) | 0.018587 | 0.018475 (-0.000112) | 0.018475 (-0.000112) | **+0.000000** | +| volume_peak_count_20 | ICIR | 0.224592 | 0.223694 (-0.000898) | 0.224592 | 0.223694 (-0.000898) | 0.223694 (-0.000898) | **+0.000000** | +| volume_peak_count_20 | NW-t | 7.758584 | 7.765443 (+0.006859) | 7.758584 | 7.765443 (+0.006859) | 7.765443 (+0.006859) | **+0.000000** | +| volume_peak_count_20 | N_eff | 1055.757320 | 1068.478323 (+12.721003) | 1055.757320 | 1068.478323 (+12.721003) | 1068.478323 (+12.721003) | **+0.000000** | +| volume_peak_count_20 | incr. ICIR | n/a | n/a (—) | 0.120357 | 0.121800 (+0.001443) | 0.112905 (-0.007452) | **-0.008895** | +| volume_peak_count_20 | **verdict** | Reject | Reject (same) | Reject | Reject (same) | Reject (same) | 同 | +| volume_peak_count_20 | **三轴标签** | FAIL/NOT_/NOT_ | FAIL/NOT_/NOT_ (same) | FAIL/FAIL/NOT_ | FAIL/FAIL/NOT_ (same) | FAIL/FAIL/NOT_ (same) | 同 | +| intraday_amp_cut_10 | IC mean | -0.035941 | -0.035806 (+0.000135) | -0.035941 | -0.035806 (+0.000135) | -0.035806 (+0.000135) | **+0.000000** | +| intraday_amp_cut_10 | ICIR | -0.454801 | -0.453101 (+0.001700) | -0.454801 | -0.453101 (+0.001700) | -0.453101 (+0.001700) | **+0.000000** | +| intraday_amp_cut_10 | NW-t | -15.653555 | -15.615023 (+0.038532) | -15.653555 | -15.615023 (+0.038532) | -15.615023 (+0.038532) | **+0.000000** | +| intraday_amp_cut_10 | N_eff | 959.099290 | 955.010692 (-4.088598) | 959.099290 | 955.010692 (-4.088598) | 955.010692 (-4.088598) | **+0.000000** | +| intraday_amp_cut_10 | incr. ICIR | n/a | n/a (—) | -0.258500 | -0.258500 (+0.000000) | -0.262065 (-0.003565) | **-0.003565** | +| intraday_amp_cut_10 | **verdict** | INSUFFICIENT-DATA | INSUFFICIENT-DATA (same) | Watch | Watch (same) | Watch (same) | 同 | +| intraday_amp_cut_10 | **三轴标签** | INSU/NOT_/NOT_ | INSU/NOT_/NOT_ (same) | INSU/PASS/NOT_ | INSU/PASS/NOT_ (same) | INSU/PASS/NOT_ (same) | 同 | +| peak_interval_kurtosis_20 | IC mean | 0.000030 | -0.000078 (-0.000108) | 0.000030 | -0.000078 (-0.000108) | -0.000078 (-0.000108) | **+0.000000** | +| peak_interval_kurtosis_20 | ICIR | 0.000815 | -0.002072 (-0.002887) | 0.000815 | -0.002072 (-0.002887) | -0.002072 (-0.002887) | **+0.000000** | +| peak_interval_kurtosis_20 | NW-t | 0.033214 | -0.084467 (-0.117681) | 0.033214 | -0.084467 (-0.117681) | -0.084467 (-0.117681) | **+0.000000** | +| peak_interval_kurtosis_20 | N_eff | 1190.000000 | 1209.000000 (+19.000000) | 1190.000000 | 1209.000000 (+19.000000) | 1209.000000 (+19.000000) | **+0.000000** | +| peak_interval_kurtosis_20 | incr. ICIR | n/a | n/a (—) | 0.045255 | 0.042346 (-0.002909) | 0.035443 (-0.009812) | **-0.006903** | +| peak_interval_kurtosis_20 | **verdict** | Reject | Reject (same) | Reject | Reject (same) | Reject (same) | 同 | +| peak_interval_kurtosis_20 | **三轴标签** | FAIL/NOT_/NOT_ | FAIL/NOT_/NOT_ (same) | FAIL/FAIL/NOT_ | FAIL/FAIL/NOT_ (same) | FAIL/FAIL/NOT_ (same) | 同 | +| valley_relative_vwap_20 | IC mean | 0.033656 | 0.033325 (-0.000331) | 0.033656 | 0.033325 (-0.000331) | 0.033325 (-0.000331) | **+0.000000** | +| valley_relative_vwap_20 | ICIR | 0.526483 | 0.519995 (-0.006488) | 0.526483 | 0.519995 (-0.006488) | 0.519995 (-0.006488) | **+0.000000** | +| valley_relative_vwap_20 | NW-t | 17.580989 | 17.495268 (-0.085721) | 17.580989 | 17.495268 (-0.085721) | 17.495268 (-0.085721) | **+0.000000** | +| valley_relative_vwap_20 | N_eff | 822.812580 | 829.057881 (+6.245301) | 822.812580 | 829.057881 (+6.245301) | 829.057881 (+6.245301) | **+0.000000** | +| valley_relative_vwap_20 | incr. ICIR | n/a | n/a (—) | 0.348870 | 0.348049 (-0.000821) | 0.350587 (+0.001717) | **+0.002538** | +| valley_relative_vwap_20 | **verdict** | Watch | Watch (same) | Watch | Watch (same) | Watch (same) | 同 | +| valley_relative_vwap_20 | **三轴标签** | PASS/NOT_/NOT_ | PASS/NOT_/NOT_ (same) | PASS/PASS/NOT_ | PASS/PASS/NOT_ (same) | PASS/PASS/NOT_ (same) | 同 | +| valley_ridge_vwap_ratio_20 | IC mean | 0.034667 | 0.034648 (-0.000019) | 0.034667 | 0.034648 (-0.000019) | 0.034648 (-0.000019) | **+0.000000** | +| valley_ridge_vwap_ratio_20 | ICIR | 0.439482 | 0.440794 (+0.001312) | 0.439482 | 0.440794 (+0.001312) | 0.440794 (+0.001312) | **+0.000000** | +| valley_ridge_vwap_ratio_20 | NW-t | 14.350975 | 14.629276 (+0.278301) | 14.350975 | 14.629276 (+0.278301) | 14.629276 (+0.278301) | **+0.000000** | +| valley_ridge_vwap_ratio_20 | N_eff | 815.607462 | 842.525194 (+26.917732) | 815.607462 | 842.525194 (+26.917732) | 842.525194 (+26.917732) | **+0.000000** | +| valley_ridge_vwap_ratio_20 | incr. ICIR | n/a | n/a (—) | 0.295070 | 0.295828 (+0.000758) | 0.296398 (+0.001328) | **+0.000570** | +| valley_ridge_vwap_ratio_20 | **verdict** | INSUFFICIENT-DATA | INSUFFICIENT-DATA (same) | Watch | Watch (same) | Watch (same) | 同 | +| valley_ridge_vwap_ratio_20 | **三轴标签** | INSU/NOT_/NOT_ | INSU/NOT_/NOT_ (same) | INSU/PASS/NOT_ | INSU/PASS/NOT_ (same) | INSU/PASS/NOT_ (same) | 同 | +| ridge_minute_return_20 | IC mean | -0.032373 | -0.031852 (+0.000521) | -0.032373 | -0.031852 (+0.000521) | -0.031852 (+0.000521) | **+0.000000** | +| ridge_minute_return_20 | ICIR | -0.397967 | -0.391910 (+0.006057) | -0.397967 | -0.391910 (+0.006057) | -0.391910 (+0.006057) | **+0.000000** | +| ridge_minute_return_20 | NW-t | -13.690742 | -13.552495 (+0.138247) | -13.690742 | -13.552495 (+0.138247) | -13.552495 (+0.138247) | **+0.000000** | +| ridge_minute_return_20 | N_eff | 1058.906714 | 1059.087960 (+0.181246) | 1058.906714 | 1059.087960 (+0.181246) | 1059.087960 (+0.181246) | **+0.000000** | +| ridge_minute_return_20 | incr. ICIR | n/a | n/a (—) | -0.150500 | -0.150937 (-0.000437) | -0.155878 (-0.005378) | **-0.004941** | +| ridge_minute_return_20 | **verdict** | INSUFFICIENT-DATA | INSUFFICIENT-DATA (same) | INSUFFICIENT-DATA | INSUFFICIENT-DATA (same) | INSUFFICIENT-DATA (same) | 同 | +| ridge_minute_return_20 | **三轴标签** | INSU/NOT_/NOT_ | INSU/NOT_/NOT_ (same) | INSU/INSU/NOT_ | INSU/INSU/NOT_ (same) | INSU/INSU/NOT_ (same) | 同 | +| valley_price_quantile_20 | IC mean | 0.026937 | 0.027028 (+0.000091) | 0.026937 | 0.027028 (+0.000091) | 0.027028 (+0.000091) | **+0.000000** | +| valley_price_quantile_20 | ICIR | 0.424804 | 0.426259 (+0.001455) | 0.424804 | 0.426259 (+0.001455) | 0.426259 (+0.001455) | **+0.000000** | +| valley_price_quantile_20 | NW-t | 13.358750 | 13.417417 (+0.058667) | 13.358750 | 13.417417 (+0.058667) | 13.417417 (+0.058667) | **+0.000000** | +| valley_price_quantile_20 | N_eff | 761.173410 | 764.155900 (+2.982490) | 761.173410 | 764.155900 (+2.982490) | 764.155900 (+2.982490) | **+0.000000** | +| valley_price_quantile_20 | incr. ICIR | n/a | n/a (—) | 0.309642 | 0.311285 (+0.001643) | 0.305919 (-0.003723) | **-0.005366** | +| valley_price_quantile_20 | **verdict** | Watch | Watch (same) | Watch | Watch (same) | Watch (same) | 同 | +| valley_price_quantile_20 | **三轴标签** | PASS/NOT_/NOT_ | PASS/NOT_/NOT_ (same) | PASS/PASS/NOT_ | PASS/PASS/NOT_ (same) | PASS/PASS/NOT_ (same) | 同 | +| peak_ridge_amount_ratio_20 | IC mean | 0.041017 | 0.041098 (+0.000081) | 0.041017 | 0.041098 (+0.000081) | 0.041098 (+0.000081) | **+0.000000** | +| peak_ridge_amount_ratio_20 | ICIR | 0.510337 | 0.513597 (+0.003260) | 0.510337 | 0.513597 (+0.003260) | 0.513597 (+0.003260) | **+0.000000** | +| peak_ridge_amount_ratio_20 | NW-t | 16.523066 | 16.884838 (+0.361772) | 16.523066 | 16.884838 (+0.361772) | 16.884838 (+0.361772) | **+0.000000** | +| peak_ridge_amount_ratio_20 | N_eff | 933.277963 | 958.265755 (+24.987792) | 933.277963 | 958.265755 (+24.987792) | 958.265755 (+24.987792) | **+0.000000** | +| peak_ridge_amount_ratio_20 | incr. ICIR | n/a | n/a (—) | 0.308282 | 0.315855 (+0.007573) | 0.321288 (+0.013006) | **+0.005433** | +| peak_ridge_amount_ratio_20 | **verdict** | Watch | Watch (same) | Watch | Watch (same) | Watch (same) | 同 | +| peak_ridge_amount_ratio_20 | **三轴标签** | PASS/NOT_/NOT_ | PASS/NOT_/NOT_ (same) | PASS/PASS/NOT_ | PASS/PASS/NOT_ (same) | PASS/PASS/NOT_ (same) | 同 | + +**分解读数(不是整体归因)**: + +1. **A 与 B 逐指标同号同量级**:IC / ICIR / NW-t / N_eff 四个指标在 A 与 B 两格的 Δ + **完全相同**(书不进入这些指标的计算),差异全部来自已登记的 `warmup_aggregate_effect` + ——即 panels 腿那些 warmup 格的下游聚合效应。 +2. **C−B 非零的格子恰好 11 个,且全部是 `incr. ICIR`**:57 个指标行里只有这 11 行动了。 + 这正是结构上应有的结果——**书只能进入 Incremental 轴**,其余指标对书视图免疫。 + 幅度 **−0.0089 ~ +0.0130**。**"with_book 变了是因为书视图改了"这句整体归因在这里被拆开 + 验证了:它只对 Incremental 轴成立,若引擎回归藏在别的指标里,上表会显示 C−B 在那些行非零。** +3. **verdict 与三轴标签:11/11 因子、每一格全部未变**(含 A、B、C 三格各自与冻结同名格 + 比较)。C−B 的书视图修正**没有翻动任何一个 Incremental 轴标签**。 + +## 五、判定 | 因子 | 腿 1 性质映射 | 腿 2 anchors | 腿 3 reports | 腿 4 panels | 判定 | |---|---|---|---|---|---| -| (11 行,每行 PASS/FAIL + 一句话归因) | 59/59(本准备步已立) | TBD | TBD | TBD | TBD | - -**整体结论**:TBD(全 11 因子四腿全过才进入 C6 删除旧 runner;任一不过则旧 runner -不删、迭代新 runner——设计 v3.2 §九 D5 行回滚条款)。 +| jump_amount_corr_20 | 59/59 | PASS (failed=0) | PASS (rc=0) | PASS (unclassified=0) | **PASS** | +| minute_ideal_amp_10 | 59/59 | PASS | PASS | PASS | **PASS** | +| amp_marginal_anomaly_vol_20 | 59/59 | PASS | PASS | PASS | **PASS**(F1 修复后) | +| volume_peak_count_20 | 59/59 | PASS | PASS | PASS | **PASS** | +| intraday_amp_cut_10 | 59/59 | PASS | PASS | PASS | **PASS**(F4 判据修正后) | +| peak_interval_kurtosis_20 | 59/59 | PASS | PASS | PASS | **PASS**(F2 + 成因 B 登记后) | +| valley_relative_vwap_20 | 59/59 | PASS | PASS | PASS | **PASS**(同上) | +| valley_ridge_vwap_ratio_20 | 59/59 | PASS | PASS | PASS | **PASS**(F3 + 成因 A/B 登记后) | +| ridge_minute_return_20 | 59/59 | PASS | PASS | PASS | **PASS**(F3 + 成因 A) | +| valley_price_quantile_20 | 59/59 | PASS | PASS | PASS | **PASS** | +| peak_ridge_amount_ratio_20 | 59/59 | PASS | PASS | PASS | **PASS**(F3 重新推导 + 成因 A) | + +(腿 1 的 59/59 来自 `docs/factors/d5_property_test_migration_map.md`,本步未改动它。) + +**整体结论:11 因子四腿全过,C5 通过** ⇒ 满足 C6(删除 11 个旧 runner)的前置条件之一。 +每一处差异都落在 §二 的预登记清单或 §二之三 的本轮新增登记内,**类外差异为 0**。 + +⚠️ **本判定覆盖的是"新引擎与冻结基线的差异是否全部可归因",不是因子的研究结论。** +十一个因子的 verdict 与三轴标签本轮**一个都没变**,研究侧结论(全部封顶 Watch、无一 Adopt) +不受本步影响。 ## 六、已知局限(登记,不阻塞) From 5984b3ba8c16c1b2e5bb93fa2794beb21efb6c27 Mon Sep 17 00:00:00 2001 From: shaofl <2899218482@qq.com> Date: Thu, 30 Jul 2026 13:18:40 -0700 Subject: [PATCH 09/11] docs(factors): correct two overclaims the review measured as false MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HIGH-1. The sparse-tail class was described as a MECHANISM whose whitelist is the mechanism's enumeration. Measured, the density predicate admits 420 + 404 + 426 = 1,250 (factor, symbol) pairs inside the window against the 18 the class covers — roughly 69x. So the predicate is necessary and nowhere near sufficient: it CONSTRAINS the member set without DETERMINING it, and the whitelist is the enumeration of OBSERVED cells. Relabelled as an enumeration class with a necessary-condition guard, in all three places that said otherwise. The label is operational, not cosmetic: "mechanism class" invites the next person to extend it by derivation, which is exactly what must not happen, and the anti-ratchet note now says so explicitly. The guard moves from the median to the 25th percentile. A median gate passes half the universe by construction, so it could hardly fail; all 18 in-class pairs sit below their factor's p25 (worst percentile rank 21.7%, 15 of 18 below 3%), so tightening costs nothing and turns an almost-unfailable check into one that can fail. Mutation evidence both ways: a dense-everywhere name is still caught under p25, and loosening back to the median still passes, which is what shows p25 is strictly tighter on this data. The 600906.SH asymmetry now carries its reach: it is a CONDITIONAL prediction whose NEGATIVE direction alone holds (dense => absent, corroborated by the dense control factor's zero cells), while sparse => present is false by that same 69x — and it is one data point whose dense side clears its median by a single day. HIGH-2. The with_book section claimed "if an engine regression hid in other metrics, C-B would be non-zero there". That inference is VACUOUS and the review demonstrated it: scaling ic_mean by 1.5 in all three books leaves C-B at exactly 0.0, because A/B/C hold identical values on those metrics and a regression moves all three and cancels. The "11 of 57 rows" framing was also a statement about the table's own row selection; at artifact level C-B is non-zero on 174 leaves. Replaced with what the data does support — those 174/174 land in sections[3] plus book_view plus verdict reasons, so the book view's blast radius is bounded — and the report now says plainly that engine regressions are caught by the strict no_book/bookclose gates against the frozen baseline, not by C-B. The refuted inference stays ON THE RECORD with its mutation, as the compare_postmerge empty-reconcile was recorded: the audit conclusion is unchanged, what was wrong is the attribution. LOW-1. The Markdown-prefix test could not fail for the tuple it named: both sides read DERIVED_PAYLOAD_PROPERTIES, so deleting an entry left it green (measured). Its docstring now states that reach, and a new test anchors the tuple to the union of @property names declared on the coverage classes — which does go red on that exact edit. LOW-2. The report flagged only jump's 101/101. Added the sparse-tail window's right edge (drawn AT its last cell: zero days of headroom) and its cell cap at 19/20, alongside the looser bounds, so a reader can see which boundaries are one step from firing. NIT-1: the appended-section re-verification denominator is 12 grids (4 factors x 3), not 8. NIT-2: registered, not fixed — jump's reports leg and the decision-book grid do not gate VALUE changes (518 of 593 diffs ride the registered correction carrier; the decision grid is report-only), while structural additions and removals still gate. Scope: confirmed empirically rather than argued — perturbing this harness module leaves all five sampled factor code_hash keys unchanged, so the store stays valid and only the reports leg needed re-running (5 affected factors re-run, rc=0, zero unregistered). --- docs/factors/d5_c5_audit_report.md | 75 ++++++++++--- .../factors/d5_runner_difference_catalogue.md | 56 ++++++---- qt/factor_eval_reconcile.py | 84 ++++++++++----- tests/test_factor_eval_reconcile.py | 100 ++++++++++++++---- 4 files changed, 233 insertions(+), 82 deletions(-) diff --git a/docs/factors/d5_c5_audit_report.md b/docs/factors/d5_c5_audit_report.md index c32985b..df6ff61 100644 --- a/docs/factors/d5_c5_audit_report.md +++ b/docs/factors/d5_c5_audit_report.md @@ -78,9 +78,9 @@ unclassified → FAIL。** |---|---|---|---| | 15 | `threshold_flip_contamination` bars-only 臂 | 同窗同 symbol;**相对**界按因子(kurtosis ≤5e-3 / relative_vwap ≤1e-5);≤25 cell/因子 | 编目 §七之七 F2 | | 16 | `warmup_left_extension` 新方向 `frozen_finite_new_nan` | 仅 valid-day pooled 因子 + 早区窗内 | 编目 §七之七 F3① | -| 17 | `warmup_sparse_valid_day_tail` | 仅 pooled;[2021-11-01, 2021-11-15];9 票白名单(成员判据=发行密度低于该因子中位数,**已证伪检查 18/18**);≤20 cell/因子;**反棘轮** | 编目 §七之七 F3② | +| 17 | `warmup_sparse_valid_day_tail` | 仅 pooled;[2021-11-01, 2021-11-15];**9 票白名单即成员**(**枚举类 + 必要条件守卫,不是机制类**:密度守卫在 **p25**、18/18 通过,但该谓词单独允许 **1,250** 对 ≈ 本类 **69 倍** ⇒ 约束成员集但不决定它);≤20 cell/因子;**反棘轮 + 禁止照密度推导新成员** | 编目 §七之七 F3② | | 18 | 绝对 float-dust 前移到区域分支之前 | `abs ≤ 1e-12` 按机制分类,不按位置 | 编目 §七之七 F4 | -| 19 | 诊断 sink 披露节(三个因子) | `old=None` 纯新增 + verdict 标签不变 + **无索引位移**,逐 grid 复验 8/8;**反棘轮** | 编目 §七之八 A | +| 19 | 诊断 sink 披露节(三个因子) | `old=None` 纯新增 + verdict 标签不变 + **无索引位移**,逐 grid 复验 **12/12**(4 因子 × 3 grid);**反棘轮** | 编目 §七之八 A | | 20 | `spec.description` 的 D2 provenance 改写 | **逐对精确枚举**(3 因子);其它一律 FAIL;不 bump 版本、不触发更正承载 | 编目 §七之八 B | ## 二之二、C5 首轮全量跑的原始结果(pre-fix)—— 誊自日志,**耐久化** @@ -191,10 +191,29 @@ panels/anchors 的入口函数逐字节未变——见 §六.9)。 **总计:33 格(11 因子 × 三腿)全部 rc=0**;panels 的 `unclassified` 与 anchors 的 `failed` 全为 0,reports 三格零类外叶子。 -⚠️ **`jump_amount_corr_20` 的 float_tail 是 101/101 —— 零余量**。该 cap 当初按这个因子的 -观测值标定,**再多一格就 FAIL**。本轮判据改动没有往里加格(它那 101 格全在 warmup 区外, -F4 前移一格未动),但这个零余量是**登记在案的脆弱点**:下次任何触及 rolling 求和顺序的改动 -都可能顶破它,而那时失败信息会是"cap 超了",不是"哪里错了"。 +### 三之四、贴边的边界(登记为脆弱点) + +反棘轮裁定使这些"紧"是**有意的牙**——但读者得知道它们有多紧: + +| 边界 | 上限 | 实测 | 余量 | +|---|---|---|---| +| `jump_amount_corr_20` float_tail cap | 101 | **101** | **0 格** | +| `warmup_sparse_valid_day_tail` 窗口右端 | 2021-11-15 | 该日**恰有 1 格** | **0 天** | +| `warmup_sparse_valid_day_tail` cell cap | 20 | **19**(peak_ridge) | **1 格** | +| bars-only flip contamination cap | 25 | 20 | 5 格 | +| kurtosis 相对界 | 5e-3 | 2.904e-3 | 1.72× | +| relative_vwap 相对界 | 1e-5 | 1.530e-6 | 6.54× | + +**前三行是零/近零余量**: +- **jump 的 101/101**:该 cap 当初按这个因子的观测值标定,**再多一格就 FAIL**。本轮判据改动没有 + 往里加格(它那 101 格全在 warmup 区外,F4 前移一格未动);但下次任何触及 rolling 求和顺序的 + 改动都可能顶破它,而那时失败信息会是"cap 超了",**不是"哪里错了"**。 +- **sparse-tail 窗口右端 0 天余量**:窗口收在 2021-11-15,**恰恰因为该日有且只有 1 格** + (`688183.SH`)。窗口不是留了余地画的,是**贴着最后一格画的**。 +- **sparse-tail cap 只剩 1 格**:19/20。 + +这三处**都不许因为"贴边"而放宽**——反棘轮的裁定正是"顶破了就停下上报",贴边意味着**下一次 +触发会很快到来且必须被当真**。 ## 四、with_book 差异分解(逐指标) @@ -285,17 +304,35 @@ close 书 + 新引擎,与 A 同口径可比)· **C** = decision 书 · **C | peak_ridge_amount_ratio_20 | **verdict** | Watch | Watch (same) | Watch | Watch (same) | Watch (same) | 同 | | peak_ridge_amount_ratio_20 | **三轴标签** | PASS/NOT_/NOT_ | PASS/NOT_/NOT_ (same) | PASS/PASS/NOT_ | PASS/PASS/NOT_ (same) | PASS/PASS/NOT_ (same) | 同 | -**分解读数(不是整体归因)**: +**分解读数**: 1. **A 与 B 逐指标同号同量级**:IC / ICIR / NW-t / N_eff 四个指标在 A 与 B 两格的 Δ - **完全相同**(书不进入这些指标的计算),差异全部来自已登记的 `warmup_aggregate_effect` - ——即 panels 腿那些 warmup 格的下游聚合效应。 -2. **C−B 非零的格子恰好 11 个,且全部是 `incr. ICIR`**:57 个指标行里只有这 11 行动了。 - 这正是结构上应有的结果——**书只能进入 Incremental 轴**,其余指标对书视图免疫。 - 幅度 **−0.0089 ~ +0.0130**。**"with_book 变了是因为书视图改了"这句整体归因在这里被拆开 - 验证了:它只对 Incremental 轴成立,若引擎回归藏在别的指标里,上表会显示 C−B 在那些行非零。** -3. **verdict 与三轴标签:11/11 因子、每一格全部未变**(含 A、B、C 三格各自与冻结同名格 - 比较)。C−B 的书视图修正**没有翻动任何一个 Incremental 轴标签**。 + **完全相同**(书不进入这些指标的计算,44/44 取值逐格相同),差异全部来自已登记的 + `warmup_aggregate_effect`——即 panels 腿那些 warmup 格的下游聚合效应。 +2. **书视图的爆炸半径被限制住了**:artifact 层 C−B 非零的叶子共 **174 个**,且**全部**落在 + `sections[3]`(purity / incremental)+ `book_view` + verdict reasons —— + **174/174 通过**。这是这张表真正说得出的事:**换书视图动到的东西,恰好只在它应该动到的 + 地方**。 +3. **verdict 与三轴标签:11/11 因子、每一格全部未变**(A、B、C 三格各自与冻结同名格比较)。 + 174 个叶子的书视图修正**没有翻动任何一个 Incremental 轴标签**。 + +> ### ⚠️ 一条曾被写成检验、实为**恒真**的推论(留档,不悄悄换掉) +> +> 本节初版在读数 2 里写过:「**若引擎回归藏在别的指标里,上表会显示 C−B 在那些行非零**」。 +> **这句话是假的**,评审用 mutation 直接演示:把 `ic_mean` 在 **A / B / C 三本书里同时 ×1.5**, +> **C−B 仍精确为 0.0**。机制很直白——A/B/C 在 IC/ICIR/NW-t/N_eff 上**取值完全相同**(书对这些 +> 指标**构造性免疫**),所以**引擎回归会让 A、B、C 一起动,然后在 C−B 里对消**。C−B 检不出 +> 引擎回归,一格都检不出。 +> +> 同一句里的「C−B 非零恰好 11 格 / 57 行」也不该当成事实陈述:**11 与 57 是这张表自己选了哪些 +> 行的结果**,artifact 层的真实数字是上面的 **174** 个叶子。 +> +> **真正检出引擎回归的是别的东西**:`no_book` 与 `bookclose` 两格在 **strict 模式**下对冻结基线 +> 的逐叶子闸门(未登记的变化即 FAIL)。**它存在、而且通过了**——本 PR 的引擎结论站在它上面, +> **不站在 C−B 上**。 +> +> **审计结论不受影响,错的是归因**:数字一个没变,变的是"哪个装置在把关"。留档而不是改写, +> 与 `compare_postmerge.py` 那次空对账同一处理——**被证否的推理留在案上**。 ## 五、判定 @@ -350,7 +387,13 @@ close 书 + 新引擎,与 A 同口径可比)· **C** = decision 书 · **C F1 修复后重跑 rc=0,六格差异全落在已登记类内)。**B 阶段是这一腿的首次观测**;§四的 A/B/C 分解因此**只有冻结基线一个参照物**(见 §四的告示)。B 中若 reports 出现类外差异: **停下上报,不扩类**。 -8. **`_make_logger` 是 truncate 模式 —— 重跑会静默销毁同名日志的唯一副本**(登记为已知 +9. **jump 与 `with_book(decision)` 两格对"值改动"实际不设闸**(背景,**非本 PR 引入**,登记 + 即可、本 PR 不改):jump 的 reports 腿 593 个 diff 里 **518 个**归入既有登记 #11 的 + `registered_correction_effect`(契约 v1.1 的更正承载一旦存在,值差异即被接受); + `with_book(decision)` 格同理是 report-only(登记 #13,`book_view_effect` 全量报告不设闸)。 + **结构性增删(未登记的新增/删除、未登记的 section)仍然设闸。** 读判定时应以 + `no_book` / `bookclose` 两个 strict 格 + panels 腿为值级依据。 +10. **`_make_logger` 是 truncate 模式 —— 重跑会静默销毁同名日志的唯一副本**(登记为已知 运维风险,**本 PR 不改**:那是行为改动,属另一个 PR)。本轮已被它咬过一次:为验证判据 改动而重跑 `intraday_amp_cut_10` / `peak_interval_kurtosis_20` 的 panels 腿,覆盖掉了这 两条 C5 原始日志行(值经"覆盖前逐字抓取 + 离线独立重算"双路复原,见 §二之二 出处)。 diff --git a/docs/factors/d5_runner_difference_catalogue.md b/docs/factors/d5_runner_difference_catalogue.md index ca62e8b..44652b4 100644 --- a/docs/factors/d5_runner_difference_catalogue.md +++ b/docs/factors/d5_runner_difference_catalogue.md @@ -626,12 +626,20 @@ anchor 边缘两种几何**积累到的有效日个数**可以不同(实证 68 ② **新具名类 `warmup_sparse_valid_day_tail`**:同一 anchor 截断在**稀疏发行**的 valid-day pooled 因子上的长尾——有效日少的票把早区差异**带出早区窗**,落在 2021 年 11 月上旬一小簇。 -**成员判据是机制,白名单只是它在本窗口的枚举**——这一类的**可证伪内容**是: +**这是一个「枚举类 + 必要条件守卫」,不是机制类。** 这个标签差别是**操作性**的,不是措辞 +洁癖:读到"机制类"的人会以为**可以照机制推导着扩**,读到"枚举类"才知道**不能**。成员就是 +下面那张白名单;密度性质是每个成员都必须满足的**守卫**,不是生成这张名单的**规则**。 -> 一格属于此类,是因为**该票在该因子上的有效日发行密度,低于该因子自身的中位数**。 +密度性质说的是三件事: -这样的票发行日太少,早区的差异来不及在早区窗内摊平,于是被**带出**早区窗——这正是早区类 -吸收不了它们、需要单独一类的原因。 +1. **稀疏发行是差异"能够"被带出早区窗的前提**——这样的票发行日太少,早区差异来不及在早区窗内 + 摊平。**必要,但远不充分**:实测该谓词("发行密度低于该因子自身中位数")在本窗口内允许 + **420 + 404 + 426 = 1,250** 个 (因子, symbol) 对,而本类实际只覆盖 **18** 个——**约 69 倍**。 + (分母口径:低于中位数**且在窗口内至少发行过一天**的票;窗口内一天都不发行的票根本产生不了格。) +2. **一只稀疏票"是否真的"带出差异**,还取决于它早区**有没有**差异、以及有效日怎么落 + ⇒ 谓词**约束**成员集,**不决定**它。 +3. 所以**白名单是"观测格"的枚举**;可证伪并已检验的是那条**必要条件**:每个成员都满足它 + (18/18)。**本类刻意窄于机制** —— 这正是重点,见下面的反棘轮。 | 参数 | 值 | 实测 | |---|---|---| @@ -641,19 +649,28 @@ pooled 因子上的长尾——有效日少的票把早区差异**带出早区 | 幅度 | **不设界**(与 warmup 同理由:两种加载几何在该处本就合法地不同) | ridge 最大 rel **1.96**(符号翻转) | | cell 数 | ≤ **20**/因子 | 实测 13 / 13 / **19** 格 | -**证伪检查(做了,不是假设)**:该类涉及的 **18/18** 个 (因子, symbol) 对全部低于该因子自身 -的发行密度中位数(窗口 [2021-07-01, 2021-11-30],中位 40–41 个发行日),**0 处违反**;9 只 -白名单票**没有一只**在所有受影响因子上都是稠密的。**最锋利的一条是机制做出的不对称预言**: -600906.SH 在 peak_ridge 上发行 29 日(低于中位)、在 ridge 上 42 日(**高于**中位)——它也 -**只**出现在 peak_ridge 的簇里、**不**出现在 ridge 的。稀疏性是逐 (因子, symbol) 的,受影响 -集合随之而变。对照组 `volume_peak_count_20`(稠密 pooled,92 天里发行 83 天):同样这 9 只票 -**一格不差**。该检查已落成 committed 测试,真实冻结面板在盘上时**每次都跑**(mutation 实证: -往白名单里塞一只三个因子上都稠密的票 `000009.SZ`(48/41、49/41、47/40)→ 测试转红)。 +**证伪检查(做了,不是假设)——门槛已从中位收到 p25**:中位那道门按定义就有约一半的票能过, +几乎不可能失败;实测 **18/18** 个 (因子, symbol) 对全部低于该因子自身的 **p25**(窗口 +[2021-07-01, 2021-11-30];最差百分位秩 **21.7%**,**15/18 在 3% 以下**),**0 处违反**。 +收紧后仍 18/18,但守卫从"几乎恒真"变成"能失败"。(mutation 实证:把门槛放回中位 → 仍全过, +说明 p25 是**严格更紧**且当前数据两边都清关;往白名单里塞一只三因子都稠密的票 `000009.SZ` +(48/41、49/41、47/40)→ **p25 门下仍然转红**。) + +**不对称证据,连同它的射程一起写**:600906.SH 在 peak_ridge 上发行 29 日(低于其 p25)、在 +ridge 上 42 日——它也**只**出现在 peak_ridge 的簇里、**不**出现在 ridge 的。这是一条**条件性** +预言,且**只有否定方向成立**:**稠密 ⇒ 不进簇**(对照组 `volume_peak_count_20`,稠密 pooled、 +92 天发行 83 天,这 9 只票**一格不差**,佐证同一方向)。**稀疏 ⇒ 进簇并不成立**——上面那个 +69 倍量的就是它。而且这是**单点证据**,其稠密一侧只比中位高 **1 天**(42 vs 41),**别让它撑起 +比它能撑的更多**。 **⚠️ 反棘轮约束(lead 裁定):本类不得再次放宽。** 一直放宽下去,`unclassified` 总能归零, -而那时具名类就不再是关于机制的主张、只是**残差的描述**,「未编目的差异算失败」(设计 §六.5) -也就被悄悄废掉了。**若日后又有因子或格需要它,那件事本身就是"此类在描述残差而非机制"的证据 -⇒ 停下上报,不要再打补丁。** +而那时具名类就不再是任何主张、只是**残差的描述**,「未编目的差异算失败」(设计 §六.5)也就被 +悄悄废掉了。**若日后又有因子或格需要它,那件事本身就是这个证据 ⇒ 停下上报,不要再打补丁; +尤其不要照密度性质"推导"出新成员——那条谓词允许的是本类覆盖量的 69 倍。** + +**并且:既然没有任何规则决定成员集,反棘轮就是这一类唯一的守卫**,这也把**反向测试从配角提到 +主力**——"白名单外的票必 FAIL / 窗口外的日期必 FAIL / bounded 因子必 FAIL"这三条断言,才是让 +这份枚举保持为枚举的东西。 **这组参数是怎么被推错、又怎么重新推的(记录在案)**:初版是 6 只票 / 窗口收在 **11-12**, **只从两个因子推出来**——因为第三个因子 `peak_ridge_amount_ratio_20` 当时**正在 FAIL @@ -762,9 +779,12 @@ artifact 早于它。**vpq 之所以是唯一一条,正因为 vpq 是唯一跑 | 检查 | 结果 | |---|---| -| ① 追加下标的每个叶子都是 `old=None` 纯新增、无值改写 | **8/8 grid 全过,non_pure=0** | -| ② verdict 标签与冻结基线一致 | **8/8 全过,label_diff=none** | -| ③ **无索引位移** | **8/8:sections `8 → 9`,前 8 个名字逐位对齐,披露节追加在最后** | +| ① 追加下标的每个叶子都是 `old=None` 纯新增、无值改写 | **12/12 grid 全过,non_pure=0** | +| ② verdict 标签与冻结基线一致 | **12/12 全过,label_diff=none** | +| ③ **无索引位移** | **12/12:sections `8 → 9`,前 8 个名字逐位对齐,披露节追加在最后** | + +(分母是 **12** = 带追加节的 4 个因子 × 3 个 grid;实现方初版写成 8/8,漏算了 +`with_book(decision)` 那一格,评审逐格复核 12 个全过。) ③ 的重要性:叶子路径是**按下标**展平比对的,中途插入一节会让其后每一节错位,"其余节全同" 就成了假象。**位移是被检出而非被吸收的**——错位后 `sections[k].name` 会成为 unregistered diff --git a/qt/factor_eval_reconcile.py b/qt/factor_eval_reconcile.py index 4c091f7..62e9801 100644 --- a/qt/factor_eval_reconcile.py +++ b/qt/factor_eval_reconcile.py @@ -52,14 +52,16 @@ class is CEILED at ``(lookback_depth - 1) x |frozen symbols|`` cells 1b. ``warmup_sparse_valid_day_tail`` — the SAME anchor-truncation family on valid-day POOLED factors with a sparse emission grid, whose early-region difference is carried forward past the early window instead of averaging - out inside it. Membership is a MECHANISM — the symbol's valid-day - emission density on that factor is below that factor's own median — and - the whitelist is that criterion's enumeration in this window (checked, - not assumed: 18/18 in-class pairs satisfy it). Registered window + out inside it. AN ENUMERATION CLASS WITH A NECESSARY-CONDITION GUARD, not + a mechanism class: membership is the registered whitelist, and the + density property (emission below that factor's own p25) is a guard every + member satisfies (18/18) rather than a rule that produces the list — the + predicate alone admits ~69x as many pairs. Registered window [2021-11-01, 2021-11-15], a symbol whitelist, at most 20 cells per factor, pooled factors only; amplitude unbounded inside (the geometries - legitimately differ, as in the early region). MUST NOT BE WIDENED AGAIN - — see the constants' anti-ratchet note. + legitimately differ, as in the early region). MUST NOT BE WIDENED AGAIN, + and must NOT be extended by deriving from the density property — see the + constants' anti-ratchet note. 2. ``float_reordering_tail`` — scattered finite-vs-finite cells with rel diff <= 5e-12 (rolling-correlation summation order; the JC1 1e-12 gate is the attributable floor, this is the measured tail above it), OR with @@ -299,26 +301,46 @@ class is CEILED at ``(lookback_depth - 1) x |frozen symbols|`` cells #: of the same anchor-truncation family as ``warmup_left_extension``, on #: valid-day POOLED factors whose emission grid is sparse. #: -#: THE CLASS'S FALSIFIABLE CONTENT — the membership criterion is a MECHANISM, -#: and the whitelist below is only its enumeration in this window: +#: THIS IS AN ENUMERATION CLASS WITH A NECESSARY-CONDITION GUARD — NOT a +#: mechanism class. The distinction is operational, not stylistic: a mechanism +#: class could be EXTENDED BY DERIVING from the mechanism, and this one must +#: not be. Membership is the enumerated list below; the density property is a +#: guard every member must satisfy, not a rule that produces the list. #: -#: a cell belongs here because THAT SYMBOL'S valid-day emission density -#: ON THAT FACTOR is below THAT FACTOR'S OWN median. +#: What the density property says: #: -#: Such a name publishes on too few days for an early-region difference to -#: average out inside the early window, so it carries the difference forward -#: past it — which is why these cells land OUTSIDE the early region and the -#: early-region class cannot absorb them. +#: 1. Sparse emission is the PRECONDITION that lets an early-region +#: difference be carried PAST the early window: such a name publishes on +#: too few days for the difference to average out inside it. Necessary, +#: and NOWHERE NEAR SUFFICIENT — measured, the predicate ("emission below +#: that factor's own median") admits 420 + 404 + 426 = 1,250 +#: (factor, symbol) pairs inside this window, against the 18 the class +#: actually covers. Roughly 69x. +#: 2. Whether a sparse name ACTUALLY carries a difference forward also +#: depends on whether it had an early-region difference at all and on how +#: its valid days fall. So the predicate CONSTRAINS the member set and +#: does not DETERMINE it. +#: 3. The whitelist is therefore the enumeration of the OBSERVED cells. What +#: is falsifiable and checked is the necessary condition: every member +#: satisfies it (18/18). THE CLASS IS DELIBERATELY NARROWER THAN THE +#: MECHANISM, and that is the point — see the anti-ratchet note. #: -#: The mechanism is falsifiable and was checked, not assumed. Measured over -#: [2021-07-01, 2021-11-30] on the frozen panels: all 18 in-class -#: (factor, symbol) pairs sit below their factor's own median emission (medians -#: 40-41 days), and 0 violate it. The sharpest confirmation is an ASYMMETRY the -#: mechanism predicts: 600906.SH emits 29 days on peak_ridge (below its median) -#: and 42 on ridge (ABOVE its median) — and it appears in the former's cluster -#: and NOT in the latter's. Control group: volume_peak_count_20, a DENSE pooled -#: factor (83 of 92 days), has zero cells on these same nine names. -#: ``tests/test_factor_eval_reconcile.py`` re-runs this check against the real +#: The guard is checked at the 25th PERCENTILE, not the median. A median gate +#: is nearly vacuous by construction (half the universe passes it); measured, +#: all 18 in-class pairs sit below their factor's own p25, with a worst +#: percentile rank of 21.7% and 15 of 18 below 3%. Tightening it turns a guard +#: that almost could not fail into one that can. +#: +#: Supporting ASYMMETRY, with its reach stated: 600906.SH emits 29 days on +#: peak_ridge (below its p25) and 42 on ridge — and it appears in the former's +#: cluster and NOT in the latter's. This is a CONDITIONAL prediction and only +#: its NEGATIVE direction holds: dense => absent (corroborated by the control +#: group volume_peak_count_20, a dense pooled factor at 83 of 92 days, which +#: has zero cells on all nine names). Sparse => present does NOT hold — that +#: is what the 69x above measures. And it is a SINGLE data point whose dense +#: side clears its median by ONE DAY (42 vs 41), so it cannot carry more than +#: it is carrying here. +#: ``tests/test_factor_eval_reconcile.py`` re-runs the guard against the real #: frozen panels whenever the corpus is on disk. #: #: AMPLITUDE IS NOT BOUNDED inside the class (the measured ridge cells include a @@ -329,11 +351,17 @@ class is CEILED at ``(lookback_depth - 1) x |frozen symbols|`` cells #: #: ⚠️ ANTI-RATCHET (lead ruling, C5): THIS CLASS MUST NOT BE WIDENED AGAIN. #: Keep relaxing a class and ``unclassified`` can always be driven to zero, at -#: which point the class stops being a claim about a mechanism and becomes a -#: description of the residual — and "an uncatalogued difference is a failure" -#: (design §六.5) has been quietly repealed. If a future factor or cell needs -#: this class, that need is ITSELF evidence that the class is describing -#: residuals rather than a mechanism: STOP AND REPORT, do not patch the bounds. +#: which point the class stops being a claim at all and becomes a description +#: of the residual — and "an uncatalogued difference is a failure" (design +#: §六.5) has been quietly repealed. If a future factor or cell needs this +#: class, that need is ITSELF evidence of exactly that: STOP AND REPORT, do +#: not patch the bounds — and in particular do NOT "derive" new members from +#: the density property, which admits 69x what the class covers. +#: Because no rule determines membership, this constraint is the ONLY guard +#: this class has, which also PROMOTES THE REVERSE TESTS from a supporting +#: role to the primary one: the assertions that a name outside the whitelist, +#: a date outside the window, or a bounded factor must FAIL are what keep the +#: enumeration an enumeration. SPARSE_VALID_DAY_TAIL_LO = pd.Timestamp("2021-11-01") SPARSE_VALID_DAY_TAIL_HI = pd.Timestamp("2021-11-15") #: The enumeration of the criterion above across the THREE affected factors diff --git a/tests/test_factor_eval_reconcile.py b/tests/test_factor_eval_reconcile.py index b347251..acc5265 100644 --- a/tests/test_factor_eval_reconcile.py +++ b/tests/test_factor_eval_reconcile.py @@ -1766,22 +1766,31 @@ def test_panels_sparse_tail_whitelist_members_are_each_accepted(symbol): def _emission_density(factor_id: str) -> tuple[dict[str, int], float]: - """(symbol -> emitted days, the factor's median) over the density window.""" + """(symbol -> emitted days, the factor's p25) over the density window. + + The threshold is the 25th percentile, NOT the median. A median gate is + nearly vacuous by construction — half the universe passes it — so a guard + built on it could hardly fail. Measured, all in-class pairs sit below p25 + (worst percentile rank 21.7%, 15 of 18 below 3%), so tightening costs + nothing here and turns an almost-unfailable check into one with teeth. + """ frame = pd.read_parquet(_FROZEN_PANELS / f"{factor_id}.parquet") frame["date"] = pd.to_datetime(frame["date"]) window = frame[(frame["date"] >= _DENSITY_LO) & (frame["date"] <= _DENSITY_HI)] emitted = window.groupby("symbol")[factor_id].apply(lambda s: int(s.notna().sum())) - return emitted.to_dict(), float(emitted.median()) + return emitted.to_dict(), float(emitted.quantile(0.25)) @requires_frozen_panels def test_every_sparse_tail_name_is_sparse_on_some_affected_factor(): - """Each whitelisted name must satisfy the density criterion SOMEWHERE. - - A name that is at or above the median on all three affected factors would - mean the class is admitting it for some other reason than the mechanism the - class claims — i.e. the whitelist would be a residual description. The - ruling on that is explicit: STOP, do not widen. + """Each whitelisted name must satisfy the NECESSARY condition somewhere. + + This is a guard on the enumeration, not a rule that produces it: the + predicate admits roughly 69x as many (factor, symbol) pairs as the class + covers, so passing it says only that no member contradicts the story. + A name at or above p25 on all three affected factors WOULD contradict it — + the class would then be admitting that name for some reason nobody has + stated. The ruling on that is explicit: STOP, do not widen. """ density = {f: _emission_density(f) for f in _SPARSE_TAIL_FACTORS} offenders = {} @@ -1801,16 +1810,23 @@ class claims — i.e. the whitelist would be a residual description. The @requires_frozen_panels def test_the_density_criterion_predicts_where_600906_appears_and_where_it_does_not(): - """The asymmetry that makes the criterion a prediction rather than a label. - - 600906.SH is below its median on peak_ridge and ABOVE it on ridge — and it - shows up in the former's November cluster and not in the latter's. If the - criterion were decorative, this pair would not split. + """The asymmetry, with its reach: only the NEGATIVE direction holds. + + 600906.SH is below p25 on peak_ridge and above the median on ridge — and it + shows up in the former's November cluster and not in the latter's. What + that supports is "dense => absent" (the control group volume_peak_count_20 + corroborates it: zero cells on all nine names). It does NOT support + "sparse => present", which is false by roughly 69x. It is also ONE data + point, dense by a single day. """ - peak_emitted, peak_median = _emission_density("peak_ridge_amount_ratio_20") - ridge_emitted, ridge_median = _emission_density("ridge_minute_return_20") - assert peak_emitted["600906.SH"] < peak_median - assert ridge_emitted["600906.SH"] >= ridge_median + peak_emitted, peak_p25 = _emission_density("peak_ridge_amount_ratio_20") + ridge_emitted, _ridge_p25 = _emission_density("ridge_minute_return_20") + assert peak_emitted["600906.SH"] < peak_p25 + # The dense side is stated against the MEDIAN and clears it by ONE DAY + # (42 vs 41) — a single data point, recorded with its reach rather than + # leaned on. Only the negative direction of the prediction holds + # (dense => absent); sparse => present is false, by 69x. + assert ridge_emitted["600906.SH"] >= 41 # --------------------------------------------------------------------------- # @@ -2068,9 +2084,17 @@ def test_md_prefixes_cover_EVERY_key_the_real_section_payload_carries( Deriving the prefixes from ``dataclasses.fields`` alone missed the two COMPUTED properties ``to_section`` adds on top of ``asdict``, so three rendered Markdown lines stayed unregistered and the reports leg kept - failing with the JSON side already green. Comparing against the actual - payload keys is what closes that gap — a future payload key with no - dataclass field to be read from fails here instead of on a two-hour run. + failing with the JSON side already green. + + ⚠️ REACH, measured: this test does NOT guard + ``DERIVED_PAYLOAD_PROPERTIES`` itself. Both sides now read that tuple, so + deleting an entry from it leaves this test GREEN by construction (measured: + dropping ``validity_rate`` -> 4 passed). What catches that is the REAL + reports leg (all three Markdown grids fail) and + ``test_derived_payload_properties_are_exactly_the_coverage_properties`` + below, which pins the tuple against the payload classes themselves. This + test's own job is narrower: a payload key with no dataclass field AND no + entry in that tuple fails here rather than on a two-hour run. """ from qt.factor_eval_disclosures import ( NEUTRALIZATION_SECTION_NAME, @@ -2113,3 +2137,39 @@ def test_md_prefixes_cover_EVERY_key_the_real_section_payload_carries( f"{factor_id}/{section_name}: payload keys with no registered Markdown " f"prefix -> their rendered lines would fail the reports leg: {sorted(missing)}" ) + + +def test_derived_payload_properties_are_exactly_the_coverage_properties(): + """Pin ``DERIVED_PAYLOAD_PROPERTIES`` to the payload classes themselves. + + Both ``to_section`` and the reconcile prefix derivation read that tuple, so + every test comparing one against the other is equal BY CONSTRUCTION and + stays green when an entry is deleted (measured). The tuple therefore needs + an anchor that is not itself derived from it: the union of ``@property`` + names declared on the coverage dataclasses. Delete an entry and this goes + red; add a property to a coverage class without registering it and this + goes red too — which is the direction that would otherwise ship an + unregistered Markdown line. + """ + from qt.factor_eval_disclosures import ( + DERIVED_PAYLOAD_PROPERTIES, + NeutralizationCoverage, + PeakCoverage, + RidgeCoverage, + RidgeReturnCoverage, + ) + + declared: set[str] = set() + for cls in (RidgeCoverage, RidgeReturnCoverage, PeakCoverage, NeutralizationCoverage): + declared |= { + name + for name, attr in vars(cls).items() + if isinstance(attr, property) and not name.startswith("_") + } + # ``render`` is a method, not a property, and is carried as the section's + # note rather than as a payload key — the set below is payload-bearing only. + assert set(DERIVED_PAYLOAD_PROPERTIES) == declared, ( + "DERIVED_PAYLOAD_PROPERTIES must equal the coverage classes' property " + f"names; registered={sorted(DERIVED_PAYLOAD_PROPERTIES)} " + f"declared={sorted(declared)}" + ) From 5660332826bd1a8b20353e68b1f16179a05cabf4 Mon Sep 17 00:00:00 2001 From: shaofl <2899218482@qq.com> Date: Thu, 30 Jul 2026 13:38:51 -0700 Subject: [PATCH 10/11] docs(factors): retire the last three copies of the corrected claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delta review found the old wording surviving in three places the previous pass missed — and the worst one asserted a MEDIAN gate fifteen lines above the code that reads p25, so a reader had two contradicting sentences on one screen. That is the exact defect this rework existed to remove, reappearing because the same claim was written in four places and only three were corrected. Rather than correct a fourth copy, the claim is now AUTHORED ONCE, on SPARSE_VALID_DAY_TAIL_SYMBOLS: the module docstring and the test banner point at it instead of restating it. The two Markdown documents still have to restate — no mechanism shares prose between a Python docstring and a .md file — so they carry an explicit pointer to where the authoritative wording lives. The catalogue's dangling back-reference (it pointed at a heading this rework had already renamed, and quoted the retired claim while doing so) now describes the old title instead of quoting it: copying it verbatim would keep any scan from reaching zero and would let a skimming reader mistake it for the live claim. The "p25 is strictly tighter" mutation record is replaced, because neither entry on file actually tested that claim: "loosen back to the median -> still passes" only shows both gates clear the REGISTERED members, and the injected dense name 000009.SZ fails under the median gate too. The witness that discriminates is a name from the 151-symbol band that sits below the median but at or above p25 — 000031.SZ, 38/38/38 against p25 36 and medians 41/41/40 — which FAILS under p25 and PASSES under the median. Both the new evidence and the old evidence's miss are recorded: this is the same defect as the rest of the round, a piece of evidence that did not support the claim it was filed under. The p25 guard's own headroom joins the near-edge table: its threshold is 36 emitting days and the tightest member has 35, so ONE day. It is flagged the way the other near-edge bounds are, with the note that a red here is stop-and-report and must not be answered by moving the threshold back to the median — the move anti-ratchet forbids, and the one that looks most attractive precisely when the guard fires. Section 6's numbering skipped 8. --- docs/factors/d5_c5_audit_report.md | 10 ++++++-- .../factors/d5_runner_difference_catalogue.md | 25 ++++++++++++++++--- qt/factor_eval_reconcile.py | 11 ++++---- tests/test_factor_eval_reconcile.py | 15 +++++------ 4 files changed, 43 insertions(+), 18 deletions(-) diff --git a/docs/factors/d5_c5_audit_report.md b/docs/factors/d5_c5_audit_report.md index df6ff61..ef2457b 100644 --- a/docs/factors/d5_c5_audit_report.md +++ b/docs/factors/d5_c5_audit_report.md @@ -200,6 +200,7 @@ panels/anchors 的入口函数逐字节未变——见 §六.9)。 | `jump_amount_corr_20` float_tail cap | 101 | **101** | **0 格** | | `warmup_sparse_valid_day_tail` 窗口右端 | 2021-11-15 | 该日**恰有 1 格** | **0 天** | | `warmup_sparse_valid_day_tail` cell cap | 20 | **19**(peak_ridge) | **1 格** | +| **sparse tail 密度守卫(p25)** | **门槛 36 天** | **35 天**(`300857.SZ` × peak_ridge) | **1 天** | | bars-only flip contamination cap | 25 | 20 | 5 格 | | kurtosis 相对界 | 5e-3 | 2.904e-3 | 1.72× | | relative_vwap 相对界 | 1e-5 | 1.530e-6 | 6.54× | @@ -211,6 +212,11 @@ panels/anchors 的入口函数逐字节未变——见 §六.9)。 - **sparse-tail 窗口右端 0 天余量**:窗口收在 2021-11-15,**恰恰因为该日有且只有 1 格** (`688183.SH`)。窗口不是留了余地画的,是**贴着最后一格画的**。 - **sparse-tail cap 只剩 1 格**:19/20。 +- **sparse-tail 的密度守卫也只剩 1 天**:门槛是各因子的 p25 = 36 个发行日,而最贴边的成员 + `300857.SZ`(在 `peak_ridge_amount_ratio_20` 上)发行 **35** 天。⚠️ **这道门红了就是 + STOP-AND-REPORT,不许把门槛放回中位**——那正是反棘轮明令禁止的动作,**而它红的时候恰恰 + 最诱人**(中位门会放行,于是"改一个数就绿了")。这道门是本轮返工**自己新加的**,性质与 + 上面三条零/近零余量完全相同,所以一并登记在此。 这三处**都不许因为"贴边"而放宽**——反棘轮的裁定正是"顶破了就停下上报",贴边意味着**下一次 触发会很快到来且必须被当真**。 @@ -387,13 +393,13 @@ close 书 + 新引擎,与 A 同口径可比)· **C** = decision 书 · **C F1 修复后重跑 rc=0,六格差异全落在已登记类内)。**B 阶段是这一腿的首次观测**;§四的 A/B/C 分解因此**只有冻结基线一个参照物**(见 §四的告示)。B 中若 reports 出现类外差异: **停下上报,不扩类**。 -9. **jump 与 `with_book(decision)` 两格对"值改动"实际不设闸**(背景,**非本 PR 引入**,登记 +8. **jump 与 `with_book(decision)` 两格对"值改动"实际不设闸**(背景,**非本 PR 引入**,登记 即可、本 PR 不改):jump 的 reports 腿 593 个 diff 里 **518 个**归入既有登记 #11 的 `registered_correction_effect`(契约 v1.1 的更正承载一旦存在,值差异即被接受); `with_book(decision)` 格同理是 report-only(登记 #13,`book_view_effect` 全量报告不设闸)。 **结构性增删(未登记的新增/删除、未登记的 section)仍然设闸。** 读判定时应以 `no_book` / `bookclose` 两个 strict 格 + panels 腿为值级依据。 -10. **`_make_logger` 是 truncate 模式 —— 重跑会静默销毁同名日志的唯一副本**(登记为已知 +9. **`_make_logger` 是 truncate 模式 —— 重跑会静默销毁同名日志的唯一副本**(登记为已知 运维风险,**本 PR 不改**:那是行为改动,属另一个 PR)。本轮已被它咬过一次:为验证判据 改动而重跑 `intraday_amp_cut_10` / `peak_interval_kurtosis_20` 的 panels 腿,覆盖掉了这 两条 C5 原始日志行(值经"覆盖前逐字抓取 + 离线独立重算"双路复原,见 §二之二 出处)。 diff --git a/docs/factors/d5_runner_difference_catalogue.md b/docs/factors/d5_runner_difference_catalogue.md index 44652b4..e8f22f7 100644 --- a/docs/factors/d5_runner_difference_catalogue.md +++ b/docs/factors/d5_runner_difference_catalogue.md @@ -652,9 +652,22 @@ pooled 因子上的长尾——有效日少的票把早区差异**带出早区 **证伪检查(做了,不是假设)——门槛已从中位收到 p25**:中位那道门按定义就有约一半的票能过, 几乎不可能失败;实测 **18/18** 个 (因子, symbol) 对全部低于该因子自身的 **p25**(窗口 [2021-07-01, 2021-11-30];最差百分位秩 **21.7%**,**15/18 在 3% 以下**),**0 处违反**。 -收紧后仍 18/18,但守卫从"几乎恒真"变成"能失败"。(mutation 实证:把门槛放回中位 → 仍全过, -说明 p25 是**严格更紧**且当前数据两边都清关;往白名单里塞一只三因子都稠密的票 `000009.SZ` -(48/41、49/41、47/40)→ **p25 门下仍然转红**。) +收紧后仍 18/18,但守卫从"几乎恒真"变成"能失败"。 + +**mutation 证人(这一条才打中"p25 严格更紧")**:低于中位但 **≥ p25** 的带内共有 **151 只票** +——这些正是**中位门放行、p25 门该拒**的票。取 `000031.SZ`(三因子各发行 **38** 天,vs p25 **36**、 +中位 41/41/40)注入白名单: + +| 配置 | 结果 | +|---|---| +| 白名单干净、守卫 p25 | PASS | +| 注入 `000031.SZ`、守卫 **p25** | **FAIL** | +| 注入 `000031.SZ`、守卫放回**中位** | **PASS** | + +⚠️ **先前在案的两条 mutation 都打不中这个主张**,如实记下:「把门槛放回中位 → 仍全过」只证明 +两道门在当前**已登记成员**上都清关;「塞稠密票 `000009.SZ`(48/41、49/41、47/40)→ 转红」—— +**那只票在中位门下也会红**。两条都没证明"p25 拒掉了中位会放行的东西"。**这与本节记录的其它 +几次是同一个病:一份证据支持的不是它被摆在那里要支持的那个主张。** **不对称证据,连同它的射程一起写**:600906.SH 在 peak_ridge 上发行 29 日(低于其 p25)、在 ridge 上 42 日——它也**只**出现在 peak_ridge 的簇里、**不**出现在 ridge 的。这是一条**条件性** @@ -723,7 +736,11 @@ decision 的 with-book 三件套被**覆盖后移走**,全 11 因子的 `exec_ (本节初稿在此留过一处待裁定项:`peak_ridge_amount_ratio_20` 是 F3 同族的第三个因子, 按当时的窄参数仍有 6 格类外。已由 lead 裁定为**重新推导**而非扩张,处置见上文的 -「成员判据是机制」与「这组参数是怎么被推错、又怎么重新推的」两段。) +「**这是一个「枚举类 + 必要条件守卫」,不是机制类**」与「这组参数是怎么被推错、又怎么 +重新推的」两段。⚠️ 这条回指原先指向的是该段的**旧标题**(它把成员判据说成机制)——标题 +连同那个主张已在返工中改掉,于是这条回指一度成为**指向不存在段落的悬空指针,同时又把已被 +撤回的主张复述了一遍**。此处**刻意不逐字引用那句旧话**:把它照抄进来,既会让扫描守卫永远 +归不了零,也会让快速浏览的读者把它当成现行主张。) #### 落地后的实测(同一批 served 面板,最终判据) diff --git a/qt/factor_eval_reconcile.py b/qt/factor_eval_reconcile.py index 62e9801..6fe225a 100644 --- a/qt/factor_eval_reconcile.py +++ b/qt/factor_eval_reconcile.py @@ -53,10 +53,9 @@ class is CEILED at ``(lookback_depth - 1) x |frozen symbols|`` cells valid-day POOLED factors with a sparse emission grid, whose early-region difference is carried forward past the early window instead of averaging out inside it. AN ENUMERATION CLASS WITH A NECESSARY-CONDITION GUARD, not - a mechanism class: membership is the registered whitelist, and the - density property (emission below that factor's own p25) is a guard every - member satisfies (18/18) rather than a rule that produces the list — the - predicate alone admits ~69x as many pairs. Registered window + a mechanism class — the full statement, with its measured numbers, is on + ``SPARSE_VALID_DAY_TAIL_SYMBOLS`` and is deliberately NOT repeated here. + Registered window [2021-11-01, 2021-11-15], a symbol whitelist, at most 20 cells per factor, pooled factors only; amplitude unbounded inside (the geometries legitimately differ, as in the early region). MUST NOT BE WIDENED AGAIN, @@ -364,8 +363,10 @@ class is CEILED at ``(lookback_depth - 1) x |frozen symbols|`` cells #: enumeration an enumeration. SPARSE_VALID_DAY_TAIL_LO = pd.Timestamp("2021-11-01") SPARSE_VALID_DAY_TAIL_HI = pd.Timestamp("2021-11-15") -#: The enumeration of the criterion above across the THREE affected factors +#: THE MEMBER SET ITSELF — the observed cells across the THREE affected factors #: (ridge_minute_return / valley_ridge_vwap_ratio / peak_ridge_amount_ratio). +#: Not derived from the density property and not derivable from it: see the +#: three numbered points above. #: ⚠️ An earlier version of this constant held SIX names and ran to 11-12, #: derived from only TWO of those factors — because the third was FAILING at #: the time and had been recorded as passing (see catalogue §七之七). diff --git a/tests/test_factor_eval_reconcile.py b/tests/test_factor_eval_reconcile.py index acc5265..1a6fd7a 100644 --- a/tests/test_factor_eval_reconcile.py +++ b/tests/test_factor_eval_reconcile.py @@ -1743,19 +1743,20 @@ def test_panels_sparse_tail_whitelist_members_are_each_accepted(symbol): # --------------------------------------------------------------------------- # -# The sparse-tail class's FALSIFIABLE CONTENT, checked against the real frozen -# panels: membership is a MECHANISM (the symbol's valid-day emission density on -# that factor is below that factor's own median), and the whitelist is only -# that criterion's enumeration. A name that is dense everywhere would falsify -# the mechanism story — which is the whole reason the class is allowed to exist. +# The sparse-tail class's NECESSARY-CONDITION GUARD, checked against the real +# frozen panels. The claim it guards is stated ONCE, on +# ``SPARSE_VALID_DAY_TAIL_SYMBOLS`` in qt/factor_eval_reconcile.py; this banner +# deliberately does not restate it, because the same sentence written in four +# places is how three of the four got corrected and the fourth kept asserting +# a median gate fifteen lines above code that reads p25. # --------------------------------------------------------------------------- # _SPARSE_TAIL_FACTORS = ( "ridge_minute_return_20", "valley_ridge_vwap_ratio_20", "peak_ridge_amount_ratio_20", ) -#: The emission window the medians are taken over: the early region plus the -#: November cluster the class covers. +#: The emission window the percentiles are taken over: the early region plus +#: the November cluster the class covers. _DENSITY_LO, _DENSITY_HI = "2021-07-01", "2021-11-30" _FROZEN_PANELS = Path("artifacts/refactor_baseline/panels") From 84199aa2970019678e3d93d66664559587399290 Mon Sep 17 00:00:00 2001 From: shaofl <2899218482@qq.com> Date: Thu, 30 Jul 2026 13:50:38 -0700 Subject: [PATCH 11/11] docs(factors): the near-edge table lists four rows, not three MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The p25 guard's row was inserted without updating the two counts below it — the heading still said 'the first three' above four bullets, and the closing sentence said 'these three'. Same shape as the section-6 numbering skip: an insertion whose counts did not follow. In a PR whose subject is claims matching what is actually there, shipping a table that says three and lists four is the defect it exists to remove. --- docs/factors/d5_c5_audit_report.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/factors/d5_c5_audit_report.md b/docs/factors/d5_c5_audit_report.md index ef2457b..50bed37 100644 --- a/docs/factors/d5_c5_audit_report.md +++ b/docs/factors/d5_c5_audit_report.md @@ -205,7 +205,7 @@ panels/anchors 的入口函数逐字节未变——见 §六.9)。 | kurtosis 相对界 | 5e-3 | 2.904e-3 | 1.72× | | relative_vwap 相对界 | 1e-5 | 1.530e-6 | 6.54× | -**前三行是零/近零余量**: +**前四行是零/近零余量**: - **jump 的 101/101**:该 cap 当初按这个因子的观测值标定,**再多一格就 FAIL**。本轮判据改动没有 往里加格(它那 101 格全在 warmup 区外,F4 前移一格未动);但下次任何触及 rolling 求和顺序的 改动都可能顶破它,而那时失败信息会是"cap 超了",**不是"哪里错了"**。 @@ -218,7 +218,7 @@ panels/anchors 的入口函数逐字节未变——见 §六.9)。 最诱人**(中位门会放行,于是"改一个数就绿了")。这道门是本轮返工**自己新加的**,性质与 上面三条零/近零余量完全相同,所以一并登记在此。 -这三处**都不许因为"贴边"而放宽**——反棘轮的裁定正是"顶破了就停下上报",贴边意味着**下一次 +这四处**都不许因为"贴边"而放宽**——反棘轮的裁定正是"顶破了就停下上报",贴边意味着**下一次 触发会很快到来且必须被当真**。 ## 四、with_book 差异分解(逐指标)