Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion analytics/eval/contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,47 @@
(``eval_contract_version``) in every report JSON, plus four new provenance rows in
the Markdown. No metric, no axis input and no threshold moves. The D5 C5
reconciliation registers this as a named, expected artifact drift.

CONTRACT v1.1 — WHAT CHANGED AND WHY
------------------------------------
v1.0 could state which information set a factor's values came from. It could not
state that those values SUPERSEDE previously published ones — and the obvious
place to write that, ``FactorSpec.description``, turned out not to work.

``sanitize_payload`` caps every exported string at :data:`MAX_VALUE_CHARS` (200)
and appends ``...[truncated]``. That cap is correct for arbitrary payload values
and it is generic, not path-specific: measured across the 44 shipped eval JSONs,
**44/44** carry a truncated ``spec.description`` (218 ``[truncated]`` markers in
all, three of them methodological notes in every artifact). So a correction
written as prose in the description reaches the Markdown and the dashboard and is
CUT OUT of the JSON — the copy a summary layer reads. A machine consumer would
see restated numbers with no sign that they replaced a defective run: a report
failing to say the thing about its own provenance that it must say, which is the
same shape as the factor defect that produced the correction in the first place.

v1.1 adds ONE top-level key, ``corrections``, built from the new structured
``FactorSpec.corrections`` (a tuple of ``FactorCorrection``). It is emitted OUTSIDE
the capped path (``analytics.eval.render.corrections_record``) — redacted, never
truncated — and over-length RAISES at spec construction instead of trimming, so
the carrier cannot silently lose the thing it exists to carry. The same tuple
renders one Markdown provenance row per correction and a marker on the dashboard,
so the three surfaces cannot disagree.

An empty tuple is the default and means "no correction has been DECLARED" — never
"this factor is known to be correct".

WHAT DID NOT CHANGE (v1.1)
--------------------------
Every decision rule, every threshold, every metric, and the 200-char cap itself
(it is right for arbitrary payload values; the fix is to stop routing a
load-bearing disclosure through it).
"""

from __future__ import annotations

#: The evaluation contract version this package implements. Bumped ONLY with a
#: written statement of what changed (this module's docstring is that statement).
EVAL_CONTRACT_VERSION = "1.0"
EVAL_CONTRACT_VERSION = "1.1"

#: The identity fields a v1.0 report must be able to state about itself. Named
#: here once so the config validator, the renderer and the cross-basis summary
Expand Down
110 changes: 109 additions & 1 deletion analytics/eval/figures.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,105 @@ def _definition_block_line(spec: FactorSpec) -> str | None:
)


#: Wrap width of the description inside the FACTOR DEFINITION band.
DEFINITION_WRAP_WIDTH = 150

#: How many wrapped lines the band's description slot can hold before the text
#: collides with the metadata row below it. NOT a style preference — the slot is
#: bounded by fixed axes-fraction anchors (description at 0.62 va="top"), and the
#: metadata row's anchor DEPENDS ON THE SPEC FORM: 0.10 for a daily spec, 0.20
#: for an intraday one (which also draws the minute block at 0.04). A longer
#: description silently DRAWS OVER the metadata.
#:
#: The two forms therefore have DIFFERENT capacities, and sizing by the daily
#: branch alone is exactly the defect this split fixes: ``build(factor_id).spec``
#: is daily (``is_intraday=False``) for every shipped minute factor, but the
#: exec-basis dashboard renders ``intraday_spec_variant`` (``is_intraday=True``),
#: so a single 5 measured on the daily branch overprints 9 of the 11 exec-form
#: dashboards.
#:
#: MEASURED, not chosen, on the real dashboard geometry (figsize 15x21.5, the
#: GridSpec of ``_render``); the gap between the two boxes falls 15 px per line:
#:
#: * daily branch (metadata at 0.10): 4 lines +18.8 px, 5 lines +3.8 px,
#: 6 lines -11.2 px -> capacity 5;
#: * intraday branch (metadata at 0.20): 4 lines +1.5 px, 5 lines -13.5 px
#: -> capacity 4.
#:
#: Neither number is reduced for comfort: every factor whose description fits its
#: own branch keeps rendering in full, and only the ones that genuinely overflow
#: (7 of the 11 minute factors at 5 lines daily, 9 of 11 at 4 lines intraday)
#: get an elision marker.
#:
#: The slack at capacity is thin (+3.8 / +1.5 px, about a quarter of a line).
#: That is a fact about the layout, not a hidden risk:
#: ``tests/test_definition_band_layout.py`` re-measures every shipped spec in
#: BOTH forms against the real geometry, so a font or matplotlib change that
#: eats the slack turns the guard RED instead of silently overprinting. The
#: numbers are checked, never trusted.
DEFINITION_MAX_LINES_DAILY = 5
DEFINITION_MAX_LINES_INTRADAY = 4


def definition_max_lines(spec) -> int:
"""The description-line budget for THIS spec's form — daily 5, intraday 4.

