Skip to content
16 changes: 16 additions & 0 deletions analytics/eval/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@
"""

from analytics.eval.config import EvalConfig
from analytics.eval.contract import (
EVAL_CONTRACT_VERSION,
IDENTITY_FIELDS,
basis_identity_phrase,
)
from analytics.eval.evaluator import EvalIR, FactorEvaluator
from analytics.eval.ir import EvalContext, StandardEvalIR, build_eval_ir
from analytics.eval.report import (
Expand All @@ -27,6 +32,11 @@
extract_verdict_inputs,
)
from analytics.eval.standard import StandardFactorEvaluator
from analytics.eval.summary import (
REQUIRED_SUMMARY_COLUMNS,
render_verdict_summary,
require_basis_columns,
)
from analytics.eval.sections import (
MANDATORY_SECTIONS,
VERDICT_KEYS,
Expand Down Expand Up @@ -56,6 +66,12 @@
__all__ = [
"ADOPT",
"AXIS_FAIL",
"EVAL_CONTRACT_VERSION",
"IDENTITY_FIELDS",
"REQUIRED_SUMMARY_COLUMNS",
"basis_identity_phrase",
"render_verdict_summary",
"require_basis_columns",
"AXIS_INSUFFICIENT_DATA",
"AXIS_NAMES",
"AXIS_NOT_ASSESSED",
Expand Down
68 changes: 68 additions & 0 deletions analytics/eval/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from dataclasses import dataclass

from analytics.eval.verdict import VerdictThresholds
from data.availability_policy import ReturnBasis, View, require_legal_pairing

# Tolerance for spotting the base (multiplier 1.0) cost scenario among floats.
_BASE_COST = 1.0
Expand Down Expand Up @@ -65,6 +66,30 @@ class EvalConfig:
n_factors_screened : multiple-testing background (how many factors were
looked at to find this one).
data_snapshot_id : data/cache version, for reproducibility.
view : (contract v1.0) the INFORMATION-SET view the factor values were taken
under — ``"decision"`` (14:50) or ``"close"``. Not decoration: a verdict
computed on one view is a different statistical claim from the same
verdict on the other, and before v1.0 a report could not say which it was.
return_basis : (contract v1.0) the forward-return basis the factor was scored
on — ``"exec_to_exec"`` (the 14:51 execution anchor) or
``"close_to_close"``. VALIDATED AS A PAIR with ``view``: the two legal
pairings are decision<->exec_to_exec and close<->close_to_close, and
anything else raises here rather than being a doc convention (design
§1.4 mechanism 1, enforced through ``data.availability_policy``).
book_view : (contract v1.0) the view the KNOWN-FACTOR BOOK was taken under,
or None when no book was supplied. A with-book run has TWO information
sets — the subject factor's and the book's — and ``view`` can only
describe one of them, so it describes the subject and this describes the
book. Design §1.1 records the current book (value_ep / value_bp /
volatility_20) as taking close(d) values while exec holding windows open
at 14:51(d): a KNOWN live defect, closed at D7. Until then the honest
artifact says ``view=decision, book_view=close`` — one field claiming
both would be exactly the "report declares a check it did not do" failure
this contract exists to prevent, one level up.

DELIBERATELY NOT pairing-checked against ``return_basis``. The book being
on a different view from the subject IS the disclosure; refusing it would
not fix the mixture, it would only stop the artifact from saying so.
success_criteria : the PRE-REGISTERED verdict bar (design §6, v0.6). A frozen
:class:`~analytics.eval.verdict.VerdictThresholds` declared HERE, before
``evaluate`` runs, IS pre-registered by construction: you cannot tune the
Expand Down Expand Up @@ -97,6 +122,22 @@ class EvalConfig:
tuned: bool = False
n_factors_screened: int | None = None
data_snapshot_id: str | None = None
# -- contract v1.0 identity (see analytics/eval/contract.py) --------------
#
# THE DEFAULT IS THE LEGACY PAIRING, ON PURPOSE. A default's only job is to be
# TRUE for the callers that do not pass the field, and today those are the
# eleven close-basis runners: each builds ONE EvalConfig and hands it to BOTH
# its close_to_close reports and (through qt.exec_basis_eval) its exec ones.
# Defaulting to decision/exec_to_exec would make every close artifact state a
# basis it was not scored on — the describe-the-check drift of #76/#78/#82,
# introduced by the very field added to prevent it. The exec path sets the
# identity EXPLICITLY, exactly as it already derives the spec's exec twin
# (``intraday_spec_variant``), and the unified exec-only runner declares it too.
# When the close runners retire (D5 C6) this default becomes a one-line change
# with no false statement left behind.
view: str = View.CLOSE.value
return_basis: str = ReturnBasis.CLOSE_TO_CLOSE.value
book_view: str | None = None
success_criteria: VerdictThresholds | None = None

def __post_init__(self) -> None:
Expand All @@ -106,6 +147,7 @@ def __post_init__(self) -> None:
self._check_costs()
self._check_capacity()
self._check_declared_sequences()
self._check_view_basis()
self._check_success_criteria()

# -- validators (enforcement layer #1) --------------------------------
Expand Down Expand Up @@ -247,6 +289,32 @@ def _check_capacity(self) -> None:
f"{self.limit_feasibility!r}."
)

def _check_view_basis(self) -> None:
"""Contract v1.0: the view x return-basis pairing, enforced at construction.

