Skip to content

refactor(factors): retire the three regeneration tools, verify the frozen bytes instead - #115

Merged
StackOverFlow11 merged 10 commits into
mainfrom
feat/factor-refactor-c6-verify-only
Jul 31, 2026
Merged

refactor(factors): retire the three regeneration tools, verify the frozen bytes instead#115
StackOverFlow11 merged 10 commits into
mainfrom
feat/factor-refactor-c6-verify-only

Conversation

@StackOverFlow11

Copy link
Copy Markdown
Owner

C6 的第一个 PR:改造。 按 owner 2026-07-28 裁定「退役重生成能力」,把三个工具改成只读验证,D1 基线从此 frozen-forever。本 PR 一个 runner 都不删(删除是下一个 PR,owner 要求改造与删除分开、各带评审)。

为什么这是安全的

这三个工具的重生成路径依赖 11 个旧 runner(panel_freeze.py 直接 import 它们并调其私有 _load_*_panel)。删 runner 会让重生成失效——与其留一条跑起来就会坏的代码路径,不如显式退役它,让它只做验证

工具 重生成 验证
qt/panel_freeze.py 恒 raise RegenerationRetiredError--resume/--only 仍被 parser 接受(否则用户拿到 unrecognized arguments 而非退役说明) 以前不存在的一条路:逐面板重算 canonical hash + 整条 manifest 行,对 git 里的 docs/factors/ 哈希表核,gitignored manifest.json 作第二见证互核。实跑 15/15
qt/panel_reconcile.py 退役 比较仍真跑(它本就不需要 runner);D1 侧先经上面的验证器认证。实跑 14/14,max_rel=0.000e+00
qt/hand_anchors_engine_values.py 退役 用记录自身的数重导出 rel_diff / ok。⚠️ 今天在真实树上是红的,见下

新增 qt/frozen_manifest_doc.py(文档表解析器,header 驱动;两张表 / 零张表 / 重复 id / 坏哈希全 raise)。
qt/cli.py11 个 run-eval-* 命令收敛到 run-factor-eval,敲旧名 rc=1 + 迁移提示;映射FACTOR_TO_REPORT_NAME 派生,不是第二份抄写(评审进程内扰动该表验证:旧名失效、新名生效)。

R16 覆盖数非降(删除 PR 的准入证)

10 文件 / 59 条逐条核销:59/59 在映射表里有行、0 条无映射;表引用的新套件 node-ID 全部收集得到;3 个家族通配数目吻合;裸引用全部唯一解析问题数 0 ⇒ R16 前置条件成立。 结果写进 d5_property_test_migration_map.md §十,并留下常设守卫 tests/test_property_test_migration_map.py(射程写进自己的 docstring:只查引用能否解析,改名会抓住、就地掏空不会)。

顺带查出来的:冻结目录并不是不可变的

普查 artifacts/refactor_baseline/ 全部 113 个文件exec_baseline 77 + panels 14 + panels_d2 14 + pr_c 1 有 git 里的哈希覆盖,7 个没有。已分级(3 个 manifest.json 字节未钉但内容三方互核;3 个 .md 可推导;只有 hand_anchors_d2.json 真正无保护)。

而它恰恰是唯一被实际覆盖过的:2026-07-25 一次 hand_anchor_rows 重跑把 engine 比较记录冲掉了(现 20 pending / 0 compared),而填回它的工具本 PR 退役 ⇒ hand_anchors --verify 在真实树上永久红,措辞 NOT VERIFIED (nothing recorded) 与 FAILED 区分、未进任何 gates 清单