Keyed on ``spec.is_intraday`` because that is what moves the metadata row
(see :data:`DEFINITION_MAX_LINES_DAILY`). A spec carries its own form, so
the budget cannot be measured on one branch and spent on the other.
"""
if spec.is_intraday:
return DEFINITION_MAX_LINES_INTRADAY
return DEFINITION_MAX_LINES_DAILY


def definition_description_lines(spec) -> list[str]:
"""The description lines the band may draw, bounded so it cannot overlap.

The bound is per spec form (:func:`definition_max_lines`): the slot holds
5 lines in the daily branch and 4 in the intraday one, so the same
description can fit one dashboard and need elision on the other. Before
any bound existed, nine of the eleven shipped minute factors had
descriptions long enough to collide with the metadata row (measured with
the real renderer: ``valley_price_quantile_20`` is 25 wrapped lines and,
unbounded, overruns the metadata row by 296 px in the daily branch / 314
px in the intraday one). The renderer had exactly one commit in its
history when the bound was added, so every dashboard produced before it
was drawn that way — this is a standing defect, not a regression, and
"both texts unreadable" is the worst of the options.

Bounding it visibly is strictly better than overdrawing: an elided tail is
marked and points at the copy that carries the description in full (the
Markdown provenance box — the JSON's is capped at
``analytics.eval.render.MAX_VALUE_CHARS``). Nothing is dropped without
saying so, which is the whole difference between this and what it
replaces.
"""
cap = definition_max_lines(spec)
lines = textwrap.wrap(spec.description, width=DEFINITION_WRAP_WIDTH)
if len(lines) <= cap:
return lines
elided = len(lines) - (cap - 1)
return lines[: cap - 1] + [
f"... [{elided} more lines elided to fit — FULL definition in the "
f"Markdown report's provenance box]"
]


def _correction_marker(spec) -> str:
"""Compact "this version supersedes published values" marker, or ``""``.

Empty when nothing is declared — an unconditional badge would say something
about every factor, and "no correction declared" is not "known correct".
"""
corrections = tuple(getattr(spec, "corrections", ()) or ())
if not corrections:
return ""
first = corrections[0]
extra = f" (+{len(corrections) - 1})" if len(corrections) > 1 else ""
return f"CORRECTED v{first.from_version} -> v{first.to_version}{extra}"


def _definition_band(ax, data: DashboardData) -> None:
"""MANDATORY panel: how the factor is computed, from the FactorSpec.