Delegated to :func:`data.availability_policy.require_legal_pairing` — the
SINGLE source of the legal pairs (D0). Re-stating the rule here would be
the describe-the-check drift the project keeps paying for. The normalized
enum VALUES are written back so the exported record carries the canonical
spelling whatever the caller passed (enum member or string).
"""
resolved_view, resolved_basis = require_legal_pairing(
self.view, self.return_basis
)
object.__setattr__(self, "view", resolved_view.value)
object.__setattr__(self, "return_basis", resolved_basis.value)
if self.book_view is not None:
try:
book = View(self.book_view)
except ValueError:
allowed = ", ".join(repr(v.value) for v in View)
raise ValueError(
f"EvalConfig.book_view must be None (no book supplied) or one "
f"of {allowed}; got {self.book_view!r}. A book whose view is "
f"unstated is exactly the mixture this field exists to expose."
) from None
object.__setattr__(self, "book_view", book.value)

def _check_success_criteria(self) -> None:
"""The pre-registered bar, if supplied, must be a real VerdictThresholds.

Expand Down
85 changes: 85 additions & 0 deletions analytics/eval/contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""The factor-evaluation contract's VERSION and its declared identity fields.

The statistical adjudication core (three axes / asymmetric gate / "unknown never
convicts" / N_eff CI / exploratory capped at Watch) is NOT rewritten by the
factor-layer refactor — it is UPGRADED, and this module is the explicit version
statement that upgrade owes (the PR #74 precedent: a change to a frozen contract
is stated, not slipped in).

CONTRACT v1.0 — WHAT CHANGED AND WHY
------------------------------------
v0.9 (the eleven-factor loop's frozen contract) described a factor and one
evaluation of it, but it could not say WHICH INFORMATION SET the factor values
were taken under. That was survivable while every run used one basis; it stopped
being survivable when the same factor started being evaluated on two
(``close_to_close`` and ``exec_to_exec``, PR #79), because two reports then
carried the same verdict word for two different statistical claims.

v1.0 adds exactly two identity fields to ``EvalConfig`` and renders four more off
the ``FactorSpec`` that D1 already made mandatory:

* ``EvalConfig.view`` / ``EvalConfig.return_basis`` — the information-set view and
the forward-return basis, validated as a LEGAL PAIRING at construction time via
:func:`data.availability_policy.require_legal_pairing`. A close-view factor
scored on ``exec_to_exec`` returns is now a readable error, not a convention.
* the provenance box renders ``FactorSpec.requires`` / ``adjustment`` /
``overnight_boundary`` / ``lookback_depth`` (contract v1.0/v1.1 of the FACTOR
spec, PR #86 / #91) as named rows instead of leaving them to the JSON's
``vars(spec)`` dump.

WHAT DID NOT CHANGE (and must not, in this step)
-----------------------------------------------
Every decision rule and every threshold. ``VerdictThresholds`` defaults
(``min_abs_icir=0.30``, ``min_incremental_abs_icir=0.15``,
``min_monotonicity_spearman=0.0``, the three-part sample gate) are carried over
BYTE-FOR-BYTE as the UNVALIDATED close-era defaults they have always been
(design v3.2 §十 R24). Re-calibrating a bar is a separate pre-registered run; a
refactor that quietly moved one would make every verdict in this cycle
uninterpretable.

IMPACT SURFACE
--------------
Two new keys in the exported ``eval_config`` block and one new top-level key
(``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.
"""

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"

#: 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
#: guard all refer to ONE list instead of three spellings of it.
IDENTITY_FIELDS: tuple[str, ...] = ("view", "return_basis")

#: How an absent book is rendered. A with-book run whose book view is unknown and a
#: no-book run must not look alike, so the absence is WORDED, never left blank.
NO_BOOK = "none (no book supplied)"


def basis_identity_phrase(
view: str, return_basis: str, book_view: str | None
) -> str:
"""The ONE sentence that states a report's information sets + return basis.

Author-once (#76/#78/#82): the provenance row, the cross-basis summary header
and any prose that needs to say which basis a number belongs to COMPOSE this
string rather than each writing their own. A regex cannot assert that no other
sentence says this; "there is no other sentence" can.

``book_view`` is part of the sentence rather than an optional addendum: a
with-book evaluation carries TWO information sets, and a phrase that mentions
only the subject's would be the same one-field-for-two-facts problem the field
was added to fix (see ``EvalConfig.book_view``).
"""
return (
f"view={view} x return_basis={return_basis}, "
f"book_view={book_view or NO_BOOK}"
)


__all__ = ["EVAL_CONTRACT_VERSION", "IDENTITY_FIELDS", "basis_identity_phrase"]
32 changes: 32 additions & 0 deletions analytics/eval/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from collections.abc import Mapping
from typing import TYPE_CHECKING

from analytics.eval.contract import EVAL_CONTRACT_VERSION, basis_identity_phrase
from analytics.eval.sections import MANDATORY_SECTIONS, Skipped
from data.quality.report import clean_value, sanitize_text

Expand Down Expand Up @@ -150,6 +151,10 @@ def report_to_dict(report: FactorEvalReport) -> dict:
sections.append(dict(sorted(entry.items())))
return {
"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
# — the #74 lesson that a verdict is only interpretable against its contract.
"eval_contract_version": EVAL_CONTRACT_VERSION,
"eval_config": sanitize_payload(vars(report.cfg)),
"schema_version": report.SCHEMA_VERSION,
"sections": sections,
Expand Down Expand Up @@ -219,6 +224,21 @@ def render_report(report: FactorEvalReport, mandatory: tuple[str, ...]) -> str:
return "\n".join(lines).rstrip() + "\n"


def _requires_row(spec) -> str:
"""``spec.requires`` as ``source.field`` pairs, sorted, or an explicit marker.

The JSON's ``vars(spec)`` dump renders these through ``clean_value``'s ``str()``
fallback, i.e. as dataclass REPRs — readable by accident, not by design. The
provenance box states them as data (contract v1.0), and an EMPTY declaration is
marked rather than rendered as a blank row: "this factor declares no endpoint
input" is a fact a reader must not have to infer from whitespace.
"""
fields = tuple(spec.requires or ())
if not fields:
return "(none declared)"
return ", ".join(sorted(f"{f.source}.{f.field}" for f in fields))


def _provenance_rows(report: FactorEvalReport) -> list[tuple[str, str]]:
spec, cfg = report.spec, report.cfg
rows: list[tuple[str, object]] = [
Expand All @@ -227,6 +247,18 @@ def _provenance_rows(report: FactorEvalReport) -> list[tuple[str, str]]:
("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}"),
# -- contract v1.0 identity + factor declarations (D1 contract v1.0/v1.1) --
(
"evaluation contract",
f"v{EVAL_CONTRACT_VERSION}, "
+ basis_identity_phrase(cfg.view, cfg.return_basis, cfg.book_view),
),
("requires (endpoint inputs)", _requires_row(spec)),
(
"adjustment / overnight boundary",
f"{spec.adjustment} / {spec.overnight_boundary}",
),
("lookback depth (trailing trading days)", spec.lookback_depth),
("input fields", ", ".join(spec.input_fields)),
("price adjust / warm-up bars", f"{spec.price_adjust} / {spec.min_history_bars}"),
("universe", f"{cfg.universe} (PIT={cfg.universe_is_pit})"),
Expand Down
97 changes: 97 additions & 0 deletions analytics/eval/summary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""Cross-basis verdict summaries: basis/view are NON-OMITTABLE columns (R24).

A single report states its own information set and return basis in its provenance
box. A SUMMARY does not: a table listing eleven factors' verdicts side by side is
the surface where a close-era "Watch" and an exec-era "Watch" get read as the same
claim, because nothing on the row says they are not. Design v3.2 §3.6 R24 makes
the rule structural: any multi-verdict rendering carries ``basis`` and ``view``
columns, and a caller that omits them gets a readable error instead of a table.

This is the summary-level form of the #76 lesson ("a rendering that does not state
its provenance"), and it is deliberately a REFUSAL rather than a default: filling
in a missing basis would be the silent degradation the project forbids, and
guessing it from the majority of the rows is worse than refusing.

Layering: pure formatting over plain mappings — no pandas, no qt, no store. The
row dicts come from whoever assembled them (a registry read, a directory of report
JSONs); this module only decides what a table is allowed to omit.
"""

from __future__ import annotations

from collections.abc import Mapping, Sequence

from analytics.eval.contract import IDENTITY_FIELDS

#: Columns every cross-basis summary MUST carry. Same tuple the contract declares
#: as a report's identity fields — one list, not two spellings (author once).
REQUIRED_SUMMARY_COLUMNS: tuple[str, ...] = IDENTITY_FIELDS

_MISSING = "—"


def require_basis_columns(columns: Sequence[str]) -> tuple[str, ...]:
"""Return ``columns`` as a tuple, or raise naming the omitted identity column.

Split out from the renderer so a caller that builds a table some other way
(HTML, a notebook) can enforce the same rule without re-deriving it.
"""
resolved = tuple(str(c) for c in columns)
missing = [c for c in REQUIRED_SUMMARY_COLUMNS if c not in resolved]
if missing:
raise ValueError(
f"a cross-basis verdict summary must carry the identity column(s) "
f"{missing}: a verdict from one view/return-basis is a DIFFERENT "
f"statistical claim from the same word under another, and a table that "
f"omits the column invites them to be read as one (design v3.2 §3.6 "
f"R24). Add the column — the summary will not infer or default it."
)
return resolved


def _cell(row: Mapping[str, object], column: str) -> str:
value = row.get(column, None)
return _MISSING if value is None else str(value)


def render_verdict_summary(
rows: Sequence[Mapping[str, object]],
*,
columns: Sequence[str],
title: str | None = None,
) -> str:
"""A deterministic Markdown table of many verdicts, basis/view guaranteed present.

``columns`` fixes the column order (so a diff of two summaries is a diff of the
data). Every row must SUPPLY the identity columns as non-empty values: declaring
the column and then leaving it blank would satisfy the letter of R24 and defeat
its purpose, so it is rejected with the same readable error shape.
"""
resolved = require_basis_columns(columns)
for index, row in enumerate(rows):
blank = [
c
for c in REQUIRED_SUMMARY_COLUMNS
if not str(row.get(c, "") or "").strip()
]
if blank:
raise ValueError(
f"cross-basis summary row {index} ({row.get('factor_id', '?')!r}) "
f"leaves the identity column(s) {blank} empty. Declaring the column "
f"and not filling it is the same omission R24 forbids."
)
lines: list[str] = []
if title:
lines += [f"### {title}", ""]
lines.append("| " + " | ".join(resolved) + " |")
lines.append("|" + "|".join("---" for _ in resolved) + "|")
for row in rows:
lines.append("| " + " | ".join(_cell(row, c) for c in resolved) + " |")
return "\n".join(lines) + "\n"


__all__ = [
"REQUIRED_SUMMARY_COLUMNS",
"render_verdict_summary",
"require_basis_columns",
]
Loading