丢的是可再验证性,不是结论——D2 手算锚那条腿的结论仍在案(PR #89 + 进度文档「88 行分层手算锚 0 失配」)。

⇒ 编目现在写明:「冻结基线一个字节未写」是关于某次操作的陈述,不是关于该目录不可变的陈述。

已登记、留给删除 PR

  • C5 harness 有三处裸读不核哈希load_anchor_rows(anchors JSON)/ run_panels_mode(冻结 D1 面板)/ run_anchors_mode(再读一次求 warmup 网格,--mode anchors 单跑时是唯一的冻结面板读入点)。两句实话都在编目里:C5 四腿全绿是在一棵从未经哈希核对的基线上得出的同时,今天 panel_freeze --verify 15/15、panel_reconcile --verify 14/14 max_rel=0.0面板此刻完好,C5 结论不受影响清单用符号名不用行号——本 PR 自己就把 :1539 挪成了 :1542
  • hand_anchor_rows 的 payload 只写 daily_pending_engine 不写 daily_engine_compared(即 07-25 冲掉记录的机制)。
  • 退役理由的不对称如实登记panel_freeze/panel_reconcile 依赖全部 11 个 runner 的私有 loader ⇒ 删 runner 后必然失效;而 hand_anchors_engine_values 只用了一个前置检查,本可改指统一 runner 继续活着 ⇒ 这一条退役真的放弃了一个未必注定失效的能力。裁定维持(owner 裁定的是目的,不是从依赖强弱推出的推论),但事实照记。

评审揪出的两个 MEDIUM

① 还在教用户跑一条已退役的命令(runtime 输出,不是文档)。 hand_anchor_rows 每次跑完 print (run python -m qt.hand_anchors_engine_values)——而它现在 raise;同一函数正是 07-25 冲掉记录的那个 ⇒ 用户刚把记录冲掉,程序就指他去跑死路。

实现方照评审的方法扫全仓,自己又多抓 3 处(共 4 处),其中第 4 处是它新加的 author-once 测试当场咬出来的。修法是结构性的:指针 author-once 成 ENGINE_COMPARISON_POINTER + pending_engine_line(),printer 只组合不复述

⚠️ 指针必须住在 hand_anchors_d2 而非退役模块,这条承重hand_anchors_engine_valuesqt.panel_freezedata.clean.schema,而手算侧绝不能载入引擎。评审独立实测:import qt.hand_anchors_d2 载入 756 个模块,factors.* / data.clean.* 恰好 0 个。用 AST import 检查钉住(第一版用子串检查,被一条为解释 data.clean 为何被挡在外而提到 data.clean 的注释绊倒——守卫太粗的又一例)。

② 裸读清单少一处且行号已失效(见上,已改三处 + 符号名)。

教训:只扫自己改过的地方的守卫,只能确认你已经知道的事#82 的复发)

两处 md 改对了、runtime print 漏了;评审扫全仓抓到 1 处,实现方再扫全仓又抓 3 处。清单不全 = 给自己发一张只覆盖三分之二的通行证。

另记本轮第 11 例"不可能失败的测试":闭集删除测试的第一版用默认 fixture(只有一个 minute 因子),删掉唯一条目会触发既有的"整块为空"分支在被检验的新检查不存在时也通过。改成 ≥2 个 minute 因子(真实形态 11→10)并断言 problems 里不含旧那句。又一次不是被测试抓住的。

Gates(lead 独立重跑,未采信 subagent 自报)

pytest -p no:warnings 2701 passed · ruff clean · phase0 锚 ic 0.9600 / annual 0.8408 · validate-config 32/32 · exec_baseline_freeze --verify 77/77 @ 45c14aa · secret 0 · 三个重生成入口 rc=1(loud) · 两个 --verify 仍 OK · 11 个 runner 模块 + 10 个 runner 测试文件 0 改动且仍在 · 冻结基线自 2026-07-25 起 0 写入

实现方 mutation 19/19 有牙、评审自写 mutation 全部独立复现;真实数据 mutation 全程跑在 cp -r 副本上,两方各自的前后快照证明冻结基线 0 写入。

…s instead

The D1 baseline was rebuilt by calling the eleven legacy eval runners' private
`_load_*_panel` loaders. C6 deletes those runners, so the rebuild could not work
again; rather than leave a path that breaks on first use, the capability is
retired (owner ruling 2026-07-28) and the baseline is frozen-forever.

Retiring it is the safe direction, not merely the available one. The provenance
rule was always that this baseline may only be regenerated from the pinned
pre-D2 SHA, never from the tree being validated -- rebuilding it here is exactly
the empty-reconciliation failure mode this repository committed once. Removing
the rebuild removes the only way to break that rule by accident.

What replaces it is a real check, not a smaller one. `--verify` recomputes every
frozen panel's canonical content hash AND its whole manifest row (rows, date
range, symbol count, NaN count, mean, std, file sha256) and checks them against
the hashes authored in `docs/factors/` -- the git-tracked side of the split that
keeps the bulk panels out of git. That split is load-bearing: a tree regenerated
together with its own manifest.json makes the two local witnesses agree with
each other, and only the git-tracked table still convicts. Both frozen trees are
covered (14 D1 panels + the PR-C corrected jump reference panel); there is no
partial mode, because a frozen-forever baseline verified in part is a baseline
believed further than it was checked.

