From a9b049d7970096509e502e50117ff2b2f92eece1 Mon Sep 17 00:00:00 2001 From: shaofl <2899218482@qq.com> Date: Thu, 30 Jul 2026 14:31:25 -0700 Subject: [PATCH 01/10] refactor(factors): retire the D1 panel freeze, verify the frozen bytes 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. --- qt/frozen_manifest_doc.py | 180 +++++ qt/panel_freeze.py | 1025 +++++++++++++---------------- tests/fixtures/frozen_baseline.py | 173 +++++ tests/test_frozen_manifest_doc.py | 147 +++++ tests/test_panel_freeze.py | 308 ++++++--- 5 files changed, 1160 insertions(+), 673 deletions(-) create mode 100644 qt/frozen_manifest_doc.py create mode 100644 tests/fixtures/frozen_baseline.py create mode 100644 tests/test_frozen_manifest_doc.py diff --git a/qt/frozen_manifest_doc.py b/qt/frozen_manifest_doc.py new file mode 100644 index 0000000..0661b66 --- /dev/null +++ b/qt/frozen_manifest_doc.py @@ -0,0 +1,180 @@ +"""Parsers for the git-tracked frozen-baseline manifest DOCUMENTS. + +The bulk artifacts of the factor-layer refactor baselines are gitignored; their +expected hashes are authored in Markdown documents under ``docs/factors/`` that +call themselves the authoritative manifest. Keeping the hashes in git and the +bytes out of it is what gives verification teeth: whoever regenerates a frozen +tree together with its machine manifest still cannot move the expectations +without it showing up in ``git diff``. + +So the expectations are READ FROM those documents rather than transcribed into +code — a transcription is a second copy, and the copy in code is the one nobody +updates. + +Parsing is header-driven and fail-closed. The table is located by requiring the +columns ``factor_id`` and ``canonical_sha256`` (documents carry several other +tables), every other column is read BY NAME, and anything ambiguous — two +matching tables, none, a duplicate factor id, a malformed hash — is a readable +error. A parser that silently returned an empty expectation set would make the +verifier pass everything. + +Consumers: ``qt.panel_freeze`` (D1 baseline + the PR-C reference panel) and +``qt.panel_reconcile`` (the D2 rebuild, which is expected to be bit-identical to +the D1 baseline and is therefore verified against the SAME document). +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path + + +_HEX64 = re.compile(r"^[0-9a-f]{64}$") +_SHA40 = re.compile(r"[0-9a-f]{40}") +#: Cell decorations the documents legitimately carry (bold emphasis on the +#: numbers a reader should notice, code ticks around hashes). +_CELL_NOISE = re.compile(r"[`*]") + + +@dataclass(frozen=True) +class DocManifestRow: + """One factor's expectations as authored in the git-tracked manifest doc. + + Fields absent from a given document are ``None`` — the PR-C reference table + carries no ``kind`` or ``file_sha256`` column, and a verifier that invented + values for them would be checking its own invention. + """ + + factor_id: str + canonical_sha256: str + kind: str | None = None + rows: int | None = None + n_symbols: int | None = None + n_nan: int | None = None + mean: float | None = None + std: float | None = None + file_sha256: str | None = None + + +def _split_table_row(line: str) -> list[str]: + cells = line.strip().strip("|").split("|") + return [_CELL_NOISE.sub("", cell).strip() for cell in cells] + + +def _is_separator(cells: list[str]) -> bool: + return bool(cells) and all(set(cell) <= set("-: ") and "-" in cell for cell in cells) + + +def parse_doc_manifest(path: Path | str) -> dict[str, DocManifestRow]: + """Parse the factor-hash table out of a git-tracked manifest document. + + Header-driven, not position-driven: the table is located by requiring the + columns ``factor_id`` and ``canonical_sha256``, and every other column is + read by NAME. Two matching tables, zero matching tables, a duplicate factor + id, or a hash that is not 64 lowercase hex are all readable errors — a + verifier whose expectations parsed to an empty dict would "pass" everything. + """ + path = Path(path) + lines = path.read_text(encoding="utf-8").splitlines() + tables: list[dict[str, DocManifestRow]] = [] + index = 0 + while index < len(lines) - 1: + cells = _split_table_row(lines[index]) if "|" in lines[index] else [] + if ( + "factor_id" in cells + and "canonical_sha256" in cells + and "|" in lines[index + 1] + and _is_separator(_split_table_row(lines[index + 1])) + ): + columns = {name: position for position, name in enumerate(cells)} + rows: dict[str, DocManifestRow] = {} + cursor = index + 2 + while cursor < len(lines) and lines[cursor].lstrip().startswith("|"): + values = _split_table_row(lines[cursor]) + cursor += 1 + if len(values) != len(cells): + raise ValueError( + f"{path.name}: manifest table row has {len(values)} cells but " + f"the header has {len(cells)}: {values!r}" + ) + row = _doc_row(path, columns, values) + if row.factor_id in rows: + raise ValueError( + f"{path.name}: duplicate factor id {row.factor_id!r} in the " + "manifest table." + ) + rows[row.factor_id] = row + if not rows: + raise ValueError(f"{path.name}: manifest table has no data rows.") + tables.append(rows) + index = cursor + continue + index += 1 + if len(tables) != 1: + raise ValueError( + f"{path.name}: expected exactly ONE table carrying factor_id + " + f"canonical_sha256; found {len(tables)}. The verifier refuses to guess " + "which one is the manifest." + ) + return tables[0] + + +def _doc_row(path: Path, columns: dict[str, int], values: list[str]) -> DocManifestRow: + def cell(name: str) -> str | None: + if name not in columns: + return None + text = values[columns[name]] + return text or None + + canonical = cell("canonical_sha256") or "" + if not _HEX64.match(canonical): + raise ValueError( + f"{path.name}: canonical_sha256 {canonical!r} is not 64 lowercase hex." + ) + file_hash = cell("file_sha256") + if file_hash is not None and not _HEX64.match(file_hash): + raise ValueError( + f"{path.name}: file_sha256 {file_hash!r} is not 64 lowercase hex." + ) + factor_id = cell("factor_id") + if not factor_id: + raise ValueError(f"{path.name}: manifest table row has an empty factor_id.") + + def as_int(name: str) -> int | None: + text = cell(name) + return None if text is None else int(text) + + def as_float(name: str) -> float | None: + text = cell(name) + return None if text is None else float(text) + + return DocManifestRow( + factor_id=factor_id, + canonical_sha256=canonical, + kind=cell("kind"), + rows=as_int("rows"), + n_symbols=as_int("n_symbols"), + n_nan=as_int("n_nan"), + mean=as_float("mean"), + std=as_float("std"), + file_sha256=file_hash, + ) + + +def parse_doc_producing_sha(path: Path | str) -> str | None: + """The producing SHA the document pins, if it states one. + + Read from the document rather than transcribed into this module: the SHA + would otherwise exist in two places, and the copy in code is the one nobody + updates. + """ + for line in Path(path).read_text(encoding="utf-8").splitlines(): + lowered = line.lower() + if "producing" in lowered and ("sha" in lowered or "git_sha" in lowered): + found = _SHA40.search(line) + if found: + return found.group(0) + return None + + diff --git a/qt/panel_freeze.py b/qt/panel_freeze.py index bb32ef0..8022465 100644 --- a/qt/panel_freeze.py +++ b/qt/panel_freeze.py @@ -1,31 +1,36 @@ -"""D1 end-of-stage RAW factor-value panel freeze (the pre-D2 baseline for D5). - -Freezes the closing 14 factors' RAW daily factor-value panels (11 minute-derived -factors + the 3 confirmed book factors) on the eleven-factor evaluation data -plane (CSI500 ``000905.SH``, 2021-07-01 .. 2026-06-30, CACHE-ONLY). ``main`` at -the producing SHA still carries the pre-refactor factor math bit-for-bit (the D1 -registry PR was dispatch-only), so these panels are the ONLY baseline the D5 -cell-by-cell reconciliation can compare against once D2 rewrites the math. - -NO FACTOR MATH IS REIMPLEMENTED HERE. Every panel is produced by calling the -SAME loader chain the ``qt/eval_*.py`` runners call, with the SAME constants, -imported FROM the runner modules themselves (identity with the runner call -sites, not a transcription of them): - -* minute factors — each runner's private ``_load_*_panel`` (per-symbol - cache-only 1min read -> ``data.clean`` ``compute_*`` -> daily aggregation), - then the runner's own restriction to the daily panel dates - (``load.factor[dates.isin(panel_dates)]``). The frozen value is the RAW - restricted series, BEFORE ``_process_factors`` (z-score / neutralization are - shared machinery, not factor math). -* book factors — the runners' ``_build_book_factors()`` + - ``factor.compute(panel)`` on the enriched daily panel, frozen RAW. - -PROVENANCE RULE (design v3.2 §5 leg 4): regenerating this baseline is only -legitimate from a checkout of the pinned pre-D2 producing SHA recorded in the -manifest — NEVER from current code. Rebuilding the baseline from the same tree -that is being validated would make the D5 reconciliation structurally unable to -fail (the ``compare_postmerge.py`` empty-reconciliation failure mode). +"""D1 RAW factor-value panel baseline — VERIFY-ONLY since D5 C6. + +The 14 closing factors' RAW daily factor-value panels (11 minute-derived + 3 +confirmed book factors) were frozen once, on the eleven-factor evaluation data +plane (CSI500 ``000905.SH``, 2021-07-01 .. 2026-06-30, CACHE-ONLY), from ``main`` +at the producing SHA recorded in ``docs/factors/d1_panel_freeze_manifest.md``. +They are the ONLY baseline the D5 cell-by-cell reconciliation compares against. + +REGENERATION IS RETIRED (owner ruling 2026-07-28, D5 C6) +--------------------------------------------------------- +This module used to rebuild those panels by calling the eleven legacy +``qt/eval_*.py`` runners' private ``_load_*_panel`` loaders. C6 deletes those +runners, so the rebuild could not work again; rather than leave a code path that +breaks the moment it is used, the capability is retired outright and the +baseline is **frozen-forever**. What remains is verification: read the frozen +bytes and check them against the manifest. + +That direction is the safe one. The provenance rule (design v3.2 §5 leg 4) was +always that regenerating this baseline is legitimate ONLY from a checkout of the +pinned pre-D2 SHA — never from current code, because rebuilding the baseline +from the tree being validated makes the D5 reconciliation structurally unable to +fail (the ``compare_postmerge.py`` empty-reconciliation failure mode this repo +committed once). Retiring the rebuild removes the only way to violate that rule +by accident. + +Where the authority lives +------------------------- +The bulk panels are gitignored; **the hashes are in git**, in the manifest +document's table. That split is what gives verification teeth: someone who +regenerates the panels and their machine manifest cannot also silently move the +expected hashes, because those show up in ``git diff``. The gitignored +``manifest.json`` is checked too — as a second, richer witness (row counts, +NaN counts, mean/std, file sha256) that must AGREE with the git-tracked table. Canonical content hash ---------------------- @@ -46,7 +51,14 @@ hashed (content equality, not labelling). NaN payload bits are collapsed on purpose; +0.0 and -0.0 stay distinct (a real IEEE value difference). -Run: ``python -m qt.panel_freeze`` (deliberately NOT registered in qt/cli). +Run (deliberately NOT registered in qt/cli, as before):: + + python -m qt.panel_freeze --verify + +There is no partial verification. A frozen-forever baseline verified in part is +a baseline you believe more than you checked, so ``--verify`` always covers +every panel in both frozen trees (the D1 baseline and the PR-C corrected +``jump_amount_corr_20`` reference panel). """ from __future__ import annotations @@ -54,36 +66,37 @@ import argparse import hashlib import json -import logging import os -import subprocess -import time -from collections.abc import Sequence +import sys from dataclasses import dataclass from pathlib import Path -from typing import Callable import numpy as np import pandas as pd from data.clean.schema import DATE_LEVEL, SYMBOL_LEVEL +from qt.frozen_manifest_doc import ( + DocManifestRow, + parse_doc_manifest, + parse_doc_producing_sha, +) CANONICAL_HASH_VERSION = "qt.panel_freeze canonical v1" DEFAULT_CONFIG = "config/phase_c_jump_amount_corr.yaml" DEFAULT_OUTPUT_ROOT = "artifacts/refactor_baseline" -#: PanelStore artifact name for THIS run — content-identical to the runners' -#: per-run panel (same cache, same window), under a freeze-specific name so the -#: freeze never clobbers an accepted eval run's ``artifacts/data`` artifact. +#: PanelStore artifact name the freeze used — kept because the frozen +#: ``manifest.json`` header records it and the verifier reads that header. PANEL_STORE_NAME = "d1_panel_freeze_daily" -#: Determinism double-run subjects (task §2): two minute factors — including -#: valley_price_quantile, the one loader that also consumes the daily panel — -#: plus one book factor. -DETERMINISM_FACTORS = ("jump_amount_corr_20", "valley_price_quantile_20", "value_ep") -#: data_coverage payload fields reconciled against the shipped eval JSONs. -#: They describe the PROCESSED series at the evaluator boundary (see -#: ``analytics/eval/standard.py::data_coverage``), so the reconciliation -#: re-derives that boundary from the frozen RAW panel via the runners' own -#: ``_process_factors`` — the frozen artifact itself stays raw. +#: The git-tracked authority for the D1 baseline (14 panels). +D1_MANIFEST_DOC = "docs/factors/d1_panel_freeze_manifest.md" +#: The git-tracked authority for the PR-C corrected jump reference panel (1). +PR_C_MANIFEST_DOC = "docs/factors/pr_c_cutoff_fix_reference_panel.md" +#: Sub-root of the PR-C reference panel, relative to the baseline root. +PR_C_SUBDIR = "pr_c_cutoff_fix" +#: data_coverage payload fields the freeze reconciled against the shipped eval +#: JSONs BEFORE accepting each panel. The reconciliation itself is history; the +#: field list is not, because ``manifest.json`` records the per-field outcome +#: and verification re-reads that record. RECONCILE_INT_FIELDS = ( "panel_rows", "evaluation_periods", @@ -93,6 +106,42 @@ ) RECONCILE_FLOAT_FIELDS = ("factor_nan_rate",) +#: The single authored statement of what C6 retired. Every tool that retired a +#: regeneration path COMPOSES this rather than restating it: a regex cannot +#: assert that no other sentence says the same thing, but "there is no other +#: sentence" can (CLAUDE.md methodology #2). +RETIREMENT_RULING = "owner ruling 2026-07-28, D5 C6" + + +class RegenerationRetiredError(RuntimeError): + """Raised when a retired regeneration entry point is invoked. + + A retired capability must fail LOUDLY and READABLY (redline #9). Letting the + call succeed as a no-op, or die on a bare ``ImportError`` from a deleted + runner, both leave the caller guessing whether anything happened. + """ + + +def retirement_message(tool: str, product: str, why: str, verifies: str) -> str: + """The retirement error text, composed from one authored core plus two + per-tool clauses. + + Only what is true of ALL THREE retired tools is shared: the ruling, the fact + that this repository no longer rebuilds the product, and the pointer to + ``--verify``. Both ``why`` (what the rebuild depended on) and ``verifies`` + (what verification actually checks) are supplied by the caller, because the + three tools differ on both counts — they did not lean on the legacy runners + to the same degree, and they do not verify the same kind of thing. A single + shared sentence covering those would have to overstate one of them, which is + this repository's recurring defect: text that describes a check other than + the one performed. + """ + return ( + f"{tool}: regeneration is RETIRED ({RETIREMENT_RULING}). This repository " + f"no longer rebuilds {product}. {why} " + f"Run `{tool} --verify` instead: {verifies}" + ) + # --------------------------------------------------------------------------- # # Canonical content hash + atomic write (pure, network-free, unit-tested) @@ -169,6 +218,12 @@ def atomic_write_parquet(panel: pd.Series | pd.DataFrame, path: Path | str) -> s tmp-file + ``os.replace`` in the target directory: readers never observe a partial file, and a failed write leaves no tmp residue (and never touches an existing target). + + This is the byte-layout contract the frozen panels were laid down with. No + production caller remains after C6 (the freeze that used it is retired); it + is kept because it DEFINES the on-disk form the verifier reads, and because + a verifier that cannot construct a valid frozen tree cannot be given a + negative test. """ series = _as_single_series(panel).sort_index(kind="mergesort") target = Path(path) @@ -231,7 +286,12 @@ def manifest_row( file_name: str, ) -> dict: """One manifest record. mean/std are float64 ``Series.mean()``/``std(ddof=1)`` - with NaN skipped (pandas defaults), reported at full precision.""" + with NaN skipped (pandas defaults), reported at full precision. + + Still live after C6: verification RE-DERIVES each row from the frozen panel + and compares it field by field with the recorded one, so a manifest whose + hash was patched but whose statistics were not is caught as well. + """ series = _as_single_series(panel) dates = series.index.get_level_values(DATE_LEVEL) return { @@ -251,7 +311,12 @@ def manifest_row( def render_manifest_markdown(header: dict, rows: list[dict]) -> str: - """Deterministic Markdown manifest: a header block + one row per factor.""" + """Deterministic Markdown manifest: a header block + one row per factor. + + This is the format definition of the frozen ``manifest.md``; ``--verify + --show-manifest`` re-renders the table from the panels actually on disk so + it can be eyeballed (or diffed) against the frozen one. + """ lines = ["# D1 panel freeze manifest", ""] for key in sorted(header): lines.append(f"- **{key}**: {header[key]}") @@ -269,577 +334,393 @@ def render_manifest_markdown(header: dict, rows: list[dict]) -> str: return "\n".join(lines) -def atomic_write_text(text: str, path: Path | str) -> None: - """tmp + ``os.replace`` text write (same atomicity contract as the parquet).""" - target = Path(path) - target.parent.mkdir(parents=True, exist_ok=True) - tmp = target.with_name(target.name + ".tmp") - try: - tmp.write_text(text, encoding="utf-8") - os.replace(tmp, target) - finally: - tmp.unlink(missing_ok=True) - - # --------------------------------------------------------------------------- # -# Recipes: the runner call sites, referenced (not transcribed) +# Verification # --------------------------------------------------------------------------- # @dataclass(frozen=True) -class MinuteRecipe: - """One minute factor's runner-owned build chain. - - ``build`` closes over the runner module's loader AND its constants, so the - call is identical to the runner's own call site by construction. - """ +class PanelVerification: + """One frozen panel's verification outcome (no secrets).""" factor_id: str - stem: str # the runner's _REPORT_STEM (names the eval JSON artifacts) - spec: object - build: Callable[[object, list[str], pd.DataFrame, logging.Logger], object] + file: str + rows: int + canonical_sha256: str + problems: tuple[str, ...] + @property + def ok(self) -> bool: + return not self.problems -def minute_recipes() -> list[MinuteRecipe]: - """The 11 minute factors, each bound to its runner's loader + constants. - Imports live inside the function so importing ``qt.panel_freeze`` for the - network-free unit tests stays light. +@dataclass(frozen=True) +class VerifyResult: + """A whole frozen tree's verification outcome.""" + + root: Path + doc: Path + panels: tuple[PanelVerification, ...] + problems: tuple[str, ...] + producing_git_sha: str | None + rendered_manifest: str | None = None + + @property + def ok(self) -> bool: + return not self.problems and all(panel.ok for panel in self.panels) + + @property + def n_ok(self) -> int: + return sum(1 for panel in self.panels if panel.ok) + + +def _load_machine_manifest(root: Path) -> dict: + """The gitignored ``manifest.json`` document, or ``{}`` when absent.""" + path = root / "manifest.json" + if not path.exists(): + return {} + return json.loads(path.read_text(encoding="utf-8")) + + +def _check_recorded_reconciliation(document: dict, problems: list[str]) -> None: + """The freeze reconciled each minute panel against its shipped eval JSON and + recorded the per-field outcome. Verification re-reads that record. + + This is deliberately a check of the RECORD, not a re-run: re-running it + needs the processed series, which needs the data plane, which needs the + loaders C6 deletes. What can still be asserted is that the record says what + a passing reconciliation says — every declared field present, every check + ``ok`` — so a manifest edited to paper over a failure is caught. """ - import qt.eval_amp_marginal_anomaly_vol as e - import qt.eval_intraday_amp_cut as g - import qt.eval_jump_amount_corr as c - import qt.eval_minute_ideal_amplitude as d - import qt.eval_peak_interval_kurtosis as h - import qt.eval_peak_ridge_amount_ratio as m - import qt.eval_ridge_minute_return as k - import qt.eval_valley_price_quantile as ell - import qt.eval_valley_relative_vwap as i - import qt.eval_valley_ridge_vwap_ratio as j - import qt.eval_volume_peak_count as f - - recipes: list[MinuteRecipe] = [] - - def add(module, factor, build) -> None: - spec = factor.spec - recipes.append( - MinuteRecipe( - factor_id=spec.factor_id, - stem=module._REPORT_STEM, - spec=spec, - build=build, - ) + recorded = document.get("reconciliation", {}) + if not recorded: + problems.append( + "manifest.json carries no reconciliation block; the frozen panels' " + "agreement with the shipped eval artifacts is unwitnessed." ) + return + for factor_id in sorted(recorded): + entry = recorded[factor_id] or {} + artifact = entry.get("artifact") + if not artifact: + problems.append(f"reconciliation[{factor_id}] names no eval artifact") + checks = entry.get("checks", {}) + for field in RECONCILE_INT_FIELDS + RECONCILE_FLOAT_FIELDS: + check = checks.get(field) + if check is None: + problems.append( + f"reconciliation[{factor_id}] is missing the {field!r} check" + ) + elif not check.get("ok"): + problems.append( + f"reconciliation[{factor_id}].{field} was recorded as NOT ok " + f"({check.get('artifact')!r} vs {check.get('frozen')!r}) — a " + "divergent panel must never have been frozen." + ) - jac = c.JumpAmountCorrFactor(lookback_days=c.JUMP_LOOKBACK_DAYS) - add(c, jac, lambda cfg, symbols, panel, logger, _s=jac.spec: c._load_jump_factor_panel( - cfg, symbols, _s, logger, - lookback_days=c.JUMP_LOOKBACK_DAYS, min_pairs=c.JUMP_MIN_PAIRS, - )) - - mia = d.MinuteIdealAmplitudeFactor(lookback_days=d.IDEAL_AMP_LOOKBACK_DAYS) - add(d, mia, lambda cfg, symbols, panel, logger, _s=mia.spec: d._load_minute_ideal_amp_panel( - cfg, symbols, _s, logger, - lookback_days=d.IDEAL_AMP_LOOKBACK_DAYS, lam=d.IDEAL_AMP_LAMBDA, - min_minutes=d.IDEAL_AMP_MIN_MINUTES, - )) - - amav = e.AmpMarginalAnomalyVolFactor(lookback_days=e.AMP_ANOMALY_LOOKBACK_DAYS) - add(e, amav, lambda cfg, symbols, panel, logger, _s=amav.spec: e._load_amp_anomaly_vol_panel( - cfg, symbols, _s, logger, - lookback_days=e.AMP_ANOMALY_LOOKBACK_DAYS, min_pool=e.AMP_ANOMALY_MIN_POOL, - min_selected=e.AMP_ANOMALY_MIN_SELECTED, sigma_k=e.AMP_ANOMALY_SIGMA_K, - )) - - vpc = f.VolumePeakCountFactor(lookback_days=f.VOLUME_PRV_LOOKBACK_DAYS) - add(f, vpc, lambda cfg, symbols, panel, logger, _s=vpc.spec: f._load_volume_peak_count_panel( - cfg, symbols, _s, logger, - lookback_days=f.VOLUME_PRV_LOOKBACK_DAYS, baseline_days=f.VOLUME_PRV_BASELINE_DAYS, - baseline_min_obs=f.VOLUME_PRV_BASELINE_MIN_OBS, sigma_k=f.VOLUME_PRV_SIGMA_K, - min_valid_days=f.VOLUME_PRV_MIN_VALID_DAYS, - min_classifiable=f.VOLUME_PRV_MIN_CLASSIFIABLE, - )) - - iac = g.IntradayAmpCutFactor(lookback_days=g.AMP_CUT_LOOKBACK_DAYS) - add(g, iac, lambda cfg, symbols, panel, logger, _s=iac.spec: g._load_amp_cut_panel( - cfg, symbols, _s, logger, - lookback_days=g.AMP_CUT_LOOKBACK_DAYS, lam=g.AMP_CUT_LAMBDA, - min_day_minutes=g.AMP_CUT_MIN_DAY_MINUTES, min_valid_days=g.AMP_CUT_MIN_VALID_DAYS, - min_cross_section=g.AMP_CUT_MIN_CROSS_SECTION, - )) - - pik = h.PeakIntervalKurtosisFactor(lookback_days=h.PEAK_INTERVAL_LOOKBACK_DAYS) - add(h, pik, lambda cfg, symbols, panel, logger, _s=pik.spec: h._load_peak_interval_kurtosis_panel( - cfg, symbols, _s, logger, - lookback_days=h.PEAK_INTERVAL_LOOKBACK_DAYS, baseline_days=h.VOLUME_PRV_BASELINE_DAYS, - baseline_min_obs=h.VOLUME_PRV_BASELINE_MIN_OBS, sigma_k=h.VOLUME_PRV_SIGMA_K, - min_valid_days=h.VOLUME_PRV_MIN_VALID_DAYS, - min_classifiable=h.VOLUME_PRV_MIN_CLASSIFIABLE, - min_intervals=h.PEAK_INTERVAL_MIN_INTERVALS, - )) - - vrv = i.ValleyRelativeVwapFactor(lookback_days=i.VALLEY_VWAP_LOOKBACK_DAYS) - add(i, vrv, lambda cfg, symbols, panel, logger, _s=vrv.spec: i._load_valley_relative_vwap_panel( - cfg, symbols, _s, logger, - lookback_days=i.VALLEY_VWAP_LOOKBACK_DAYS, baseline_days=i.VOLUME_PRV_BASELINE_DAYS, - baseline_min_obs=i.VOLUME_PRV_BASELINE_MIN_OBS, sigma_k=i.VOLUME_PRV_SIGMA_K, - min_valid_days=i.VOLUME_PRV_MIN_VALID_DAYS, - min_classifiable=i.VOLUME_PRV_MIN_CLASSIFIABLE, - min_valley_bars=i.VALLEY_VWAP_MIN_VALLEY_BARS, - )) - - vrr = j.ValleyRidgeVwapRatioFactor(lookback_days=j.VALLEY_RIDGE_LOOKBACK_DAYS) - add(j, vrr, lambda cfg, symbols, panel, logger, _s=vrr.spec: j._load_valley_ridge_vwap_ratio_panel( - cfg, symbols, _s, logger, - lookback_days=j.VALLEY_RIDGE_LOOKBACK_DAYS, baseline_days=j.VOLUME_PRV_BASELINE_DAYS, - baseline_min_obs=j.VOLUME_PRV_BASELINE_MIN_OBS, sigma_k=j.VOLUME_PRV_SIGMA_K, - min_valid_days=j.VOLUME_PRV_MIN_VALID_DAYS, - min_classifiable=j.VOLUME_PRV_MIN_CLASSIFIABLE, - min_valley_bars=j.VALLEY_RIDGE_MIN_VALLEY_BARS, - min_ridge_bars=j.VALLEY_RIDGE_MIN_RIDGE_BARS, - )) - - rmr = k.RidgeMinuteReturnFactor(lookback_days=k.RIDGE_RETURN_LOOKBACK_DAYS) - add(k, rmr, lambda cfg, symbols, panel, logger, _s=rmr.spec: k._load_ridge_minute_return_panel( - cfg, symbols, _s, logger, - lookback_days=k.RIDGE_RETURN_LOOKBACK_DAYS, baseline_days=k.VOLUME_PRV_BASELINE_DAYS, - baseline_min_obs=k.VOLUME_PRV_BASELINE_MIN_OBS, sigma_k=k.VOLUME_PRV_SIGMA_K, - min_valid_days=k.VOLUME_PRV_MIN_VALID_DAYS, - min_classifiable=k.VOLUME_PRV_MIN_CLASSIFIABLE, - min_ridge_bars=k.RIDGE_RETURN_MIN_RIDGE_BARS, - )) - - vpq = ell.ValleyPriceQuantileFactor(lookback_days=ell.VALLEY_QUANTILE_LOOKBACK_DAYS) - # The ONE loader that also consumes the daily panel (its reversal - # neutralization needs daily closes) — the runner passes it positionally. - add(ell, vpq, lambda cfg, symbols, panel, logger, _s=vpq.spec: ell._load_valley_price_quantile_panel( - cfg, symbols, _s, panel, logger, - lookback_days=ell.VALLEY_QUANTILE_LOOKBACK_DAYS, - baseline_days=ell.VOLUME_PRV_BASELINE_DAYS, - baseline_min_obs=ell.VOLUME_PRV_BASELINE_MIN_OBS, sigma_k=ell.VOLUME_PRV_SIGMA_K, - min_valid_days=ell.VOLUME_PRV_MIN_VALID_DAYS, - min_classifiable=ell.VOLUME_PRV_MIN_CLASSIFIABLE, - min_valley_bars=ell.VALLEY_QUANTILE_MIN_VALLEY_BARS, - min_cross_section=ell.VALLEY_QUANTILE_MIN_CROSS_SECTION, - reversal_days=ell.VALLEY_QUANTILE_REVERSAL_DAYS, - )) - - pra = m.PeakRidgeAmountRatioFactor(lookback_days=m.PEAK_RIDGE_LOOKBACK_DAYS) - add(m, pra, lambda cfg, symbols, panel, logger, _s=pra.spec: m._load_peak_ridge_amount_ratio_panel( - cfg, symbols, _s, logger, - lookback_days=m.PEAK_RIDGE_LOOKBACK_DAYS, baseline_days=m.VOLUME_PRV_BASELINE_DAYS, - baseline_min_obs=m.VOLUME_PRV_BASELINE_MIN_OBS, sigma_k=m.VOLUME_PRV_SIGMA_K, - min_valid_days=m.VOLUME_PRV_MIN_VALID_DAYS, - min_classifiable=m.VOLUME_PRV_MIN_CLASSIFIABLE, - min_peak_bars=m.PEAK_RIDGE_MIN_PEAK_BARS, - min_ridge_bars=m.PEAK_RIDGE_MIN_RIDGE_BARS, - )) - - return recipes - - -def freezable_factor_ids() -> frozenset[str]: - """Every factor id this freeze can produce (3 book + 11 minute). - - Derived from the recipes and the runners' own book builder, so it cannot - drift from what the freeze actually writes. - """ - from qt.eval_jump_amount_corr import _build_book_factors - - return frozenset( - {f.name for f in _build_book_factors()} - | {r.factor_id for r in minute_recipes()} - ) - - -def resolve_selection(only: Sequence[str] | None) -> tuple[str, ...] | None: - """Validate a ``--only`` selection against the ids the freeze can produce. - ``None`` (freeze everything) passes straight through — the default path is - untouched. A named id that this freeze does not produce is a readable error - rather than a silently empty output root: "froze 0 panels" reported as - success is how a correctness rerun quietly produces nothing. +def verify_frozen_panels( + root: Path | str, + doc_path: Path | str, + *, + show_manifest: bool = False, +) -> VerifyResult: + """Check a frozen panel tree against its git-tracked manifest document. + + Per panel, four independent statements must hold: + + 1. the recomputed canonical content hash equals the hash AUTHORED IN GIT; + 2. it also equals the hash in the gitignored ``manifest.json`` (so a tree + regenerated together with its machine manifest is still caught: the two + witnesses would agree with each other and disagree with git); + 3. the whole manifest row re-derived from the panel (rows / date range / + symbol count / NaN count / mean / std / file sha256) equals the recorded + one — a patched hash with stale statistics does not survive this; + 4. the document's own columns (rows / n_symbols / n_nan / mean / std where + the document carries them) agree as well. + + Plus tree-level statements: the set of ``*.parquet`` files equals the set of + factor ids in the document (an EXTRA panel is a problem, not a bonus), and + the recorded producing SHA equals the one the document pins. """ - if only is None: - return None - selected = tuple(only) - if not selected: - raise ValueError( - "--only was given an empty selection; omit it to freeze all factors." + root = Path(root) + doc_path = Path(doc_path) + problems: list[str] = [] + + expected = parse_doc_manifest(doc_path) + document = _load_machine_manifest(root) + if not document: + problems.append( + f"machine manifest missing: {root / 'manifest.json'} — the frozen " + "tree is incomplete (the git-tracked document alone cannot witness " + "row counts, NaN counts, mean/std or the file hashes)." ) - known = freezable_factor_ids() - unknown = [fid for fid in selected if fid not in known] - if unknown: - raise ValueError( - f"--only names factor id(s) this freeze does not produce: {unknown}; " - f"known ids are {sorted(known)}." + else: + _check_recorded_reconciliation(document, problems) + machine_rows = {str(row["factor_id"]): row for row in document.get("rows", [])} + header = document.get("header", {}) + + doc_sha = parse_doc_producing_sha(doc_path) + recorded_sha = header.get("producing_git_sha") + if doc_sha and recorded_sha and doc_sha != recorded_sha: + problems.append( + f"producing SHA mismatch: {doc_path.name} pins {doc_sha} but " + f"manifest.json records {recorded_sha} — this tree was not produced " + "by the run the document describes." ) - return selected - -# --------------------------------------------------------------------------- # -# Artifact reconciliation (against the shipped eval JSONs) -# --------------------------------------------------------------------------- # -def reconcile_with_eval_artifact( - stem: str, - processed: pd.Series, - declared_symbols: list[str], - reports_dir: Path, -) -> dict: - """Check the frozen panel's coverage against ``{stem}_no_book.json``. - - The JSON's ``data_coverage`` fields describe the PROCESSED factor series at - the evaluator boundary; ``processed`` here was re-derived from the frozen - RAW panel through the runners' own ``_process_factors``, so equality proves - the frozen panel is the same series the shipped evaluation consumed. Any - mismatch raises — a divergent panel must NOT be recorded (task: stop and - investigate, never freeze a discrepancy). - """ - json_path = reports_dir / f"{stem}_no_book.json" - if not json_path.exists(): - raise FileNotFoundError( - f"eval artifact {json_path.name} not found under {reports_dir} — the " - "reconciliation target is missing; refusing to freeze unreconciled." + panels_dir = root / "panels" + if not panels_dir.is_dir(): + problems.append(f"frozen panels directory not found: {panels_dir}") + return VerifyResult( + root=root, + doc=doc_path, + panels=(), + problems=tuple(problems), + producing_git_sha=recorded_sha, ) - document = json.loads(json_path.read_text(encoding="utf-8")) - coverage = None - for section in document.get("sections", []): - if section.get("name") == "data_coverage": - coverage = section.get("payload", {}) - if not coverage: - raise ValueError(f"{json_path.name} carries no data_coverage payload.") - - values = np.asarray(processed.to_numpy(), dtype=float) - finite = np.isfinite(values) - total = int(len(processed)) - evaluated = pd.unique(processed.index.get_level_values(SYMBOL_LEVEL)) - ours: dict[str, object] = { - "panel_rows": total, - "evaluation_periods": int( - pd.unique(processed.index.get_level_values(DATE_LEVEL)).size - ), - "symbols_evaluated": int(len(evaluated)), - "universe_symbols_declared": int(len(declared_symbols)), - "dropped_symbols_count": len( - set(map(str, declared_symbols)) - set(map(str, evaluated)) - ), - # the eval JSON writer rounds floats to 6 decimals (data.quality clean_value) - "factor_nan_rate": round(1.0 - finite.sum() / total, 6) if total else None, - } - checks: dict[str, dict] = {} - mismatches: list[str] = [] - for field in RECONCILE_INT_FIELDS + RECONCILE_FLOAT_FIELDS: - expected = coverage.get(field) - actual = ours[field] - ok = expected == actual - checks[field] = {"artifact": expected, "frozen": actual, "ok": bool(ok)} - if not ok: - mismatches.append(f"{field}: artifact={expected!r} frozen={actual!r}") - if mismatches: - raise ValueError( - f"frozen panel disagrees with {json_path.name} — NOT freezing: " - + "; ".join(mismatches) - ) - return {"artifact": json_path.name, "checks": checks} - -# --------------------------------------------------------------------------- # -# Freeze orchestration -# --------------------------------------------------------------------------- # -@dataclass(frozen=True) -class FreezeResult: - """Immutable summary of one panel-freeze run (no secrets).""" - - output_root: Path - manifest_json: Path - manifest_md: Path - rows: tuple[dict, ...] - header: dict - elapsed: float - - -def _git_head_sha() -> str: - """Best-effort producing SHA (the manifest doc records the authoritative one).""" - try: - out = subprocess.run( - ["git", "rev-parse", "HEAD"], - capture_output=True, text=True, check=True, timeout=10, + on_disk = {path.stem for path in panels_dir.glob("*.parquet")} + for extra in sorted(on_disk - set(expected)): + problems.append( + f"unregistered panel on disk: {extra}.parquet is not in " + f"{doc_path.name}; the frozen inventory is defined by the document." ) - return out.stdout.strip() - except Exception: # noqa: BLE001 - provenance is best-effort here - return "UNKNOWN" + verifications: list[PanelVerification] = [] + rendered_rows: list[dict] = [] + for factor_id in sorted(expected): + verification, rendered = _verify_one_panel( + factor_id, expected[factor_id], panels_dir, machine_rows + ) + verifications.append(verification) + if rendered is not None: + rendered_rows.append(rendered) -def run_panel_freeze( - config_path: str = DEFAULT_CONFIG, - output_root: str | Path = DEFAULT_OUTPUT_ROOT, - *, - resume: bool = False, - only: Sequence[str] | None = None, -) -> FreezeResult: - """Freeze all 14 RAW factor panels + verify determinism + reconcile artifacts. - - ``only`` restricts the freeze to the named factor ids (default ``None`` = - all 14, byte-identical to before). It exists so a SINGLE factor whose - definition was corrected can be re-frozen into its OWN output root as a - reference panel, without a second freeze implementation and without - re-reading the 279M-row minute cache for the thirteen that did not change. - The shared data plane is built from the FULL book either way, so the daily - panel a filtered run computes on is identical to the full run's. - - ``resume=True`` completes an interrupted freeze: a minute factor whose panel - file already exists is NOT rebuilt from the minute cache — its RAW series is - read back FROM the frozen file and then pushed through the SAME processing + - eval-artifact reconciliation before being accepted into the manifest (a - stale or corrupted file fails loudly; nothing is reused unverified). Its - canonical hash is therefore the hash of the file CONTENT. Book factors are - always recomputed (cheap). The determinism double-run still rebuilds its - subjects end-to-end via the runner loaders, so a resumed subject is compared - fresh-build-vs-frozen-file — a strictly stronger check than two in-process - builds. Resumed factor ids are disclosed in the manifest header. - """ - from qt.config import load_config - from qt.eval_jump_amount_corr import _build_book_factors, _check_preconditions - from qt.pipeline import ( - _build_cache, - _build_universe, - _load_panel, - _log_run_cache_stats, - _make_logger, - _maybe_enrich_covariates, - _maybe_enrich_value, - _process_factors, - ) - - started = time.monotonic() - # Validate the selection BEFORE the expensive data plane: a typo must cost a - # readable error, not an hour of cache reads followed by an empty freeze. - selected = resolve_selection(only) - cfg = load_config(config_path) - _check_preconditions(cfg) # tushare + cache-only + PIT index + neutralize, as the runners - # Freeze-specific PanelStore name: identical content, never clobbers an - # accepted eval run's per-run panel artifact. - cfg = cfg.model_copy( - update={"data": cfg.data.model_copy(update={"output_name": PANEL_STORE_NAME})} + rendered_manifest = ( + render_manifest_markdown(dict(header), rendered_rows) if show_manifest else None ) - - out_root = Path(output_root) - panels_dir = out_root / "panels" - log_path = Path(cfg.output.log_dir) / "panel_freeze.log" - logger = _make_logger(log_path, name="qt.panel_freeze") - logger.info("panel freeze: config=%s output_root=%s", config_path, out_root) - - # -- shared data plane, the runners' exact preamble --------------------- # - cache = _build_cache(cfg) - universe, symbols = _build_universe(cfg, logger, cache) - panel = _load_panel(cfg, symbols, logger, cache) - book_factors = _build_book_factors() - panel = _maybe_enrich_value(cfg, panel, symbols, book_factors, logger, cache) - panel = _maybe_enrich_covariates(cfg, panel, symbols, logger, cache) - _log_run_cache_stats(cache, logger) - panel_dates = pd.Index( - pd.unique(panel.index.get_level_values(DATE_LEVEL)), name=DATE_LEVEL + return VerifyResult( + root=root, + doc=doc_path, + panels=tuple(verifications), + problems=tuple(problems), + producing_git_sha=recorded_sha, + rendered_manifest=rendered_manifest, ) - rows: list[dict] = [] - reconciliations: dict[str, dict] = {} - canonical_by_id: dict[str, str] = {} - live_calls_total = 0 - - def _keep(factor_id: str) -> bool: - return selected is None or factor_id in selected - # -- book factors (raw, pre-processing) --------------------------------- # - kept_book = [factor for factor in book_factors if _keep(factor.name)] - book_raw = ( - pd.concat( - [factor.compute(panel).rename(factor.name) for factor in kept_book], axis=1 - ) - if kept_book - else pd.DataFrame() - ) - for factor in kept_book: - raw = book_raw[factor.name].rename(factor.name) - canonical = canonical_content_hash(raw) - target = panels_dir / f"{factor.name}.parquet" - sha = atomic_write_parquet(raw, target) - rows.append(manifest_row(factor.name, "book", raw, canonical, sha, target.name)) - canonical_by_id[factor.name] = canonical - logger.info("frozen book %s: rows=%d canonical=%s", factor.name, len(raw), canonical) - - # -- minute factors (raw, the runners' own loader chains) ---------------- # - resumed: list[str] = [] - kept_recipes = [r for r in minute_recipes() if _keep(r.factor_id)] - for recipe in kept_recipes: - target = panels_dir / f"{recipe.factor_id}.parquet" - if resume and target.exists(): - raw = read_frozen_panel(target, recipe.factor_id) - resumed.append(recipe.factor_id) - covered_note = "resumed-from-file" - else: - load = recipe.build(cfg, symbols, panel, logger) - live_calls = int(load.live_calls) - live_calls_total += live_calls - if live_calls != 0: - raise RuntimeError( - f"{recipe.factor_id}: stk_mins_live_calls={live_calls} != 0 — the " - "freeze is cache-only by contract." - ) - raw = load.factor[ - load.factor.index.get_level_values(DATE_LEVEL).isin(panel_dates) - ].rename(recipe.factor_id) - covered_note = f"covered={len(load.covered)}/{load.requested}" - # reconcile BEFORE accepting (a divergent panel is never frozen/kept) - processed = _process_factors( - cfg, raw.to_frame(recipe.factor_id), panel - )[recipe.factor_id] - reconciliations[recipe.factor_id] = reconcile_with_eval_artifact( - recipe.stem, processed, symbols, Path(cfg.output.report_dir) - ) - canonical = canonical_content_hash(raw) - if recipe.factor_id in resumed: - sha = file_sha256(target) - else: - sha = atomic_write_parquet(raw, target) - rows.append( - manifest_row(recipe.factor_id, "minute", raw, canonical, sha, target.name) +def _verify_one_panel( + factor_id: str, + expected: DocManifestRow, + panels_dir: Path, + machine_rows: dict[str, dict], +) -> tuple[PanelVerification, dict | None]: + problems: list[str] = [] + path = panels_dir / f"{factor_id}.parquet" + if not path.exists(): + return ( + PanelVerification( + factor_id=factor_id, + file=path.name, + rows=0, + canonical_sha256="", + problems=(f"frozen panel missing from disk: {path}",), + ), + None, ) - canonical_by_id[recipe.factor_id] = canonical - logger.info( - "frozen minute %s: rows=%d %s canonical=%s (reconciled vs %s)", - recipe.factor_id, len(raw), covered_note, canonical, - reconciliations[recipe.factor_id]["artifact"], + try: + panel = read_frozen_panel(path, factor_id) + except (ValueError, TypeError, OSError) as exc: + return ( + PanelVerification( + factor_id=factor_id, + file=path.name, + rows=0, + canonical_sha256="", + problems=(f"frozen panel unreadable: {type(exc).__name__}: {exc}",), + ), + None, ) - # -- determinism double-run (2 minute + 1 book) -------------------------- # - determinism: dict[str, dict] = {} - determinism_subjects = tuple(fid for fid in DETERMINISM_FACTORS if _keep(fid)) - for factor_id in determinism_subjects: - rebuilt = _rebuild_for_determinism( - factor_id, cfg, symbols, panel, panel_dates, logger + canonical = canonical_content_hash(panel) + derived = manifest_row( + factor_id, + str(expected.kind or (machine_rows.get(factor_id, {}) or {}).get("kind", "")), + panel, + canonical, + file_sha256(path), + path.name, + ) + + if canonical != expected.canonical_sha256: + problems.append( + f"canonical content hash {canonical} != the git-tracked " + f"{expected.canonical_sha256} — these are NOT the frozen bytes." ) - second = canonical_content_hash(rebuilt) - first = canonical_by_id[factor_id] - determinism[factor_id] = {"first": first, "second": second, "ok": second == first} - if second != first: - raise RuntimeError( - f"determinism double-run FAILED for {factor_id}: {first} != {second}" + for field, want in ( + ("rows", expected.rows), + ("n_symbols", expected.n_symbols), + ("n_nan", expected.n_nan), + ("mean", expected.mean), + ("std", expected.std), + ): + if want is not None and derived[field] != want: + problems.append( + f"{field}: git-tracked document says {want!r}, panel gives " + f"{derived[field]!r}" + ) + if expected.file_sha256 is not None and derived["file_sha256"] != expected.file_sha256: + problems.append( + f"file sha256 {derived['file_sha256']} != the git-tracked " + f"{expected.file_sha256} — the file was rewritten" + + ( + " (its CONTENT still matches, so this is a re-write, not a value " + "change — but a frozen-forever file has no legitimate reason to " + "be rewritten)." + if canonical == expected.canonical_sha256 + else "." ) - logger.info("determinism double-run ok: %s %s", factor_id, first) - - header = { - "producing_git_sha": _git_head_sha(), - "config": str(config_path), - "universe": f"{cfg.universe.type}:{cfg.universe.index_code}", - "window": f"{cfg.data.start}..{cfg.data.end}", - "cache_root": str(cfg.data.cache.root_dir), - "panel_store_name": PANEL_STORE_NAME, - "panel_rows": int(len(panel)), - "panel_dates": int(len(panel_dates)), - "universe_symbols": int(len(symbols)), - "stk_mins_live_calls": int(live_calls_total), - "resumed_factors": ",".join(resumed) if resumed else "none", - "selected_factors": "all" if selected is None else ",".join(selected), - "elapsed_seconds": round(time.monotonic() - started, 1), - "generated_utc": pd.Timestamp.now(tz="UTC").strftime("%Y-%m-%d %H:%M:%S"), - "canonical_hash_version": CANONICAL_HASH_VERSION, - "determinism_double_run": "; ".join( - f"{fid}:{'ok' if determinism[fid]['ok'] else 'FAIL'}" - for fid in determinism_subjects ) - or "none (no determinism subject selected)", - "artifact_reconciliation": ( - f"{len(reconciliations)}/{len(kept_recipes)} minute factors reconciled " - "against eval_*_no_book.json data_coverage (book factors carry no " - "coverage fields in the eval artifacts — disclosed, not skipped silently)" - ), - } - manifest = { - "header": header, - "rows": rows, - "reconciliation": reconciliations, - "determinism": determinism, - } - manifest_json = out_root / "manifest.json" - manifest_md = out_root / "manifest.md" - atomic_write_text(json.dumps(manifest, indent=2, sort_keys=True), manifest_json) - atomic_write_text(render_manifest_markdown(header, rows), manifest_md) - logger.info( - "panel freeze complete: %d panels, %ss, manifest=%s", - len(rows), header["elapsed_seconds"], manifest_json, - ) - return FreezeResult( - output_root=out_root, - manifest_json=manifest_json, - manifest_md=manifest_md, - rows=tuple(rows), - header=header, - elapsed=time.monotonic() - started, + recorded = machine_rows.get(factor_id) + if recorded is None: + problems.append("no row for this factor in manifest.json") + else: + for field in MANIFEST_ROW_FIELDS: + if field not in recorded: + problems.append(f"manifest.json row is missing field {field!r}") + continue + if recorded[field] != derived[field]: + problems.append( + f"manifest.json {field}: recorded {recorded[field]!r}, panel " + f"gives {derived[field]!r}" + ) + + return ( + PanelVerification( + factor_id=factor_id, + file=path.name, + rows=int(derived["rows"]), + canonical_sha256=canonical, + problems=tuple(problems), + ), + derived, ) -def _rebuild_for_determinism( - factor_id: str, - cfg, - symbols: list[str], - panel: pd.DataFrame, - panel_dates: pd.Index, - logger: logging.Logger, -) -> pd.Series: - """Second, independent build of one factor's RAW panel (same chains). - - Minute factors re-run the runner loader end-to-end (a fresh cache-store - read); book factors re-instantiate ``_build_book_factors()`` and recompute - from the shared enriched panel (the panel load itself is once-per-process, - exactly as in the runners; its own determinism is covered by the cache - equivalence suites). +def verify_all( + baseline_root: Path | str = DEFAULT_OUTPUT_ROOT, + *, + show_manifest: bool = False, +) -> list[VerifyResult]: + """Verify BOTH frozen panel trees: the D1 baseline and the PR-C reference.""" + baseline_root = Path(baseline_root) + repo_root = repo_root_for(baseline_root) + return [ + verify_frozen_panels( + baseline_root, repo_root / D1_MANIFEST_DOC, show_manifest=show_manifest + ), + verify_frozen_panels( + baseline_root / PR_C_SUBDIR, + repo_root / PR_C_MANIFEST_DOC, + show_manifest=show_manifest, + ), + ] + + +def repo_root_for(baseline_root: Path | str) -> Path: + """Where to look for the git-tracked manifest documents. + + ``docs/`` sits next to the repository root, while the baseline root may be + an arbitrary directory (a copy under test, say). Walk up from the baseline + root looking for ``docs/factors``; fall back to this module's own repository + so a copied tree still verifies against the documents in git. """ - from qt.eval_jump_amount_corr import _build_book_factors + for candidate in (baseline_root, *baseline_root.parents): + if (candidate / D1_MANIFEST_DOC).exists(): + return candidate + return Path(__file__).resolve().parent.parent + - for factor in _build_book_factors(): - if factor.name == factor_id: - return factor.compute(panel).rename(factor_id) - for recipe in minute_recipes(): - if recipe.factor_id == factor_id: - load = recipe.build(cfg, symbols, panel, logger) - return load.factor[ - load.factor.index.get_level_values(DATE_LEVEL).isin(panel_dates) - ].rename(factor_id) - raise KeyError(f"unknown determinism factor_id {factor_id!r}") +# --------------------------------------------------------------------------- # +# Retired regeneration entry point +# --------------------------------------------------------------------------- # +def run_panel_freeze(*args, **kwargs): + """RETIRED (D5 C6). Always raises :class:`RegenerationRetiredError`. + + Kept as a stub rather than deleted outright so that a stale caller — a + script, a notebook, a copied command line — gets the retirement explanation + instead of an ``AttributeError`` that reads like a packaging accident. + """ + raise RegenerationRetiredError( + retirement_message( + "python -m qt.panel_freeze", + "the D1 RAW factor-value panel baseline, which is frozen-forever", + "The rebuild called the eleven legacy eval runners' private " + "`_load_*_panel` loaders, which C6 deletes; and rebuilding this " + "baseline from the tree under validation is exactly the " + "empty-reconciliation failure mode the provenance rule forbids.", + "it recomputes every frozen panel's canonical content hash and " + "manifest row and checks them against the hashes authored in " + "`docs/factors/`.", + ) + ) def main(argv: list[str] | None = None) -> int: - """CLI: ``python -m qt.panel_freeze [--config ...] [--output-root ...]``.""" + """CLI: ``python -m qt.panel_freeze --verify``. + + The retired flags are still ACCEPTED by the parser so that someone typing + the old documented command gets the retirement explanation rather than + ``unrecognized arguments: --resume``, which reads like a version mismatch. + """ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) - parser.add_argument("--config", default=DEFAULT_CONFIG) - parser.add_argument("--output-root", default=DEFAULT_OUTPUT_ROOT) + parser.add_argument("--config", default=DEFAULT_CONFIG, help=argparse.SUPPRESS) + parser.add_argument("--output-root", default=DEFAULT_OUTPUT_ROOT, help=argparse.SUPPRESS) + parser.add_argument("--resume", action="store_true", help=argparse.SUPPRESS) + parser.add_argument("--only", default=None, help=argparse.SUPPRESS) parser.add_argument( - "--resume", + "--baseline-root", + default=None, + help="frozen baseline root (default: the --output-root location)", + ) + parser.add_argument( + "--verify", action="store_true", - help=( - "complete an interrupted freeze: existing minute panels are read back " - "from their frozen files and re-verified (processing + eval-artifact " - "reconciliation) instead of being rebuilt from the minute cache" - ), + help="verify the frozen panels against the git-tracked manifest documents", ) parser.add_argument( - "--only", - default=None, - help=( - "comma-separated factor ids to freeze (default: all 14). Used to " - "re-freeze a SINGLE corrected factor into its own --output-root; an " - "unknown id is a readable error, never a silently empty freeze" - ), + "--show-manifest", + action="store_true", + help="also print the manifest table re-derived from the panels on disk", ) args = parser.parse_args(argv) - only = None if args.only is None else tuple(x for x in args.only.split(",") if x) - result = run_panel_freeze( - args.config, args.output_root, resume=args.resume, only=only - ) - print(f"panels frozen: {len(result.rows)} -> {result.output_root / 'panels'}") - print(f"manifest: {result.manifest_json}") - print(f"stk_mins_live_calls: {result.header['stk_mins_live_calls']}") - print(f"elapsed_seconds: {result.header['elapsed_seconds']}") - return 0 + + if not args.verify: + try: + run_panel_freeze() + except RegenerationRetiredError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + + root = Path(args.baseline_root or args.output_root) + results = verify_all(root, show_manifest=args.show_manifest) + failed = False + for result in results: + print( + f"{result.root}: verified {result.n_ok}/{len(result.panels)} panels " + f"against {result.doc.name} (producing sha " + f"{result.producing_git_sha or 'unrecorded'})" + ) + for problem in result.problems: + print(f" PROBLEM {problem}") + for panel in result.panels: + for problem in panel.problems: + print(f" PROBLEM {panel.factor_id}: {problem}") + if result.rendered_manifest: + print(result.rendered_manifest) + failed = failed or not result.ok + print("panel freeze verification: " + ("FAILED" if failed else "OK")) + return 1 if failed else 0 if __name__ == "__main__": # pragma: no cover - thin CLI shim diff --git a/tests/fixtures/frozen_baseline.py b/tests/fixtures/frozen_baseline.py new file mode 100644 index 0000000..47523b3 --- /dev/null +++ b/tests/fixtures/frozen_baseline.py @@ -0,0 +1,173 @@ +"""Builders for a SYNTHETIC frozen-baseline tree (D1 / D2 verify-only tests). + +The real baseline is 109 MB of parquet and must never be written to, so the +verification tests operate on a miniature tree with the same shape: a +``panels/`` directory, a machine ``manifest.json`` (rows + reconciliation +block), an optional ``panels_d2/`` with its ``manifest_d2.json``, and a +git-tracked-style Markdown manifest document carrying the expected hashes. + +The tree is built through the production writer (``atomic_write_parquet``) and +the production row/renderer helpers, so a synthetic tree is a valid frozen tree +by construction rather than by transcription. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import numpy as np +import pandas as pd + +from data.clean.schema import DATE_LEVEL, SYMBOL_LEVEL +from qt.panel_freeze import ( + RECONCILE_FLOAT_FIELDS, + RECONCILE_INT_FIELDS, + atomic_write_parquet, + canonical_content_hash, + manifest_row, +) + +#: Two factors is enough to show that a problem is attributed to ONE of them. +DEFAULT_FACTORS = {"alpha_20": "minute", "book_x": "book"} +DATES = ("2024-01-02", "2024-01-03", "2024-01-04") +SYMBOLS = ("000001.SZ", "600000.SH") + + +def make_panel(factor_id: str, *, offset: float = 0.0, nan_last: bool = True) -> pd.Series: + """A small, deterministic (date, symbol) factor panel.""" + keys = [(pd.Timestamp(d), s) for d in DATES for s in SYMBOLS] + values = [offset + 0.5 * position for position in range(len(keys))] + if nan_last: + values[-1] = float("nan") + index = pd.MultiIndex.from_tuples(keys, names=[DATE_LEVEL, SYMBOL_LEVEL]) + return pd.Series(np.asarray(values, dtype="float64"), index=index, name=factor_id) + + +def render_doc(rows: list[dict], *, producing_sha: str, include_file_sha: bool = True) -> str: + """A Markdown manifest document in the shape the real ones use. + + Deliberately includes a decoy table BEFORE the manifest table (the real + documents carry several), so the header-driven table finder is exercised + rather than a "first table wins" shortcut. + """ + columns = ["factor_id", "kind", "rows", "n_symbols", "n_nan", "mean", "std", + "canonical_sha256"] + if include_file_sha: + columns.append("file_sha256") + lines = [ + "# synthetic manifest", + "", + f"- **producing SHA**: `{producing_sha}`", + "", + "| item | value |", + "|---|---|", + "| universe | synthetic |", + "", + "| " + " | ".join(columns) + " |", + "|" + "|".join("---" for _ in columns) + "|", + ] + for row in rows: + cells = [] + for column in columns: + value = row[column] + cells.append(repr(value) if isinstance(value, float) else str(value)) + lines.append("| " + " | ".join(cells) + " |") + lines.append("") + return "\n".join(lines) + + +def build_frozen_tree( + root: Path, + *, + factors: dict[str, str] | None = None, + producing_sha: str = "0" * 40, + with_d2: bool = False, + include_file_sha: bool = True, +) -> Path: + """Write a complete synthetic frozen tree; return the manifest document path.""" + factors = dict(factors or DEFAULT_FACTORS) + panels_dir = root / "panels" + rows: list[dict] = [] + for position, (factor_id, kind) in enumerate(factors.items()): + panel = make_panel(factor_id, offset=float(position)) + target = panels_dir / f"{factor_id}.parquet" + file_hash = atomic_write_parquet(panel, target) + rows.append( + manifest_row( + factor_id, kind, panel, canonical_content_hash(panel), file_hash, + target.name, + ) + ) + + reconciliation = { + row["factor_id"]: { + "artifact": f"eval_{row['factor_id']}_no_book.json", + "checks": { + field: {"artifact": 1, "frozen": 1, "ok": True} + for field in RECONCILE_INT_FIELDS + RECONCILE_FLOAT_FIELDS + }, + } + for row in rows + if factors[row["factor_id"]] == "minute" + } + (root / "manifest.json").write_text( + json.dumps( + { + "header": {"producing_git_sha": producing_sha, "panel_rows": 6}, + "rows": rows, + "reconciliation": reconciliation, + }, + indent=2, + sort_keys=True, + ), + encoding="utf-8", + ) + + if with_d2: + d2_dir = root / "panels_d2" + d2_rows = [] + for position, factor_id in enumerate(factors): + panel = make_panel(factor_id, offset=float(position)) + atomic_write_parquet(panel, d2_dir / f"{factor_id}.parquet") + d2_rows.append( + { + "factor_id": factor_id, + "canonical_sha256": canonical_content_hash(panel), + "frozen_sha256": canonical_content_hash(panel), + "rows": int(len(panel)), + } + ) + (root / "manifest_d2.json").write_text( + json.dumps( + { + "producing_git_sha": producing_sha, + "header": {"producing_git_sha": producing_sha, "all_ok": True}, + "rows": d2_rows, + }, + indent=2, + sort_keys=True, + ), + encoding="utf-8", + ) + + doc_path = root / "manifest_doc.md" + doc_path.write_text( + render_doc(rows, producing_sha=producing_sha, include_file_sha=include_file_sha), + encoding="utf-8", + ) + return doc_path + + +def rewrite_panel(root: Path, factor_id: str, panel: pd.Series, *, d2: bool = False) -> None: + """Overwrite one panel of a synthetic tree (the tampering primitive).""" + subdir = "panels_d2" if d2 else "panels" + atomic_write_parquet(panel.rename(factor_id), root / subdir / f"{factor_id}.parquet") + + +def patch_manifest(root: Path, name: str, mutate) -> None: + """Read/modify/write one of the synthetic tree's JSON manifests.""" + path = root / name + document = json.loads(path.read_text(encoding="utf-8")) + mutate(document) + path.write_text(json.dumps(document, indent=2, sort_keys=True), encoding="utf-8") diff --git a/tests/test_frozen_manifest_doc.py b/tests/test_frozen_manifest_doc.py new file mode 100644 index 0000000..10aab69 --- /dev/null +++ b/tests/test_frozen_manifest_doc.py @@ -0,0 +1,147 @@ +"""Tests for the git-tracked frozen-baseline manifest document parser. + +The parser is the load-bearing half of "the hashes live in git": if it silently +returned an empty or partial expectation set, ``--verify`` would pass a tampered +tree while looking busy. So every failure mode it is supposed to be loud about +gets a test, and the two REAL documents are parsed here as a coupling check — +a reformat that breaks the table stops the suite, not a verification run. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from qt.frozen_manifest_doc import parse_doc_manifest, parse_doc_producing_sha +from qt.panel_freeze import D1_MANIFEST_DOC, PR_C_MANIFEST_DOC + +REPO = Path(__file__).resolve().parents[1] + +HEADER = "| factor_id | rows | canonical_sha256 |" +SEPARATOR = "|---|---|---|" +HASH_A = "a" * 64 +HASH_B = "b" * 64 + + +def _doc(tmp_path: Path, body: str, name: str = "doc.md") -> Path: + path = tmp_path / name + path.write_text(body, encoding="utf-8") + return path + + +def test_parses_the_table_and_reads_columns_by_name(tmp_path: Path): + path = _doc( + tmp_path, + "\n".join( + [ + "# heading", + "", + "| item | value |", # decoy table without the required columns + "|---|---|", + "| universe | CSI500 |", + "", + HEADER, + SEPARATOR, + f"| alpha_20 | 10 | `{HASH_A}` |", + f"| beta_20 | **20** | {HASH_B} |", + ] + ), + ) + rows = parse_doc_manifest(path) + assert set(rows) == {"alpha_20", "beta_20"} + assert rows["alpha_20"].canonical_sha256 == HASH_A + assert rows["beta_20"].rows == 20 # bold markers stripped, read by column NAME + + +def test_absent_columns_are_none_rather_than_invented(tmp_path: Path): + """The PR-C table carries no ``kind`` / ``file_sha256``; a verifier that made + values up for them would be checking its own invention.""" + path = _doc( + tmp_path, + "\n".join([HEADER, SEPARATOR, f"| alpha_20 | 10 | {HASH_A} |"]), + ) + row = parse_doc_manifest(path)["alpha_20"] + assert row.kind is None and row.file_sha256 is None and row.n_nan is None + assert row.rows == 10 + + +def test_two_matching_tables_is_a_readable_error(tmp_path: Path): + path = _doc( + tmp_path, + "\n".join( + [HEADER, SEPARATOR, f"| alpha_20 | 10 | {HASH_A} |", "", + HEADER, SEPARATOR, f"| alpha_20 | 10 | {HASH_B} |"] + ), + ) + with pytest.raises(ValueError, match="exactly ONE table"): + parse_doc_manifest(path) + + +def test_no_matching_table_is_a_readable_error(tmp_path: Path): + path = _doc(tmp_path, "# nothing here\n\n| a | b |\n|---|---|\n| 1 | 2 |\n") + with pytest.raises(ValueError, match="exactly ONE table"): + parse_doc_manifest(path) + + +def test_duplicate_factor_id_is_rejected(tmp_path: Path): + path = _doc( + tmp_path, + "\n".join( + [HEADER, SEPARATOR, f"| alpha_20 | 10 | {HASH_A} |", + f"| alpha_20 | 10 | {HASH_B} |"] + ), + ) + with pytest.raises(ValueError, match="duplicate factor id"): + parse_doc_manifest(path) + + +@pytest.mark.parametrize( + "bad", ["not-a-hash", "A" * 64, "a" * 63, ""], +) +def test_a_malformed_hash_is_rejected(tmp_path: Path, bad: str): + path = _doc(tmp_path, "\n".join([HEADER, SEPARATOR, f"| alpha_20 | 10 | {bad} |"])) + with pytest.raises(ValueError, match="64 lowercase hex"): + parse_doc_manifest(path) + + +def test_a_ragged_row_is_rejected(tmp_path: Path): + path = _doc(tmp_path, "\n".join([HEADER, SEPARATOR, f"| alpha_20 | {HASH_A} |"])) + with pytest.raises(ValueError, match="cells but"): + parse_doc_manifest(path) + + +def test_producing_sha_is_read_from_the_document(tmp_path: Path): + sha = "3" * 40 + path = _doc(tmp_path, f"- **producing SHA**: `{sha}` (some note)\n") + assert parse_doc_producing_sha(path) == sha + + +def test_producing_sha_absent_reads_as_none(tmp_path: Path): + assert parse_doc_producing_sha(_doc(tmp_path, "# no provenance line\n")) is None + + +# --------------------------------------------------------------------------- # +# Coupling to the REAL git-tracked documents +# --------------------------------------------------------------------------- # +def test_the_real_d1_document_lists_the_fourteen_frozen_factors(): + rows = parse_doc_manifest(REPO / D1_MANIFEST_DOC) + assert len(rows) == 14 + assert {"value_ep", "value_bp", "volatility_20"} <= set(rows) # the 3 book factors + assert sum(1 for row in rows.values() if row.kind == "minute") == 11 + assert all(row.file_sha256 is not None for row in rows.values()) + + +def test_the_real_pr_c_document_lists_the_one_corrected_panel(): + rows = parse_doc_manifest(REPO / PR_C_MANIFEST_DOC) + assert set(rows) == {"jump_amount_corr_20"} + # The corrected panel is a DIFFERENT factor definition from the D1 row of the + # same name; if these ever hashed equal, one of the two documents is wrong. + d1 = parse_doc_manifest(REPO / D1_MANIFEST_DOC)["jump_amount_corr_20"] + assert rows["jump_amount_corr_20"].canonical_sha256 != d1.canonical_sha256 + + +def test_both_real_documents_pin_a_producing_sha(): + for doc in (D1_MANIFEST_DOC, PR_C_MANIFEST_DOC): + sha = parse_doc_producing_sha(REPO / doc) + assert sha is not None and len(sha) == 40, doc diff --git a/tests/test_panel_freeze.py b/tests/test_panel_freeze.py index 2bed9dc..58292b2 100644 --- a/tests/test_panel_freeze.py +++ b/tests/test_panel_freeze.py @@ -1,24 +1,24 @@ -"""Network-free tests for qt.panel_freeze (the D1 raw-panel freeze tool). +"""Network-free tests for qt.panel_freeze (VERIFY-ONLY since D5 C6). -Synthetic panels only — the real-data obligations (determinism double-run, -artifact reconciliation, live-call zero) are RUN-time verifications recorded in -the freeze manifest, not unit tests. Here we pin the pure machinery: +Synthetic panels only. Two halves: -* canonical content hash: sensitive to any value / index change, row-order - independent, NaN-payload independent, +0/-0 distinct, loud on malformed input; -* atomic parquet write: readers never see a partial file, failed writes leave - no tmp residue and never touch an existing target; -* manifest row / renderer: field-complete, full-precision, deterministic; -* eval-artifact reconciliation: exact-match discipline — a mismatch raises. +* the pure machinery that DESCRIBES the frozen artifacts — canonical content + hash (sensitive to any value / index change, row-order independent, + NaN-payload independent, +0/-0 distinct, loud on malformed input), the atomic + parquet write it was laid down with, the manifest row and its renderer; +* verification — every way a frozen tree can be wrong must come back red, and + the retired regeneration entry point must fail loudly rather than no-op. Every invariance claim here is paired with a sensitivity assertion on the same axis (the shuffle test alone could be satisfied by a constant hash; the -value/index sensitivity tests kill that degenerate implementation). +value/index sensitivity tests kill that degenerate implementation). The same +rule applies to the verification tests: each starts from a tree that verifies +GREEN, so a red result is attributable to the tampering and not to a fixture +that never verified in the first place. """ from __future__ import annotations -import json import struct from pathlib import Path @@ -28,17 +28,24 @@ from data.clean.schema import DATE_LEVEL, SYMBOL_LEVEL from qt.panel_freeze import ( - DETERMINISM_FACTORS, MANIFEST_ROW_FIELDS, + RegenerationRetiredError, atomic_write_parquet, canonical_content_hash, file_sha256, - freezable_factor_ids, + main, manifest_row, read_frozen_panel, - reconcile_with_eval_artifact, render_manifest_markdown, - resolve_selection, + retirement_message, + run_panel_freeze, + verify_frozen_panels, +) +from tests.fixtures.frozen_baseline import ( + build_frozen_tree, + make_panel, + patch_manifest, + rewrite_panel, ) @@ -240,114 +247,213 @@ def test_render_manifest_markdown_deterministic_and_full_precision(): assert repr(row["mean"]) in text -def test_determinism_subjects_cover_two_minute_and_one_book(): - assert "value_ep" in DETERMINISM_FACTORS # the book subject - minute = [f for f in DETERMINISM_FACTORS if f.endswith("_20")] - assert len(minute) >= 2 # two minute subjects (incl. the panel-consuming loader) # --------------------------------------------------------------------------- # -# eval-artifact reconciliation +# Retired regeneration entry point # --------------------------------------------------------------------------- # -def _write_eval_json(reports_dir: Path, stem: str, payload: dict) -> None: - document = {"sections": [{"name": "data_coverage", "payload": payload}]} - reports_dir.mkdir(parents=True, exist_ok=True) - (reports_dir / f"{stem}_no_book.json").write_text( - json.dumps(document), encoding="utf-8" - ) - +def test_regeneration_raises_instead_of_quietly_doing_nothing(): + with pytest.raises(RegenerationRetiredError, match="RETIRED"): + run_panel_freeze() -def _matching_payload(processed: pd.Series, declared: list[str]) -> dict: - values = processed.to_numpy(dtype=float) - evaluated = set(processed.index.get_level_values(SYMBOL_LEVEL)) - return { - "panel_rows": int(len(processed)), - "evaluation_periods": int( - pd.unique(processed.index.get_level_values(DATE_LEVEL)).size - ), - "symbols_evaluated": len(evaluated), - "universe_symbols_declared": len(declared), - "dropped_symbols_count": len(set(declared) - evaluated), - "factor_nan_rate": round(1.0 - np.isfinite(values).sum() / len(values), 6), - } +def test_the_retirement_message_names_the_ruling_the_tool_and_the_way_forward(): + with pytest.raises(RegenerationRetiredError) as caught: + run_panel_freeze("config/whatever.yaml") + text = str(caught.value) + assert "owner ruling 2026-07-28, D5 C6" in text + assert "python -m qt.panel_freeze --verify" in text + assert "no longer rebuilds" in text -def test_reconcile_passes_on_exact_match(tmp_path: Path): - processed = _panel([1.0, np.nan, 3.0, 4.0], KEYS) - declared = ["000001.SZ", "600000.SH", "999999.SZ"] - _write_eval_json(tmp_path, "eval_x", _matching_payload(processed, declared)) - out = reconcile_with_eval_artifact("eval_x", processed, declared, tmp_path) - assert out["artifact"] == "eval_x_no_book.json" - assert all(check["ok"] for check in out["checks"].values()) +def test_the_retirement_core_is_composed_not_restated(): + """The shared sentence is authored once and every tool composes it. -def test_reconcile_raises_on_mismatch(tmp_path: Path): - processed = _panel([1.0, np.nan, 3.0, 4.0], KEYS) - declared = ["000001.SZ", "600000.SH"] - payload = _matching_payload(processed, declared) - payload["panel_rows"] += 1 # the artifact claims one more row than we froze - _write_eval_json(tmp_path, "eval_x", payload) - with pytest.raises(ValueError, match="panel_rows"): - reconcile_with_eval_artifact("eval_x", processed, declared, tmp_path) + A regex cannot assert that no other sentence says the same thing; "there is + no other sentence" can (CLAUDE.md methodology #2). So: the core must come + out of ``retirement_message``, and the per-tool clauses must be the ONLY + difference between the three messages. + """ + composed = retirement_message("tool-x", "product-y", "WHY.", "VERIFIES.") + assert composed.startswith("tool-x: regeneration is RETIRED") + assert "WHY." in composed and "VERIFIES." in composed + source = Path(__file__).resolve().parents[1] / "qt" / "panel_freeze.py" + body = source.read_text(encoding="utf-8") + assert body.count("regeneration is RETIRED (") == 1 -def test_reconcile_raises_on_nan_rate_mismatch(tmp_path: Path): - processed = _panel([1.0, np.nan, 3.0, 4.0], KEYS) - declared = ["000001.SZ", "600000.SH"] - payload = _matching_payload(processed, declared) - payload["factor_nan_rate"] = round(payload["factor_nan_rate"] + 1e-6, 6) - _write_eval_json(tmp_path, "eval_x", payload) - with pytest.raises(ValueError, match="factor_nan_rate"): - reconcile_with_eval_artifact("eval_x", processed, declared, tmp_path) +def test_the_old_command_line_still_parses_so_it_gets_the_explanation(capsys): + """``--resume`` / ``--only`` were the documented regeneration flags. Dropping + them from the parser would answer the old command with ``unrecognized + arguments``, which reads like a broken install rather than a retirement.""" + assert main(["--resume", "--only", "value_ep"]) == 1 + assert "RETIRED" in capsys.readouterr().err -def test_reconcile_missing_artifact_is_loud(tmp_path: Path): - processed = _panel([1.0, 2.0, 3.0, 4.0], KEYS) - with pytest.raises(FileNotFoundError, match="no_book.json"): - reconcile_with_eval_artifact("eval_x", processed, ["000001.SZ"], tmp_path) +def test_a_bare_invocation_is_non_zero_and_explains_itself(capsys): + assert main([]) == 1 + captured = capsys.readouterr() + assert "RETIRED" in captured.err and "--verify" in captured.err -def test_reconcile_missing_payload_is_loud(tmp_path: Path): - (tmp_path / "eval_x_no_book.json").write_text( - json.dumps({"sections": [{"name": "other", "payload": {"a": 1}}]}), - encoding="utf-8", +# --------------------------------------------------------------------------- # +# Verification: the green control, then one tampering per failure mode +# --------------------------------------------------------------------------- # +def test_an_untouched_frozen_tree_verifies_green(tmp_path: Path): + doc = build_frozen_tree(tmp_path) + result = verify_frozen_panels(tmp_path, doc) + assert result.ok and result.n_ok == 2 and not result.problems + + +def test_a_changed_cell_is_convicted_and_attributed_to_its_factor(tmp_path: Path): + doc = build_frozen_tree(tmp_path) + assert verify_frozen_panels(tmp_path, doc).ok # green control + + panel = make_panel("alpha_20") + panel.iloc[0] = float(panel.iloc[0]) + 1e-9 + rewrite_panel(tmp_path, "alpha_20", panel) + + result = verify_frozen_panels(tmp_path, doc) + assert not result.ok + bad = {panel.factor_id for panel in result.panels if not panel.ok} + assert bad == {"alpha_20"} + assert any("canonical content hash" in p for p in _problems(result, "alpha_20")) + + +def test_moving_the_machine_manifest_too_does_not_buy_a_pass(tmp_path: Path): + """The failure mode the git/gitignore split exists for: a tree regenerated + together with its own manifest. Both local witnesses now agree with each + other; the git-tracked document must still convict.""" + doc = build_frozen_tree(tmp_path) + panel = make_panel("alpha_20") + panel.iloc[0] = float(panel.iloc[0]) + 1e-9 + rewrite_panel(tmp_path, "alpha_20", panel) + new_hash = canonical_content_hash(panel) + new_file_hash = file_sha256(tmp_path / "panels" / "alpha_20.parquet") + + def _move(document): + for row in document["rows"]: + if row["factor_id"] == "alpha_20": + row.update( + canonical_sha256=new_hash, + file_sha256=new_file_hash, + mean=float(panel.mean()), + std=float(panel.std()), + ) + + patch_manifest(tmp_path, "manifest.json", _move) + result = verify_frozen_panels(tmp_path, doc) + assert not result.ok + assert any("git-tracked" in p for p in _problems(result, "alpha_20")) + + +def test_a_missing_panel_is_convicted(tmp_path: Path): + doc = build_frozen_tree(tmp_path) + (tmp_path / "panels" / "book_x.parquet").unlink() + result = verify_frozen_panels(tmp_path, doc) + assert not result.ok + assert any("missing from disk" in p for p in _problems(result, "book_x")) + + +def test_an_extra_panel_is_convicted_rather_than_ignored(tmp_path: Path): + """An unregistered panel is a problem, not a bonus: the frozen inventory is + defined by the document, so an extra file means someone wrote into the + frozen tree.""" + doc = build_frozen_tree(tmp_path) + atomic_write_parquet(make_panel("intruder_20"), tmp_path / "panels" / "intruder_20.parquet") + result = verify_frozen_panels(tmp_path, doc) + assert not result.ok + assert any("unregistered panel" in p for p in result.problems) + + +def test_a_missing_machine_manifest_is_convicted(tmp_path: Path): + doc = build_frozen_tree(tmp_path) + (tmp_path / "manifest.json").unlink() + result = verify_frozen_panels(tmp_path, doc) + assert not result.ok + assert any("machine manifest missing" in p for p in result.problems) + + +def test_a_producing_sha_that_disagrees_with_the_document_is_convicted(tmp_path: Path): + doc = build_frozen_tree(tmp_path, producing_sha="a" * 40) + patch_manifest( + tmp_path, "manifest.json", + lambda d: d["header"].update(producing_git_sha="b" * 40), ) - processed = _panel([1.0, 2.0, 3.0, 4.0], KEYS) - with pytest.raises(ValueError, match="data_coverage"): - reconcile_with_eval_artifact("eval_x", processed, ["000001.SZ"], tmp_path) + result = verify_frozen_panels(tmp_path, doc) + assert not result.ok + assert any("producing SHA mismatch" in p for p in result.problems) -# --------------------------------------------------------------------------- # -# --only selection (added for the PR-C intraday-cutoff correctness re-freeze) -# --------------------------------------------------------------------------- # -def test_selection_default_is_none_so_the_full_freeze_is_untouched(): - assert resolve_selection(None) is None +def test_a_reconciliation_recorded_as_not_ok_is_convicted(tmp_path: Path): + """A divergent panel must never have been frozen; a manifest edited to say + otherwise is exactly what this check is for.""" + doc = build_frozen_tree(tmp_path) + def _fail_one(document): + document["reconciliation"]["alpha_20"]["checks"]["panel_rows"]["ok"] = False -def test_selection_keeps_the_given_order_and_accepts_book_and_minute_ids(): - assert resolve_selection(["jump_amount_corr_20", "value_ep"]) == ( - "jump_amount_corr_20", - "value_ep", - ) + patch_manifest(tmp_path, "manifest.json", _fail_one) + result = verify_frozen_panels(tmp_path, doc) + assert not result.ok + assert any("recorded as NOT ok" in p for p in result.problems) -def test_selection_rejects_an_unknown_factor_id(): - """A typo must cost a readable error, not an empty output root reported as - success — "froze 0 panels" is how a correctness re-freeze silently produces - nothing at all.""" - with pytest.raises(ValueError, match="does not produce"): - resolve_selection(["jump_amount_corr"]) # missing the _20 window suffix +def test_a_dropped_reconciliation_check_is_convicted(tmp_path: Path): + doc = build_frozen_tree(tmp_path) + patch_manifest( + tmp_path, "manifest.json", + lambda d: d["reconciliation"]["alpha_20"]["checks"].pop("factor_nan_rate"), + ) + result = verify_frozen_panels(tmp_path, doc) + assert not result.ok + assert any("missing the 'factor_nan_rate' check" in p for p in result.problems) + + +def test_a_rewritten_but_content_identical_file_is_still_reported(tmp_path: Path): + """Same values, new bytes. A frozen-forever file has no legitimate reason to + be rewritten, so this is reported — and the message says which of the two it + is, so nobody reads it as a value change.""" + doc = build_frozen_tree(tmp_path) + target = tmp_path / "panels" / "alpha_20.parquet" + before = file_sha256(target) + frame = pd.read_parquet(target) + frame.to_parquet(target, engine="pyarrow", index=False, compression="gzip") + assert file_sha256(target) != before # the mutation landed + + result = verify_frozen_panels(tmp_path, doc) + assert not result.ok + problems = _problems(result, "alpha_20") + assert any("file sha256" in p and "CONTENT still matches" in p for p in problems) + assert not any("canonical content hash" in p for p in problems) + + +def test_verify_writes_nothing_into_the_tree_it_checks(tmp_path: Path): + doc = build_frozen_tree(tmp_path, with_d2=True) + before = { + path: (path.stat().st_mtime_ns, path.stat().st_size) + for path in sorted(tmp_path.rglob("*")) if path.is_file() + } + assert verify_frozen_panels(tmp_path, doc, show_manifest=True).ok + after = { + path: (path.stat().st_mtime_ns, path.stat().st_size) + for path in sorted(tmp_path.rglob("*")) if path.is_file() + } + assert after == before -def test_selection_rejects_an_empty_selection(): - with pytest.raises(ValueError, match="empty selection"): - resolve_selection([]) +def test_show_manifest_renders_the_table_from_the_panels_on_disk(tmp_path: Path): + doc = build_frozen_tree(tmp_path) + result = verify_frozen_panels(tmp_path, doc, show_manifest=True) + assert result.rendered_manifest is not None + assert "alpha_20" in result.rendered_manifest + assert verify_frozen_panels(tmp_path, doc).rendered_manifest is None -def test_freezable_ids_are_the_14_the_freeze_writes(): - ids = freezable_factor_ids() - assert len(ids) == 14 - assert {"value_ep", "value_bp", "volatility_20"} <= ids - # Every determinism subject must be freezable, or --only could silently drop - # the double-run subject the manifest still claims to have checked. - assert set(DETERMINISM_FACTORS) <= ids +def _problems(result, factor_id: str) -> list[str]: + return [ + problem + for panel in result.panels + if panel.factor_id == factor_id + for problem in panel.problems + ] From 7b1237bd1b29df3a41eb40c5ab0e7ddb36c3b266 Mon Sep 17 00:00:00 2001 From: shaofl <2899218482@qq.com> Date: Thu, 30 Jul 2026 14:31:50 -0700 Subject: [PATCH 02/10] refactor(factors): retire the D2 panel rebuild, re-derive its verdict 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. --- qt/panel_reconcile.py | 431 ++++++++++++++++++------------- tests/test_panel_reconcile_d2.py | 178 ++++++++++++- 2 files changed, 419 insertions(+), 190 deletions(-) diff --git a/qt/panel_reconcile.py b/qt/panel_reconcile.py index 768a672..7aefe6c 100644 --- a/qt/panel_reconcile.py +++ b/qt/panel_reconcile.py @@ -1,10 +1,9 @@ -"""D2 cell-by-cell reconciliation of the migrated engine vs the D1 frozen baseline. +"""D2 cell-by-cell panel reconciliation — VERIFY-ONLY since D5 C6. -Design v3.2 §五 leg 4 (R11), pulled forward into D2 by the task card: rebuild -all 14 closing factors' RAW panels on the CURRENT tree (the ``qt.panel_freeze`` -recipes route through the ``data.clean`` shims, so they exercise the migrated -``factors.compute.minute`` math automatically), write them to a SEPARATE -directory, and compare each panel CELL BY CELL against the frozen D1 baseline: +D2 rewrote the factor math into ``factors.compute.minute``. To show the rewrite +changed no value, all 14 closing factors' RAW panels were rebuilt on the D2 tree +into ``artifacts/refactor_baseline/panels_d2`` and compared CELL BY CELL against +the D1 frozen baseline: * the (date, symbol) index sets must be IDENTICAL; * the NaN sets must be IDENTICAL (a NaN-set change would be an undeclared @@ -13,46 +12,76 @@ ``|new - old| / max(|old|, |new|)`` must be <= 1e-12 (the float-reordering budget; any excess means STOP AND FIX THE ENGINE, never widen the budget). +It passed 14/14 at ``max_rel_diff = 0.0`` — bit-identical, canonical hashes +equal. Both sides are now frozen. + +REGENERATION IS RETIRED (owner ruling 2026-07-28, D5 C6) +--------------------------------------------------------- +The rebuild ran the ``qt.panel_freeze`` recipes, i.e. the eleven legacy eval +runners' private ``_load_*_panel`` loaders, which C6 deletes. Retired rather +than left to break on first use. + +What survives is the part that never needed the runners: **the comparison +itself**. ``--verify`` re-derives the D2 verdict from the frozen bytes of both +sides — it re-reads the two panel sets from disk and re-runs +:func:`compare_panels` over all 14 factors. That is a real check, not a +restatement of a recorded one: if either frozen tree drifted, the cells no +longer match and the run goes red. + Anti-empty-reconciliation provenance (the ``compare_postmerge.py`` lesson): -* the frozen side is NEVER regenerated here — it is read from - ``artifacts/refactor_baseline/panels`` exactly as D1 froze it, and each - frozen file's canonical content hash is first checked against the D1 - ``manifest.json`` (a stale/clobbered baseline fails loudly before any - comparison is trusted); -* the comparison reads BOTH sides from disk through two independent file - reads — the freshly built panel is written to - ``artifacts/refactor_baseline/panels_d2`` first and read back, so no shared - in-memory object can make the equality vacuous. - -Run: ``python -m qt.panel_reconcile`` (deliberately NOT registered in qt/cli; -``--resume`` reads back already-written panels_d2 files after checking that -they were produced at the SAME git SHA recorded in ``manifest_d2.json``). +* neither side is ever regenerated here; +* both sides are read from disk through two INDEPENDENT file reads, so no + shared in-memory object can make the equality vacuous; +* before any cell is compared, both sides are checked against manifests — the + D2 panels against the git-tracked D1 document (they are expected to be + bit-identical to it, which IS the D2 result) and against their own + ``manifest_d2.json``. + +Run (deliberately NOT registered in qt/cli, as before):: + + python -m qt.panel_reconcile --verify """ from __future__ import annotations import argparse import json -import time +import sys from dataclasses import dataclass from pathlib import Path import numpy as np import pandas as pd -from data.clean.schema import DATE_LEVEL +from qt.frozen_manifest_doc import parse_doc_manifest from qt.panel_freeze import ( + D1_MANIFEST_DOC, DEFAULT_CONFIG, PANEL_STORE_NAME, - atomic_write_parquet, - atomic_write_text, + RegenerationRetiredError, canonical_content_hash, - minute_recipes, read_frozen_panel, - _git_head_sha, + repo_root_for, + retirement_message, + verify_frozen_panels, ) +__all__ = [ + "CellComparison", + "D2_SUBDIR", + "DEFAULT_BASELINE_ROOT", + "DEFAULT_CONFIG", + "PANEL_STORE_NAME", + "RELATIVE_TOLERANCE", + "ReconcileVerification", + "compare_panels", + "main", + "render_report", + "run_panel_reconcile", + "verify_d2_reconciliation", +] + DEFAULT_BASELINE_ROOT = "artifacts/refactor_baseline" D2_SUBDIR = "panels_d2" RELATIVE_TOLERANCE = 1e-12 @@ -123,29 +152,6 @@ def compare_panels(frozen: pd.Series, new: pd.Series, factor_id: str) -> CellCom ) -def _verify_frozen_against_manifest(baseline_root: Path) -> dict[str, str]: - """Check every frozen panel's canonical hash against the D1 manifest. - - Returns {factor_id: canonical_hash}. Any mismatch raises — a clobbered or - regenerated baseline must never be silently reconciled against. - """ - manifest = json.loads((baseline_root / "manifest.json").read_text(encoding="utf-8")) - frozen_hashes: dict[str, str] = {} - for row in manifest["rows"]: - factor_id = row["factor_id"] - path = baseline_root / "panels" / row["file"] - frozen = read_frozen_panel(path, factor_id) - actual = canonical_content_hash(frozen) - if actual != row["canonical_sha256"]: - raise RuntimeError( - f"frozen baseline {row['file']} canonical hash {actual} does not " - f"match the D1 manifest ({row['canonical_sha256']}) — the baseline " - "has been altered since the freeze; refusing to reconcile." - ) - frozen_hashes[factor_id] = actual - return frozen_hashes - - def render_report(rows: list[CellComparison], header: dict) -> str: lines = ["# D2 panel reconciliation vs the D1 frozen baseline", ""] for key in sorted(header): @@ -169,162 +175,221 @@ def render_report(rows: list[CellComparison], header: dict) -> str: return "\n".join(lines) -def run_panel_reconcile( - config_path: str = DEFAULT_CONFIG, - baseline_root: str | Path = DEFAULT_BASELINE_ROOT, - *, - resume: bool = False, -) -> list[CellComparison]: - """Rebuild the 14 RAW panels on the current tree and reconcile vs the freeze.""" - from qt.config import load_config - from qt.eval_jump_amount_corr import _build_book_factors, _check_preconditions - from qt.pipeline import ( - _build_cache, - _build_universe, - _load_panel, - _log_run_cache_stats, - _make_logger, - _maybe_enrich_covariates, - _maybe_enrich_value, - ) +# --------------------------------------------------------------------------- # +# Verification +# --------------------------------------------------------------------------- # +@dataclass(frozen=True) +class ReconcileVerification: + """Outcome of re-deriving the D2 verdict from the two frozen panel sets.""" + + baseline_root: Path + comparisons: tuple[CellComparison, ...] + problems: tuple[str, ...] + d1_ok: bool + recorded_all_ok: bool | None - started = time.monotonic() - root = Path(baseline_root) - d2_dir = root / D2_SUBDIR - d2_dir.mkdir(parents=True, exist_ok=True) - head_sha = _git_head_sha() - - # provenance for --resume: a panels_d2 file may only be reused if it was - # produced at the SAME git SHA (else it might carry an older engine). - manifest_d2_path = root / "manifest_d2.json" - prior: dict = {} - if manifest_d2_path.exists(): - prior = json.loads(manifest_d2_path.read_text(encoding="utf-8")) - if resume and prior and prior.get("producing_git_sha") != head_sha: - raise RuntimeError( - f"--resume refused: existing panels_d2 were produced at " - f"{prior.get('producing_git_sha')} but HEAD is {head_sha}; delete " - f"{d2_dir} to rebuild from scratch." + @property + def ok(self) -> bool: + return ( + self.d1_ok + and not self.problems + and bool(self.comparisons) + and all(c.ok for c in self.comparisons) ) - frozen_hashes = _verify_frozen_against_manifest(root) - cfg = load_config(config_path) - _check_preconditions(cfg) - cfg = cfg.model_copy( - update={"data": cfg.data.model_copy(update={"output_name": PANEL_STORE_NAME})} - ) - log_path = Path(cfg.output.log_dir) / "panel_reconcile.log" - logger = _make_logger(log_path, name="qt.panel_reconcile") - logger.info("panel reconcile: config=%s baseline_root=%s sha=%s", - config_path, root, head_sha) - - cache = _build_cache(cfg) - universe, symbols = _build_universe(cfg, logger, cache) - panel = _load_panel(cfg, symbols, logger, cache) - book_factors = _build_book_factors() - panel = _maybe_enrich_value(cfg, panel, symbols, book_factors, logger, cache) - panel = _maybe_enrich_covariates(cfg, panel, symbols, logger, cache) - _log_run_cache_stats(cache, logger) - panel_dates = pd.Index( - pd.unique(panel.index.get_level_values(DATE_LEVEL)), name=DATE_LEVEL - ) +def verify_d2_reconciliation( + baseline_root: Path | str = DEFAULT_BASELINE_ROOT, + doc_path: Path | str | None = None, +) -> ReconcileVerification: + """Re-derive the D2 cell-by-cell verdict from the frozen bytes of both sides. - results: list[CellComparison] = [] - d2_rows: list[dict] = [] + Order matters. The D1 side is verified FIRST, against the git-tracked + document (delegated to :func:`qt.panel_freeze.verify_frozen_panels`, so + there is one implementation of "is this the frozen baseline?"). Comparing + two panel sets while neither has been authenticated would report agreement + between two unknowns. - def _reconcile_one(factor_id: str, raw: pd.Series | None) -> None: - target = d2_dir / f"{factor_id}.parquet" - if raw is None: # resume path: reuse the already-written d2 panel - logger.info("resume: reading back %s", target.name) - else: - atomic_write_parquet(raw.rename(factor_id), target) - new = read_frozen_panel(target, factor_id) # file read #1 - frozen = read_frozen_panel(root / "panels" / f"{factor_id}.parquet", factor_id) # #2 - comp = compare_panels(frozen, new, factor_id) - results.append(comp) - d2_rows.append( - {"factor_id": factor_id, "canonical_sha256": canonical_content_hash(new), - "frozen_sha256": frozen_hashes[factor_id], "rows": comp.rows} - ) - logger.info( - "reconciled %s: ok=%s max_rel=%.3e nan_frozen_only=%d nan_new_only=%d", - factor_id, comp.ok, comp.max_rel_diff, - comp.nan_only_in_frozen, comp.nan_only_in_new, + The D2 side is then checked against the SAME git-tracked hashes. That is 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 that lives in git. + + ``doc_path`` overrides which manifest document supplies the expectations; + it exists so the tests can build a small frozen tree with its own document + instead of being forced through the 14 real factors. + """ + baseline_root = Path(baseline_root) + doc = Path(doc_path) if doc_path is not None else _doc_path(baseline_root) + problems: list[str] = [] + + d1 = verify_frozen_panels(baseline_root, doc) + for problem in d1.problems: + problems.append(f"D1 side: {problem}") + for panel in d1.panels: + for problem in panel.problems: + problems.append(f"D1 side {panel.factor_id}: {problem}") + + expected = parse_doc_manifest(doc) + d2_dir = baseline_root / D2_SUBDIR + if not d2_dir.is_dir(): + problems.append(f"D2 panel directory not found: {d2_dir}") + return ReconcileVerification( + baseline_root=baseline_root, + comparisons=(), + problems=tuple(problems), + d1_ok=d1.ok, + recorded_all_ok=None, ) - # book factors (raw, pre-processing) — same call shape as the freeze - for factor in book_factors: - target = d2_dir / f"{factor.name}.parquet" - if resume and target.exists(): - _reconcile_one(factor.name, None) - else: - _reconcile_one(factor.name, factor.compute(panel).rename(factor.name)) - - # minute factors — the freeze recipes, now routed through the D2 shims - live_total = 0 - for recipe in minute_recipes(): - target = d2_dir / f"{recipe.factor_id}.parquet" - if resume and target.exists(): - _reconcile_one(recipe.factor_id, None) + on_disk = {path.stem for path in d2_dir.glob("*.parquet")} + for extra in sorted(on_disk - set(expected)): + problems.append(f"unregistered D2 panel on disk: {extra}.parquet") + + recorded_rows, recorded_all_ok = _read_manifest_d2(d2_dir.parent, problems) + + comparisons: list[CellComparison] = [] + for factor_id in sorted(expected): + d2_path = d2_dir / f"{factor_id}.parquet" + d1_path = baseline_root / "panels" / f"{factor_id}.parquet" + if not d2_path.exists() or not d1_path.exists(): + problems.append( + f"{factor_id}: cannot compare — missing " + f"{'D2' if not d2_path.exists() else 'D1'} panel on disk." + ) continue - load = recipe.build(cfg, symbols, panel, logger) - live = int(load.live_calls) - live_total += live - if live != 0: - raise RuntimeError( - f"{recipe.factor_id}: stk_mins_live_calls={live} != 0 — the " - "reconciliation is cache-only by contract." + # TWO independent file reads; no shared in-memory object. + new = read_frozen_panel(d2_path, factor_id) + frozen = read_frozen_panel(d1_path, factor_id) + comparison = compare_panels(frozen, new, factor_id) + comparisons.append(comparison) + + d2_hash = canonical_content_hash(new) + if d2_hash != expected[factor_id].canonical_sha256: + problems.append( + f"{factor_id}: D2 panel canonical hash {d2_hash} != the git-tracked " + f"{expected[factor_id].canonical_sha256}. D2 concluded the rewrite " + "was bit-identical, so these must agree." ) - raw = load.factor[ - load.factor.index.get_level_values(DATE_LEVEL).isin(panel_dates) - ].rename(recipe.factor_id) - _reconcile_one(recipe.factor_id, raw) - - header = { - "producing_git_sha": head_sha, - "config": str(config_path), - "baseline_root": str(root), - "relative_tolerance": repr(RELATIVE_TOLERANCE), - "stk_mins_live_calls": live_total, - "panels": len(results), - "all_ok": all(r.ok for r in results), - "elapsed_seconds": round(time.monotonic() - started, 1), - "generated_utc": pd.Timestamp.now(tz="UTC").strftime("%Y-%m-%d %H:%M:%S"), - } - atomic_write_text( - json.dumps({"producing_git_sha": head_sha, "rows": d2_rows, "header": header}, - indent=2, sort_keys=True), - manifest_d2_path, + recorded = recorded_rows.get(factor_id) + if recorded is None: + problems.append(f"{factor_id}: no row in manifest_d2.json") + else: + if recorded.get("canonical_sha256") != d2_hash: + problems.append( + f"{factor_id}: manifest_d2.json records canonical " + f"{recorded.get('canonical_sha256')}, panel gives {d2_hash}" + ) + if recorded.get("rows") != comparison.rows: + problems.append( + f"{factor_id}: manifest_d2.json records rows " + f"{recorded.get('rows')!r}, panel has {comparison.rows}" + ) + + if recorded_all_ok is False: + problems.append( + "manifest_d2.json records all_ok=False — the frozen D2 reconciliation " + "did not pass, and nothing downstream may treat it as if it had." + ) + return ReconcileVerification( + baseline_root=baseline_root, + comparisons=tuple(comparisons), + problems=tuple(problems), + d1_ok=d1.ok, + recorded_all_ok=recorded_all_ok, + ) + + +def _doc_path(baseline_root: Path) -> Path: + return repo_root_for(baseline_root) / D1_MANIFEST_DOC + + +def _read_manifest_d2( + root: Path, problems: list[str] +) -> tuple[dict[str, dict], bool | None]: + path = root / "manifest_d2.json" + if not path.exists(): + problems.append(f"D2 manifest missing: {path}") + return {}, None + document = json.loads(path.read_text(encoding="utf-8")) + rows = {str(row["factor_id"]): row for row in document.get("rows", [])} + return rows, document.get("header", {}).get("all_ok") + + +# --------------------------------------------------------------------------- # +# Retired regeneration entry point +# --------------------------------------------------------------------------- # +def run_panel_reconcile(*args, **kwargs): + """RETIRED (D5 C6). Always raises :class:`RegenerationRetiredError`. + + Kept as a stub so a stale caller gets the retirement explanation rather than + an ``AttributeError`` that reads like a packaging accident. + """ + raise RegenerationRetiredError( + retirement_message( + "python -m qt.panel_reconcile", + "the D2 rebuilt panel set (`panels_d2`), which is frozen-forever", + "The rebuild ran the qt.panel_freeze recipes, i.e. the eleven legacy " + "eval runners' private `_load_*_panel` loaders, which C6 deletes.", + "the COMPARISON never needed those loaders, so it still runs — it " + "re-reads both frozen panel sets from disk and re-derives the " + "cell-by-cell D2 verdict.", + ) ) - atomic_write_text(render_report(results, header), root / "reconcile_d2.md") - logger.info("panel reconcile complete: all_ok=%s (%ss)", - header["all_ok"], header["elapsed_seconds"]) - return results def main(argv: list[str] | None = None) -> int: + """CLI: ``python -m qt.panel_reconcile --verify``. + + ``--resume`` is still accepted by the parser so the old documented command + produces the retirement explanation rather than ``unrecognized arguments``, + which reads like a version mismatch. + """ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) - parser.add_argument("--config", default=DEFAULT_CONFIG) + parser.add_argument("--config", default=DEFAULT_CONFIG, help=argparse.SUPPRESS) parser.add_argument("--baseline-root", default=DEFAULT_BASELINE_ROOT) + parser.add_argument("--resume", action="store_true", help=argparse.SUPPRESS) parser.add_argument( - "--resume", action="store_true", - help="reuse already-written panels_d2 files (same-SHA guarded)", + "--doc", + default=None, + help="manifest document supplying the expected hashes " + f"(default: {D1_MANIFEST_DOC})", ) - args = parser.parse_args(argv) - results = run_panel_reconcile( - args.config, args.baseline_root, resume=args.resume + parser.add_argument( + "--verify", + action="store_true", + help="re-derive the D2 cell-by-cell verdict from the frozen bytes", ) - for r in results: + args = parser.parse_args(argv) + + if not args.verify: + try: + run_panel_reconcile() + except RegenerationRetiredError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + + result = verify_d2_reconciliation(args.baseline_root, args.doc) + for comparison in result.comparisons: print( - f"{'OK ' if r.ok else 'MISMATCH'} {r.factor_id}: rows={r.rows} " - f"max_rel={r.max_rel_diff:.3e} max_abs={r.max_abs_diff:.3e} " - f"nan_frozen_only={r.nan_only_in_frozen} nan_new_only={r.nan_only_in_new} " - f"hash_equal={r.hashes_equal}" + f"{'OK ' if comparison.ok else 'MISMATCH'} {comparison.factor_id}: " + f"rows={comparison.rows} max_rel={comparison.max_rel_diff:.3e} " + f"max_abs={comparison.max_abs_diff:.3e} " + f"nan_frozen_only={comparison.nan_only_in_frozen} " + f"nan_new_only={comparison.nan_only_in_new} " + f"hash_equal={comparison.hashes_equal}" ) - ok = all(r.ok for r in results) - print(f"reconcile: {'ALL OK' if ok else 'MISMATCH — stop and fix the engine'}") - return 0 if ok else 1 + for problem in result.problems: + print(f" PROBLEM {problem}") + print( + f"D1 side verified: {result.d1_ok}; recorded all_ok: {result.recorded_all_ok}; " + f"{len(result.comparisons)} factors re-compared" + ) + print( + "panel reconcile verification: " + + ("OK" if result.ok else "FAILED — stop and investigate the frozen trees") + ) + return 0 if result.ok else 1 if __name__ == "__main__": # pragma: no cover - thin CLI shim diff --git a/tests/test_panel_reconcile_d2.py b/tests/test_panel_reconcile_d2.py index 8b6d65f..428c49e 100644 --- a/tests/test_panel_reconcile_d2.py +++ b/tests/test_panel_reconcile_d2.py @@ -1,18 +1,39 @@ -"""Teeth tests for the D2 cell-by-cell panel comparator (network-free). - -A reconciliation whose comparator cannot fail is the ``compare_postmerge.py`` -failure mode; these tests feed the comparator engineered defects and assert it -CONVICTS each one — and that the one legitimate difference class (float -reordering within 1e-12) passes without being confused with hash equality. +"""Teeth tests for the D2 panel reconciliation (network-free, VERIFY-ONLY era). + +Two halves: + +* the cell-by-cell comparator — a reconciliation whose comparator cannot fail is + the ``compare_postmerge.py`` failure mode, so it is fed engineered defects and + must CONVICT each one, while the one legitimate difference class (float + reordering within 1e-12) passes without being confused with hash equality; +* the verify-only entry point, which re-derives the frozen D2 verdict from the + two frozen panel sets. Its tests always establish a GREEN tree first, so a red + result is attributable to the tampering rather than to a comparator that is + red on everything. """ from __future__ import annotations +from pathlib import Path + import numpy as np import pandas as pd import pytest -from qt.panel_reconcile import RELATIVE_TOLERANCE, compare_panels +from qt.panel_freeze import RegenerationRetiredError, canonical_content_hash +from qt.panel_reconcile import ( + RELATIVE_TOLERANCE, + compare_panels, + main, + run_panel_reconcile, + verify_d2_reconciliation, +) +from tests.fixtures.frozen_baseline import ( + build_frozen_tree, + make_panel, + patch_manifest, + rewrite_panel, +) def _series(values, dates=None, symbols=("AAA", "BBB")): @@ -78,3 +99,146 @@ def test_sign_flip_at_zero_magnitude_is_within_denominator_rule(): b2.iloc[0] = 1e-30 comp2 = compare_panels(a, b2, "f") assert not comp2.ok and comp2.max_rel_diff == pytest.approx(1.0) + + +# --------------------------------------------------------------------------- # +# Retired regeneration entry point +# --------------------------------------------------------------------------- # +def test_the_rebuild_raises_instead_of_quietly_doing_nothing(): + with pytest.raises(RegenerationRetiredError, match="RETIRED"): + run_panel_reconcile() + + +def test_the_retirement_message_says_the_comparison_survives(): + """The three retired tools do NOT verify the same kind of thing, so each + states its own. Claiming a check it does not perform is the defect this + repository keeps catching.""" + with pytest.raises(RegenerationRetiredError) as caught: + run_panel_reconcile() + text = str(caught.value) + assert "python -m qt.panel_reconcile --verify" in text + assert "COMPARISON never needed those loaders" in text + + +def test_the_old_resume_command_still_parses_so_it_gets_the_explanation(capsys): + assert main(["--resume"]) == 1 + assert "RETIRED" in capsys.readouterr().err + + +# --------------------------------------------------------------------------- # +# Verification: re-deriving the frozen D2 verdict +# --------------------------------------------------------------------------- # +def test_two_intact_frozen_sides_re_derive_the_passing_verdict(tmp_path: Path): + doc = build_frozen_tree(tmp_path, with_d2=True) + result = verify_d2_reconciliation(tmp_path, doc) + assert result.ok and result.d1_ok and result.recorded_all_ok is True + assert len(result.comparisons) == 2 + assert all(c.max_rel_diff == 0.0 and c.hashes_equal for c in result.comparisons) + + +def test_a_drifted_d2_panel_is_convicted(tmp_path: Path): + doc = build_frozen_tree(tmp_path, with_d2=True) + assert verify_d2_reconciliation(tmp_path, doc).ok # green control + + panel = make_panel("alpha_20") + panel.iloc[0] = float(panel.iloc[0]) + 1.0 + rewrite_panel(tmp_path, "alpha_20", panel, d2=True) + + result = verify_d2_reconciliation(tmp_path, doc) + assert not result.ok + convicted = [c for c in result.comparisons if not c.ok] + assert [c.factor_id for c in convicted] == ["alpha_20"] + assert any("D2 panel canonical hash" in p for p in result.problems) + + +def test_a_drifted_d1_side_is_convicted_before_the_cells_are_believed(tmp_path: Path): + """Comparing two panel sets while neither has been authenticated would report + agreement between two unknowns — so a D1 side that no longer matches git + fails the run even though the two sides still agree with EACH OTHER.""" + doc = build_frozen_tree(tmp_path, with_d2=True) + panel = make_panel("alpha_20") + panel.iloc[0] = float(panel.iloc[0]) + 1.0 + rewrite_panel(tmp_path, "alpha_20", panel) + rewrite_panel(tmp_path, "alpha_20", panel, d2=True) + + result = verify_d2_reconciliation(tmp_path, doc) + assert all(c.ok for c in result.comparisons) # the two sides DO agree + assert not result.d1_ok and not result.ok + assert any(p.startswith("D1 side") for p in result.problems) + + +def test_a_nan_set_change_in_the_d2_side_is_convicted(tmp_path: Path): + doc = build_frozen_tree(tmp_path, with_d2=True) + panel = make_panel("alpha_20") + panel.iloc[1] = np.nan + rewrite_panel(tmp_path, "alpha_20", panel, d2=True) + result = verify_d2_reconciliation(tmp_path, doc) + convicted = [c for c in result.comparisons if not c.ok] + assert convicted and convicted[0].nan_only_in_new == 1 + assert not result.ok + + +def test_a_missing_d2_manifest_is_convicted(tmp_path: Path): + doc = build_frozen_tree(tmp_path, with_d2=True) + (tmp_path / "manifest_d2.json").unlink() + result = verify_d2_reconciliation(tmp_path, doc) + assert not result.ok + assert any("D2 manifest missing" in p for p in result.problems) + + +def test_a_recorded_failure_is_never_treated_as_a_pass(tmp_path: Path): + doc = build_frozen_tree(tmp_path, with_d2=True) + patch_manifest(tmp_path, "manifest_d2.json", lambda d: d["header"].update(all_ok=False)) + result = verify_d2_reconciliation(tmp_path, doc) + assert not result.ok + assert any("all_ok=False" in p for p in result.problems) + + +def test_a_d2_manifest_row_that_disagrees_with_its_panel_is_convicted(tmp_path: Path): + doc = build_frozen_tree(tmp_path, with_d2=True) + + def _lie(document): + for row in document["rows"]: + if row["factor_id"] == "alpha_20": + row["canonical_sha256"] = "f" * 64 + + patch_manifest(tmp_path, "manifest_d2.json", _lie) + result = verify_d2_reconciliation(tmp_path, doc) + assert not result.ok + assert any("manifest_d2.json records canonical" in p for p in result.problems) + + +def test_an_absent_d2_directory_cannot_pass_by_having_nothing_to_compare(tmp_path: Path): + """Zero comparisons is not a passing reconciliation.""" + doc = build_frozen_tree(tmp_path, with_d2=False) + result = verify_d2_reconciliation(tmp_path, doc) + assert not result.ok and result.comparisons == () + + +def test_verify_writes_nothing_into_the_frozen_trees(tmp_path: Path): + doc = build_frozen_tree(tmp_path, with_d2=True) + before = { + path: (path.stat().st_mtime_ns, path.stat().st_size) + for path in sorted(tmp_path.rglob("*")) if path.is_file() + } + assert verify_d2_reconciliation(tmp_path, doc).ok + after = { + path: (path.stat().st_mtime_ns, path.stat().st_size) + for path in sorted(tmp_path.rglob("*")) if path.is_file() + } + assert after == before + + +def test_the_two_sides_are_read_from_disk_independently(tmp_path: Path): + """Both sides come from their own file read; no shared in-memory object can + make the equality vacuous. Shown by making the two files differ: if one read + were reused for both, this could not be detected.""" + doc = build_frozen_tree(tmp_path, with_d2=True) + panel = make_panel("book_x", offset=1.0) + panel.iloc[2] = float(panel.iloc[2]) + 7.0 + rewrite_panel(tmp_path, "book_x", panel, d2=True) + assert canonical_content_hash(panel) != canonical_content_hash(make_panel("book_x", offset=1.0)) + + result = verify_d2_reconciliation(tmp_path, doc) + convicted = [c for c in result.comparisons if not c.ok] + assert [c.factor_id for c in convicted] == ["book_x"] From a2348cc18ddad47ac6c2733ce29d68d3b473b92b Mon Sep 17 00:00:00 2001 From: shaofl <2899218482@qq.com> Date: Thu, 30 Jul 2026 14:31:50 -0700 Subject: [PATCH 03/10] refactor(factors): retire the daily hand-anchor engine rebuild 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. --- qt/hand_anchors_engine_values.py | 274 ++++++++++++++++++++++--------- tests/test_hand_anchor_record.py | 202 +++++++++++++++++++++++ 2 files changed, 402 insertions(+), 74 deletions(-) create mode 100644 tests/test_hand_anchor_record.py diff --git a/qt/hand_anchors_engine_values.py b/qt/hand_anchors_engine_values.py index 8f27af5..2e63ea3 100644 --- a/qt/hand_anchors_engine_values.py +++ b/qt/hand_anchors_engine_values.py @@ -1,98 +1,224 @@ -"""Engine-side comparison for the four daily hand anchors (D2, R12 companion). +"""Daily hand-anchor engine comparison — VERIFY-ONLY since D5 C6. ``qt.hand_anchors_d2`` / ``qt.hand_anchor_rows`` hand-compute momentum_20 / reversal_20 / liquidity_20 / overnight_mom_20 anchor rows WITHOUT importing the engine and leave them in ``hand_anchors_d2.json`` under -``daily_pending_engine``. This companion is the one place ALLOWED to import -the engine: it rebuilds the freeze data plane (same config, cache-only), runs -the ops-rewritten factor classes, and compares each pending row at the same -<= 1e-12 relative tolerance. The independence requirement binds the HAND side, -not the comparer (module docstring of hand_anchors_d2). - -Run AFTER ``python -m qt.hand_anchor_rows``: - - python -m qt.hand_anchors_engine_values +``daily_pending_engine``. This companion used to be the one place ALLOWED to +import the engine: it rebuilt the freeze data plane (same config, cache-only), +ran the ops-rewritten factor classes, and wrote each comparison back into the +JSON under ``daily_engine_compared`` / ``all_ok_daily``. + +REGENERATION IS RETIRED (owner ruling 2026-07-28, D5 C6) +--------------------------------------------------------- +The rebuild imported a legacy runner's precondition check and re-derived the +whole data plane behind it; C6 deletes that runner. Two honest notes about the +scope of this particular retirement, because overstating it would be the very +defect this repository keeps catching: + +* the dependency on the legacy runners was THIN here — one precondition helper — + not the eleven private loaders that ``qt.panel_freeze`` called. Retiring this + tool therefore gives up a capability that was not, on its own, doomed; +* the four daily factor classes themselves are untouched and keep their own + tests. What is retired is this hand-vs-engine COMPARISON, not the factors. + +What ``--verify`` does +---------------------- +It re-derives the recorded comparison: for every row of +``daily_engine_compared`` it recomputes the relative difference from the +recorded ``hand`` / ``engine`` values at the same tolerance and checks the +recorded ``rel_diff`` / ``ok`` / ``all_ok_daily`` against what those values +actually imply, and checks that every pending anchor got a comparison. So a +record edited to turn a mismatch into a pass does not survive; a record whose +NUMBERS were never produced cannot be re-created here at all. + +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 a +verification command that reports success without verifying anything is the +empty-reconciliation shape this repository has already committed once. + +Run:: + + python -m qt.hand_anchors_engine_values --verify """ from __future__ import annotations +import argparse import json +import math import sys +from dataclasses import dataclass from pathlib import Path -import numpy as np - from qt.hand_anchors_d2 import OUT_JSON, TOL +from qt.panel_freeze import RegenerationRetiredError, retirement_message + +#: The four ops-rewritten daily factors this comparison covers. +DAILY_FACTORS = ("momentum_20", "reversal_20", "liquidity_20", "overnight_mom_20") +#: Fields every recorded comparison row must carry. +COMPARED_ROW_FIELDS = ("factor_id", "class", "date", "symbol", "hand", "engine", + "rel_diff", "ok") + + +def relative_difference(hand: float, engine: float) -> float: + """The recorded comparison's own rule, restated once and used by the checker.""" + if math.isfinite(hand) and math.isfinite(engine): + denominator = max(abs(hand), abs(engine)) + return abs(hand - engine) / denominator if denominator > 0 else 0.0 + if math.isnan(hand) and math.isnan(engine): + return 0.0 + return float("inf") + + +@dataclass(frozen=True) +class AnchorRecordVerification: + """Outcome of checking the recorded daily hand-anchor comparison.""" + + path: Path + n_compared: int + n_pending: int + problems: tuple[str, ...] + recorded: bool + + @property + def ok(self) -> bool: + return self.recorded and not self.problems + + +def verify_recorded_comparison(path: Path | str = OUT_JSON) -> AnchorRecordVerification: + """Re-derive the recorded daily hand-anchor comparison from its own numbers.""" + path = Path(path) + problems: list[str] = [] + if not path.exists(): + return AnchorRecordVerification( + path=path, + n_compared=0, + n_pending=0, + problems=(f"anchor record not found: {path}",), + recorded=False, + ) + payload = json.loads(path.read_text(encoding="utf-8")) + pending = payload.get("daily_pending_engine", []) or [] + compared = payload.get("daily_engine_compared", []) or [] + if not compared: + return AnchorRecordVerification( + path=path, + n_compared=0, + n_pending=len(pending), + problems=( + f"{path.name} carries no daily_engine_compared rows " + f"({len(pending)} anchor(s) still pending). The comparison that " + "would fill them in is retired, so this record cannot be " + "completed from this tree.", + ), + recorded=False, + ) + n_bad = 0 + for position, row in enumerate(compared): + missing = [field for field in COMPARED_ROW_FIELDS if field not in row] + if missing: + problems.append(f"row {position}: missing field(s) {missing}") + continue + label = f"{row['factor_id']} {row['date']} {row['symbol']}" + if row["factor_id"] not in DAILY_FACTORS: + problems.append(f"row {position}: unexpected factor id {row['factor_id']!r}") + expected_rel = relative_difference(float(row["hand"]), float(row["engine"])) + recorded_rel = float(row["rel_diff"]) + if not _same_number(expected_rel, recorded_rel): + problems.append( + f"{label}: recorded rel_diff {recorded_rel!r} but hand/engine imply " + f"{expected_rel!r}" + ) + expected_ok = expected_rel <= TOL + if bool(row["ok"]) != expected_ok: + problems.append( + f"{label}: recorded ok={row['ok']!r} but rel {expected_rel!r} vs " + f"tolerance {TOL!r} implies {expected_ok}" + ) + n_bad += 0 if expected_ok else 1 + + recorded_all_ok = payload.get("all_ok_daily") + if recorded_all_ok is None: + problems.append("all_ok_daily is absent from the record") + elif bool(recorded_all_ok) != (n_bad == 0): + problems.append( + f"all_ok_daily is recorded as {recorded_all_ok!r} but the rows imply " + f"{n_bad == 0} ({n_bad} mismatching row(s))" + ) + elif n_bad: + problems.append( + f"{n_bad} recorded row(s) exceed the tolerance — the daily anchors did " + "not agree with the engine." + ) -def main(argv: list[str] | None = None) -> int: - import pandas as pd - - from factors.compute.candidates import ( - LiquidityFactor, - OvernightMomentumFactor, - ReversalFactor, + compared_keys = {(r.get("factor_id"), r.get("date"), r.get("symbol")) for r in compared} + for row in pending: + key = (row.get("factor_id"), row.get("date"), row.get("symbol")) + if key not in compared_keys: + problems.append(f"pending anchor never compared: {key}") + + return AnchorRecordVerification( + path=path, + n_compared=len(compared), + n_pending=len(pending), + problems=tuple(problems), + recorded=True, ) - from factors.compute.momentum import MomentumFactor - from qt.config import load_config - from qt.eval_jump_amount_corr import _check_preconditions - from qt.panel_freeze import DEFAULT_CONFIG, PANEL_STORE_NAME - from qt.pipeline import _build_cache, _build_universe, _load_panel, _make_logger - - payload = json.loads(Path(OUT_JSON).read_text(encoding="utf-8")) - pending = payload.get("daily_pending_engine", []) - if not pending: - print("no pending daily rows; run qt.hand_anchor_rows first") - return 1 - cfg = load_config(DEFAULT_CONFIG) - _check_preconditions(cfg) - cfg = cfg.model_copy( - update={"data": cfg.data.model_copy(update={"output_name": PANEL_STORE_NAME})} + +def _same_number(left: float, right: float) -> bool: + if math.isnan(left) and math.isnan(right): + return True + return left == right + + +def run_engine_comparison(*args, **kwargs): + """RETIRED (D5 C6). Always raises :class:`RegenerationRetiredError`.""" + raise RegenerationRetiredError( + retirement_message( + "python -m qt.hand_anchors_engine_values", + "the daily hand-anchor engine comparison", + "The rebuild imported a legacy eval runner's precondition check and " + "re-derived the whole data plane behind it; C6 deletes that runner. " + "The four daily factor classes are untouched — what is retired is " + "this comparison, not the factors.", + "it re-derives the RECORDED comparison in hand_anchors_d2.json from " + "its own hand/engine numbers. It cannot produce numbers that were " + "never recorded, and says so rather than reporting success.", + ) ) - logger = _make_logger( - Path(cfg.output.log_dir) / "hand_anchors_engine.log", - name="qt.hand_anchors_engine", + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--record", default=str(OUT_JSON)) + parser.add_argument( + "--verify", + action="store_true", + help="re-derive the recorded daily hand-anchor comparison from its own numbers", ) - cache = _build_cache(cfg) - _, symbols = _build_universe(cfg, logger, cache) - panel = _load_panel(cfg, symbols, logger, cache) - - factories = { - "momentum_20": MomentumFactor(window=20), - "reversal_20": ReversalFactor(window=20), - "liquidity_20": LiquidityFactor(window=20), - "overnight_mom_20": OvernightMomentumFactor(window=20), - } - values = {name: f.compute(panel) for name, f in factories.items()} + args = parser.parse_args(argv) - n_bad = 0 - out_rows = [] - for row in pending: - series = values[row["factor_id"]] - key = (pd.Timestamp(row["date"]), row["symbol"]) - engine = float(series.loc[key]) if key in series.index else float("nan") - hand = row["hand"] - if np.isfinite(hand) and np.isfinite(engine): - denom = max(abs(hand), abs(engine)) - rel = abs(hand - engine) / denom if denom > 0 else 0.0 - elif np.isnan(hand) and np.isnan(engine): - rel = 0.0 - else: - rel = float("inf") - ok = rel <= TOL - n_bad += 0 if ok else 1 - out_rows.append({**row, "engine": engine, "rel_diff": rel, "ok": bool(ok)}) - print( - f"{'OK ' if ok else 'FAIL'} {row['factor_id']:18s} {row['class']:12s} " - f"{row['date']} {row['symbol']} hand={hand!r} engine={engine!r} rel={rel:.2e}" - ) + if not args.verify: + try: + run_engine_comparison() + except RegenerationRetiredError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 - payload["daily_engine_compared"] = out_rows - payload["all_ok_daily"] = n_bad == 0 - Path(OUT_JSON).write_text(json.dumps(payload, indent=2), encoding="utf-8") - print(f"daily anchors: {len(out_rows)} rows, {n_bad} mismatches -> {OUT_JSON}") - return 0 if n_bad == 0 else 1 + result = verify_recorded_comparison(args.record) + print( + f"{result.path}: {result.n_compared} recorded comparison(s), " + f"{result.n_pending} pending anchor(s)" + ) + for problem in result.problems: + print(f" PROBLEM {problem}") + if not result.recorded: + print("daily hand-anchor verification: NOT VERIFIED (nothing recorded)") + return 1 + print("daily hand-anchor verification: " + ("OK" if result.ok else "FAILED")) + return 0 if result.ok else 1 if __name__ == "__main__": # pragma: no cover - thin CLI shim diff --git a/tests/test_hand_anchor_record.py b/tests/test_hand_anchor_record.py new file mode 100644 index 0000000..19f4d13 --- /dev/null +++ b/tests/test_hand_anchor_record.py @@ -0,0 +1,202 @@ +"""Tests for the daily hand-anchor record verification (VERIFY-ONLY since C6). + +The comparison that produced ``daily_engine_compared`` is retired, so what is +left to check is whether the RECORD says what its own numbers imply. These tests +pin both directions: an honest record passes, and every way of editing it into a +false pass comes back red. + +The absence case gets its own test because it is the one with a judgement call +in it: nothing recorded exits NON-ZERO with the wording "NOT VERIFIED". 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 this repository has already committed once. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from qt.hand_anchors_d2 import TOL +from qt.hand_anchors_engine_values import ( + DAILY_FACTORS, + AnchorRecordVerification, + main, + relative_difference, + run_engine_comparison, + verify_recorded_comparison, +) +from qt.panel_freeze import RegenerationRetiredError + + +def _row(factor_id="momentum_20", hand=1.0, engine=1.0, **overrides) -> dict: + rel = relative_difference(hand, engine) + row = { + "factor_id": factor_id, + "class": "random", + "date": "2024-05-30", + "symbol": "000001.SZ", + "hand": hand, + "engine": engine, + "rel_diff": rel, + "ok": rel <= TOL, + } + row.update(overrides) + return row + + +def _record(tmp_path: Path, compared: list[dict], pending: list[dict] | None = None, + all_ok: bool | None = None) -> Path: + if all_ok is None: + all_ok = all(bool(row.get("ok")) for row in compared) + path = tmp_path / "hand_anchors_d2.json" + path.write_text( + json.dumps( + { + "seed": 20260724, + "tolerance": TOL, + "daily_pending_engine": pending or [], + "daily_engine_compared": compared, + "all_ok_daily": all_ok, + } + ), + encoding="utf-8", + ) + return path + + +# --------------------------------------------------------------------------- # +# Retired regeneration entry point +# --------------------------------------------------------------------------- # +def test_the_engine_comparison_raises_instead_of_quietly_doing_nothing(): + with pytest.raises(RegenerationRetiredError, match="RETIRED"): + run_engine_comparison() + + +def test_the_retirement_message_does_not_overstate_what_was_lost(): + """This tool leaned on ONE precondition helper, not the eleven private + loaders, and its factors are untouched. Saying otherwise would be the same + class of defect as a report claiming a check it did not run.""" + with pytest.raises(RegenerationRetiredError) as caught: + run_engine_comparison() + text = str(caught.value) + assert "precondition check" in text + assert "four daily factor classes are untouched" in text + assert "python -m qt.hand_anchors_engine_values --verify" in text + # It must NOT claim to check frozen bytes against a git-tracked manifest — + # that is what the two panel tools do, not this one. + assert "git-tracked manifest" not in text + + +def test_a_bare_invocation_is_non_zero_and_explains_itself(capsys): + assert main([]) == 1 + assert "RETIRED" in capsys.readouterr().err + + +# --------------------------------------------------------------------------- # +# Verification of the record +# --------------------------------------------------------------------------- # +def test_an_honest_record_verifies_green(tmp_path: Path): + path = _record(tmp_path, [_row(factor_id=fid) for fid in DAILY_FACTORS]) + result = verify_recorded_comparison(path) + assert result.ok and result.n_compared == 4 and not result.problems + + +def test_a_flipped_ok_flag_is_convicted(tmp_path: Path): + """The record claims a row passed; its own hand/engine numbers say it did + not. This is the edit that a verify-only tool exists to catch.""" + honest = _row(hand=1.0, engine=2.0) + assert honest["ok"] is False # the numbers really do disagree + path = _record(tmp_path, [{**honest, "ok": True}], all_ok=True) + result = verify_recorded_comparison(path) + assert not result.ok + assert any("recorded ok=True" in p for p in result.problems) + + +def test_a_doctored_rel_diff_is_convicted(tmp_path: Path): + path = _record(tmp_path, [_row(hand=1.0, engine=2.0, rel_diff=0.0, ok=True)], + all_ok=True) + result = verify_recorded_comparison(path) + assert not result.ok + assert any("imply" in p for p in result.problems) + + +def test_a_recorded_mismatch_is_reported_even_when_labelled_consistently(tmp_path: Path): + """Row honestly says ok=False and all_ok_daily is honestly False. The record + is self-consistent — and it records a FAILURE, which must not read green.""" + path = _record(tmp_path, [_row(hand=1.0, engine=2.0)]) + result = verify_recorded_comparison(path) + assert not result.ok + assert any("exceed the tolerance" in p for p in result.problems) + + +def test_an_all_ok_flag_that_contradicts_the_rows_is_convicted(tmp_path: Path): + path = _record(tmp_path, [_row(hand=1.0, engine=2.0)], all_ok=True) + result = verify_recorded_comparison(path) + assert not result.ok + assert any("all_ok_daily is recorded as True" in p for p in result.problems) + + +def test_a_pending_anchor_that_was_never_compared_is_convicted(tmp_path: Path): + """A partially filled record must not pass on the strength of the rows that + ARE there.""" + pending = [{"factor_id": "liquidity_20", "date": "2024-05-30", "symbol": "600000.SH"}] + path = _record(tmp_path, [_row()], pending=pending) + result = verify_recorded_comparison(path) + assert not result.ok + assert any("never compared" in p for p in result.problems) + + +def test_a_row_missing_fields_is_convicted(tmp_path: Path): + honest = _row() + honest.pop("engine") + path = _record(tmp_path, [honest], all_ok=True) + result = verify_recorded_comparison(path) + assert not result.ok + assert any("missing field(s)" in p for p in result.problems) + + +def test_an_unexpected_factor_id_is_convicted(tmp_path: Path): + path = _record(tmp_path, [_row(factor_id="not_a_daily_factor")]) + result = verify_recorded_comparison(path) + assert not result.ok + assert any("unexpected factor id" in p for p in result.problems) + + +def test_nothing_recorded_is_not_verified_and_exits_non_zero(tmp_path: Path, capsys): + path = _record(tmp_path, [], pending=[{"factor_id": "momentum_20", + "date": "2024-05-30", + "symbol": "000001.SZ"}]) + result = verify_recorded_comparison(path) + assert isinstance(result, AnchorRecordVerification) + assert not result.recorded and not result.ok and result.n_pending == 1 + assert main(["--verify", "--record", str(path)]) == 1 + assert "NOT VERIFIED (nothing recorded)" in capsys.readouterr().out + + +def test_an_absent_record_file_is_not_verified(tmp_path: Path): + result = verify_recorded_comparison(tmp_path / "nope.json") + assert not result.recorded and not result.ok + + +def test_verify_writes_nothing_into_the_record(tmp_path: Path): + path = _record(tmp_path, [_row(factor_id=fid) for fid in DAILY_FACTORS]) + before = (path.stat().st_mtime_ns, path.stat().st_size) + assert main(["--verify", "--record", str(path)]) == 0 + assert (path.stat().st_mtime_ns, path.stat().st_size) == before + + +@pytest.mark.parametrize( + "hand, engine, expected", + [ + (1.0, 1.0, 0.0), + (0.0, 0.0, 0.0), + (float("nan"), float("nan"), 0.0), + (1.0, float("nan"), float("inf")), + (2.0, 1.0, 0.5), + ], +) +def test_the_relative_difference_rule_matches_the_recorded_one(hand, engine, expected): + assert relative_difference(hand, engine) == expected From 6c45afcd447584fdf596fa28019e4afae0d569ef Mon Sep 17 00:00:00 2001 From: shaofl <2899218482@qq.com> Date: Thu, 30 Jul 2026 14:39:26 -0700 Subject: [PATCH 04/10] test(factors): pin the second witness the mutation run found unguarded 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. --- tests/test_panel_freeze.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/test_panel_freeze.py b/tests/test_panel_freeze.py index 58292b2..34f69cc 100644 --- a/tests/test_panel_freeze.py +++ b/tests/test_panel_freeze.py @@ -457,3 +457,24 @@ def _problems(result, factor_id: str) -> list[str]: if panel.factor_id == factor_id for problem in panel.problems ] + + +def test_a_machine_manifest_row_that_disagrees_with_its_panel_is_convicted(tmp_path: Path): + """The document carries the hashes; the machine manifest carries the fuller + statistical row. This case is the one only the SECOND witness can see: the + document still agrees with the panel, and only the recorded row does not.""" + doc = build_frozen_tree(tmp_path) + assert verify_frozen_panels(tmp_path, doc).ok # green control + + def _lie(document): + for row in document["rows"]: + if row["factor_id"] == "alpha_20": + row["n_nan"] = row["n_nan"] + 7 + + patch_manifest(tmp_path, "manifest.json", _lie) + result = verify_frozen_panels(tmp_path, doc) + assert not result.ok + problems = _problems(result, "alpha_20") + assert any("manifest.json n_nan" in p for p in problems) + # nothing else fired: the git-tracked side is untouched and still agrees + assert not any("git-tracked" in p for p in problems) From 711c0a85758d6c30e0dfd4d082953fa01aedccd9 Mon Sep 17 00:00:00 2001 From: shaofl <2899218482@qq.com> Date: Thu, 30 Jul 2026 14:39:26 -0700 Subject: [PATCH 05/10] refactor(qt): converge the eleven run-eval-* commands on run-factor-eval 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 \ --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. --- qt/cli.py | 480 +++--------------------- qt/factor_eval_reconcile.py | 9 +- tests/test_cli_retired_eval_commands.py | 91 +++++ tests/test_factor_eval_reconcile.py | 4 +- 4 files changed, 146 insertions(+), 438 deletions(-) create mode 100644 tests/test_cli_retired_eval_commands.py diff --git a/qt/cli.py b/qt/cli.py index e8f59da..7a28488 100644 --- a/qt/cli.py +++ b/qt/cli.py @@ -203,195 +203,6 @@ def _cmd_run_phase_i5d_intraday_groups(args: argparse.Namespace) -> int: return 0 -def _cmd_run_eval_jump_amount_corr(args: argparse.Namespace) -> int: - """Run the two real jump-amount-corr factor evaluations (cache-only) + reports.""" - from qt.eval_jump_amount_corr import run_eval_jump_amount_corr - from qt.exec_basis_eval import format_exec_basis_line - - try: - result = run_eval_jump_amount_corr(args.config) - except (ConfigError, ValueError, FileNotFoundError) as exc: - print(f"ERROR: {exc}", file=sys.stderr) - return 1 - nb, wb = result.no_book_metrics, result.with_book_metrics - print( - f"OK run-eval-jump-amount-corr: covered={result.covered_symbols}/" - f"{result.requested_symbols}, stk_mins_live_calls={result.minute_live_calls}, " - f"factor_rows={result.factor_rows} ({result.elapsed:.1f}s)\n" - f"no-book: {nb['deployment']} (predictive={nb['predictive']}) " - f"ic_mean={nb['ic_mean']:.4f} ic_ir={nb['ic_ir']:.3f} N_eff={nb['effective_samples']:.1f}\n" - f"with-book: {wb['deployment']} (incremental={wb['incremental']}) " - f"incr_ic_ir={wb['incremental_ic_ir']:.3f}\n" - f"reports: {result.reports.no_book_md} | {result.reports.with_book_md}\n" - f"dashboards: {result.reports.no_book_dashboard} | " - f"{result.reports.with_book_dashboard}" - ) - print(format_exec_basis_line(result.exec_basis)) - return 0 - - -def _cmd_run_eval_minute_ideal_amplitude(args: argparse.Namespace) -> int: - """Run the two real minute-ideal-amplitude factor evaluations (cache-only) + reports.""" - from qt.eval_minute_ideal_amplitude import run_eval_minute_ideal_amplitude - from qt.exec_basis_eval import format_exec_basis_line - - try: - result = run_eval_minute_ideal_amplitude(args.config) - except (ConfigError, ValueError, FileNotFoundError) as exc: - print(f"ERROR: {exc}", file=sys.stderr) - return 1 - nb, wb = result.no_book_metrics, result.with_book_metrics - print( - f"OK run-eval-minute-ideal-amplitude: covered={result.covered_symbols}/" - f"{result.requested_symbols}, stk_mins_live_calls={result.minute_live_calls}, " - f"factor_rows={result.factor_rows} ({result.elapsed:.1f}s)\n" - f"no-book: {nb['deployment']} (predictive={nb['predictive']}) " - f"ic_mean={nb['ic_mean']:.4f} ic_ir={nb['ic_ir']:.3f} N_eff={nb['effective_samples']:.1f}\n" - f"with-book: {wb['deployment']} (incremental={wb['incremental']}) " - f"incr_ic_ir={wb['incremental_ic_ir']:.3f}\n" - f"reports: {result.reports.no_book_md} | {result.reports.with_book_md}\n" - f"dashboards: {result.reports.no_book_dashboard} | " - f"{result.reports.with_book_dashboard}" - ) - print(format_exec_basis_line(result.exec_basis)) - return 0 - - -def _cmd_run_eval_amp_marginal_anomaly_vol(args: argparse.Namespace) -> int: - """Run the two real amp-marginal-anomaly-vol factor evaluations (cache-only) + reports.""" - from qt.eval_amp_marginal_anomaly_vol import run_eval_amp_marginal_anomaly_vol - from qt.exec_basis_eval import format_exec_basis_line - - try: - result = run_eval_amp_marginal_anomaly_vol(args.config) - except (ConfigError, ValueError, FileNotFoundError) as exc: - print(f"ERROR: {exc}", file=sys.stderr) - return 1 - nb, wb = result.no_book_metrics, result.with_book_metrics - print( - f"OK run-eval-amp-marginal-anomaly-vol: covered={result.covered_symbols}/" - f"{result.requested_symbols}, stk_mins_live_calls={result.minute_live_calls}, " - f"factor_rows={result.factor_rows} ({result.elapsed:.1f}s)\n" - f"no-book: {nb['deployment']} (predictive={nb['predictive']}) " - f"ic_mean={nb['ic_mean']:.4f} ic_ir={nb['ic_ir']:.3f} N_eff={nb['effective_samples']:.1f}\n" - f"with-book: {wb['deployment']} (incremental={wb['incremental']}) " - f"incr_ic_ir={wb['incremental_ic_ir']:.3f}\n" - f"reports: {result.reports.no_book_md} | {result.reports.with_book_md}\n" - f"dashboards: {result.reports.no_book_dashboard} | " - f"{result.reports.with_book_dashboard}" - ) - print(format_exec_basis_line(result.exec_basis)) - return 0 - - -def _cmd_run_eval_volume_peak_count(args: argparse.Namespace) -> int: - """Run the two real volume-peak-count factor evaluations (cache-only) + reports.""" - from qt.eval_volume_peak_count import run_eval_volume_peak_count - from qt.exec_basis_eval import format_exec_basis_line - - try: - result = run_eval_volume_peak_count(args.config) - except (ConfigError, ValueError, FileNotFoundError) as exc: - print(f"ERROR: {exc}", file=sys.stderr) - return 1 - nb, wb = result.no_book_metrics, result.with_book_metrics - print( - f"OK run-eval-volume-peak-count: covered={result.covered_symbols}/" - f"{result.requested_symbols}, stk_mins_live_calls={result.minute_live_calls}, " - f"factor_rows={result.factor_rows} ({result.elapsed:.1f}s)\n" - f"no-book: {nb['deployment']} (predictive={nb['predictive']}) " - f"ic_mean={nb['ic_mean']:.4f} ic_ir={nb['ic_ir']:.3f} N_eff={nb['effective_samples']:.1f}\n" - f"with-book: {wb['deployment']} (incremental={wb['incremental']}) " - f"incr_ic_ir={wb['incremental_ic_ir']:.3f}\n" - f"reports: {result.reports.no_book_md} | {result.reports.with_book_md}\n" - f"dashboards: {result.reports.no_book_dashboard} | " - f"{result.reports.with_book_dashboard}" - ) - print(format_exec_basis_line(result.exec_basis)) - return 0 - - -def _cmd_run_eval_intraday_amp_cut(args: argparse.Namespace) -> int: - """Run the two real intraday-amp-cut factor evaluations (cache-only) + reports.""" - from qt.eval_intraday_amp_cut import run_eval_intraday_amp_cut - from qt.exec_basis_eval import format_exec_basis_line - - try: - result = run_eval_intraday_amp_cut(args.config) - except (ConfigError, ValueError, FileNotFoundError) as exc: - print(f"ERROR: {exc}", file=sys.stderr) - return 1 - nb, wb = result.no_book_metrics, result.with_book_metrics - print( - f"OK run-eval-intraday-amp-cut: covered={result.covered_symbols}/" - f"{result.requested_symbols}, stk_mins_live_calls={result.minute_live_calls}, " - f"factor_rows={result.factor_rows} ({result.elapsed:.1f}s)\n" - f"no-book: {nb['deployment']} (predictive={nb['predictive']}) " - f"ic_mean={nb['ic_mean']:.4f} ic_ir={nb['ic_ir']:.3f} N_eff={nb['effective_samples']:.1f}\n" - f"with-book: {wb['deployment']} (incremental={wb['incremental']}) " - f"incr_ic_ir={wb['incremental_ic_ir']:.3f}\n" - f"reports: {result.reports.no_book_md} | {result.reports.with_book_md}\n" - f"dashboards: {result.reports.no_book_dashboard} | " - f"{result.reports.with_book_dashboard}" - ) - print(format_exec_basis_line(result.exec_basis)) - return 0 - - -def _cmd_run_eval_peak_interval_kurtosis(args: argparse.Namespace) -> int: - """Run the two real peak-interval-kurtosis evaluations (cache-only) + reports.""" - from qt.eval_peak_interval_kurtosis import run_eval_peak_interval_kurtosis - from qt.exec_basis_eval import format_exec_basis_line - - try: - result = run_eval_peak_interval_kurtosis(args.config) - except (ConfigError, ValueError, FileNotFoundError) as exc: - print(f"ERROR: {exc}", file=sys.stderr) - return 1 - nb, wb = result.no_book_metrics, result.with_book_metrics - print( - f"OK run-eval-peak-interval-kurtosis: covered={result.covered_symbols}/" - f"{result.requested_symbols}, stk_mins_live_calls={result.minute_live_calls}, " - f"factor_rows={result.factor_rows} ({result.elapsed:.1f}s)\n" - f"no-book: {nb['deployment']} (predictive={nb['predictive']}) " - f"ic_mean={nb['ic_mean']:.4f} ic_ir={nb['ic_ir']:.3f} N_eff={nb['effective_samples']:.1f}\n" - f"with-book: {wb['deployment']} (incremental={wb['incremental']}) " - f"incr_ic_ir={wb['incremental_ic_ir']:.3f}\n" - f"reports: {result.reports.no_book_md} | {result.reports.with_book_md}\n" - f"dashboards: {result.reports.no_book_dashboard} | " - f"{result.reports.with_book_dashboard}" - ) - print(format_exec_basis_line(result.exec_basis)) - return 0 - - -def _cmd_run_eval_valley_relative_vwap(args: argparse.Namespace) -> int: - """Run the two real valley-relative-VWAP evaluations (cache-only) + reports.""" - from qt.eval_valley_relative_vwap import run_eval_valley_relative_vwap - from qt.exec_basis_eval import format_exec_basis_line - - try: - result = run_eval_valley_relative_vwap(args.config) - except (ConfigError, ValueError, FileNotFoundError) as exc: - print(f"ERROR: {exc}", file=sys.stderr) - return 1 - nb, wb = result.no_book_metrics, result.with_book_metrics - print( - f"OK run-eval-valley-relative-vwap: covered={result.covered_symbols}/" - f"{result.requested_symbols}, stk_mins_live_calls={result.minute_live_calls}, " - f"factor_rows={result.factor_rows} ({result.elapsed:.1f}s)\n" - f"no-book: {nb['deployment']} (predictive={nb['predictive']}) " - f"ic_mean={nb['ic_mean']:.4f} ic_ir={nb['ic_ir']:.3f} N_eff={nb['effective_samples']:.1f}\n" - f"with-book: {wb['deployment']} (incremental={wb['incremental']}) " - f"incr_ic_ir={wb['incremental_ic_ir']:.3f}\n" - f"reports: {result.reports.no_book_md} | {result.reports.with_book_md}\n" - f"dashboards: {result.reports.no_book_dashboard} | " - f"{result.reports.with_book_dashboard}" - ) - print(format_exec_basis_line(result.exec_basis)) - return 0 - - def _fmt_metric(value: object, spec: str = ".4f") -> str: """Format a metric that a SKIPPED section may legitimately leave absent. @@ -409,173 +220,6 @@ def _fmt_metric(value: object, spec: str = ".4f") -> str: return str(value) -def _cmd_run_eval_ridge_minute_return(args: argparse.Namespace) -> int: - """Run the two real ridge-minute-return evaluations (cache-only) + reports.""" - from qt.eval_ridge_minute_return import run_eval_ridge_minute_return - from qt.exec_basis_eval import format_exec_basis_line - - try: - result = run_eval_ridge_minute_return(args.config) - except (ConfigError, ValueError, FileNotFoundError) as exc: - print(f"ERROR: {exc}", file=sys.stderr) - return 1 - nb, wb = result.no_book_metrics, result.with_book_metrics - net = ( - " ".join( - f"{m:g}x={v:+.6f}" for m, v in sorted(nb["net_long_short_by_cost"].items()) - ) - or "n/a" - ) - print( - f"OK run-eval-ridge-minute-return: covered={result.covered_symbols}/" - f"{result.requested_symbols}, stk_mins_live_calls={result.minute_live_calls}, " - f"factor_rows={result.factor_rows} ({result.elapsed:.1f}s)\n" - # Ridge bars are structurally scarce and the return guard narrows them further; - # the realized distribution and the day-validity rate are surfaced so a coverage - # regression is visible. - f"{result.ridge_coverage.render()}\n" - f"no-book: {nb['deployment']} (predictive={nb['predictive']}) " - f"ic_mean={nb['ic_mean']:.4f} ic_ir={nb['ic_ir']:.3f} N_eff={nb['effective_samples']:.1f}\n" - f"with-book: {wb['deployment']} (incremental={wb['incremental']}) " - f"incr_ic_ir={wb['incremental_ic_ir']:.3f}\n" - # sign=-1 makes the frozen layer's aligned_spread_* unreliable (it adds costs back - # instead of deducting them); the sign-agnostic net spreads are printed instead. - f"net long-short by cost (aligned_spread_* UNRELIABLE at sign=-1): {net}\n" - f"turnover={_fmt_metric(nb['long_short_turnover'])} " - f"rank_autocorr_lag1={_fmt_metric(nb['rank_autocorr_lag1'])} " - f"half_life={_fmt_metric(nb['half_life_periods'], '.2f')}\n" - f"reports: {result.reports.no_book_md} | {result.reports.with_book_md}\n" - f"dashboards: {result.reports.no_book_dashboard} | " - f"{result.reports.with_book_dashboard}" - ) - print(format_exec_basis_line(result.exec_basis)) - return 0 - - -def _cmd_run_eval_peak_ridge_amount_ratio(args: argparse.Namespace) -> int: - """Run the two real peak/ridge amount-ratio evaluations (cache-only) + reports.""" - from qt.eval_peak_ridge_amount_ratio import run_eval_peak_ridge_amount_ratio - from qt.exec_basis_eval import format_exec_basis_line - - try: - result = run_eval_peak_ridge_amount_ratio(args.config) - except (ConfigError, ValueError, FileNotFoundError) as exc: - print(f"ERROR: {exc}", file=sys.stderr) - return 1 - nb, wb = result.no_book_metrics, result.with_book_metrics - net = ( - " ".join( - f"{m:g}x={v:+.6f}" for m, v in sorted(nb["net_long_short_by_cost"].items()) - ) - or "n/a" - ) - print( - f"OK run-eval-peak-ridge-amount-ratio: covered={result.covered_symbols}/" - f"{result.requested_symbols}, stk_mins_live_calls={result.minute_live_calls}, " - f"factor_rows={result.factor_rows} ({result.elapsed:.1f}s)\n" - # PEAK bars are the structurally scarce leg of THIS pair (the reverse of PR-J); - # the realized distribution, the day-validity rate and the counterfactual at a - # peak floor of 10 are surfaced so the lowered gate is a number, not a claim. - f"{result.peak_coverage.render()}\n" - f"no-book: {nb['deployment']} (predictive={nb['predictive']}) " - f"ic_mean={nb['ic_mean']:.4f} ic_ir={nb['ic_ir']:.3f} N_eff={nb['effective_samples']:.1f}\n" - f"with-book: {wb['deployment']} (incremental={wb['incremental']}) " - f"incr_ic_ir={wb['incremental_ic_ir']:.3f}\n" - # sign=+1, so the frozen layer's aligned-spread cost-sign defect does NOT apply - # to this factor; net spreads are still printed for sibling comparability. - f"net long-short by cost: {net}\n" - f"ic_rank={_fmt_metric(nb['ic_mean'])} " - f"ic_pearson={_fmt_metric(nb['ic_pearson_mean'])} " - f"monotonicity={_fmt_metric(nb['monotonicity_spearman'])}\n" - f"turnover={_fmt_metric(nb['long_short_turnover'])} " - f"rank_autocorr_lag1={_fmt_metric(nb['rank_autocorr_lag1'])} " - f"half_life={_fmt_metric(nb['half_life_periods'], '.2f')}\n" - f"reports: {result.reports.no_book_md} | {result.reports.with_book_md}\n" - f"dashboards: {result.reports.no_book_dashboard} | " - f"{result.reports.with_book_dashboard}" - ) - print(format_exec_basis_line(result.exec_basis)) - return 0 - - -def _cmd_run_eval_valley_price_quantile(args: argparse.Namespace) -> int: - """Run the two real valley-price-quantile evaluations (cache-only) + reports.""" - from qt.eval_valley_price_quantile import run_eval_valley_price_quantile - from qt.exec_basis_eval import format_exec_basis_line - - try: - result = run_eval_valley_price_quantile(args.config) - except (ConfigError, ValueError, FileNotFoundError) as exc: - print(f"ERROR: {exc}", file=sys.stderr) - return 1 - nb, wb = result.no_book_metrics, result.with_book_metrics - net = ( - " ".join( - f"{m:g}x={v:+.6f}" for m, v in sorted(nb["net_long_short_by_cost"].items()) - ) - or "n/a" - ) - print( - f"OK run-eval-valley-price-quantile: covered={result.covered_symbols}/" - f"{result.requested_symbols}, stk_mins_live_calls={result.minute_live_calls}, " - f"factor_rows={result.factor_rows} ({result.elapsed:.1f}s)\n" - # The reversal neutralization is the structural novelty of this factor; a - # neutralization that silently ate the panel shows up here as a number. - # render() is the single home of this line (catalogue section 3 one-site - # normalization; it used to be an inline f-string here). - f"{result.neutralization.render()}\n" - f"no-book: {nb['deployment']} (predictive={nb['predictive']}) " - f"ic_mean={nb['ic_mean']:.4f} ic_ir={nb['ic_ir']:.3f} N_eff={nb['effective_samples']:.1f}\n" - f"with-book: {wb['deployment']} (incremental={wb['incremental']}) " - f"incr_ic_ir={wb['incremental_ic_ir']:.3f}\n" - # sign=+1, so the frozen layer's aligned-spread cost-sign defect does NOT apply - # to this factor; net spreads are still printed for sibling comparability. - f"net long-short by cost: {net}\n" - # The PR-K review's regularity, first tested here on a positive-sign factor. - f"ic_rank={_fmt_metric(nb['ic_mean'])} " - f"ic_pearson={_fmt_metric(nb['ic_pearson_mean'])} " - f"monotonicity={_fmt_metric(nb['monotonicity_spearman'])}\n" - f"turnover={_fmt_metric(nb['long_short_turnover'])} " - f"rank_autocorr_lag1={_fmt_metric(nb['rank_autocorr_lag1'])} " - f"half_life={_fmt_metric(nb['half_life_periods'], '.2f')}\n" - f"reports: {result.reports.no_book_md} | {result.reports.with_book_md}\n" - f"dashboards: {result.reports.no_book_dashboard} | " - f"{result.reports.with_book_dashboard}" - ) - print(format_exec_basis_line(result.exec_basis)) - return 0 - - -def _cmd_run_eval_valley_ridge_vwap_ratio(args: argparse.Namespace) -> int: - """Run the two real valley/ridge VWAP-ratio evaluations (cache-only) + reports.""" - from qt.eval_valley_ridge_vwap_ratio import run_eval_valley_ridge_vwap_ratio - from qt.exec_basis_eval import format_exec_basis_line - - try: - result = run_eval_valley_ridge_vwap_ratio(args.config) - except (ConfigError, ValueError, FileNotFoundError) as exc: - print(f"ERROR: {exc}", file=sys.stderr) - return 1 - nb, wb = result.no_book_metrics, result.with_book_metrics - print( - f"OK run-eval-valley-ridge-vwap-ratio: covered={result.covered_symbols}/" - f"{result.requested_symbols}, stk_mins_live_calls={result.minute_live_calls}, " - f"factor_rows={result.factor_rows} ({result.elapsed:.1f}s)\n" - # Ridge bars are structurally scarce; the realized distribution and the - # day-validity rate are surfaced so a coverage regression is visible. - f"{result.ridge_coverage.render()}\n" - f"no-book: {nb['deployment']} (predictive={nb['predictive']}) " - f"ic_mean={nb['ic_mean']:.4f} ic_ir={nb['ic_ir']:.3f} N_eff={nb['effective_samples']:.1f}\n" - f"with-book: {wb['deployment']} (incremental={wb['incremental']}) " - f"incr_ic_ir={wb['incremental_ic_ir']:.3f}\n" - f"reports: {result.reports.no_book_md} | {result.reports.with_book_md}\n" - f"dashboards: {result.reports.no_book_dashboard} | " - f"{result.reports.with_book_dashboard}" - ) - print(format_exec_basis_line(result.exec_basis)) - return 0 - - def _cmd_run_factor_eval(args: argparse.Namespace) -> int: """Run the unified exec-only factor evaluation (D5 C4).""" from qt.exec_basis_eval import format_exec_basis_line @@ -832,83 +476,6 @@ def build_parser() -> argparse.ArgumentParser: p_i5d.add_argument("--config", required=True, help="Path to the YAML config.") p_i5d.set_defaults(func=_cmd_run_phase_i5d_intraday_groups) - p_jac = sub.add_parser( - "run-eval-jump-amount-corr", - help="Run the first real factor evaluation (jump-amount-corr, CSI500, cache-only).", - ) - p_jac.add_argument("--config", required=True, help="Path to the YAML config.") - p_jac.set_defaults(func=_cmd_run_eval_jump_amount_corr) - - p_mia = sub.add_parser( - "run-eval-minute-ideal-amplitude", - help="Run the minute-ideal-amplitude factor evaluation (CSI500, cache-only).", - ) - p_mia.add_argument("--config", required=True, help="Path to the YAML config.") - p_mia.set_defaults(func=_cmd_run_eval_minute_ideal_amplitude) - - p_amav = sub.add_parser( - "run-eval-amp-marginal-anomaly-vol", - help="Run the amp-marginal-anomaly-vol factor evaluation (CSI500, cache-only).", - ) - p_amav.add_argument("--config", required=True, help="Path to the YAML config.") - p_amav.set_defaults(func=_cmd_run_eval_amp_marginal_anomaly_vol) - - p_vpc = sub.add_parser( - "run-eval-volume-peak-count", - help="Run the volume-peak-count factor evaluation (CSI500, cache-only).", - ) - p_vpc.add_argument("--config", required=True, help="Path to the YAML config.") - p_vpc.set_defaults(func=_cmd_run_eval_volume_peak_count) - - p_iac = sub.add_parser( - "run-eval-intraday-amp-cut", - help="Run the intraday-amp-cut factor evaluation (CSI500, cache-only).", - ) - p_iac.add_argument("--config", required=True, help="Path to the YAML config.") - p_iac.set_defaults(func=_cmd_run_eval_intraday_amp_cut) - - p_pik = sub.add_parser( - "run-eval-peak-interval-kurtosis", - help="Run the peak-interval-kurtosis factor evaluation (CSI500, cache-only).", - ) - p_pik.add_argument("--config", required=True, help="Path to the YAML config.") - p_pik.set_defaults(func=_cmd_run_eval_peak_interval_kurtosis) - - p_vrv = sub.add_parser( - "run-eval-valley-relative-vwap", - help="Run the valley-relative-VWAP factor evaluation (CSI500, cache-only).", - ) - p_vrv.add_argument("--config", required=True, help="Path to the YAML config.") - p_vrv.set_defaults(func=_cmd_run_eval_valley_relative_vwap) - - p_vrr = sub.add_parser( - "run-eval-valley-ridge-vwap-ratio", - help="Run the valley/ridge VWAP-ratio factor evaluation (CSI500, cache-only).", - ) - p_vrr.add_argument("--config", required=True, help="Path to the YAML config.") - p_vrr.set_defaults(func=_cmd_run_eval_valley_ridge_vwap_ratio) - - p_rmr = sub.add_parser( - "run-eval-ridge-minute-return", - help="Run the ridge-minute-return factor evaluation (CSI500, cache-only).", - ) - p_rmr.add_argument("--config", required=True, help="Path to the YAML config.") - p_rmr.set_defaults(func=_cmd_run_eval_ridge_minute_return) - - p_vpq = sub.add_parser( - "run-eval-valley-price-quantile", - help="Run the valley-price-quantile factor evaluation (CSI500, cache-only).", - ) - p_vpq.add_argument("--config", required=True, help="Path to the YAML config.") - p_vpq.set_defaults(func=_cmd_run_eval_valley_price_quantile) - - p_pra = sub.add_parser( - "run-eval-peak-ridge-amount-ratio", - help="Run the peak/ridge amount-ratio factor evaluation (CSI500, cache-only).", - ) - p_pra.add_argument("--config", required=True, help="Path to the YAML config.") - p_pra.set_defaults(func=_cmd_run_eval_peak_ridge_amount_ratio) - p_fe = sub.add_parser( "run-factor-eval", help="Run the UNIFIED exec-only factor evaluation (D5 C4, cache-only).", @@ -961,8 +528,55 @@ def build_parser() -> argparse.ArgumentParser: return parser +#: Prefix of the eleven per-factor evaluation subcommands retired in D5 C6. +RETIRED_EVAL_PREFIX = "run-eval-" + + +def retired_eval_commands() -> dict[str, str]: + """Retired subcommand name -> the factor id ``run-factor-eval`` wants. + + DERIVED, not transcribed: the old command names were the frozen exec report + names with underscores turned into dashes, and that factor-id/report-name + map already exists as one closed table. Restating eleven pairs here would be + a second copy, and the copy in the CLI is the one nobody would update. + """ + from qt.factor_eval_reconcile import FACTOR_TO_REPORT_NAME + + return { + f"{RETIRED_EVAL_PREFIX}{report_name.replace('_', '-')}": factor_id + for factor_id, report_name in FACTOR_TO_REPORT_NAME.items() + } + + +def _retired_command_hint(name: str) -> str | None: + """The migration message for a retired subcommand, or None if unknown. + + argparse's own answer to a removed subcommand is ``invalid choice``, which + tells an operator that they typed something wrong — not that the command was + replaced, nor by what. The eleven per-factor runners collapsed into ONE + parameterised command, so the mapping is mechanical and worth printing. + """ + factor_id = retired_eval_commands().get(name) + if factor_id is None: + return None + return ( + f"{name} was retired in D5 C6: the eleven per-factor evaluation commands " + f"collapsed into one. Use instead:\n" + f" python -m qt.cli run-factor-eval --config " + f"--factor {factor_id} [--book-mode decision|close]\n" + "It is exec-only (the close basis left the evaluation contract in D5) and " + "writes factor_eval_* artifacts rather than eval_*." + ) + + def main(argv: list[str] | None = None) -> int: """Parse args and dispatch. Returns a process exit code.""" + argv = list(sys.argv[1:] if argv is None else argv) + if argv and argv[0].startswith(RETIRED_EVAL_PREFIX): + hint = _retired_command_hint(argv[0]) + if hint is not None: + print(f"ERROR: {hint}", file=sys.stderr) + return 1 parser = build_parser() args = parser.parse_args(argv) return args.func(args) diff --git a/qt/factor_eval_reconcile.py b/qt/factor_eval_reconcile.py index 6fe225a..8c8b1a5 100644 --- a/qt/factor_eval_reconcile.py +++ b/qt/factor_eval_reconcile.py @@ -379,7 +379,10 @@ class is CEILED at ``(lookback_depth - 1) x |frozen symbols|`` cells #: 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] = { +#: PUBLIC because it is the single source for the retired-command hints in +#: qt.cli too (D5 C6) -- the eleven old ``run-eval-*`` names were these report +#: names with dashes, so the CLI derives them here rather than keeping a copy. +FACTOR_TO_REPORT_NAME: dict[str, str] = { "jump_amount_corr_20": "jump_amount_corr", "minute_ideal_amp_10": "minute_ideal_amplitude", "amp_marginal_anomaly_vol_20": "amp_marginal_anomaly_vol", @@ -392,7 +395,7 @@ class is CEILED at ``(lookback_depth - 1) x |frozen symbols|`` cells "valley_price_quantile_20": "valley_price_quantile", "peak_ridge_amount_ratio_20": "peak_ridge_amount_ratio", } -assert set(_FACTOR_TO_REPORT_NAME.values()) == set(REPORT_FACTOR_NAMES) +assert set(FACTOR_TO_REPORT_NAME.values()) == set(REPORT_FACTOR_NAMES) #: Registered JSON additions (catalogue §七 spec 16->20 keys; §七之二 contract #: v1.0/v1.1). ``corrections`` and ``spec.requires`` are matched as PREFIXES @@ -1477,7 +1480,7 @@ def load_anchor_rows(factor_id: str, repo_root: Path) -> list[dict]: # --------------------------------------------------------------------------- # def _report_name(factor_id: str) -> str: try: - return _FACTOR_TO_REPORT_NAME[factor_id] + return FACTOR_TO_REPORT_NAME[factor_id] except KeyError: raise ReconciliationError( f"{factor_id!r} has no frozen exec artifact name mapping." diff --git a/tests/test_cli_retired_eval_commands.py b/tests/test_cli_retired_eval_commands.py new file mode 100644 index 0000000..f680c09 --- /dev/null +++ b/tests/test_cli_retired_eval_commands.py @@ -0,0 +1,91 @@ +"""The eleven per-factor `run-eval-*` commands collapsed into `run-factor-eval`. + +Two things are pinned here. First, the eleven names really are gone from the +parser — a retirement that left them registered would be a rename, not a +convergence. Second, typing one of them still produces the migration route: +argparse answers a removed subcommand with ``invalid choice``, which tells an +operator they mistyped rather than that the command was replaced and by what. + +The historical list of retired names lives in THIS file on purpose. The +production map is derived from ``FACTOR_TO_REPORT_NAME`` so there is no second +copy to drift; a literal list is still needed to assert that the derivation +produces exactly the names that used to exist, and a test is where a historical +claim belongs. +""" + +from __future__ import annotations + +import pytest + +from qt.cli import build_parser, main, retired_eval_commands +from qt.factor_eval_reconcile import FACTOR_TO_REPORT_NAME + +#: The eleven subcommands qt/cli.py registered before D5 C6 (git history). +RETIRED_NAMES = ( + "run-eval-jump-amount-corr", + "run-eval-minute-ideal-amplitude", + "run-eval-amp-marginal-anomaly-vol", + "run-eval-volume-peak-count", + "run-eval-intraday-amp-cut", + "run-eval-peak-interval-kurtosis", + "run-eval-valley-relative-vwap", + "run-eval-valley-ridge-vwap-ratio", + "run-eval-ridge-minute-return", + "run-eval-valley-price-quantile", + "run-eval-peak-ridge-amount-ratio", +) + + +def _registered_commands() -> set[str]: + parser = build_parser() + actions = [a for a in parser._actions if getattr(a, "choices", None)] + return set(actions[0].choices) + + +def test_the_derived_map_is_exactly_the_eleven_names_that_used_to_exist(): + assert set(retired_eval_commands()) == set(RETIRED_NAMES) + assert len(RETIRED_NAMES) == 11 + + +def test_every_retired_name_maps_to_a_real_factor_id(): + mapping = retired_eval_commands() + assert set(mapping.values()) == set(FACTOR_TO_REPORT_NAME) + assert mapping["run-eval-valley-price-quantile"] == "valley_price_quantile_20" + # the one whose command name is NOT its factor id with dashes + assert mapping["run-eval-minute-ideal-amplitude"] == "minute_ideal_amp_10" + + +@pytest.mark.parametrize("name", RETIRED_NAMES) +def test_the_retired_name_is_no_longer_a_subcommand(name: str): + assert name not in _registered_commands() + + +def test_the_unified_command_is_the_one_that_remains(): + commands = _registered_commands() + assert "run-factor-eval" in commands + assert not any(c.startswith("run-eval-") for c in commands) + + +@pytest.mark.parametrize("name", RETIRED_NAMES) +def test_a_retired_name_gets_the_migration_route_not_invalid_choice(name, capsys): + assert main([name, "--config", "config/factor_eval_csi500.yaml"]) == 1 + err = capsys.readouterr().err + assert "retired in D5 C6" in err + assert "run-factor-eval" in err + assert f"--factor {retired_eval_commands()[name]}" in err + assert "invalid choice" not in err + + +def test_an_unknown_run_eval_name_still_falls_through_to_argparse(capsys): + """The hint is for names that really existed. Anything else is a typo and + should get argparse's list of valid choices, not a fabricated migration.""" + with pytest.raises(SystemExit): + main(["run-eval-not-a-real-factor"]) + assert "invalid choice" in capsys.readouterr().err + + +def test_the_hint_does_not_hijack_an_unrelated_command(capsys): + """Only the retired prefix is intercepted; everything else parses normally.""" + with pytest.raises(SystemExit): + main(["run-factor-eval"]) # missing required --config/--factor + assert "retired in D5 C6" not in capsys.readouterr().err diff --git a/tests/test_factor_eval_reconcile.py b/tests/test_factor_eval_reconcile.py index 1a6fd7a..a901e25 100644 --- a/tests/test_factor_eval_reconcile.py +++ b/tests/test_factor_eval_reconcile.py @@ -1980,12 +1980,12 @@ def test_the_registered_description_pairs_match_the_frozen_artifacts_exactly(): DEFAULT_MANIFEST, FrozenExecBaseline, ) - from qt.factor_eval_reconcile import _FACTOR_TO_REPORT_NAME + 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") + frozen = baseline.report_json(FACTOR_TO_REPORT_NAME[factor_id], "no_book") assert frozen["spec"]["description"] == old_text, factor_id From 191b443821860dc3b3fe65d7217027bb8e0e652a Mon Sep 17 00:00:00 2001 From: shaofl <2899218482@qq.com> Date: Thu, 30 Jul 2026 14:44:54 -0700 Subject: [PATCH 06/10] docs(factors): reconcile the 59 legacy property tests, and guard the map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../factors/d5_property_test_migration_map.md | 38 +++++ tests/test_property_test_migration_map.py | 146 ++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 tests/test_property_test_migration_map.py diff --git a/docs/factors/d5_property_test_migration_map.md b/docs/factors/d5_property_test_migration_map.md index 0a07f03..93c7ba1 100644 --- a/docs/factors/d5_property_test_migration_map.md +++ b/docs/factors/d5_property_test_migration_map.md @@ -168,3 +168,41 @@ eval_peak_ridge_amount_ratio tests/` 无命中)。新套件在此是**净增** | 8 | `tests/test_factor_store_universe.py::test_min_cross_section_gate_still_bites_on_the_read_path` | ✅ | | 9 | `tests/test_exec_basis_eval.py::test_exec_basis_cli_line_survives_an_absent_metric` | ✅ | | 10 | `tests/test_factor_requires_spec_v1.py::test_shipped_factor_declarations_match_the_d0_table` | ✅ | + +## 十、C6 准入核销(2026-07-30,改造 PR 内实跑) + +R16 的门是"删旧 runner 测试文件之前,59 条逐条有映射"。本节是**在树上跑出来的**核销结果, +不是对上面表格的复述。审计脚本 `tmp/context/cc_c6_verify_only/r16_audit.py`(gitignored, +一次性);**耐久的守卫是 `tests/test_property_test_migration_map.py`(7 条)**,它把同样三个 +问题变成常跑测试。 + +| 问题 | 方法 | 结果 | +|---|---|---| +| 旧套件真的是 10 文件 / 59 条吗 | 逐文件 `grep "^def test"` | **10 / 59** ✅(4+4+4+4+4+10+14+4+7+4,与 §零 口径一致) | +| 59 条**逐条**在本表有行吗 | 26 个 distinct 旧测试名 vs 表格第一列,再按文件展开 | **59 / 59** ✅,0 条无映射 | +| 本表引用的新套件 node-ID 今天还在吗 | 解析全文引用 → 与 `pytest --collect-only`(2687 个 node-ID)比对 | **56 / 56 全部命中** ✅ | +| 家族通配(`…_*(N 条)` 形式)的 N 属实吗 | 按前缀数实际测试数 | **3 / 3 家族数目吻合** ✅(ridge_return 3、valley_ridge 2、neutralization 2) | +| 引用是否唯一解析 | 省略文件名的裸引用按**名字**在全套件查找 | **全部唯一** ✅(无重名歧义——本仓 `tests/` 无 `__init__.py`,重名会静默丢一条) | + +**结论:R16 覆盖数非降的前置条件成立,删除 PR 可以开始。** + +**两条如实记录(都不改变结论)**: + +- 审计脚本第一版把 `pytest --collect-only -q` 传成了 `-qq`(`pyproject.toml` 已含 + `addopts="-q"`,见 handoff 陷阱 2),node-ID 列表被吞掉,于是报出一大批"引用了但没收集到"。 + **是"与预期矛盾的观察"暴露的,不是被任何测试抓住的。** +- 第二版用 `(?!\*)` 排除家族通配,被**回溯**绕过:正则把名字末尾的下划线让出来,改匹配一个更短 + 但同样错误的名字,于是三个家族 stem 仍被当成缺失的测试。改成 `(?![A-Za-z0-9_*])` 才成立。 + 守卫里保留了这条注释,因为下一个人写同样的正则会踩同样的坑。 + +**守卫的射程(写进它自己的 docstring)**:它检查引用**能否解析**,不检查被引用的测试是否 +仍然断言本表声称的那条性质。**改名会被抓住,就地掏空不会。** + +**`test_no_legacy_runner_test_is_missing_from_the_map` 在旧文件被删后会变成空过**——这是设计 +如此:它的职责是"只要旧文件还在,就不许存在没被映射的旧测试",而不是让旧文件活下去;一个会 +因为删除而变红的守卫,对本表要授权的那个 PR 就是个陷阱。 + +**守卫立刻咬到了本节自己**:§十初稿在表格里用一个虚构的双冒号引用当占位符举例,守卫把它当成 +真引用、判定"引用了但不存在"。占位符已改成不含双冒号的描述。**这不是误报**——本表的引用格式 +就是双冒号加测试名,任何写成那个形状的东西都应该指向一个真实测试。写这一段本身又踩了一次 +(复述时把占位符原样抄了回来),第二次才改对。 diff --git a/tests/test_property_test_migration_map.py b/tests/test_property_test_migration_map.py new file mode 100644 index 0000000..80ee310 --- /dev/null +++ b/tests/test_property_test_migration_map.py @@ -0,0 +1,146 @@ +"""R16: the property-test migration map must stay true to the suite. + +``docs/factors/d5_property_test_migration_map.md`` is the admission ticket for +deleting the eleven legacy eval runners and their tests: it claims that each of +the 59 legacy runner tests has a mapping into the new suite, so coverage does +not fall. A claim like that is worth exactly as much as its citations, and a +document cannot notice when a test it names is renamed away. + +So these tests read the map and check it against the tree: + +* every new-suite test the map cites exists, resolves to ONE file, and family + wildcards have the size the document claims; +* while the legacy files still exist, none of their tests is absent from the + map. + +Guard range, stated here because a guard that does not say what it cannot see +invites being trusted further than it should be: this checks that citations +RESOLVE, not that the cited test still asserts the property the map claims for +it. A test renamed away is caught; a test gutted in place is not. + +The second check goes vacuous the day the legacy files are deleted — by design. +Its job is to make an UNMAPPED legacy test impossible while they exist, not to +keep them alive; a guard that failed on their deletion would be a booby trap for +the very PR this map exists to authorise. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[1] +MAP = REPO / "docs/factors/d5_property_test_migration_map.md" +TESTS = REPO / "tests" + +QUALIFIED = re.compile( + r"tests/test_[A-Za-z0-9_]+\.py::(test_[A-Za-z0-9_]+)(?![A-Za-z0-9_*])" +) +# ``(?![A-Za-z0-9_*])`` on both patterns: a bare ``(?!\*)`` is defeated by +# backtracking (the name simply gives back its trailing underscore and matches a +# shorter, still-wrong name), which reports three phantom missing tests. +BARE = re.compile(r"(? dict[str, set[str]]: + """{test function name: {files defining it}} across the whole suite.""" + names: dict[str, set[str]] = {} + for path in sorted(TESTS.rglob("test_*.py")): + for line in path.read_text(encoding="utf-8").splitlines(): + if line.startswith("def test"): + names.setdefault(line[4:].split("(")[0], set()).add(path.name) + return names + + +def _map_text() -> str: + return MAP.read_text(encoding="utf-8") + + +def _legacy_runner_tests() -> dict[str, list[str]]: + return { + path.name: [ + line[4:].split("(")[0] + for line in path.read_text(encoding="utf-8").splitlines() + if line.startswith("def test") + ] + for path in sorted(TESTS.glob("test_eval_*_runner.py")) + } + + +def test_the_map_document_exists_and_carries_citations(): + """Anti-vacuity: every check below is a loop over parsed citations, and an + empty parse would make all of them pass.""" + text = _map_text() + cited = set(QUALIFIED.findall(text)) | set(BARE.findall(text)) + assert len(cited) >= 40, f"only {len(cited)} citations parsed — parser or map broke" + + +@pytest.mark.parametrize("pattern", [QUALIFIED, BARE], ids=["qualified", "bare"]) +def test_every_cited_new_suite_test_exists_and_is_unambiguous(pattern: re.Pattern): + suite = _suite_test_names() + missing, ambiguous = [], [] + for name in sorted(set(pattern.findall(_map_text()))): + files = suite.get(name) + if not files: + missing.append(name) + elif len(files) > 1: + ambiguous.append(f"{name} -> {sorted(files)}") + assert not missing, f"cited by the migration map but not defined anywhere: {missing}" + # A duplicated test-function name silently drops one of the two from a full + # run in this repo (no __init__.py in tests/), so an ambiguous citation is a + # finding in its own right. + assert not ambiguous, f"cited name defined in more than one file: {ambiguous}" + + +def test_every_family_wildcard_has_the_size_the_document_claims(): + suite = _suite_test_names() + families = FAMILY.findall(_map_text()) + assert families, "no family wildcards parsed — the pattern or the map changed" + for stem, claimed in families: + prefix = f"{stem}_" + found = sorted(name for name in suite if name.startswith(prefix)) + assert len(found) == int(claimed), ( + f"{prefix}*: document claims {claimed}, suite has {len(found)}: {found}" + ) + + +def test_no_legacy_runner_test_is_missing_from_the_map(): + """Vacuous once the legacy files are deleted — see the module docstring.""" + text = _map_text() + legacy = _legacy_runner_tests() + unmapped = { + file_name: [name for name in names if f"`{name}`" not in text] + for file_name, names in legacy.items() + } + unmapped = {k: v for k, v in unmapped.items() if v} + assert not unmapped, f"legacy tests with no row in the migration map: {unmapped}" + + +def test_the_counting_table_is_internally_consistent(): + """7 migrated + 50 covered + 2 carrier-retired must equal the 59 claimed.""" + text = _map_text() + numbers = { + label: int(re.search(pattern, text).group(1)) + for label, pattern in ( + ("total", r"旧套件总条数(10 文件) \| \*\*(\d+)\*\*"), + ("migrated", r"已搬迁(逐字移入新文件) \| (\d+)"), + ("covered", r"已覆盖(性质在新套件有钉) \| (\d+)"), + ("retired", r"载体退役([^|]*) \| (\d+)"), + ) + } + assert ( + numbers["migrated"] + numbers["covered"] + numbers["retired"] == numbers["total"] + ), numbers + assert "59 / 59(100%)" in text + assert "完全无映射、无理由退役 | **0**" in text + + +def test_the_claimed_total_matches_the_legacy_files_while_they_exist(): + legacy = _legacy_runner_tests() + if not legacy: # deleted: the claim is history, not a live count + pytest.skip("legacy runner test files are gone; the count is historical") + assert len(legacy) == 10 + assert sum(len(names) for names in legacy.values()) == 59 From 923a6163420cac888cd1a45dbf3473a56ef13444 Mon Sep 17 00:00:00 2001 From: shaofl <2899218482@qq.com> Date: Thu, 30 Jul 2026 14:47:42 -0700 Subject: [PATCH 07/10] docs(factors): stop documenting three commands that no longer run 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. --- docs/factors/d1_panel_freeze_manifest.md | 19 ++++++++++++----- .../factors/d5_runner_difference_catalogue.md | 21 ++++++++++++++----- .../pr_c_cutoff_fix_reference_panel.md | 13 +++++++++--- 3 files changed, 40 insertions(+), 13 deletions(-) diff --git a/docs/factors/d1_panel_freeze_manifest.md b/docs/factors/d1_panel_freeze_manifest.md index 03e7609..9609979 100644 --- a/docs/factors/d1_panel_freeze_manifest.md +++ b/docs/factors/d1_panel_freeze_manifest.md @@ -19,15 +19,24 @@ 的结构性预防。具体操作:checkout `3669c90`,把本分支的 `qt/panel_freeze.py` 原样放入 (它只 import、不改任何因子数学模块),再跑下方命令。**任何在 D2 之后的树上直接重跑 本工具得到的"基线"都不是基线**,与冻结哈希不一致时以本文记录的哈希为准。 -- **重跑命令**(cwd = 仓库根,缓存根就位): +- ⚠️ **重生成能力已于 D5 C6 退役(owner 2026-07-28 裁定),本基线 frozen-forever。** + 重生成路径调的是 11 个旧 eval runner 的私有 `_load_*_panel`,C6 删除它们后这条路必然 + 失效;与其留一条跑起来就会坏的代码,不如显式退役。`python -m qt.panel_freeze` + (不带 `--verify`)现在是**可读的报错**,不是静默 no-op。**从当前树重跑本来就不合法** + (见上一条 provenance 规则),所以失去的不是复核能力。 + +- **现在能跑的是验证**(cwd = 仓库根): ``` - /home/shaofl/Development/env_tools/envs/quant_mf/bin/python -m qt.panel_freeze + /home/shaofl/Development/env_tools/envs/quant_mf/bin/python -m qt.panel_freeze --verify ``` - 输出根默认 `artifacts/refactor_baseline`(`--output-root` 可改;`--resume` 语义见 - 模块 docstring——已存在的面板从冻结文件读回并**重新走 process + 对账**后才被接受, - 绝不盲信旧文件)。 + 它对**两棵**冻结树(本基线 14 个面板 + `pr_c_cutoff_fix` 1 个)逐面板重算 canonical + content hash 与整条 manifest 行(rows / 日期范围 / symbol 数 / NaN 数 / mean / std / + file sha256),与**本文 §六表格**(git 里的权威)以及 `manifest.json`(gitignored 的第二 + 见证)双向核对,任一不符即非零退出。多出一个未登记的面板文件同样判负。 + **本文的表格因此不只是记录,而是被程序读取的期望值**——改它就等于改验证结果,会出现在 + `git diff` 里。 ## 二、数据面(与十一因子评估循环同面) diff --git a/docs/factors/d5_runner_difference_catalogue.md b/docs/factors/d5_runner_difference_catalogue.md index e8f22f7..762fd63 100644 --- a/docs/factors/d5_runner_difference_catalogue.md +++ b/docs/factors/d5_runner_difference_catalogue.md @@ -231,12 +231,12 @@ aligned_base = sign * gross - base_cost (`tests/__init__.py` 收集陷阱)同一接口。**非测试模块共四个**(实测 `grep -rln "qt\.eval_" --include=*.py | grep -v ^./tests/`): -| 模块 | 用途 | C6 处置 | +| 模块 | 用途 | C6 处置(**已执行**,改造 PR,2026-07-30) | |---|---|---| -| `qt/cli.py` | 11 个 `_cmd_run_eval_*` 子命令(§五 C13) | 随统一 runner 收敛为一个子命令 | -| **`qt/panel_freeze.py`** | **生产 D1 冻结基线的工具**——调各 runner 私有 `_load_*_panel` + `_build_book_factors`,「零公式重抄」正是靠 import 它们实现的 | **删 runner 会打断「重新生成 / 重新验证 D1 基线」的能力** | -| **`qt/panel_reconcile.py`** | D2 逐格对账工具(同上依赖) | 同上 | -| **`qt/hand_anchors_engine_values.py`** | D2 手算锚的引擎侧取值 | 同上 | +| `qt/cli.py` | 11 个 `_cmd_run_eval_*` 子命令(§五 C13) | ✅ 收敛为 `run-factor-eval` 一个子命令;11 个旧命令名保留**迁移提示**(argparse 的 `invalid choice` 只会让人以为自己打错了) | +| **`qt/panel_freeze.py`** | **生产 D1 冻结基线的工具**——调各 runner 私有 `_load_*_panel` + `_build_book_factors`,「零公式重抄」正是靠 import 它们实现的 | ✅ **改 verify-only**:重生成 loud raise,`--verify` 逐面板重算 canonical hash + 整条 manifest 行,对着 **git 里的**哈希表核 | +| **`qt/panel_reconcile.py`** | D2 逐格对账工具(同上依赖) | ✅ **改 verify-only**:重建退役,**比较仍然真跑**(它本来就不需要 runner)——两侧各自从盘上独立读回、逐格重导出 D2 结论 | +| **`qt/hand_anchors_engine_values.py`** | D2 手算锚的引擎侧取值 | ✅ **改 verify-only**:重算退役,`--verify` 用记录自身的 hand/engine 数重导出 rel_diff / ok / all_ok_daily | ⚠️ **后三个全是 D1/D2 的验收工具**,而 **D5 面板腿的比较对象正是 `panel_freeze.py` 的产物**。 按设计 v3.2 §五第 4 腿的 **provenance 规则**,基线只许从钉住的 pre-D2 SHA 重新生成—— @@ -251,6 +251,17 @@ import 的 runner loader,即便统一 runner 已上线)、**要么显式记 **必须同批更新**——否则就是本项目 #76/#78/#82 那条形态的又一次复发(文档教人跑一条已经 跑不了的命令)。 +**已落实(2026-07-30 改造 PR)**:走的是第二条路——owner 2026-07-28 裁定**退役重生成能力**, +**D1/D2 基线自此不可从本仓再生**,只能依赖已冻结的 artifact + 哈希。三个工具都不删,改成 +只读验证;重生成入口是 **loud raise 而不是静默 no-op**(红线 #9)。要求 ② 同批完成: +`d1_panel_freeze_manifest.md` §一 与 `pr_c_cutoff_fix_reference_panel.md` §四 的「重跑命令」 +已改为退役声明 + 验证命令。 + +**这条不对称损失的账,最后是这样结的**:担心的是「工具已被删、想复核也无从下手」。现在复核 +能力**变强了而不是变弱**——旧的重生成路径从来不能用于复核(provenance 规则禁止从当前树重跑, +从被验证的树重建基线正是本仓犯过一次的空对账),而新的 `--verify` 是一条以前根本不存在的路: +15 个面板逐一重算 canonical hash 与整条 manifest 行,对着 **git 里的**期望值核,3 秒跑完。 + ## 七、与 C5 对账的接口 **本表 §二/§三 = 允许出现的差异白名单;§四 = 必须逐值一致的项;§五 = 已判定归一(对账中 diff --git a/docs/factors/pr_c_cutoff_fix_reference_panel.md b/docs/factors/pr_c_cutoff_fix_reference_panel.md index cdced0b..19e0b20 100644 --- a/docs/factors/pr_c_cutoff_fix_reference_panel.md +++ b/docs/factors/pr_c_cutoff_fix_reference_panel.md @@ -41,7 +41,7 @@ | 产出 | D1 冻结 run(`main` @ `3669c90`),见 `d1_panel_freeze_manifest.md` | 见下 §四 | | 引擎 | pre-D2 runner loader | **同一条 runner loader**(`qt.panel_freeze` + `--only`) | | 用途 | **已发布内容的忠实记录**;D2 逐位对账的历史参照 | **D5 面板腿对该因子的有效参照** | -| 是否可被覆盖 | **否**(本次改动只新增,一个字节没动) | 可再生(命令在 §四) | +| 是否可被覆盖 | **否**(本次改动只新增,一个字节没动) | **否**(C6 起同样 frozen-forever;产生方式已退役,见 §四) | **为什么「旧引擎 + 截断输入」是干净的 refactor-only 对照**:本次改动是**纯输入截断**—— 被喂进相关系数计算的 bar 少了一批,`compute_jump_amount_corr` 里从 `amp` 到 Pearson @@ -52,10 +52,17 @@ **不要**把两份面板放进同一次对账去「取平均」或「看哪个更接近」:它们是两个不同定义的因子值, 不是同一个量的两次测量。 -## 四、新参照面板的产生方式(可复跑) +## 四、新参照面板的产生方式(**已于 D5 C6 退役**,此处存档) + +⚠️ **下面这条命令不再可用**:owner 2026-07-28 裁定退役重生成能力,`--only` 连同整个冻结 +路径一并退役(它依赖 11 个旧 runner 的私有 loader,C6 删除它们后必然失效)。本面板与 D1 +基线一样 **frozen-forever**;验证走 +`python -m qt.panel_freeze --verify`,它会把本文 §五 表格里的 `canonical_sha256` 当作 +git 里的权威期望值,对盘上这个面板逐格重算核对。原命令留在这里是 provenance 的一部分—— +它记录的是这份面板**当初怎么来的**,不是现在怎么再来一次。 ``` -cd # 缓存根 artifacts/cache/tushare/v1 就位 +cd # 缓存根 artifacts/cache/tushare/v1 就位 [RETIRED, C6] /home/shaofl/Development/env_tools/envs/quant_mf/bin/python -m qt.panel_freeze \ --config config/phase_c_jump_amount_corr.yaml \ --output-root artifacts/refactor_baseline/pr_c_cutoff_fix \ From 8ae1763a3f7843a48355d2667f19b151090faf0f Mon Sep 17 00:00:00 2001 From: shaofl <2899218482@qq.com> Date: Thu, 30 Jul 2026 14:59:20 -0700 Subject: [PATCH 08/10] docs(factors): register the three C6 findings, including an unprotected 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. --- .../factors/d5_runner_difference_catalogue.md | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/docs/factors/d5_runner_difference_catalogue.md b/docs/factors/d5_runner_difference_catalogue.md index 762fd63..cc68200 100644 --- a/docs/factors/d5_runner_difference_catalogue.md +++ b/docs/factors/d5_runner_difference_catalogue.md @@ -860,3 +860,87 @@ description 相应改名(`REUSED from ...` → `SHARED taxonomy in ...`); `classify_panel_differences` / `classify_anchor_row` / `run_panels_mode` / `run_anchors_mode` / `load_anchor_rows` / `frozen_panel_path` / `_build_bundle` **一个字节未变** ⇒ panels 与 anchors 的结论不受影响,**只重跑 reports 腿**。 + +## 六之三、C6 改造期登记的三件事(lead 2026-07-30 裁定,本节只登记不修) + +### (1) D2 手算锚的 engine 比较记录已被冲掉——**丢的是可再验证性,不是结论** + +`artifacts/refactor_baseline/hand_anchors_d2.json` 现在只有 20 条 `daily_pending_engine`、 +**0 条 `daily_engine_compared`**:2026-07-25 18:04 一次 `python -m qt.hand_anchor_rows` 重跑 +(PR #103 的 jump 截断修正配套)把 engine 侧的比较结果整段覆盖掉了,而填回它的 +`qt.hand_anchors_engine_values` 已在 C6 退役。 + +**必须分清的两件事**: + +- **D2 手算锚那条腿的结论仍然在案**——PR #89 正文与 + [`docs/progress/07_factor_layer_refactor.md`](../progress/07_factor_layer_refactor.md) 的 D2 条目 + 白纸黑字记着「**88 行分层手算锚 0 失配**(脚本运行时守卫禁 import 引擎,曾自抓 3 个口径错)」。 + D2 的验收**不因此动摇**。 +- **丢的是「今天再验一次」的能力**。四条 daily 锚(momentum/reversal/liquidity/overnight_mom) + 的 engine 侧数字已不在盘上,退役后也无法重算。 + +因此 `python -m qt.hand_anchors_engine_values --verify` **在今天的树上必然非零退出**,措辞是 +`NOT VERIFIED (nothing recorded)` 而**不是** `FAILED`——"没验"与"验了不过"是两回事,退出码只有 +一位,措辞必须补上这个区分。 + +⚠️ **它不进标准 gates 清单**(lead 裁定):一条永远不转绿的红,只会训练人忽略红。 + +### (2) 冻结目录里有 7 个**不受任何哈希保护**的文件 + +上一条暴露的问题比它自己大:`hand_anchors_d2.json` 就在 +`artifacts/refactor_baseline/`——那个被反复称作「绝不可覆盖」的目录——**而它确实被覆盖了**。 +于是普查了整个目录(113 个文件): + +| 类别 | 数量 | 保护来源 | +|---|---|---| +| `exec_baseline/*` | 77 | **git**:`docs/factors/d5_exec_baseline_manifest.json` 逐文件 sha256 | +| `panels/*.parquet` | 14 | **git**:`d1_panel_freeze_manifest.md` §六(canonical + file sha) | +| `panels_d2/*.parquet` | 14 | **git**:同上表(canonical,经 `panel_reconcile --verify`) | +| `pr_c_cutoff_fix/panels/*.parquet` | 1 | **git**:`pr_c_cutoff_fix_reference_panel.md` §五(canonical) | +| **无任何哈希覆盖** | **7** | —— | + +那 7 个是:`hand_anchors_d2.json`、`manifest.json`、`manifest.md`、`manifest_d2.json`、 +`reconcile_d2.md`、`pr_c_cutoff_fix/manifest.json`、`pr_c_cutoff_fix/manifest.md`。 +**它们之间还有程度差别,不要一概而论**: + +- `manifest.json` / `manifest_d2.json` / `pr_c manifest.json` —— **自身字节未被钉,但内容受约束**: + `panel_freeze --verify` 把每一行(canonical / file sha / rows / 日期 / symbol 数 / NaN 数 / + mean / std)与 git 文档、与盘上面板三方互核,header 的 `producing_git_sha` 也与文档核。 + 改内容会被抓,改字节(重排、格式化)不会。 +- `manifest.md` / `reconcile_d2.md` / `pr_c manifest.md` —— 渲染产物,内容可从别处推导,风险低。 +- **`hand_anchors_d2.json` —— 真正无保护**:没有任何一处记着它该长什么样,而它是唯一一个 + **已被实际覆盖**的。 + +**要记住的那句话**:「本次改动冻结基线一个字节未写」是**关于今天这次操作**的陈述, +**不是关于该目录不可变的陈述**。目录本身没有强制不可变性——上面这 7 个文件谁都能改, +其中 1 个已经被改过。 + +### (3) C5 对账 harness 读这两样东西时**不核任何哈希** + +- `qt/factor_eval_reconcile.py:1539` —— `pd.read_parquet(frozen_panel_path(...))` 裸读冻结 D1 面板; +- `qt/factor_eval_reconcile.py:1467` —— 裸读 `hand_anchors_d2.json`(anchors 腿)。 + +**两句话都必须写下,缺一句都会误导**: + +- **C5 的四腿全绿,是在一棵从未经过哈希核对的基线上得出的。** 而 (2) 已经证明这个目录**能**被写, + 所以这不是假想风险。 +- **同时**:今天 `python -m qt.panel_freeze --verify` 对 15 个面板逐一重算 canonical hash 与整条 + manifest 行、**15/15 通过**,`panel_reconcile --verify` 14/14 `max_rel=0.0` + ⇒ **面板此刻完好,C5 的结论不受影响**。 + +**处置**:修在**下一个 PR(删除 PR)**——那个 PR 的主题正是「让 frozen-forever 名副其实」, +把 `qt.panel_freeze.verify_frozen_panels` 接进 C5 harness 的读入点正属于它。**本 PR 不动 C5 harness。** + +### (4) 第三个工具的退役理由:一条如实记录的不对称 + +C6 退役的三个工具对 11 个旧 runner 的依赖**不是一个量级**: + +| 工具 | 对旧 runner 的依赖 | 删 runner 后是否必然失效 | +|---|---|---| +| `qt/panel_freeze.py` | `minute_recipes()` import **全部 11 个模块**并调其私有 `_load_*_panel` | **是** | +| `qt/panel_reconcile.py` | 走 `qt.panel_freeze` 的 recipes,同上 | **是** | +| `qt/hand_anchors_engine_values.py` | 只用了 `qt.eval_jump_amount_corr._check_preconditions` **一个前置检查** | **否**——改指统一 runner 即可继续活着 | + +**裁定维持**(owner 2026-07-28 的裁定是「退役重生成能力、基线 frozen-forever」,那是**目的**, +不是从「依赖 11 个 runner」推出来的推论;依赖强弱不改变裁定)。但如实记下: +**第三条退役真的放弃了一个未必注定失效的能力**,这一点已单独提给 owner。 From 2d1310b8d1464cf6a81370a1acab8fe65b245081 Mon Sep 17 00:00:00 2001 From: shaofl <2899218482@qq.com> Date: Thu, 30 Jul 2026 15:08:31 -0700 Subject: [PATCH 09/10] fix(factors): make the reconciliation check a closed set, not a roll 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). --- qt/panel_freeze.py | 29 +++++++++++++++++ tests/test_panel_freeze.py | 65 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/qt/panel_freeze.py b/qt/panel_freeze.py index 8022465..88ed60f 100644 --- a/qt/panel_freeze.py +++ b/qt/panel_freeze.py @@ -397,6 +397,35 @@ def _check_recorded_reconciliation(document: dict, problems: list[str]) -> None: "agreement with the shipped eval artifacts is unwitnessed." ) return + + # CLOSED SET, not "whatever happens to be here". Iterating only the entries + # present cannot see a DELETED one -- and the PR that follows this one is a + # deletion PR, so a verifier blind to removals would be blind exactly when it + # is needed. Every minute panel was reconciled before the freeze accepted it + # (book factors carry no coverage fields in the eval artifacts, so they have + # no entry by design); anything else means the record was edited. + # + # The expected set comes from this same document's rows, which are themselves + # tamperable -- but not usefully: deleting a row too is caught by the panel + # loop, which iterates the GIT-TRACKED factor list and reports a panel whose + # machine-manifest row is absent. The two checks close each other. + expected = { + str(row["factor_id"]) + for row in document.get("rows", []) + if str(row.get("kind")) == "minute" + } + for factor_id in sorted(expected - set(recorded)): + problems.append( + f"reconciliation has no entry for {factor_id}, which manifest.json " + "lists as a minute panel — every minute panel was reconciled before " + "being frozen, so an absent entry means the record lost one." + ) + for factor_id in sorted(set(recorded) - expected): + problems.append( + f"reconciliation carries an entry for {factor_id}, which is not a " + "minute panel in this manifest." + ) + for factor_id in sorted(recorded): entry = recorded[factor_id] or {} artifact = entry.get("artifact") diff --git a/tests/test_panel_freeze.py b/tests/test_panel_freeze.py index 34f69cc..75110c1 100644 --- a/tests/test_panel_freeze.py +++ b/tests/test_panel_freeze.py @@ -478,3 +478,68 @@ def _lie(document): assert any("manifest.json n_nan" in p for p in problems) # nothing else fired: the git-tracked side is untouched and still agrees assert not any("git-tracked" in p for p in problems) + + +def test_a_deleted_reconciliation_entry_is_convicted(tmp_path: Path): + """The gap this closes: iterating only the entries PRESENT cannot see one + that was removed. The next PR is a deletion PR, so a verifier blind to + removals would be blind exactly when it matters. + + TWO minute factors on purpose. With one, deleting its entry empties the + block and the pre-existing "no reconciliation block" message fires — which + would let this test pass without the closed-set check existing at all. The + real case is 11 entries becoming 10, and that is what is built here. + """ + doc = build_frozen_tree( + tmp_path, factors={"alpha_20": "minute", "beta_20": "minute", "book_x": "book"} + ) + assert verify_frozen_panels(tmp_path, doc).ok # green control + + def _drop_one(document): + removed = document["reconciliation"].pop("alpha_20") + assert removed, "fixture no longer has the entry this test removes" + + patch_manifest(tmp_path, "manifest.json", _drop_one) + result = verify_frozen_panels(tmp_path, doc) + assert not result.ok + assert any( + "reconciliation has no entry for alpha_20" in p for p in result.problems + ), result.problems + # The block is NOT empty, so the pre-existing "no reconciliation block" + # message cannot be what convicted here. + assert not any("carries no reconciliation block" in p for p in result.problems) + + +def test_an_entry_for_a_non_minute_panel_is_convicted(tmp_path: Path): + """The other direction of the closed set: an invented entry.""" + doc = build_frozen_tree(tmp_path) + + def _invent(document): + document["reconciliation"]["book_x"] = { + "artifact": "eval_book_x_no_book.json", + "checks": {}, + } + + patch_manifest(tmp_path, "manifest.json", _invent) + result = verify_frozen_panels(tmp_path, doc) + assert not result.ok + assert any("carries an entry for book_x" in p for p in result.problems) + + +def test_the_real_manifests_reconcile_exactly_their_minute_panels(tmp_path: Path): + """Coupling check: the closed set is only meaningful if the real frozen trees + actually satisfy it. Both do — D1 has 11 minute panels and 11 entries, the + PR-C tree 1 and 1 — so this check is a live constraint, not a rule the real + data was already exempt from.""" + import json + + root = Path(__file__).resolve().parents[1] / "artifacts" / "refactor_baseline" + if not (root / "manifest.json").exists(): # gitignored bulk tree + pytest.skip("frozen baseline not present in this checkout") + for relative in ("manifest.json", "pr_c_cutoff_fix/manifest.json"): + document = json.loads((root / relative).read_text(encoding="utf-8")) + minute = { + row["factor_id"] for row in document["rows"] if row["kind"] == "minute" + } + assert minute == set(document["reconciliation"]), relative + assert minute, relative From c29e2c816a5ab3cd70eeaf7c90b76fd8df9c6c80 Mon Sep 17 00:00:00 2001 From: shaofl <2899218482@qq.com> Date: Fri, 31 Jul 2026 08:52:47 -0700 Subject: [PATCH 10/10] fix(factors): stop a successful run from pointing at a command that raises 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. --- .../factors/d5_property_test_migration_map.md | 15 ++- .../factors/d5_runner_difference_catalogue.md | 29 ++++- qt/hand_anchor_rows.py | 11 +- qt/hand_anchors_d2.py | 29 ++++- tests/test_hand_anchor_record.py | 113 +++++++++++++++++- 5 files changed, 185 insertions(+), 12 deletions(-) diff --git a/docs/factors/d5_property_test_migration_map.md b/docs/factors/d5_property_test_migration_map.md index 93c7ba1..f94fe22 100644 --- a/docs/factors/d5_property_test_migration_map.md +++ b/docs/factors/d5_property_test_migration_map.md @@ -180,12 +180,25 @@ R16 的门是"删旧 runner 测试文件之前,59 条逐条有映射"。本节 |---|---|---| | 旧套件真的是 10 文件 / 59 条吗 | 逐文件 `grep "^def test"` | **10 / 59** ✅(4+4+4+4+4+10+14+4+7+4,与 §零 口径一致) | | 59 条**逐条**在本表有行吗 | 26 个 distinct 旧测试名 vs 表格第一列,再按文件展开 | **59 / 59** ✅,0 条无映射 | -| 本表引用的新套件 node-ID 今天还在吗 | 解析全文引用 → 与 `pytest --collect-only`(2687 个 node-ID)比对 | **56 / 56 全部命中** ✅ | +| 本表引用的新套件 node-ID 今天还在吗 | 解析全文引用 → 与 `pytest --collect-only` 比对(见下「口径与出处」) | **56 / 56 全部命中** ✅ | | 家族通配(`…_*(N 条)` 形式)的 N 属实吗 | 按前缀数实际测试数 | **3 / 3 家族数目吻合** ✅(ridge_return 3、valley_ridge 2、neutralization 2) | | 引用是否唯一解析 | 省略文件名的裸引用按**名字**在全套件查找 | **全部唯一** ✅(无重名歧义——本仓 `tests/` 无 `__init__.py`,重名会静默丢一条) | **结论:R16 覆盖数非降的前置条件成立,删除 PR 可以开始。** +### 口径与出处(评审 LOW-1 / NIT-1 的更正) + +- **本节数字实跑于本 PR 的最后一个代码 commit 之后的工作树**,`pytest --collect-only` 收集到 + **2,701** 个 node-ID。**初稿写的 2,687 在任何一个 commit 上都不成立**:那次审计跑在**它自己 + 那一批 7 条守卫测试落地之前**(`191b443` 上实为 2,694),也就是**审计跑在自己的守卫存在之前**。 + 更正连同原因一起记在这里,而不是把数字悄悄换掉。 +- ⚠️ **收集总数是背景,不是主张**——它随任何新增测试变动(本 PR 期间就走过 + 2,694 → 2,697 → 2,701)。**承重的三个数是 59/59、56/56、0 未解析**,它们与总数无关。 + 引数字时**必须连同它是在哪棵树上量的一起引**(这与本仓「报极值必须连它的窗口一起报」同形)。 +- **「56」的口径**:*去重后、家族通配展开后的 distinct 新套件 node-ID*——同一个 node-ID 在表中 + 被引多次只计一次,`::name_*(N 条)` 展开为它的 N 个成员。评审用另一套口径数到 **58**; + **两者都是 0 条未解析**,所以差的是计数约定、不是覆盖。此处把本表的口径写死,使这个数可复算。 + **两条如实记录(都不改变结论)**: - 审计脚本第一版把 `pytest --collect-only -q` 传成了 `-qq`(`pyproject.toml` 已含 diff --git a/docs/factors/d5_runner_difference_catalogue.md b/docs/factors/d5_runner_difference_catalogue.md index cc68200..bbeb8a9 100644 --- a/docs/factors/d5_runner_difference_catalogue.md +++ b/docs/factors/d5_runner_difference_catalogue.md @@ -917,8 +917,17 @@ anchors 的结论不受影响,**只重跑 reports 腿**。 ### (3) C5 对账 harness 读这两样东西时**不核任何哈希** -- `qt/factor_eval_reconcile.py:1539` —— `pd.read_parquet(frozen_panel_path(...))` 裸读冻结 D1 面板; -- `qt/factor_eval_reconcile.py:1467` —— 裸读 `hand_anchors_d2.json`(anchors 腿)。 +**三处**(`qt/factor_eval_reconcile.py`,**按符号名记,不记行号**——行号会随任何平移失效, +本 PR 自己就是证据:初稿写的 `:1539` 取自 `main`,而本分支的 `711c0a8` 把该文件平移了 +3): + +| 符号 | 读什么 | 何时是唯一读入点 | +|---|---|---| +| `run_panels_mode` | `pd.read_parquet(frozen_panel_path(...))` 冻结 D1 面板 | `--mode panels` | +| `load_anchor_rows` | `hand_anchors_d2.json` | `--mode anchors` | +| `run_anchors_mode` | 经 `frozen_panel_path()` 再读一次冻结 D1 面板,求 warmup 日期网格 | **`--mode anchors` 单跑时,这是唯一的冻结面板读入点** | + +⚠️ 第三处是评审补的,初稿漏了。**清单不全 = 那一处留着永远不验**——删除 PR 的处置正是 +「把 verify 接进读入点」,接两处而漏一处,等于给自己发一张只覆盖三分之二的通行证。 **两句话都必须写下,缺一句都会误导**: @@ -929,7 +938,21 @@ anchors 的结论不受影响,**只重跑 reports 腿**。 ⇒ **面板此刻完好,C5 的结论不受影响**。 **处置**:修在**下一个 PR(删除 PR)**——那个 PR 的主题正是「让 frozen-forever 名副其实」, -把 `qt.panel_freeze.verify_frozen_panels` 接进 C5 harness 的读入点正属于它。**本 PR 不动 C5 harness。** +把 `qt.panel_freeze.verify_frozen_panels` 接进 C5 harness 的**三个**读入点正属于它。 +**本 PR 不动 C5 harness。** + +### (3之二) `hand_anchor_rows` 的 payload 只写 pending、不写 compared —— 已知后果,本 PR 不改行为 + +`qt/hand_anchor_rows.py::run_hand_anchors` 每次成功跑完都会把 `hand_anchors_d2.json` 整份重写, +payload 只含 `daily_pending_engine`,**不含 `daily_engine_compared`** ⇒ **它正是 2026-07-25 冲掉 +engine 比较记录的那个动作**。而在本 PR 之前,它跑完还会 print 「run python -m +qt.hand_anchors_engine_values」—— **用户刚把记录冲掉,程序就指他去跑一条现在会 raise 的命令**。 + +- **那行 print 已在本 PR 改掉**(改为指向 `--verify` 并说明记录已无法重建;措辞 + author-once 于 `qt.hand_anchors_d2.ENGINE_COMPARISON_POINTER`,printer 只组合不复述)。 +- **「`hand_anchor_rows` 是否应当被禁止覆盖冻结目录里的记录」属于行为变更**,与 + §六之三(2) 的「该目录没有强制不可变性」是同一件事 ⇒ **并进删除 PR**(与上面三个读入点的 + verify 接线一起做)。**本 PR 不改它的写入行为。** ### (4) 第三个工具的退役理由:一条如实记录的不对称 diff --git a/qt/hand_anchor_rows.py b/qt/hand_anchor_rows.py index 90cac08..725cb04 100644 --- a/qt/hand_anchor_rows.py +++ b/qt/hand_anchor_rows.py @@ -4,9 +4,11 @@ of :mod:`qt.hand_anchors_d2`, compares each against the NEW-ENGINE value read from the ``panels_d2`` parquet files (file reads — never engine imports), and writes the selection + results JSON. The four ops-rewritten daily factors that -have no frozen panel are SELECTED and hand-computed here too; their engine-side -values are filled in by ``qt.hand_anchors_engine_values`` (the only module of -the trio allowed to import the engine). +have no frozen panel are SELECTED and hand-computed here too, and left in +``daily_pending_engine``. The companion that USED to fill in their engine-side +values was retired in D5 C6, so those rows now stay pending: see +``qt.hand_anchors_d2.ENGINE_COMPARISON_POINTER``, which this module prints +rather than restating. Selection sources (all engine-free): the ``panels_d2`` files give the finite / NaN pattern (warm-up ends, interior rows); the ``adj_factor`` cache gives the @@ -455,8 +457,7 @@ def compare(factor_id, cls, key, hand, note=""): out_path.write_text(json.dumps(payload, indent=2), encoding="utf-8") n_bad = sum(1 for r in results if not r["ok"]) print(f"hand anchors (frozen 14): {len(results)} rows, {n_bad} mismatches -> {out_path}") - print(f"daily rows pending engine comparison: {len(daily_rows)} " - f"(run python -m qt.hand_anchors_engine_values)") + print(H.pending_engine_line(len(daily_rows))) return 0 if n_bad == 0 else 1 diff --git a/qt/hand_anchors_d2.py b/qt/hand_anchors_d2.py index 8756933..8e5732f 100644 --- a/qt/hand_anchors_d2.py +++ b/qt/hand_anchors_d2.py @@ -32,10 +32,14 @@ class — (a) the warm-up END (the symbol's first finite value; verified by hand * qfq for the daily factors: raw close x af / af(symbol's last panel day) — the ``front_adjust`` anchor convention, re-derived here from its definition. -Run AFTER ``python -m qt.panel_reconcile`` produced ``panels_d2``: +``panels_d2`` is FROZEN (D5 C6): ``qt.panel_reconcile`` no longer rebuilds it, +so the files this module reads are simply there. Run: python -m qt.hand_anchors_d2 # select + hand-compute + compare (14) - python -m qt.hand_anchors_engine_values # compare the 4 daily factors + +The companion that filled in the four daily factors' engine values is RETIRED +(D5 C6) -- see ``ENGINE_COMPARISON_POINTER`` below for what to run instead and +why those rows can no longer be completed. """ from __future__ import annotations @@ -54,6 +58,27 @@ class — (a) the warm-up END (the symbol's first finite value; verified by hand WINDOW_LO = pd.Timestamp("2021-07-01") WINDOW_HI = pd.Timestamp("2026-06-30") TOL = 1e-12 + +#: The ONE authored sentence about what an operator can still do with the daily +#: engine comparison, and what they cannot. ``qt.hand_anchor_rows`` composes it +#: rather than restating it: a regex cannot assert that no other sentence points +#: at the retired command, but "there is no other sentence" can (methodology #2). +#: +#: It lives HERE, not in ``qt.hand_anchors_engine_values``, because that module +#: imports ``qt.panel_freeze`` -> ``data.clean.schema``, and the hand side must +#: never load the engine (``assert_no_engine_imports`` below). An author-once +#: home has to be reachable from the pure side. +ENGINE_COMPARISON_POINTER = ( + "the engine-side comparison is RETIRED (D5 C6) and these rows can no longer " + "be completed from this tree; run `python -m qt.hand_anchors_engine_values " + "--verify` to re-check whatever comparison is already recorded" +) + + +def pending_engine_line(n_pending: int) -> str: + """The run-summary line about daily rows awaiting an engine comparison.""" + return f"daily rows pending engine comparison: {n_pending} — {ENGINE_COMPARISON_POINTER}" + SEED = 20260724 # ---- pinned definition constants, restated as PLAIN NUMBERS (the point of a diff --git a/tests/test_hand_anchor_record.py b/tests/test_hand_anchor_record.py index 19f4d13..f9aa488 100644 --- a/tests/test_hand_anchor_record.py +++ b/tests/test_hand_anchor_record.py @@ -19,7 +19,7 @@ import pytest -from qt.hand_anchors_d2 import TOL +from qt.hand_anchors_d2 import TOL, pending_engine_line from qt.hand_anchors_engine_values import ( DAILY_FACTORS, AnchorRecordVerification, @@ -200,3 +200,114 @@ def test_verify_writes_nothing_into_the_record(tmp_path: Path): ) def test_the_relative_difference_rule_matches_the_recorded_one(hand, engine, expected): assert relative_difference(hand, engine) == expected + + +# --------------------------------------------------------------------------- # +# The run-summary pointer (review MEDIUM-1) +# --------------------------------------------------------------------------- # +def test_the_pending_line_does_not_send_anyone_to_a_command_that_raises(): + """`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 writes `daily_pending_engine` WITHOUT + `daily_engine_compared`, so it is the thing that empties the record. A user + had just wiped the comparison and was then pointed down a dead end. + """ + line = pending_engine_line(20) + assert "20" in line + assert "python -m qt.hand_anchors_engine_values --verify" in line + assert "RETIRED" in line + assert "no longer be completed" in line + # the bare invocation must not appear as an instruction + assert "hand_anchors_engine_values)" not in line + assert "run python -m qt.hand_anchors_engine_values\n" not in line + + +def test_the_pointer_is_composed_by_the_printer_not_restated(): + """Author-once, structurally: the printing module must carry NO literal of + its own. A regex cannot assert that no other sentence points at the retired + command; "there is no other sentence in this file" can.""" + source = (Path(__file__).resolve().parents[1] / "qt" / "hand_anchor_rows.py").read_text() + # The INVOCATION is what must exist once. Naming the module in prose is + # fine and useful; telling someone to run it is the thing that goes stale. + assert "python -m qt.hand_anchors_engine_values" not in source + assert "pending_engine_line" in source + + +def test_the_pointer_lives_where_the_pure_side_can_reach_it(): + """It is in `hand_anchors_d2`, not in the retired module, because the latter + imports the engine and the hand side must never load it. If this ever moves, + `hand_anchor_rows` would drag `data.clean` in and break hand-anchor purity.""" + import qt.hand_anchors_d2 as pure + + assert isinstance(pure.ENGINE_COMPARISON_POINTER, str) + # AST, not substring: the module has a COMMENT naming data.clean to explain + # why it stays out, and a text match cannot tell that from an import. + import ast + + for name in ("hand_anchors_d2.py", "hand_anchor_rows.py"): + path = Path(__file__).resolve().parents[1] / "qt" / name + for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))): + targets: list[str] = [] + if isinstance(node, ast.Import): + targets = [alias.name for alias in node.names] + elif isinstance(node, ast.ImportFrom): + targets = [node.module or ""] + for target in targets: + assert not target.startswith(("factors", "data.clean")), ( + f"{name} imports the engine ({target}); the hand side must not" + ) + + +RETIRED_INVOCATIONS = ( + "python -m qt.panel_freeze", + "python -m qt.panel_reconcile", + "python -m qt.hand_anchors_engine_values", +) +#: Sites allowed to name a retired invocation WITHOUT `--verify`, each because +#: the tool is the SUBJECT of a retirement statement rather than an instruction. +#: Reviewed one by one; anything new must be looked at, not appended blindly. +NAMES_A_RETIRED_TOOL_AS_SUBJECT = { + "qt/panel_freeze.py", # the `tool` argument of retirement_message + "qt/panel_reconcile.py", # ditto + "qt/hand_anchors_engine_values.py", # ditto + "docs/factors/d1_panel_freeze_manifest.md", # "(不带 --verify) 现在是可读的报错" + "docs/factors/pr_c_cutoff_fix_reference_panel.md", # archived [RETIRED, C6] block +} + + +def test_no_new_file_starts_instructing_people_to_run_a_retired_tool(): + """An inventory net, NOT a meaning check — range stated so it is not trusted + further than it reaches. + + It cannot tell an instruction from a description; it only notices when a NEW + file starts naming a retired invocation without `--verify`. That is worth + having because the defect it follows was a pre-existing line in a file the + author never scanned: fixing the two documents and missing the runtime print + is exactly the #82 shape — a guard that only looks where you already looked + confirms only what you already know. `docs/progress/` is excluded as a + historical archive: it records what was true then. + """ + repo = Path(__file__).resolve().parents[1] + offenders: list[str] = [] + for path in sorted(repo.rglob("*")): + if path.suffix not in {".py", ".md", ".yaml", ".txt"} or not path.is_file(): + continue + relative = path.relative_to(repo).as_posix() + if relative.startswith(("docs/progress/", ".git/", "tests/")): + continue + text = path.read_text(encoding="utf-8") + for call in RETIRED_INVOCATIONS: + start = text.find(call) + while start != -1: + # WINDOW, not line: these sentences wrap, so `--verify` routinely + # lands on the following source line. + window = text[start : start + len(call) + 40] + if "--verify" not in window and relative not in NAMES_A_RETIRED_TOOL_AS_SUBJECT: + number = text.count("\n", 0, start) + 1 + offenders.append(f"{relative}:{number}: {call}") + start = text.find(call, start + 1) + assert not offenders, ( + "these name a retired invocation without --verify, from a file not on the " + "reviewed subject list:\n " + "\n ".join(offenders) + )