-
Notifications
You must be signed in to change notification settings - Fork 0
Add strict latest freshness validation #37
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,9 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import datetime as dt | ||
| import json | ||
| from collections.abc import Mapping, Sequence | ||
| from pathlib import Path | ||
| from typing import Any | ||
|
|
||
|
|
||
|
|
@@ -130,6 +132,50 @@ def validate_signal(payload: Mapping[str, Any]) -> None: | |
| _require_string(policy.get("downstream_use"), "policy.downstream_use") | ||
|
|
||
|
|
||
| def validate_latest_signal( | ||
| payload: Mapping[str, Any], | ||
| *, | ||
| reference_date: dt.date | None = None, | ||
| theme_artifact_path: str | Path | None = None, | ||
| allow_expired: bool = False, | ||
| ) -> None: | ||
| """Validate a current signal and its linked theme artifact fail-closed.""" | ||
| validate_signal(payload) | ||
| if str(payload.get("schema_version")) != "2": | ||
| raise SignalValidationError("strict latest validation requires signal schema v2") | ||
| effective_date = reference_date or dt.datetime.now(dt.UTC).date() | ||
| if not allow_expired and dt.date.fromisoformat(str(payload["expires_at"])) < effective_date: | ||
| raise SignalValidationError(f"signal artifact expired on {payload['expires_at']}") | ||
|
|
||
| source_paths = list(payload.get("evidence", {}).get("sources", [])) | ||
| artifact = Path(theme_artifact_path) if theme_artifact_path else next( | ||
| (Path(source) for source in source_paths if "theme_momentum_snapshot" in str(source)), None | ||
| ) | ||
| if artifact is None: | ||
| raise SignalValidationError("latest signal requires a referenced theme momentum artifact") | ||
| if not artifact.exists(): | ||
| raise SignalValidationError(f"referenced theme momentum artifact not found: {artifact}") | ||
| try: | ||
| theme_payload = json.loads(artifact.read_text(encoding="utf-8")) | ||
| from .theme_momentum import validate_theme_momentum_snapshot | ||
|
|
||
| validate_theme_momentum_snapshot( | ||
| theme_payload, | ||
| reference_date=effective_date, | ||
| compatibility=False, | ||
| check_freshness=True, | ||
|
Comment on lines
+160
to
+164
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This strict check only tests the linked theme artifact against the wall/reference date, so a signal with Useful? React with 👍 / 👎. |
||
| require_gate=True, | ||
| allow_expired=allow_expired, | ||
| ) | ||
| except (OSError, json.JSONDecodeError, ValueError) as exc: | ||
| if isinstance(exc, SignalValidationError): | ||
| raise | ||
| raise SignalValidationError(f"invalid referenced theme momentum artifact: {exc}") from exc | ||
| gate = theme_payload["data_quality"]["gate"] | ||
| if gate["allow_downstream_recommendation"] is not True: | ||
| raise SignalValidationError("referenced theme momentum artifact is blocked by data_quality.gate") | ||
|
|
||
|
|
||
| def _validate_bias_mapping(mapping: Mapping[str, Any], name: str) -> None: | ||
| for key, bias in mapping.items(): | ||
| _require_string(key, f"{name} key") | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -250,6 +250,11 @@ def build_theme_momentum_snapshot( | |
| }, | ||
| "theme_ranks": theme_ranks, | ||
| "data_quality": { | ||
| "gate": { | ||
| "status": "pass", | ||
| "allow_downstream_recommendation": True, | ||
| "reasons": [], | ||
| }, | ||
|
Comment on lines
+253
to
+257
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the snapshot is built from partial price data, the artifact can record missing symbols, insufficient history, or unranked themes but still emits a passing gate with Useful? React with 👍 / 👎. |
||
| "coverage": { | ||
| "configured_symbol_count": len(exposure_symbols), | ||
| "priced_symbol_count": len(priced_exposure_symbols), | ||
|
|
@@ -271,7 +276,15 @@ def build_theme_momentum_snapshot( | |
| } | ||
|
|
||
|
|
||
| def validate_theme_momentum_snapshot(snapshot: Mapping[str, Any]) -> None: | ||
| def validate_theme_momentum_snapshot( | ||
| snapshot: Mapping[str, Any], | ||
| *, | ||
| reference_date: dt.date | None = None, | ||
| compatibility: bool = True, | ||
| check_freshness: bool = False, | ||
| require_gate: bool = False, | ||
| allow_expired: bool = False, | ||
| ) -> None: | ||
| """Validate the stable metadata and core shape of v1/v2 theme artifacts.""" | ||
| required = ("schema_version", "as_of", "generated_at", "mode", "artifact_type", "theme_ranks", "data_quality", "policy") | ||
| missing = [key for key in required if key not in snapshot] | ||
|
|
@@ -280,18 +293,45 @@ def validate_theme_momentum_snapshot(snapshot: Mapping[str, Any]) -> None: | |
| schema_version = str(snapshot["schema_version"]) | ||
| if schema_version not in {"1", "2"}: | ||
| raise ValueError("theme momentum snapshot schema_version must be '1' or '2'") | ||
| if schema_version == "1" and not compatibility: | ||
| raise ValueError("schema 1 theme momentum snapshots require explicit compatibility=True") | ||
| if snapshot["mode"] != "theme_momentum_snapshot": | ||
| raise ValueError("theme momentum snapshot mode is invalid") | ||
| if snapshot["artifact_type"] != THEME_MOMENTUM_ARTIFACT_TYPE: | ||
| raise ValueError("theme momentum snapshot artifact_type is invalid") | ||
| parse_price_date(snapshot["as_of"]) | ||
| generated_at = snapshot["generated_at"] | ||
| if not isinstance(generated_at, str) or not generated_at.strip(): | ||
| raise ValueError("theme momentum snapshot generated_at must be a non-empty string") | ||
| raise ValueError("theme momentum snapshot generated_at must be an ISO datetime") | ||
| normalized_generated_at = generated_at[:-1] + "+00:00" if generated_at.endswith("Z") else generated_at | ||
| try: | ||
| dt.datetime.fromisoformat(normalized_generated_at) | ||
| except ValueError as exc: | ||
| raise ValueError("theme momentum snapshot generated_at must be an ISO datetime") from exc | ||
| if schema_version == "2": | ||
| for key in ("expires_at", "model_version", "scoring_version"): | ||
| if not isinstance(snapshot.get(key), str) or not snapshot[key].strip(): | ||
| raise ValueError(f"theme momentum snapshot {key} must be a non-empty string") | ||
| if parse_price_date(snapshot["expires_at"]) < parse_price_date(snapshot["as_of"]): | ||
| expires_at = parse_price_date(snapshot["expires_at"]) | ||
| as_of = parse_price_date(snapshot["as_of"]) | ||
| if expires_at < as_of: | ||
| raise ValueError("theme momentum snapshot expires_at must not be before as_of") | ||
| if check_freshness and reference_date and expires_at < reference_date and not allow_expired: | ||
| raise ValueError(f"theme momentum snapshot expired on {expires_at.isoformat()}") | ||
| if not isinstance(snapshot["theme_ranks"], list) or not isinstance(snapshot["data_quality"], Mapping): | ||
| raise ValueError("theme momentum snapshot core shape is invalid") | ||
| if require_gate: | ||
| gate = snapshot["data_quality"].get("gate") | ||
| if not isinstance(gate, Mapping): | ||
| raise ValueError("theme momentum snapshot data_quality.gate is required") | ||
| if gate.get("status") not in {"pass", "blocked"}: | ||
| raise ValueError("theme momentum snapshot data_quality.gate.status is invalid") | ||
| if not isinstance(gate.get("allow_downstream_recommendation"), bool): | ||
| raise ValueError("theme momentum snapshot data_quality.gate.allow_downstream_recommendation must be boolean") | ||
| if not isinstance(gate.get("reasons"), list): | ||
| raise ValueError("theme momentum snapshot data_quality.gate.reasons must be a list") | ||
| if gate["status"] != ("pass" if gate["allow_downstream_recommendation"] else "blocked"): | ||
| raise ValueError("theme momentum snapshot data_quality.gate is inconsistent") | ||
|
|
||
|
|
||
| def write_theme_momentum_snapshot(snapshot: Mapping[str, Any], path: str | Path) -> Path: | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In strict-latest mode, a fresh schema 1 signal with no
model_versionorscoring_versionstill passes because this call intentionally preserves the historical v1 compatibility path and there is no later check that the top-level signal is schema 2. That leaves the new strict validator unable to enforce provenance/version metadata on the currentlatest_signal.json, even though the linked theme artifact is forced through strict v2 validation.Useful? React with 👍 / 👎.