The manifest-document parser moves to qt/frozen_manifest_doc.py: header-driven,
fail-closed on two matching tables, none, a duplicate id or a malformed hash. A
parser that silently returned an empty expectation set would pass everything.

Also removed with the rebuild: the recipe table, the `--only` selection, the
determinism double-run and the eval-artifact reconciliation. The reconciliation
outcome survives as a check of the RECORD in manifest.json (every declared field
present, every check ok), so a manifest edited to paper over a failure is still
caught. The retired flags stay in the parser so the old documented command line
answers with the retirement explanation rather than "unrecognized arguments".

Tests: 34 (from 33). Six covering the removed machinery are gone with it
(determinism subjects, four `--only` selection cases, freezable ids); the "14
frozen factors" property they anchored is now pinned against the git-tracked
document itself. Fifteen new parser tests, and one verification test per way a
frozen tree can be wrong -- each starting from a tree that verifies GREEN, so a
red result is attributable to the tampering.
The D2 rebuild ran the qt.panel_freeze recipes -- the same eleven legacy runner
loaders -- so it goes the same way as the freeze (owner ruling 2026-07-28).

The comparison, however, never needed those loaders, and it is the part worth
keeping. `--verify` re-derives the D2 cell-by-cell verdict from the frozen bytes
of BOTH sides: it re-reads the two panel sets from disk through two independent
file reads and re-runs compare_panels over all 14 factors. That is a derivation,
not a restatement of a recorded result -- if either frozen tree drifts, cells
stop matching and the run goes red.

The D1 side is authenticated FIRST, by delegating to the freeze's verifier so
there is one implementation of "is this the frozen baseline?". Comparing two
panel sets while neither has been authenticated reports agreement between two
unknowns -- and a test pins exactly that case: two sides tampered identically
still agree with each other, and the run still fails.

The D2 side is checked against the SAME git-tracked D1 hashes. Not a shortcut:
"bit-identical to D1" is precisely what the D2 reconciliation concluded, so the
D1 table IS the D2 expectation, and it is the only one of the two in git.
manifest_d2.json is still read, for its own per-factor rows and for its recorded
all_ok -- a recorded failure must never read as a pass.

Tests: 19 (from 6). The six comparator teeth tests are untouched; thirteen new
ones cover the retirement and the verification, including that an absent
panels_d2 cannot pass by having nothing to compare.
The companion that compared the four ops-rewritten daily factors against their
hand-computed anchors imported a legacy runner's precondition check and rebuilt
the whole data plane behind it. C6 deletes that runner (owner ruling
2026-07-28).

Two things stated plainly rather than folded into the shared retirement wording,
because overstating them would be the defect this repository keeps catching:
this tool's dependency on the legacy runners was ONE precondition helper, not
the eleven private loaders the panel tools called, so what is given up here was
not independently doomed; and the four daily factor classes are untouched and
keep their own tests -- what is retired is this comparison, not the factors.

`--verify` re-derives the RECORDED comparison from its own numbers: for every
row it recomputes the relative difference from the stored hand/engine values at
the same tolerance and checks the recorded rel_diff, ok flag and all_ok_daily
against what those numbers actually imply, plus that every pending anchor got a
comparison. A record edited to turn a mismatch into a pass does not survive it.

When the record is ABSENT the exit code is non-zero and the wording is "NOT
VERIFIED", not "FAILED". Absence of evidence is not evidence of failure, but the
exit-code channel is binary, and a verification command that reports success
without verifying anything is the empty-reconciliation shape already committed
once here. On the tree today that case is live: hand_anchors_d2.json carries 20
pending anchors and no recorded comparison, a later `hand_anchor_rows` run
having reset it -- so the command reports NOT VERIFIED rather than pretending.