Expand All @@ -235,7 +334,16 @@ def _definition_band(ax, data: DashboardData) -> None:
ax.text(0.185, 0.90, f"{spec.factor_id} (v{spec.version})", fontsize=9.5,
fontweight="bold", color="#111111", transform=ax.transAxes, va="top",
family="DejaVu Sans Mono")
ax.text(0.012, 0.62, textwrap.fill(spec.description, width=150), fontsize=9.2,
# Corrected factors say so ON the picture. The dashboard is the surface a
# reader is most likely to look at alone, and "this version supersedes
# published values" is not something they should have to open the JSON for.
# Sourced from the SAME structured tuple as the JSON block and the Markdown
# row — never re-worded here.
marker = _correction_marker(spec)
if marker:
ax.text(0.60, 0.90, marker, fontsize=9.0, fontweight="bold", color="#A33A00",
transform=ax.transAxes, va="top", family="DejaVu Sans Mono")
ax.text(0.012, 0.62, "\n".join(definition_description_lines(spec)), fontsize=9.2,
color="#222222", transform=ax.transAxes, va="top")
block = _definition_block_line(spec)
meta_y = 0.20 if block is not None else 0.10
Expand Down
58 changes: 57 additions & 1 deletion analytics/eval/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,29 @@ def sanitize_payload(value: object) -> object:
return cleaned


def corrections_record(spec) -> list[dict[str, str]]:
"""``spec.corrections`` as plain dicts — REDACTED but never truncated.

Deliberately NOT routed through :func:`sanitize_payload`. That function caps a
string at :data:`MAX_VALUE_CHARS` and appends ``...[truncated]``, which is right
for arbitrary payload values (a payload can accidentally hold a whole panel's
repr) and WRONG here: measured on the 44 shipped eval JSONs, 44/44 had a
truncated ``spec.description``, so a correction written as prose in the
description reached the Markdown and the dashboard and was cut out of the JSON —
the one copy a machine consumer reads. A record that drops the disclosure of its
own provenance is the same shape as the defect this project keeps re-learning.

Safe to skip the cap because these are not arbitrary values: every field is an
authored, fixed-shape string that ``FactorCorrection`` already bounds at
construction, and over-length there RAISES rather than trimming. Redaction still
runs — dropping the cap must not drop the secret guard.
"""
return [
{k: sanitize_text(str(v)) for k, v in c.as_dict().items()}
for c in (getattr(spec, "corrections", ()) or ())
]


def format_value(value: object) -> str:
"""Compact deterministic rendering of one payload value."""
if isinstance(value, dict):
Expand Down Expand Up @@ -150,6 +173,11 @@ def report_to_dict(report: FactorEvalReport) -> dict:
entry["note"] = section.note
sections.append(dict(sorted(entry.items())))
return {
# Contract v1.1: the factor's STRUCTURED correction statements, top level
# (sibling of eval_contract_version) rather than inside ``spec``, because
# the ``spec`` block goes through the 200-char payload cap and a superseded
# -values disclosure must never be the thing that gets trimmed.
"corrections": corrections_record(report.spec),
"criteria_source": report.criteria_source,
# Contract v1.0 (analytics/eval/contract.py): stated in the machine-readable
# record so a stored verdict can be matched to the contract that produced it
Expand All @@ -158,7 +186,15 @@ def report_to_dict(report: FactorEvalReport) -> dict:
"eval_config": sanitize_payload(vars(report.cfg)),
"schema_version": report.SCHEMA_VERSION,
"sections": sections,
"spec": sanitize_payload(vars(report.spec)),
# ``corrections`` is dropped from the spec dump: it is exported whole at
# top level, and leaving it here too would put a SECOND copy in the record
# — one rendered by ``clean_value``'s ``str()`` fallback (a dataclass repr)
# and then cut at MAX_VALUE_CHARS. Two carriers for one fact, of which the
# in-spec one is broken, is worse than the single-carrier problem this
# whole field was added to fix.
"spec": sanitize_payload(
{k: v for k, v in vars(report.spec).items() if k != "corrections"}
),
# the ACTUAL criteria values used, whatever their source (design §6, v0.6).
"thresholds": sanitize_payload(vars(report.thresholds)),
# The DERIVED deployment label + the three axis-verdicts it was derived
Expand Down Expand Up @@ -239,11 +275,31 @@ def _requires_row(spec) -> str:
return ", ".join(sorted(f"{f.source}.{f.field}" for f in fields))


def _correction_rows(spec) -> list[tuple[str, str]]:
"""One provenance row per declared correction (none = no rows, not a blank one).

A factor with nothing to correct must not grow an empty "corrections:" row —
"no correction has been DECLARED" and "this factor is known to be right" are
different statements, and a blank row invites reading the second one.
"""
return [
(
f"⚠️ CORRECTION (v{c['from_version']} -> v{c['to_version']}, {c['date']})",
f"{c['defect']} {c['effect']} {c['superseded']}",
)
for c in corrections_record(spec)
]


def _provenance_rows(report: FactorEvalReport) -> list[tuple[str, str]]:
spec, cfg = report.spec, report.cfg
rows: list[tuple[str, object]] = [
("factor_id", spec.factor_id),
("description", spec.description),
# Rendered from the SAME structured tuple the JSON's top-level
# ``corrections`` block is built from, so the human copy and the machine
# copy cannot disagree about whether this factor's values were superseded.
*_correction_rows(spec),
("family", spec.family),
("hypothesis (expected IC sign, fixed pre-run)", f"{spec.expected_ic_sign:+d}"),
("horizon / return basis", f"h={spec.forward_return_horizon} / {spec.return_basis}"),
Expand Down
9 changes: 9 additions & 0 deletions config/phase_c_jump_amount_corr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@
# CACHE-ONLY: the minute read is provably live-call-free; daily / universe /
# covariate endpoints go through the read-through cache (warm -> 0 gap fetches).
#
# PIT TRUNCATION (read this before comparing any run against a pre-2026-07-25 artifact):
# the factor pools only the 1min bars visible at 14:50 (available_time <= that bar's
# trade_date + 14:50), like every sibling minute factor. It did NOT always: this factor
# was built before the standing 14:50-truncation authorization and ran on FULL-day bars,
# so its value at d read that day's 14:50-15:00 window -- POST-ENTRY information under the
# 14:51-VWAP exec_to_exec basis. Artifacts produced before the fix are superseded and are
# kept, labelled, under artifacts/reports/pr_c_untruncated_close_to_close/. The factor
# spec version distinguishes them: v1.0 = untruncated, v1.1 = truncated.
#
# Universe = CSI500 (000905.SH), PIT membership; window = 2021-07-01 .. 2026-06-30
# (the project's minute-coverage window; the report notes the factor is stronger in
# mid/small caps). Daily rebalance (project + contract default). The evaluator runs
Expand Down
7 changes: 7 additions & 0 deletions docs/factors/d1_panel_freeze_manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,13 @@ hash(哈希与首段实时监控记录逐一一致),3 个双跑对象另

(mean/std 为 float64 全精度 `Series.mean()` / `std(ddof=1)`,NaN 跳过;canonical hash 为权威。)

> ⚠️ **`jump_amount_corr_20` 一行的取值口径此后被判定为缺陷**(该因子缺 14:50 日内截断,
> 在 14:51 VWAP exec-to-exec 基准下构成前视)。本表**一个字节未改**——它是已发布内容的
> 忠实记录,D2 逐位对账的历史参照仍然是它。**D5 面板腿对该因子请改用**截断后的参照面板
> `artifacts/refactor_baseline/pr_c_cutoff_fix/`,provenance 与「为什么两份并存」见
> [`pr_c_cutoff_fix_reference_panel.md`](pr_c_cutoff_fix_reference_panel.md)。
> 其余 13 行不受影响。

| factor_id | kind | rows | date_min | date_max | n_symbols | n_nan | mean | std | canonical_sha256 | file_sha256 |
|---|---|---|---|---|---|---|---|---|---|---|
| value_ep | book | 1158912 | 2021-07-01 | 2026-06-30 | 996 | 141362 | 0.04807913635037822 | 0.050405247296850246 | 1404a68fc88778e78d47da1ed6375c39abec36244a29961c3316a74f0c042e76 | 3b3a8970fcfe51d9b221da79a8f001a03d622d37592fdd76d19275a5159164d4 |
Expand Down
Loading