The retirement message composition is now three parts, not two: the shared core
(ruling, "no longer rebuilds", pointer to --verify) plus per-tool `why` and
`verifies` clauses. A single shared sentence describing what verification checks
would have to overstate one of the three tools; this one checks a JSON record,
not frozen bytes against a git-tracked manifest, and a test asserts its message
does not claim otherwise.

Tests: 19 new.
Verification reads two witnesses: the git-tracked document (hashes) and the
gitignored manifest.json (the fuller statistical row). Disabling the machine-row
comparison broke NO test -- every existing case was also convicted by the
document, so the second witness was being read but never depended on.

Adds the case only it can see: manifest.json's n_nan is moved while the document
is left agreeing with the panel. The test asserts the conviction comes from the
machine row AND that the git-tracked side stayed quiet, so it cannot pass for
the other reason. With it, disabling that comparison is red.
The eleven per-factor evaluation subcommands were near-identical wrappers around
eleven near-identical runners; D5 C4 replaced them with one parameterised
command. This removes the eleven from the CLI: 433 lines of handler and
subparser, no behaviour of the unified command changed.

The runner MODULES are untouched -- deleting those is the next PR. This one only
stops the CLI offering eleven doors to a thing that has one.

Typing a retired name still produces the route to the new command. argparse's
own answer is "invalid choice", which tells an operator they mistyped rather
than that the command was replaced and by what; the eleven collapsed
mechanically, so the mapping can be printed:

    run-eval-valley-price-quantile was retired in D5 C6 ...
        python -m qt.cli run-factor-eval --config <config> \
            --factor valley_price_quantile_20 [--book-mode decision|close]

That map is DERIVED from factor_eval_reconcile's factor-id/report-name table
(the old names were those report names with dashes), not transcribed -- a second
copy in the CLI is the one nobody would update. The table is made public for it;
its one private call site moves to the public name. The historical list of
eleven names lives in the test, which is where a claim about what used to exist
belongs, and asserts the derivation reproduces exactly those names.

A `run-eval-` name that never existed still falls through to argparse's list of
valid choices rather than getting a fabricated migration.

Retired commands: run-eval-{jump-amount-corr, minute-ideal-amplitude,
amp-marginal-anomaly-vol, volume-peak-count, intraday-amp-cut,
peak-interval-kurtosis, valley-relative-vwap, valley-ridge-vwap-ratio,
ridge-minute-return, valley-price-quantile, peak-ridge-amount-ratio}.

Tests: 27 new.
R16's gate is "before deleting the legacy runner tests, all 59 have a mapping".
This runs that reconciliation against the tree rather than re-reading the
document, and records the measured result as section 十.

Measured: 10 files / 59 tests on disk, 59/59 with a row in the map, 56/56 cited
new-suite node-IDs collect today (against 2687 collected), all three family
wildcards the size the document claims, every citation resolving to exactly ONE
file. R16 holds; the deletion PR has its precondition.

The audit is also left behind as a standing guard rather than a one-off run
(tests/test_property_test_migration_map.py, 7 tests): a document cannot notice
when a test it names is renamed away. Its range is stated in its own docstring —
it checks that citations RESOLVE, not that the cited test still asserts the
property claimed for it. A renamed test is caught; one gutted in place is not.

The legacy-side check goes vacuous the day those files are deleted, deliberately
and stated as such: its job is to make an UNMAPPED legacy test impossible while
they exist, and a guard that turned red on their deletion would be a booby trap
for the very PR this map authorises.

Two process notes are recorded because they cost real time and will recur.
The audit first passed `--collect-only -q`, which becomes `-qq` on top of the
addopts in pyproject and swallows the node-ID listing — it reported a crowd of
phantom missing tests. Then excluding the family wildcards with `(?!\*)` was
defeated by backtracking: the name pattern gives back its trailing underscore
and matches a shorter, still-wrong name, so three family stems still read as
missing. `(?![A-Za-z0-9_*])` is the form that holds, and the comment saying so
is in the guard. Neither was caught by a test; both were caught by an
observation that contradicted the prediction.
The difference catalogue's C6 section required, in advance, that whichever
disposition was chosen for these three tools be recorded explicitly and that the
D1 manifest's "rerun command" section be updated in the same batch -- otherwise
"the document teaches you to run a command that cannot run" recurs, which this
repository has already fixed three times (#76/#78/#82).

Disposition taken: the second option. Regeneration is retired, the D1/D2
baselines cannot be rebuilt from this repository again, and the three tools stay
as verify-only rather than being deleted.

- the catalogue's disposition column now records what was actually done, and
  settles the asymmetric-loss argument it opened: the worry was losing the
  ability to re-check. That ability did not exist in the old path -- the
  provenance rule forbids rebuilding the baseline from the tree under
  validation, which is the empty reconciliation committed here once. `--verify`
  is a route that did not exist before: 15 panels, canonical hash and full
  manifest row each, against the expectations in git, in three seconds.
- the D1 manifest's rerun command becomes a retirement note plus the verify
  command, and states the consequence its table now has: the table is READ by
  the verifier, so editing it changes the verification result and shows up in
  git diff.
- the PR-C reference panel's `--only` recipe is archived as provenance (how that
  panel came to exist) and marked retired, and its "can be regenerated" cell is
  corrected to match.
…ed frozen file

Record-only, per lead ruling 2026-07-30. No verify logic changes; the C5 harness
is not touched.

(1) The D2 daily hand-anchor engine comparison was destroyed on 2026-07-25 by a
`hand_anchor_rows` rerun, and the tool that refilled it is retired. The entry
draws the distinction that matters: the D2 leg's CONCLUSION is still on record
(PR #89 and docs/progress/07: "88 hand anchors, 0 mismatches"); what is lost is
the ability to re-verify it today. `--verify` therefore exits non-zero on this
tree, worded NOT VERIFIED rather than FAILED, and is deliberately kept OUT of
the standard gate list -- a red that never turns green trains people to ignore
red.

(2) That prompted a census of the whole directory, and it found something worse
than the one file: 7 of 113 files under artifacts/refactor_baseline carry no
hash anywhere. The entry grades them rather than lumping them together -- three
manifests are byte-unpinned but content-constrained (verify cross-checks every
row against the git document AND the panels), three are derivable renderings,
and hand_anchors_d2.json is genuinely unprotected AND is the one that actually
got overwritten.

The sentence worth keeping: "this change wrote zero bytes of the frozen
baseline" is a statement about TODAY'S operation, not about the directory being
immutable. It is not immutable. One file in it has already been rewritten.

(3) The C5 reconciliation harness reads both the frozen panels
(factor_eval_reconcile.py:1539) and hand_anchors_d2.json (:1467) with no hash
check at all. Both true sentences are recorded, because either alone misleads:
C5's four legs went green against a baseline that was never hash-checked, and
(2) proves this directory can be written -- yet today's verify passes 15/15
panels and 14/14 cells, so the panels are intact and the C5 conclusion stands.
Fix belongs to the deletion PR, whose subject is making frozen-forever mean it.

(4) The three retired tools did not depend on the legacy runners equally:
panel_freeze and panel_reconcile import all eleven, while
hand_anchors_engine_values used one precondition helper and could have been
repointed at the unified runner. The ruling stands -- retiring regeneration is
the goal, not an inference from the dependency -- but the asymmetry is recorded:
that third retirement really did give up a capability that was not doomed.
…call

Defect introduced by this PR and fixed in it. `_check_recorded_reconciliation`
iterated the entries PRESENT in manifest.json and failed only when the block was
entirely empty -- so deleting one entry of eleven went unnoticed. The verifier
could not see a removal.

That is the wrong blind spot to ship right now: the next PR is the deletion PR.
A check that cannot see removals would be blind precisely during a
deletion-themed operation, at the moment it is most needed. And "the expected
values are not themselves pinned" is this PR's own subject -- leaving it for
later would contradict the thing being built.

The expected set is now derived: every row manifest.json lists as a minute panel
must have an entry, and nothing else may. Book factors have no entry by design
(the eval artifacts carry no coverage fields for them), which is why the set is
"minute rows" rather than "all rows".

On deriving the expectation from the same document that could be tampered: not
usefully tamperable. Deleting the row as well is caught by the panel loop, which
iterates the GIT-TRACKED factor list and reports a panel whose machine-manifest
row is missing. The two checks close each other, and the code says so.

Verified against reality before relying on it: both frozen trees satisfy the
invariant exactly (D1 11 minute panels / 11 entries, PR-C 1 / 1), so this is a
live constraint rather than a rule the real data was already exempt from -- a
third test pins that, skipping only where the gitignored tree is absent.

The first version of the deletion test FAILED, correctly: the default fixture
has one minute factor, so removing its entry empties the block and the
pre-existing "no reconciliation block" message fires -- the test would have
passed without the new check existing. Rebuilt with TWO minute factors, which is
the real shape (11 becoming 10). Mutation evidence: disabling either direction
of the closed set turns the matching test red (16/16 across the suite's
mutations, each asserted to have landed and restored by sha).
…aises

Review MEDIUM-1. `hand_anchor_rows` printed "(run python -m
qt.hand_anchors_engine_values)" on every successful run, and that command now
raises. Worse in context: the same function rewrites hand_anchors_d2.json with
`daily_pending_engine` and no `daily_engine_compared` -- it IS the action that
wiped the record on 2026-07-25. A user had just destroyed the comparison and was
then sent down a dead end. It is not one of the eleven runners and the deletion
PR would not have touched it.

Same shape as #82, and the reviewer found it the way #82 says to: `grep -rn`
over the WHOLE repository. I had fixed the two documents I was already looking
at and never scanned the feature surface. A guard that only looks where you
already looked confirms only what you already know.

Scanning properly found more than the reported line. Four stale statements, not
one:

- the run-summary print (the reported one);
- hand_anchors_d2's docstring "Run AFTER `python -m qt.panel_reconcile` produced
  panels_d2" -- that rebuild is retired too, and panels_d2 is simply there now;
- the next docstring line offering the retired companion as a step;
- hand_anchor_rows' own docstring claiming those values "are filled in by"
  the companion. They are not; they stay pending.

The pointer is now authored ONCE, as
`qt.hand_anchors_d2.ENGINE_COMPARISON_POINTER` plus `pending_engine_line`, and
the printer composes it. It lives in hand_anchors_d2 rather than the retired
module for a load-bearing reason: hand_anchors_engine_values imports
qt.panel_freeze -> data.clean.schema, and the hand side must never load the
engine. An author-once home has to be reachable from the pure side, and a test
pins that with an AST import check (a substring check tripped on the comment
that names data.clean precisely to explain why it stays out).

Review MEDIUM-2, in the catalogue: the bare-read site list was missing one and
its line numbers had already rotted. Now THREE sites, recorded by SYMBOL --
run_panels_mode, load_anchor_rows, run_anchors_mode. The third is the one that
matters most: under `--mode anchors` alone it is the only frozen-panel read
there is, so a two-of-three list would have handed the deletion PR a pass that
covers two thirds of the surface. The stale line numbers are their own evidence
for dropping line numbers: `:1539` was read off main, and this PR's own 711c0a8
had already shifted that file by +3.

Also registered, behaviour deliberately unchanged here: that hand_anchor_rows
overwrites a file inside the "never overwrite" directory belongs with the
frozen-forever work in the deletion PR, alongside wiring verify into all three
read sites.

Review LOW-1 / NIT-1 in the migration map: the audit's collected-node-ID number
held at no commit -- it was measured before that same commit's own seven guard
tests existed. Corrected with the reason stated rather than swapped out
quietly, and the count is now labelled as context that moves (2694 -> 2697 ->
2701 during this PR) rather than as a claim; the load-bearing numbers are
59/59, 56/56 and zero unresolved. The "56" counting convention is written down
so it can be recomputed, next to the reviewer's 58 under a different one --
both with zero unresolved, so the difference is convention, not coverage.

Mutation evidence: 19/19 with teeth, three of them new -- restoring the bare
pointer, restating it instead of composing, and importing the engine from the
hand side each turn the matching test red.
@StackOverFlow11
StackOverFlow11 merged commit fb2819e into main Jul 31, 2026
@StackOverFlow11
StackOverFlow11 deleted the feat/factor-refactor-c6-verify-only branch July 31, 2026 16:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant