diff --git a/MODULE_SPEC.yaml b/MODULE_SPEC.yaml index 7d85c2a..894a099 100644 --- a/MODULE_SPEC.yaml +++ b/MODULE_SPEC.yaml @@ -1,7 +1,7 @@ { "schema_version": 1, "module_id": "quant_engine", - "authority": {"scope": "module_metadata", "subject": "quant_engine", "owner": "quant-engine-owner", "source": "MODULE_SPEC.yaml", "revision": 1, "effective_from": "2026-08-20T00:00:00+08:00"}, + "authority": {"scope": "module_metadata", "subject": "quant_engine", "owner": "quant-engine-owner", "source": "MODULE_SPEC.yaml", "revision": 2, "effective_from": "2026-09-01T00:00:00+08:00"}, "repository": {"name": "quant_engine", "workspace_id": "researchhub", "type": "research_engine", "maturity": "operational"}, "bounded_context": { "domain": "quantitative-research-engine", @@ -24,7 +24,16 @@ {"asset_id": "quantitative-model-implementations", "kind": "model", "classification": "internal"}, {"asset_id": "simulation-and-metric-results", "kind": "artifact", "classification": "confidential"} ]}, - "contracts": {"provides": [], "consumes": []}, + "contracts": { + "provides": [ + {"contract_id": "researchhub.factor-definition", "version": "1.0.0", "authority": "quant_engine", "path": "src/quant_engine/factor_contracts.py"}, + {"contract_id": "researchhub.factor-set-ref", "version": "1.0.0", "authority": "quant_engine", "path": "src/quant_engine/factor_contracts.py"} + ], + "consumes": [ + {"contract_id": "researchhub.dataset-snapshot", "version": "1.0.0", "authority": "researchhub.data", "admission": "qualified_immutable_envelope"}, + {"contract_id": "researchhub.data-foundation", "version": "1.0.0", "authority": "researchhub.data", "admission": "content_addressed_selected_views"} + ] + }, "dependencies": [], "agent_context": { "default_entrypoints": [ diff --git a/README.md b/README.md index 42f488c..b35c73e 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ ## 模块 - `alpha_factors` — 158 alpha 公式 + 24 基础算子(移植自 qlib alpha158) +- `factor_contracts` — `FactorDefinition` / `FactorSetRef` v1 纯计算合同、严格 PIT/availability 输入准入与显式 legacy 投影 - `execution` — A 股长仓执行仿真(成本/滑点/现金约束)+ 稀疏调仓/完整交易日 Ledger + 可投影成交与 NAV 审计;T+1、涨跌停、成交量与价差提供独立约束函数 - `indicators` — 50+ 技术指标(MACD / KDJ / 布林 / ATR / ADX / 等) - `data_adapter` — 桥接 qtdb_pro 长表与新模块(rename / long-wide / 复权 / vwap 代理) @@ -195,6 +196,51 @@ print(backtest.stats()) print(backtest.benchmark_report()) ``` +## 因子/特征合同 v1 + +`quant_engine.factor_contracts` 提供 `researchhub.factor-definition` 与 +`researchhub.factor-set-ref` `1.0.0`。合同使用受限 canonical JSON:只接受 ASCII +lower-snake-case object key、UTF-8 string、bool/null 和 safe integer;小数参数必须用显式 +canonical decimal string。定义、输入映射、上游证据、输出 schema/content 和 lineage 的任一 +语义变化都会产生新 identity。 + +创建 `FactorSetRef` 必须提供完整且可重算 identity 的 `DatasetSnapshotEnvelope` 与 +`DataFoundationEnvelope`,不能用 ID 字符串或布尔值代替资格证明。每个因子输入都要映射到一个 +实际选中的 `StandardizedViewRef`,schema 必须同时匹配定义和 view;未消费、缺失、重复或跨 +snapshot/Foundation/PIT 的 view 都会失败关闭。snapshot PIT 可以早于 Foundation/view PIT, +但始终满足 knowledge ≤ snapshot PIT ≤ Foundation/view/FactorSet PIT ≤ evaluation。 + +```python +from quant_engine.factor_contracts import ( + DataFoundationEnvelope, + DatasetSnapshotEnvelope, + FactorDefinition, + FactorSetRef, +) + +snapshot = DatasetSnapshotEnvelope.from_dict(dataset_snapshot_v1) +foundation = DataFoundationEnvelope.from_dict(data_foundation_v1) + +# definition 必须是完整的 FactorDefinition;FactorSetRef.create 还要求显式 input bindings、 +# view availability、output quality/coverage、canonical output bytes 和 immutable artifact ref。 +factor_set = FactorSetRef.create( + definitions=(definition,), + dataset_snapshot=snapshot, + foundation=foundation, + **explicit_factor_set_evidence, +) +``` + +`availability_mode="as_available"` 声明 source/view 和计算产物在历史 evaluation 前实际可用; +`"retrospective_replay"` 保留历史 evaluation,但要求真实 publication/view creation、compute 和 +artifact 时间位于之后,并固定 `historical_availability="not_established"`。两种模式都不会授予 +decision、real-data、production、paper 或 live readiness。 + +旧 `governed_pipeline.FactorVersion` 的四字段构造器、`factor_id@version`、run/target/risk/order +identity 均保持不变。迁移只能通过 content-addressed `LegacyFactorBinding`,再显式调用 +`bind_legacy_factor()` 或 `project_legacy_factor()`;后者是有损投影,不表示旧 digest 与新定义 +digest 等价,也不会把旧 run 静默升级为新合同。 + ## 治理垂直切片 `governed_pipeline` 不复制因子、回测、组合或执行算法,只编排现有能力并补充版本与风险契约。 diff --git a/src/quant_engine/factor_contracts.py b/src/quant_engine/factor_contracts.py new file mode 100644 index 0000000..7e602ee --- /dev/null +++ b/src/quant_engine/factor_contracts.py @@ -0,0 +1,2507 @@ +"""Immutable, versioned factor-definition and factor-set computation contracts. + +The module is deliberately storage and provider neutral. It validates complete +DatasetSnapshot and Data Foundation v1 envelopes at the consumer boundary, but +does not fetch records, execute factor formulas, persist artifacts, or grant +decision, production, or live eligibility. +""" + +from __future__ import annotations + +import hashlib +import json +import re +from collections import defaultdict +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field, fields +from datetime import datetime +from enum import StrEnum +from itertools import pairwise +from types import MappingProxyType +from typing import Any, Final, Never, Self, cast + +from quant_engine.alpha_factors import ALPHA158_REGISTRY + +MAX_SAFE_INTEGER: Final = 9_007_199_254_740_991 +FACTOR_CONTRACT_VERSION: Final = "1.0.0" + +_CANONICAL_KEY = re.compile(r"^[a-z][a-z0-9_]*$") +_FIELD_NAME = re.compile( + r"^(?!.*(?:latest|provider|table|sql|locator|source|tushare|qtdb|edb))" + r"[a-z][a-z0-9]*(?:_[a-z0-9]+)*$" +) +_LOGICAL_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$") +_SEMVER = re.compile(r"^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$") +_SHA256 = re.compile(r"^sha256:[0-9a-f]{64}$") +_BARE_SHA256 = re.compile(r"^[0-9a-f]{64}$") +_GIT_SHA = re.compile(r"^[0-9a-f]{40}$") +_UTC_INSTANT = re.compile( + r"^[0-9]{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])" + r"T(?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](?:\.[0-9]{1,6})?Z$" +) +_CALENDAR_DATE = re.compile( + r"^[0-9]{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])$" +) +_CANONICAL_DECIMAL = re.compile(r"^-?(?:0|[1-9][0-9]*)(?:\.[0-9]*[1-9])?$") +_DATASET_ID = re.compile(r"^rhdataset:(?:market|macroeconomic):[0-9a-f]{32}$") +_TRANSFORMATION_ID = re.compile(r"^rhtransform:[0-9a-f]{32}$") +_SNAPSHOT_ID = re.compile(r"^rhdsv1:sha256:[0-9a-f]{64}$") +_FOUNDATION_ID = re.compile(r"^rhdfv1:sha256:[0-9a-f]{64}$") +_INSTRUMENT_ID = re.compile(r"^rhinstrument:[0-9a-f]{32}$") +_ROUTE_ID = re.compile(r"^rhroutev1:sha256:[0-9a-f]{64}$") +_CALENDAR_ID = re.compile(r"^rhcalendar:[0-9a-f]{32}$") +_CALENDAR_REVISION_ID = re.compile(r"^rhcalv1:sha256:[0-9a-f]{64}$") +_ACTION_ID = re.compile(r"^rhaction:[0-9a-f]{32}$") +_ACTION_REVISION_ID = re.compile(r"^rhcav1:sha256:[0-9a-f]{64}$") +_VIEW_ID = re.compile(r"^rhview:[0-9a-f]{32}$") +_VIEW_REF_ID = re.compile(r"^rhviewrefv1:sha256:[0-9a-f]{64}$") +_DEFINITION_ID = re.compile(r"^rhfactorv1:sha256:[0-9a-f]{64}$") +_FACTOR_SET_ID = re.compile(r"^rhfactorsetv1:sha256:[0-9a-f]{64}$") +_OUTPUT_ARTIFACT_ID = re.compile(r"^rhfactoroutputv1:sha256:[0-9a-f]{64}$") +_LEGACY_BINDING_ID = re.compile(r"^rhlegacyfactorv1:sha256:[0-9a-f]{64}$") +_FORBIDDEN_TOKENS: Final = frozenset( + {"latest", "provider", "table", "sql", "locator", "tushare", "wind", "bloomberg"} +) + + +class ContractErrorCode(StrEnum): + """Stable machine-readable rejection categories for the public boundary.""" + + TYPE_ERROR = "type_error" + MISSING_FIELD = "missing_field" + UNKNOWN_FIELD = "unknown_field" + INVALID_FORMAT = "invalid_format" + INVALID_VALUE = "invalid_value" + IDENTITY_MISMATCH = "identity_mismatch" + QUALIFICATION_REJECTED = "qualification_rejected" + TIME_ORDER_VIOLATION = "time_order_violation" + INPUT_CLOSURE_VIOLATION = "input_closure_violation" + LINEAGE_VIOLATION = "lineage_violation" + ARTIFACT_MISMATCH = "artifact_mismatch" + READINESS_ESCALATION = "readiness_escalation" + LEGACY_BINDING_MISMATCH = "legacy_binding_mismatch" + + +class FactorContractError(ValueError): + """Typed deterministic contract rejection with an invariant/JSON path.""" + + def __init__(self, code: ContractErrorCode, path: str, detail: str) -> None: + self.code = code + self.path = path + self.detail = detail + super().__init__(f"{code.value} at {path}: {detail}") + + +def _fail(code: ContractErrorCode, path: str, detail: str) -> Never: + raise FactorContractError(code, path, detail) + + +def _assert_canonical_profile(value: Any, path: str = "$") -> None: + if type(value) is dict: + for key, child in value.items(): + if type(key) is not str or _CANONICAL_KEY.fullmatch(key) is None: + _fail(ContractErrorCode.INVALID_FORMAT, path, "non-canonical object key") + _assert_canonical_profile(child, f"{path}.{key}") + return + if type(value) is list: + for index, child in enumerate(value): + _assert_canonical_profile(child, f"{path}[{index}]") + return + if value is None or type(value) is bool: + return + if type(value) is int: + if not -MAX_SAFE_INTEGER <= value <= MAX_SAFE_INTEGER: + _fail(ContractErrorCode.INVALID_VALUE, path, "integer exceeds safe range") + return + if type(value) is str: + try: + value.encode("utf-8") + except UnicodeEncodeError as exc: + raise FactorContractError( + ContractErrorCode.INVALID_FORMAT, + path, + "string contains an unpaired surrogate", + ) from exc + return + _fail( + ContractErrorCode.TYPE_ERROR, + path, + "value is outside the restricted canonical JSON domain", + ) + + +def canonical_json_bytes(value: Any) -> bytes: + """Return bytes for the restricted ResearchHub canonical JSON profile.""" + + _assert_canonical_profile(value) + return json.dumps( + value, + allow_nan=False, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + + +def canonical_json(value: Any) -> str: + return canonical_json_bytes(value).decode("utf-8") + + +def _duplicate_key_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + _fail(ContractErrorCode.INVALID_VALUE, "$", f"duplicate JSON key: {key}") + result[key] = value + return result + + +def _parse_json_object(value: str | bytes, path: str) -> dict[str, Any]: + try: + loaded = json.loads(value, object_pairs_hook=_duplicate_key_pairs) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise FactorContractError( + ContractErrorCode.INVALID_FORMAT, + path, + "invalid UTF-8 JSON", + ) from exc + if type(loaded) is not dict: + _fail(ContractErrorCode.TYPE_ERROR, path, "top-level JSON value must be an object") + _assert_canonical_profile(loaded, path) + return cast(dict[str, Any], loaded) + + +def _freeze_json(value: Any, path: str = "$") -> Any: + if type(value) is dict: + copied: dict[str, Any] = {} + for key, child in value.items(): + if type(key) is not str or _CANONICAL_KEY.fullmatch(key) is None: + _fail(ContractErrorCode.INVALID_FORMAT, path, "non-canonical object key") + copied[key] = _freeze_json(child, f"{path}.{key}") + return MappingProxyType(copied) + if type(value) in {list, tuple}: + return tuple(_freeze_json(child, f"{path}[{index}]") for index, child in enumerate(value)) + if value is None or type(value) in {str, bool, int}: + _assert_canonical_profile(value, path) + return value + _fail(ContractErrorCode.TYPE_ERROR, path, "unsupported mutable or custom value") + + +def _thaw_json(value: Any) -> Any: + if isinstance(value, Mapping): + return {key: _thaw_json(child) for key, child in value.items()} + if type(value) is tuple: + return [_thaw_json(child) for child in value] + return cast(dict[str, Any], value) + + +def _content_address(document: dict[str, Any], identity_field: str, prefix: str) -> str: + payload = {key: value for key, value in document.items() if key != identity_field} + return f"{prefix}{hashlib.sha256(canonical_json_bytes(payload)).hexdigest()}" + + +def _digest_bytes(value: bytes) -> str: + return f"sha256:{hashlib.sha256(value).hexdigest()}" + + +def _object( + value: Any, + path: str, + required: Sequence[str], + optional: Sequence[str] = (), +) -> dict[str, Any]: + if type(value) is not dict: + _fail(ContractErrorCode.TYPE_ERROR, path, "must be an object") + missing = [key for key in required if key not in value] + if missing: + _fail(ContractErrorCode.MISSING_FIELD, f"{path}.{missing[0]}", "field is required") + unexpected = sorted(set(value) - set(required) - set(optional)) + if unexpected: + _fail( + ContractErrorCode.UNKNOWN_FIELD, + f"{path}.{unexpected[0]}", + "field is not allowed by the closed-world contract", + ) + return cast(dict[str, Any], value) + + +def _array(value: Any, path: str, *, minimum: int = 0, unique: bool = False) -> list[Any]: + if type(value) is not list: + _fail(ContractErrorCode.TYPE_ERROR, path, "must be an array") + if len(value) < minimum: + _fail(ContractErrorCode.INVALID_VALUE, path, f"requires at least {minimum} item(s)") + if unique: + encoded = [canonical_json_bytes(item) for item in value] + if len(encoded) != len(set(encoded)): + _fail(ContractErrorCode.INVALID_VALUE, path, "items must be unique") + return value + + +def _string(value: Any, path: str, pattern: re.Pattern[str] | None = None) -> str: + if type(value) is not str: + _fail(ContractErrorCode.TYPE_ERROR, path, "must be a string") + if pattern is not None and pattern.fullmatch(value) is None: + _fail(ContractErrorCode.INVALID_FORMAT, path, "does not match the required profile") + return value + + +def _enum(value: Any, path: str, allowed: set[str]) -> str: + normalized = _string(value, path) + if normalized not in allowed: + _fail(ContractErrorCode.INVALID_VALUE, path, "unsupported enum value") + return normalized + + +def _safe_integer(value: Any, path: str, *, minimum: int | None = None) -> int: + if type(value) is not int: + _fail(ContractErrorCode.TYPE_ERROR, path, "must be an integer") + if not -MAX_SAFE_INTEGER <= value <= MAX_SAFE_INTEGER: + _fail(ContractErrorCode.INVALID_VALUE, path, "integer exceeds safe range") + if minimum is not None and value < minimum: + _fail(ContractErrorCode.INVALID_VALUE, path, f"must be at least {minimum}") + return value + + +def _parse_utc(value: Any, path: str) -> datetime: + text = _string(value, path, _UTC_INSTANT) + try: + return datetime.fromisoformat(f"{text[:-1]}+00:00") + except ValueError as exc: + raise FactorContractError( + ContractErrorCode.INVALID_FORMAT, + path, + "not a real calendar instant", + ) from exc + + +def _normalize_new_instant(value: Any, path: str) -> str: + parsed = _parse_utc(value, path) + if parsed.microsecond: + fraction = f"{parsed.microsecond:06d}".rstrip("0") + return parsed.strftime("%Y-%m-%dT%H:%M:%S") + f".{fraction}Z" + return parsed.strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _parse_date(value: Any, path: str) -> None: + text = _string(value, path, _CALENDAR_DATE) + try: + datetime.strptime(text, "%Y-%m-%d") + except ValueError as exc: + raise FactorContractError( + ContractErrorCode.INVALID_FORMAT, + path, + "not a real calendar date", + ) from exc + + +def _logical_id(value: Any, path: str) -> str: + text = _string(value, path, _LOGICAL_ID) + tokens = {token.lower() for token in re.findall(r"[A-Za-z0-9]+", text)} + if not tokens.isdisjoint(_FORBIDDEN_TOKENS) or "://" in text: + _fail(ContractErrorCode.INVALID_VALUE, path, "mutable alias or physical locator is forbidden") + return text + + +def _semver(value: Any, path: str) -> str: + return _string(value, path, _SEMVER) + + +def _digest(value: Any, path: str) -> str: + return _string(value, path, _SHA256) + + +def _git_revision(value: Any, path: str) -> str: + return _string(value, path, _GIT_SHA) + + +def _no_physical_leakage(value: Any, path: str = "$") -> None: + if type(value) is dict: + for key, child in value.items(): + tokens = set(re.findall(r"[a-z0-9]+", key.lower())) + if not tokens.isdisjoint(_FORBIDDEN_TOKENS): + _fail(ContractErrorCode.INVALID_VALUE, f"{path}.{key}", "physical token leakage") + _no_physical_leakage(child, f"{path}.{key}") + elif type(value) is list: + for index, child in enumerate(value): + _no_physical_leakage(child, f"{path}[{index}]") + elif type(value) is str: + tokens = set(re.findall(r"[a-z0-9]+", value.lower())) + if not tokens.isdisjoint(_FORBIDDEN_TOKENS): + _fail(ContractErrorCode.INVALID_VALUE, path, "physical token leakage") + + +@dataclass(frozen=True, slots=True) +class ProducerIdentity: + id: str + version: str + + def __post_init__(self) -> None: + object.__setattr__(self, "id", _logical_id(self.id, "$.producer.id")) + object.__setattr__(self, "version", _semver(self.version, "$.producer.version")) + + def to_dict(self) -> dict[str, Any]: + return {"id": self.id, "version": self.version} + + @classmethod + def from_dict(cls, value: Any, path: str = "$.producer") -> Self: + item = _object(value, path, ("id", "version")) + return cls( + id=_logical_id(item["id"], f"{path}.id"), + version=_semver(item["version"], f"{path}.version"), + ) + + +@dataclass(frozen=True, slots=True) +class TypedParameter: + type: str + value: Any + + def __post_init__(self) -> None: + parameter_type = _enum( + self.type, + "$.parameters.*.type", + {"boolean", "decimal", "integer", "json", "null", "string"}, + ) + raw = self.value + if parameter_type == "boolean" and type(raw) is not bool: + _fail(ContractErrorCode.TYPE_ERROR, "$.parameters.*.value", "must be boolean") + if parameter_type == "integer": + _safe_integer(raw, "$.parameters.*.value") + if parameter_type == "string" and type(raw) is not str: + _fail(ContractErrorCode.TYPE_ERROR, "$.parameters.*.value", "must be string") + if parameter_type == "null" and raw is not None: + _fail(ContractErrorCode.TYPE_ERROR, "$.parameters.*.value", "must be null") + if parameter_type == "decimal": + decimal = _string(raw, "$.parameters.*.value", _CANONICAL_DECIMAL) + if decimal in {"-0", "-0.0"}: + _fail( + ContractErrorCode.INVALID_FORMAT, + "$.parameters.*.value", + "negative zero is forbidden", + ) + if parameter_type == "json": + _assert_canonical_profile(_thaw_json(_freeze_json(raw)), "$.parameters.*.value") + object.__setattr__(self, "type", parameter_type) + object.__setattr__(self, "value", _freeze_json(raw, "$.parameters.*.value")) + + def to_dict(self) -> dict[str, Any]: + return {"type": self.type, "value": _thaw_json(self.value)} + + @classmethod + def from_dict(cls, value: Any, path: str) -> Self: + item = _object(value, path, ("type", "value")) + return cls(type=_string(item["type"], f"{path}.type"), value=item["value"]) + + +@dataclass(frozen=True, slots=True) +class FactorInput: + input_name: str + schema_digest: str + required_columns: tuple[str, ...] + + def __post_init__(self) -> None: + object.__setattr__(self, "input_name", _string(self.input_name, "$.inputs[].input_name", _FIELD_NAME)) + object.__setattr__(self, "schema_digest", _digest(self.schema_digest, "$.inputs[].schema_digest")) + if type(self.required_columns) not in {tuple, list}: + _fail( + ContractErrorCode.TYPE_ERROR, + "$.inputs[].required_columns", + "must be a sequence", + ) + columns = tuple( + _string(column, f"$.inputs[].required_columns[{index}]", _FIELD_NAME) + for index, column in enumerate(self.required_columns) + ) + if not columns or len(columns) != len(set(columns)): + _fail( + ContractErrorCode.INVALID_VALUE, + "$.inputs[].required_columns", + "must be non-empty and unique", + ) + object.__setattr__(self, "required_columns", columns) + + def to_dict(self) -> dict[str, Any]: + return { + "input_name": self.input_name, + "schema_digest": self.schema_digest, + "required_columns": list(self.required_columns), + } + + @classmethod + def from_dict(cls, value: Any, path: str) -> Self: + item = _object(value, path, ("input_name", "schema_digest", "required_columns")) + columns = _array(item["required_columns"], f"{path}.required_columns", minimum=1, unique=True) + return cls( + input_name=_string(item["input_name"], f"{path}.input_name"), + schema_digest=_string(item["schema_digest"], f"{path}.schema_digest"), + required_columns=tuple(columns), + ) + + +def factor_input_schema_digest(inputs: Sequence[FactorInput]) -> str: + normalized = _normalize_factor_inputs(inputs) + payload = [item.to_dict() for item in normalized] + return _digest_bytes(canonical_json_bytes(payload)) + + +def _normalize_factor_inputs(inputs: Sequence[FactorInput]) -> tuple[FactorInput, ...]: + if type(inputs) not in {tuple, list}: + _fail(ContractErrorCode.TYPE_ERROR, "$.inputs", "must be a sequence") + normalized: list[FactorInput] = [] + for index, item in enumerate(inputs): + if not isinstance(item, FactorInput): + _fail(ContractErrorCode.TYPE_ERROR, f"$.inputs[{index}]", "must be FactorInput") + normalized.append(item) + if not normalized: + _fail(ContractErrorCode.INVALID_VALUE, "$.inputs", "at least one input is required") + names = [item.input_name for item in normalized] + if len(names) != len(set(names)): + _fail(ContractErrorCode.INVALID_VALUE, "$.inputs", "duplicate input_name") + return tuple(sorted(normalized, key=lambda item: item.input_name)) + + +@dataclass(frozen=True, slots=True, init=False) +class FactorDefinition: + contract_name: str + schema_version: str + definition_id: str + factor_id: str + version: str + formula: str + parameters: Mapping[str, TypedParameter] + implementation_digest: str + input_schema_digest: str + inputs: tuple[FactorInput, ...] + valid_from: str + valid_until: str + warmup_sessions: int + lag_sessions: int + producer: ProducerIdentity + code_revision: str + + @classmethod + def create( + cls, + *, + factor_id: str, + version: str, + formula: str, + parameters: Mapping[str, TypedParameter], + implementation_digest: str, + input_schema_digest: str, + inputs: Sequence[FactorInput], + valid_from: str, + valid_until: str, + warmup_sessions: int, + lag_sessions: int, + producer: ProducerIdentity, + code_revision: str, + ) -> Self: + return cls._build( + factor_id=factor_id, + version=version, + formula=formula, + parameters=parameters, + implementation_digest=implementation_digest, + input_schema_digest=input_schema_digest, + inputs=inputs, + valid_from=valid_from, + valid_until=valid_until, + warmup_sessions=warmup_sessions, + lag_sessions=lag_sessions, + producer=producer, + code_revision=code_revision, + supplied_definition_id=None, + ) + + @classmethod + def _build( + cls, + *, + factor_id: Any, + version: Any, + formula: Any, + parameters: Any, + implementation_digest: Any, + input_schema_digest: Any, + inputs: Sequence[FactorInput], + valid_from: Any, + valid_until: Any, + warmup_sessions: Any, + lag_sessions: Any, + producer: Any, + code_revision: Any, + supplied_definition_id: Any, + ) -> Self: + normalized_factor_id = _logical_id(factor_id, "$.factor_id") + normalized_version = _semver(version, "$.version") + normalized_formula = _string(formula, "$.formula") + if not 1 <= len(normalized_formula) <= 4096: + _fail(ContractErrorCode.INVALID_VALUE, "$.formula", "length must be 1..4096") + if type(parameters) is not dict: + _fail(ContractErrorCode.TYPE_ERROR, "$.parameters", "must be an object") + normalized_parameters: dict[str, TypedParameter] = {} + for name, value in parameters.items(): + normalized_name = _string(name, "$.parameters", _FIELD_NAME) + if not isinstance(value, TypedParameter): + _fail( + ContractErrorCode.TYPE_ERROR, + f"$.parameters.{normalized_name}", + "must be TypedParameter", + ) + normalized_parameters[normalized_name] = TypedParameter(value.type, _thaw_json(value.value)) + normalized_inputs = _normalize_factor_inputs(inputs) + normalized_input_digest = _digest(input_schema_digest, "$.input_schema_digest") + expected_input_digest = factor_input_schema_digest(normalized_inputs) + if normalized_input_digest != expected_input_digest: + _fail( + ContractErrorCode.IDENTITY_MISMATCH, + "$.input_schema_digest", + "does not identify the canonical input declarations", + ) + normalized_valid_from = _normalize_new_instant(valid_from, "$.valid_from") + normalized_valid_until = _normalize_new_instant(valid_until, "$.valid_until") + if _parse_utc(normalized_valid_from, "$.valid_from") >= _parse_utc( + normalized_valid_until, "$.valid_until" + ): + _fail( + ContractErrorCode.TIME_ORDER_VIOLATION, + "$.validity", + "valid_from must be before valid_until", + ) + normalized_warmup = _safe_integer(warmup_sessions, "$.warmup_sessions", minimum=0) + normalized_lag = _safe_integer(lag_sessions, "$.lag_sessions", minimum=0) + if not isinstance(producer, ProducerIdentity): + _fail(ContractErrorCode.TYPE_ERROR, "$.producer", "must be ProducerIdentity") + payload = { + "contract_name": "researchhub.factor-definition", + "schema_version": FACTOR_CONTRACT_VERSION, + "factor_id": normalized_factor_id, + "version": normalized_version, + "formula": normalized_formula, + "parameters": { + name: normalized_parameters[name].to_dict() + for name in sorted(normalized_parameters) + }, + "implementation_digest": _digest(implementation_digest, "$.implementation_digest"), + "input_schema_digest": normalized_input_digest, + "inputs": [item.to_dict() for item in normalized_inputs], + "valid_from": normalized_valid_from, + "valid_until": normalized_valid_until, + "warmup_sessions": normalized_warmup, + "lag_sessions": normalized_lag, + "producer": producer.to_dict(), + "code_revision": _git_revision(code_revision, "$.code_revision"), + } + expected_id = f"rhfactorv1:sha256:{hashlib.sha256(canonical_json_bytes(payload)).hexdigest()}" + if supplied_definition_id is not None: + normalized_supplied = _string(supplied_definition_id, "$.definition_id", _DEFINITION_ID) + if normalized_supplied != expected_id: + _fail( + ContractErrorCode.IDENTITY_MISMATCH, + "$.definition_id", + "does not identify the complete normalized definition", + ) + instance = object.__new__(cls) + for name, value in { + **payload, + "definition_id": expected_id, + "parameters": MappingProxyType(dict(sorted(normalized_parameters.items()))), + "inputs": normalized_inputs, + "producer": producer, + }.items(): + object.__setattr__(instance, name, value) + return instance + + def to_dict(self) -> dict[str, Any]: + return { + "contract_name": self.contract_name, + "schema_version": self.schema_version, + "definition_id": self.definition_id, + "factor_id": self.factor_id, + "version": self.version, + "formula": self.formula, + "parameters": {name: value.to_dict() for name, value in self.parameters.items()}, + "implementation_digest": self.implementation_digest, + "input_schema_digest": self.input_schema_digest, + "inputs": [item.to_dict() for item in self.inputs], + "valid_from": self.valid_from, + "valid_until": self.valid_until, + "warmup_sessions": self.warmup_sessions, + "lag_sessions": self.lag_sessions, + "producer": self.producer.to_dict(), + "code_revision": self.code_revision, + } + + def to_json(self) -> str: + return canonical_json(self.to_dict()) + + @classmethod + def from_dict(cls, value: Any) -> Self: + item = _object( + value, + "$", + ( + "contract_name", + "schema_version", + "definition_id", + "factor_id", + "version", + "formula", + "parameters", + "implementation_digest", + "input_schema_digest", + "inputs", + "valid_from", + "valid_until", + "warmup_sessions", + "lag_sessions", + "producer", + "code_revision", + ), + ) + if item["contract_name"] != "researchhub.factor-definition": + _fail(ContractErrorCode.INVALID_VALUE, "$.contract_name", "unsupported contract") + if item["schema_version"] != FACTOR_CONTRACT_VERSION: + _fail(ContractErrorCode.INVALID_VALUE, "$.schema_version", "unsupported version") + if type(item["parameters"]) is not dict: + _fail(ContractErrorCode.TYPE_ERROR, "$.parameters", "must be an object") + raw_parameters = item["parameters"] + parameters = { + name: TypedParameter.from_dict(parameter, f"$.parameters.{name}") + for name, parameter in raw_parameters.items() + } + raw_inputs = _array(item["inputs"], "$.inputs", minimum=1, unique=True) + return cls._build( + factor_id=item["factor_id"], + version=item["version"], + formula=item["formula"], + parameters=parameters, + implementation_digest=item["implementation_digest"], + input_schema_digest=item["input_schema_digest"], + inputs=tuple( + FactorInput.from_dict(raw_input, f"$.inputs[{index}]") + for index, raw_input in enumerate(raw_inputs) + ), + valid_from=item["valid_from"], + valid_until=item["valid_until"], + warmup_sessions=item["warmup_sessions"], + lag_sessions=item["lag_sessions"], + producer=ProducerIdentity.from_dict(item["producer"]), + code_revision=item["code_revision"], + supplied_definition_id=item["definition_id"], + ) + + @classmethod + def from_json(cls, value: str | bytes) -> Self: + return cls.from_dict(_parse_json_object(value, "$")) + + +def validate_factor_catalog(definitions: Sequence[FactorDefinition]) -> tuple[FactorDefinition, ...]: + if type(definitions) not in {tuple, list}: + _fail(ContractErrorCode.TYPE_ERROR, "$.definitions", "must be a sequence") + normalized: list[FactorDefinition] = [] + for index, definition in enumerate(definitions): + if not isinstance(definition, FactorDefinition): + _fail( + ContractErrorCode.TYPE_ERROR, + f"$.definitions[{index}]", + "must be FactorDefinition", + ) + normalized.append(definition) + if not normalized: + _fail(ContractErrorCode.INVALID_VALUE, "$.definitions", "must not be empty") + identities = [definition.definition_id for definition in normalized] + if len(identities) != len(set(identities)): + _fail(ContractErrorCode.INVALID_VALUE, "$.definitions", "duplicate definition_id") + by_logical: dict[tuple[str, str], list[FactorDefinition]] = defaultdict(list) + for definition in normalized: + by_logical[(definition.factor_id, definition.version)].append(definition) + for group in by_logical.values(): + ordered = sorted(group, key=lambda definition: _parse_utc(definition.valid_from, "$.valid_from")) + for previous, current in pairwise(ordered): + if _parse_utc(current.valid_from, "$.valid_from") < _parse_utc( + previous.valid_until, "$.valid_until" + ): + _fail( + ContractErrorCode.TIME_ORDER_VIOLATION, + "$.definitions", + "overlapping validity for the same factor_id/version", + ) + return tuple(sorted(normalized, key=lambda definition: definition.definition_id)) + + +def factor_definition_from_alpha158( + alpha_id: str, + *, + version: str, + parameters: Mapping[str, TypedParameter], + inputs: Sequence[FactorInput], + implementation_digest: str, + input_schema_digest: str, + valid_from: str, + valid_until: str, + warmup_sessions: int, + lag_sessions: int, + producer: ProducerIdentity, + code_revision: str, +) -> FactorDefinition: + """Translate existing Alpha158 metadata without copying or executing a formula.""" + + if alpha_id not in ALPHA158_REGISTRY: + _fail(ContractErrorCode.INVALID_VALUE, "$.alpha_id", "unknown Alpha158 registry key") + metadata = ALPHA158_REGISTRY[alpha_id] + declared_parameters = metadata["params"] + if declared_parameters: + expected_names = { + item if isinstance(item, str) else item.get("name") + for item in declared_parameters + } + if None in expected_names or set(parameters) != expected_names: + _fail( + ContractErrorCode.INPUT_CLOSURE_VIOLATION, + "$.parameters", + "does not exactly correspond to Alpha158 declared parameters", + ) + elif parameters: + _fail( + ContractErrorCode.INPUT_CLOSURE_VIOLATION, + "$.parameters", + "registry declares no parameters", + ) + normalized_inputs = _normalize_factor_inputs(inputs) + columns = [column for item in normalized_inputs for column in item.required_columns] + if len(columns) != len(set(columns)) or set(columns) != set(metadata["inputs"]): + _fail( + ContractErrorCode.INPUT_CLOSURE_VIOLATION, + "$.inputs", + "does not exactly correspond to Alpha158 declared input columns", + ) + return FactorDefinition.create( + factor_id=alpha_id, + version=version, + formula=str(metadata["formula"]), + parameters=parameters, + implementation_digest=implementation_digest, + input_schema_digest=input_schema_digest, + inputs=normalized_inputs, + valid_from=valid_from, + valid_until=valid_until, + warmup_sessions=warmup_sessions, + lag_sessions=lag_sessions, + producer=producer, + code_revision=code_revision, + ) + + +@dataclass(frozen=True, slots=True) +class _SnapshotFacts: + snapshot_id: str + pit_cutoff: str + knowledge_start: datetime + knowledge_end: datetime + published_at: datetime + content_digest: str + manifest_digest: str + quality_status: str + quality_evidence_digests: tuple[str, ...] + qualification_status: str + qualification_policy_id: str + qualification_policy_version: str + qualification_evaluated_at: str + qualification_evidence_digest: str + + +def _validate_dataset_snapshot(document: dict[str, Any]) -> _SnapshotFacts: + _assert_canonical_profile(document) + root = _object(document, "$", ("contract_name", "schema_version", "snapshot_id", "descriptor")) + if root["contract_name"] != "researchhub.dataset-snapshot": + _fail(ContractErrorCode.INVALID_VALUE, "$.contract_name", "unsupported upstream contract") + if root["schema_version"] != "1.0.0": + _fail(ContractErrorCode.INVALID_VALUE, "$.schema_version", "unsupported upstream version") + snapshot_id = _string(root["snapshot_id"], "$.snapshot_id", _SNAPSHOT_ID) + descriptor = _object( + root["descriptor"], + "$.descriptor", + ("dataset", "published_at", "time_semantics", "content", "lineage", "quality", "qualification"), + ) + dataset = _object( + descriptor["dataset"], + "$.descriptor.dataset", + ("dataset_id", "dataset_kind", "record_schema_version", "dimensions"), + ) + dataset_id = _string(dataset["dataset_id"], "$.descriptor.dataset.dataset_id", _DATASET_ID) + dataset_kind = _enum( + dataset["dataset_kind"], "$.descriptor.dataset.dataset_kind", {"market", "macroeconomic"} + ) + if not dataset_id.startswith(f"rhdataset:{dataset_kind}:"): + _fail(ContractErrorCode.INVALID_VALUE, "$.descriptor.dataset.dataset_id", "kind mismatch") + _semver(dataset["record_schema_version"], "$.descriptor.dataset.record_schema_version") + dimensions = _array(dataset["dimensions"], "$.descriptor.dataset.dimensions", minimum=1, unique=True) + expected_dimensions = ( + ["instrument_id", "effective_time"] + if dataset_kind == "market" + else ["series_id", "observation_period"] + ) + if dimensions != expected_dimensions: + _fail(ContractErrorCode.INVALID_VALUE, "$.descriptor.dataset.dimensions", "kind dimensions mismatch") + for index, dimension in enumerate(dimensions): + _string(dimension, f"$.descriptor.dataset.dimensions[{index}]", _FIELD_NAME) + + published_at = _parse_utc(descriptor["published_at"], "$.descriptor.published_at") + time_semantics = _object( + descriptor["time_semantics"], + "$.descriptor.time_semantics", + ("effective_time", "knowledge_time", "pit_cutoff"), + ) + ranges: dict[str, tuple[datetime, datetime]] = {} + for range_name in ("effective_time", "knowledge_time"): + range_value = _object( + time_semantics[range_name], + f"$.descriptor.time_semantics.{range_name}", + ("start_inclusive", "end_inclusive"), + ) + start = _parse_utc( + range_value["start_inclusive"], + f"$.descriptor.time_semantics.{range_name}.start_inclusive", + ) + end = _parse_utc( + range_value["end_inclusive"], + f"$.descriptor.time_semantics.{range_name}.end_inclusive", + ) + if start > end: + _fail( + ContractErrorCode.TIME_ORDER_VIOLATION, + f"$.descriptor.time_semantics.{range_name}", + "range must be ordered", + ) + ranges[range_name] = (start, end) + pit_text = _string(time_semantics["pit_cutoff"], "$.descriptor.time_semantics.pit_cutoff", _UTC_INSTANT) + pit_cutoff = _parse_utc(pit_text, "$.descriptor.time_semantics.pit_cutoff") + + content = _object( + descriptor["content"], + "$.descriptor.content", + ( + "digest_algorithm", + "canonicalization", + "record_order", + "content_digest", + "logical_manifest", + "manifest_digest", + "record_count", + ), + ) + if content["digest_algorithm"] != "sha256" or content["canonicalization"] != "RFC8785": + _fail(ContractErrorCode.INVALID_VALUE, "$.descriptor.content", "unsupported digest profile") + if content["record_order"] != "canonical-record-byte-order": + _fail(ContractErrorCode.INVALID_VALUE, "$.descriptor.content.record_order", "unsupported order") + content_digest = _digest(content["content_digest"], "$.descriptor.content.content_digest") + record_count = _safe_integer(content["record_count"], "$.descriptor.content.record_count", minimum=0) + manifest = _object( + content["logical_manifest"], + "$.descriptor.content.logical_manifest", + ("record_count", "chunks"), + ) + manifest_count = _safe_integer( + manifest["record_count"], "$.descriptor.content.logical_manifest.record_count", minimum=0 + ) + chunks = _array( + manifest["chunks"], "$.descriptor.content.logical_manifest.chunks", minimum=1, unique=True + ) + chunk_count = 0 + for index, raw_chunk in enumerate(chunks): + chunk = _object( + raw_chunk, + f"$.descriptor.content.logical_manifest.chunks[{index}]", + ("chunk_index", "content_digest", "record_count"), + ) + if _safe_integer(chunk["chunk_index"], f"$.descriptor.content.logical_manifest.chunks[{index}].chunk_index", minimum=0) != index: + _fail( + ContractErrorCode.INVALID_VALUE, + f"$.descriptor.content.logical_manifest.chunks[{index}].chunk_index", + "chunk indexes must be contiguous", + ) + _digest(chunk["content_digest"], f"$.descriptor.content.logical_manifest.chunks[{index}].content_digest") + chunk_count += _safe_integer( + chunk["record_count"], + f"$.descriptor.content.logical_manifest.chunks[{index}].record_count", + minimum=0, + ) + if manifest_count != record_count or chunk_count != record_count: + _fail(ContractErrorCode.INVALID_VALUE, "$.descriptor.content.logical_manifest", "record counts mismatch") + manifest_digest = _digest(content["manifest_digest"], "$.descriptor.content.manifest_digest") + expected_manifest_digest = _digest_bytes(canonical_json_bytes(manifest)) + if manifest_digest != expected_manifest_digest: + _fail(ContractErrorCode.IDENTITY_MISMATCH, "$.descriptor.content.manifest_digest", "manifest mismatch") + + lineage = _object( + descriptor["lineage"], + "$.descriptor.lineage", + ("publisher", "transformation", "upstream_snapshot_ids", "upstream_content_digests"), + ) + publisher = _object(lineage["publisher"], "$.descriptor.lineage.publisher", ("id", "version")) + if publisher["id"] != "researchhub.data": + _fail(ContractErrorCode.INVALID_VALUE, "$.descriptor.lineage.publisher.id", "wrong authority") + _semver(publisher["version"], "$.descriptor.lineage.publisher.version") + transformation = _object( + lineage["transformation"], "$.descriptor.lineage.transformation", ("id", "version") + ) + _string(transformation["id"], "$.descriptor.lineage.transformation.id", _TRANSFORMATION_ID) + _semver(transformation["version"], "$.descriptor.lineage.transformation.version") + for key, pattern in (("upstream_snapshot_ids", _SNAPSHOT_ID), ("upstream_content_digests", _SHA256)): + values = _array(lineage[key], f"$.descriptor.lineage.{key}", unique=True) + for index, value in enumerate(values): + _string(value, f"$.descriptor.lineage.{key}[{index}]", pattern) + + quality = _object(descriptor["quality"], "$.descriptor.quality", ("status", "checks")) + quality_status = _enum(quality["status"], "$.descriptor.quality.status", {"passed", "failed"}) + checks = _array(quality["checks"], "$.descriptor.quality.checks", minimum=1) + check_ids: set[str] = set() + quality_evidence: list[str] = [] + all_checks_passed = True + for index, raw_check in enumerate(checks): + check = _object( + raw_check, + f"$.descriptor.quality.checks[{index}]", + ("check_id", "status", "severity", "evidence_digest"), + ) + check_id = _enum( + check["check_id"], + f"$.descriptor.quality.checks[{index}].check_id", + {"completeness", "duplicate_identity", "pit_time_integrity", "range_validity", "schema_conformance"}, + ) + if check_id in check_ids: + _fail(ContractErrorCode.INVALID_VALUE, "$.descriptor.quality.checks", "duplicate check_id") + check_ids.add(check_id) + check_status = _enum( + check["status"], f"$.descriptor.quality.checks[{index}].status", {"passed", "failed"} + ) + all_checks_passed = all_checks_passed and check_status == "passed" + _enum( + check["severity"], + f"$.descriptor.quality.checks[{index}].severity", + {"blocking", "advisory"}, + ) + quality_evidence.append( + _digest(check["evidence_digest"], f"$.descriptor.quality.checks[{index}].evidence_digest") + ) + + qualification = _object( + descriptor["qualification"], + "$.descriptor.qualification", + ("status", "policy_id", "policy_version", "evaluated_at", "evidence_digest"), + ) + qualification_status = _enum( + qualification["status"], "$.descriptor.qualification.status", {"qualified", "rejected"} + ) + if qualification["policy_id"] != "researchhub.dataset-snapshot.pit": + _fail(ContractErrorCode.INVALID_VALUE, "$.descriptor.qualification.policy_id", "wrong policy") + policy_version = _semver(qualification["policy_version"], "$.descriptor.qualification.policy_version") + evaluated_text = _string( + qualification["evaluated_at"], "$.descriptor.qualification.evaluated_at", _UTC_INSTANT + ) + evaluated_at = _parse_utc(evaluated_text, "$.descriptor.qualification.evaluated_at") + qualification_evidence = _digest( + qualification["evidence_digest"], "$.descriptor.qualification.evidence_digest" + ) + knowledge_start, knowledge_end = ranges["knowledge_time"] + if not knowledge_start <= knowledge_end <= pit_cutoff <= evaluated_at <= published_at: + _fail( + ContractErrorCode.TIME_ORDER_VIOLATION, + "$.descriptor.time_semantics", + "knowledge <= PIT <= qualification <= publication is required", + ) + if qualification_status == "qualified" and ( + quality_status != "passed" or not all_checks_passed + ): + _fail( + ContractErrorCode.QUALIFICATION_REJECTED, + "$.descriptor.qualification", + "qualified snapshots require all quality checks passed", + ) + if quality_status == "failed" and qualification_status != "rejected": + _fail(ContractErrorCode.QUALIFICATION_REJECTED, "$.descriptor.qualification", "failed quality") + expected_snapshot_id = _content_address(document, "snapshot_id", "rhdsv1:sha256:") + if snapshot_id != expected_snapshot_id: + _fail(ContractErrorCode.IDENTITY_MISMATCH, "$.snapshot_id", "snapshot envelope mismatch") + return _SnapshotFacts( + snapshot_id=snapshot_id, + pit_cutoff=pit_text, + knowledge_start=knowledge_start, + knowledge_end=knowledge_end, + published_at=published_at, + content_digest=content_digest, + manifest_digest=manifest_digest, + quality_status=quality_status, + quality_evidence_digests=tuple(quality_evidence), + qualification_status=qualification_status, + qualification_policy_id="researchhub.dataset-snapshot.pit", + qualification_policy_version=policy_version, + qualification_evaluated_at=evaluated_text, + qualification_evidence_digest=qualification_evidence, + ) + + +@dataclass(frozen=True, slots=True, init=False) +class DatasetSnapshotEnvelope: + snapshot_id: str + pit_cutoff: str + knowledge_start: datetime + knowledge_end: datetime + published_at: datetime + content_digest: str + manifest_digest: str + quality_status: str + quality_evidence_digests: tuple[str, ...] + qualification_status: str + qualification_policy_id: str + qualification_policy_version: str + qualification_evaluated_at: str + qualification_evidence_digest: str + _payload: Mapping[str, Any] = field(repr=False, compare=False) + + @classmethod + def from_dict(cls, value: Any) -> Self: + if type(value) is not dict: + _fail(ContractErrorCode.TYPE_ERROR, "$", "DatasetSnapshot envelope must be an object") + detached = _thaw_json(_freeze_json(value)) + facts = _validate_dataset_snapshot(detached) + instance = object.__new__(cls) + for fact_field in fields(facts): + object.__setattr__(instance, fact_field.name, getattr(facts, fact_field.name)) + object.__setattr__(instance, "_payload", _freeze_json(detached)) + return instance + + @classmethod + def from_json(cls, value: str | bytes) -> Self: + return cls.from_dict(_parse_json_object(value, "$")) + + def to_dict(self) -> dict[str, Any]: + return cast(dict[str, Any], _thaw_json(self._payload)) + + def to_json(self) -> str: + return canonical_json(self.to_dict()) + + def require_qualified(self) -> None: + if self.qualification_status != "qualified" or self.quality_status != "passed": + _fail( + ContractErrorCode.QUALIFICATION_REJECTED, + "$.dataset_snapshot.descriptor.qualification", + "only jointly qualified, passed snapshots are admissible", + ) + + +@dataclass(frozen=True, slots=True) +class _ViewFacts: + view_ref_id: str + schema_digest: str + content_digest: str + transformation_digest: str + + +@dataclass(frozen=True, slots=True) +class _FoundationFacts: + foundation_id: str + dataset_snapshot_id: str + pit_cutoff: str + views: Mapping[str, _ViewFacts] + evidence_scope: str + contract_evidence_digests: tuple[str, ...] + real_data_status: str + + +def _validate_revision_chain( + records: list[dict[str, Any]], + *, + identity_field: str, + group_fields: tuple[str, ...], + path: str, +) -> None: + groups: dict[tuple[Any, ...], list[dict[str, Any]]] = defaultdict(list) + for record in records: + groups[tuple(record[name] for name in group_fields)].append(record) + for group in groups.values(): + ordered = sorted(group, key=lambda record: record["revision_number"]) + if [record["revision_number"] for record in ordered] != list(range(1, len(ordered) + 1)): + _fail(ContractErrorCode.LINEAGE_VIOLATION, path, "revision chain contains a gap") + if "supersedes_revision_id" in ordered[0]: + _fail(ContractErrorCode.LINEAGE_VIOLATION, path, "first revision has a parent") + for previous, current in pairwise(ordered): + if current.get("supersedes_revision_id") != previous[identity_field]: + _fail(ContractErrorCode.LINEAGE_VIOLATION, path, "revision ancestry is not contiguous") + if _parse_utc(current["knowledge_time"], path) <= _parse_utc( + previous["knowledge_time"], path + ): + _fail(ContractErrorCode.TIME_ORDER_VIOLATION, path, "revision knowledge must increase") + + +def _validate_data_foundation(document: dict[str, Any]) -> _FoundationFacts: + _assert_canonical_profile(document) + _no_physical_leakage(document) + root = _object( + document, + "$", + ( + "contract_name", + "schema_version", + "foundation_id", + "dataset_snapshot_id", + "pit_cutoff", + "instrument_routes", + "trading_calendar_revisions", + "corporate_action_revisions", + "standardized_views", + "revision_lineage", + "readiness", + ), + ) + if root["contract_name"] != "researchhub.data-foundation": + _fail(ContractErrorCode.INVALID_VALUE, "$.contract_name", "unsupported upstream contract") + if root["schema_version"] != "1.0.0": + _fail(ContractErrorCode.INVALID_VALUE, "$.schema_version", "unsupported upstream version") + foundation_id = _string(root["foundation_id"], "$.foundation_id", _FOUNDATION_ID) + snapshot_id = _string(root["dataset_snapshot_id"], "$.dataset_snapshot_id", _SNAPSHOT_ID) + pit_text = _string(root["pit_cutoff"], "$.pit_cutoff", _UTC_INSTANT) + pit_cutoff = _parse_utc(pit_text, "$.pit_cutoff") + + routes_raw = _array(root["instrument_routes"], "$.instrument_routes", minimum=1, unique=True) + routes: list[dict[str, Any]] = [] + for index, raw_route in enumerate(routes_raw): + path = f"$.instrument_routes[{index}]" + route = _object( + raw_route, + path, + ( + "route_revision_id", + "instrument_id", + "revision_number", + "symbol", + "mic", + "currency", + "asset_class", + "instrument_type", + "calendar_id", + "effective_from", + "knowledge_time", + "evidence_digest", + ), + ("supersedes_revision_id",), + ) + _string(route["route_revision_id"], f"{path}.route_revision_id", _ROUTE_ID) + _string(route["instrument_id"], f"{path}.instrument_id", _INSTRUMENT_ID) + _safe_integer(route["revision_number"], f"{path}.revision_number", minimum=1) + if "supersedes_revision_id" in route: + _string(route["supersedes_revision_id"], f"{path}.supersedes_revision_id", _ROUTE_ID) + symbol = _string(route["symbol"], f"{path}.symbol") + if not 1 <= len(symbol) <= 32 or re.fullmatch(r"^[A-Z0-9][A-Z0-9.-]*$", symbol) is None: + _fail(ContractErrorCode.INVALID_FORMAT, f"{path}.symbol", "invalid symbol profile") + if re.fullmatch(r"^[A-Z0-9]{4}$", _string(route["mic"], f"{path}.mic")) is None: + _fail(ContractErrorCode.INVALID_FORMAT, f"{path}.mic", "invalid MIC") + if re.fullmatch(r"^[A-Z]{3}$", _string(route["currency"], f"{path}.currency")) is None: + _fail(ContractErrorCode.INVALID_FORMAT, f"{path}.currency", "invalid currency") + _enum(route["asset_class"], f"{path}.asset_class", {"equity", "fund", "fixed_income", "future", "option"}) + _enum(route["instrument_type"], f"{path}.instrument_type", {"stock", "etf", "bond", "future", "option"}) + _string(route["calendar_id"], f"{path}.calendar_id", _CALENDAR_ID) + _parse_utc(route["effective_from"], f"{path}.effective_from") + _parse_utc(route["knowledge_time"], f"{path}.knowledge_time") + _digest(route["evidence_digest"], f"{path}.evidence_digest") + if route["route_revision_id"] != _content_address(route, "route_revision_id", "rhroutev1:sha256:"): + _fail(ContractErrorCode.IDENTITY_MISMATCH, f"{path}.route_revision_id", "route mismatch") + routes.append(route) + + calendars_raw = _array( + root["trading_calendar_revisions"], "$.trading_calendar_revisions", minimum=1, unique=True + ) + calendars: list[dict[str, Any]] = [] + for index, raw_calendar in enumerate(calendars_raw): + path = f"$.trading_calendar_revisions[{index}]" + calendar = _object( + raw_calendar, + path, + ( + "calendar_revision_id", + "calendar_id", + "session_date", + "revision_number", + "status", + "sessions", + "knowledge_time", + "evidence_digest", + ), + ("supersedes_revision_id",), + ) + _string(calendar["calendar_revision_id"], f"{path}.calendar_revision_id", _CALENDAR_REVISION_ID) + _string(calendar["calendar_id"], f"{path}.calendar_id", _CALENDAR_ID) + _parse_date(calendar["session_date"], f"{path}.session_date") + _safe_integer(calendar["revision_number"], f"{path}.revision_number", minimum=1) + if "supersedes_revision_id" in calendar: + _string(calendar["supersedes_revision_id"], f"{path}.supersedes_revision_id", _CALENDAR_REVISION_ID) + status = _enum(calendar["status"], f"{path}.status", {"open", "closed"}) + sessions = _array(calendar["sessions"], f"{path}.sessions", unique=True) + if status == "open" and not sessions: + _fail(ContractErrorCode.INVALID_VALUE, f"{path}.sessions", "open session requires segments") + if status == "closed" and sessions: + _fail(ContractErrorCode.INVALID_VALUE, f"{path}.sessions", "closed session must be empty") + parsed_sessions: list[tuple[datetime, datetime]] = [] + for session_index, raw_session in enumerate(sessions): + session_path = f"{path}.sessions[{session_index}]" + session = _object(raw_session, session_path, ("opens_at", "closes_at")) + opens_at = _parse_utc(session["opens_at"], f"{session_path}.opens_at") + closes_at = _parse_utc(session["closes_at"], f"{session_path}.closes_at") + if closes_at <= opens_at: + _fail(ContractErrorCode.TIME_ORDER_VIOLATION, session_path, "session close must follow open") + parsed_sessions.append((opens_at, closes_at)) + if parsed_sessions != sorted(parsed_sessions) or any( + current[0] < previous[1] + for previous, current in pairwise(parsed_sessions) + ): + _fail(ContractErrorCode.TIME_ORDER_VIOLATION, f"{path}.sessions", "sessions overlap or are unordered") + _parse_utc(calendar["knowledge_time"], f"{path}.knowledge_time") + _digest(calendar["evidence_digest"], f"{path}.evidence_digest") + if calendar["calendar_revision_id"] != _content_address( + calendar, "calendar_revision_id", "rhcalv1:sha256:" + ): + _fail(ContractErrorCode.IDENTITY_MISMATCH, f"{path}.calendar_revision_id", "calendar mismatch") + calendars.append(calendar) + + actions_raw = _array(root["corporate_action_revisions"], "$.corporate_action_revisions", unique=True) + actions: list[dict[str, Any]] = [] + for index, raw_action in enumerate(actions_raw): + path = f"$.corporate_action_revisions[{index}]" + action = _object( + raw_action, + path, + ( + "action_revision_id", + "action_id", + "instrument_id", + "revision_number", + "action_type", + "status", + "effective_time", + "knowledge_time", + "terms_digest", + "evidence_digest", + ), + ("supersedes_revision_id",), + ) + _string(action["action_revision_id"], f"{path}.action_revision_id", _ACTION_REVISION_ID) + _string(action["action_id"], f"{path}.action_id", _ACTION_ID) + _string(action["instrument_id"], f"{path}.instrument_id", _INSTRUMENT_ID) + _safe_integer(action["revision_number"], f"{path}.revision_number", minimum=1) + if "supersedes_revision_id" in action: + _string(action["supersedes_revision_id"], f"{path}.supersedes_revision_id", _ACTION_REVISION_ID) + _enum(action["action_type"], f"{path}.action_type", {"cash_dividend", "stock_dividend", "split", "rights_issue", "symbol_change", "delisting"}) + _enum(action["status"], f"{path}.status", {"announced", "confirmed", "cancelled"}) + _parse_utc(action["effective_time"], f"{path}.effective_time") + _parse_utc(action["knowledge_time"], f"{path}.knowledge_time") + _digest(action["terms_digest"], f"{path}.terms_digest") + _digest(action["evidence_digest"], f"{path}.evidence_digest") + if action["action_revision_id"] != _content_address( + action, "action_revision_id", "rhcav1:sha256:" + ): + _fail(ContractErrorCode.IDENTITY_MISMATCH, f"{path}.action_revision_id", "action mismatch") + actions.append(action) + + _validate_revision_chain(routes, identity_field="route_revision_id", group_fields=("instrument_id",), path="$.instrument_routes") + _validate_revision_chain(calendars, identity_field="calendar_revision_id", group_fields=("calendar_id", "session_date"), path="$.trading_calendar_revisions") + _validate_revision_chain(actions, identity_field="action_revision_id", group_fields=("action_id",), path="$.corporate_action_revisions") + + route_by_id = {route["route_revision_id"]: route for route in routes} + calendar_by_id = {calendar["calendar_revision_id"]: calendar for calendar in calendars} + action_by_id = {action["action_revision_id"]: action for action in actions} + if len(route_by_id) != len(routes) or len(calendar_by_id) != len(calendars) or len(action_by_id) != len(actions): + _fail(ContractErrorCode.LINEAGE_VIOLATION, "$", "duplicate revision identity") + calendar_ids = {calendar["calendar_id"] for calendar in calendars} + instruments = {route["instrument_id"] for route in routes} + if any(route["calendar_id"] not in calendar_ids for route in routes): + _fail(ContractErrorCode.LINEAGE_VIOLATION, "$.instrument_routes", "route calendar missing") + if any(action["instrument_id"] not in instruments for action in actions): + _fail(ContractErrorCode.LINEAGE_VIOLATION, "$.corporate_action_revisions", "instrument route missing") + + lineage_raw = _array(root["revision_lineage"], "$.revision_lineage", minimum=1, unique=True) + lineage: dict[str, dict[str, Any]] = {} + allowed_revision_ids = set(route_by_id) | set(calendar_by_id) | set(action_by_id) + for index, raw_entry in enumerate(lineage_raw): + path = f"$.revision_lineage[{index}]" + entry = _object( + raw_entry, + path, + ("revision_kind", "revision_id", "revision_number", "knowledge_time", "evidence_digest"), + ("supersedes_revision_id",), + ) + kind = _enum(entry["revision_kind"], f"{path}.revision_kind", {"instrument_route", "trading_calendar", "corporate_action"}) + pattern = { + "instrument_route": _ROUTE_ID, + "trading_calendar": _CALENDAR_REVISION_ID, + "corporate_action": _ACTION_REVISION_ID, + }[kind] + revision_id = _string(entry["revision_id"], f"{path}.revision_id", pattern) + if "supersedes_revision_id" in entry: + _string(entry["supersedes_revision_id"], f"{path}.supersedes_revision_id", pattern) + _safe_integer(entry["revision_number"], f"{path}.revision_number", minimum=1) + _parse_utc(entry["knowledge_time"], f"{path}.knowledge_time") + _digest(entry["evidence_digest"], f"{path}.evidence_digest") + if revision_id in lineage: + _fail(ContractErrorCode.LINEAGE_VIOLATION, path, "duplicate lineage identity") + lineage[revision_id] = entry + if set(lineage) != allowed_revision_ids: + _fail(ContractErrorCode.LINEAGE_VIOLATION, "$.revision_lineage", "must cover every revision exactly") + for kind, records, identity_field in ( + ("instrument_route", routes, "route_revision_id"), + ("trading_calendar", calendars, "calendar_revision_id"), + ("corporate_action", actions, "action_revision_id"), + ): + for revision in records: + entry = lineage[revision[identity_field]] + if entry["revision_kind"] != kind or any( + entry[name] != revision[name] + for name in ("revision_number", "knowledge_time", "evidence_digest") + ) or entry.get("supersedes_revision_id") != revision.get("supersedes_revision_id"): + _fail(ContractErrorCode.LINEAGE_VIOLATION, "$.revision_lineage", "entry mismatch") + if _parse_utc(revision["knowledge_time"], "$.revision_lineage.knowledge_time") > pit_cutoff: + _fail(ContractErrorCode.TIME_ORDER_VIOLATION, "$.revision_lineage.knowledge_time", "future knowledge exceeds PIT") + + views_raw = _array(root["standardized_views"], "$.standardized_views", minimum=1, unique=True) + views: dict[str, _ViewFacts] = {} + for index, raw_view in enumerate(views_raw): + path = f"$.standardized_views[{index}]" + view = _object( + raw_view, + path, + ( + "view_ref_id", + "view_id", + "view_version", + "dataset_snapshot_id", + "pit_cutoff", + "schema_digest", + "content_digest", + "transformation_digest", + "instrument_route_revision_ids", + "trading_calendar_revision_ids", + "corporate_action_revision_ids", + ), + ) + view_ref_id = _string(view["view_ref_id"], f"{path}.view_ref_id", _VIEW_REF_ID) + _string(view["view_id"], f"{path}.view_id", _VIEW_ID) + _semver(view["view_version"], f"{path}.view_version") + if _string(view["dataset_snapshot_id"], f"{path}.dataset_snapshot_id", _SNAPSHOT_ID) != snapshot_id: + _fail(ContractErrorCode.INPUT_CLOSURE_VIOLATION, f"{path}.dataset_snapshot_id", "view is not snapshot-bound") + if _string(view["pit_cutoff"], f"{path}.pit_cutoff", _UTC_INSTANT) != pit_text: + _fail(ContractErrorCode.INPUT_CLOSURE_VIOLATION, f"{path}.pit_cutoff", "view uses a different PIT") + schema_digest = _digest(view["schema_digest"], f"{path}.schema_digest") + content_digest = _digest(view["content_digest"], f"{path}.content_digest") + transformation_digest = _digest(view["transformation_digest"], f"{path}.transformation_digest") + selected: list[tuple[list[Any], Mapping[str, dict[str, Any]], re.Pattern[str], str]] = [ + (_array(view["instrument_route_revision_ids"], f"{path}.instrument_route_revision_ids", minimum=1, unique=True), route_by_id, _ROUTE_ID, "instrument_route_revision_ids"), + (_array(view["trading_calendar_revision_ids"], f"{path}.trading_calendar_revision_ids", minimum=1, unique=True), calendar_by_id, _CALENDAR_REVISION_ID, "trading_calendar_revision_ids"), + (_array(view["corporate_action_revision_ids"], f"{path}.corporate_action_revision_ids", unique=True), action_by_id, _ACTION_REVISION_ID, "corporate_action_revision_ids"), + ] + for ids, known, pattern, field_name in selected: + for selected_index, revision_id in enumerate(ids): + normalized_revision_id = _string(revision_id, f"{path}.{field_name}[{selected_index}]", pattern) + if normalized_revision_id not in known: + _fail(ContractErrorCode.INPUT_CLOSURE_VIOLATION, f"{path}.{field_name}", "unknown revision") + parent = lineage[normalized_revision_id].get("supersedes_revision_id") + while parent is not None: + if parent not in ids: + _fail(ContractErrorCode.LINEAGE_VIOLATION, f"{path}.{field_name}", "revision ancestry omitted") + parent = lineage[parent].get("supersedes_revision_id") + route_calendar_ids = { + route_by_id[revision_id]["calendar_id"] + for revision_id in view["instrument_route_revision_ids"] + } + selected_calendar_ids = { + calendar_by_id[revision_id]["calendar_id"] + for revision_id in view["trading_calendar_revision_ids"] + } + if not route_calendar_ids.issubset(selected_calendar_ids): + _fail(ContractErrorCode.INPUT_CLOSURE_VIOLATION, path, "selected route calendar is not selected by view") + if view_ref_id != _content_address(view, "view_ref_id", "rhviewrefv1:sha256:"): + _fail(ContractErrorCode.IDENTITY_MISMATCH, f"{path}.view_ref_id", "view mismatch") + if view_ref_id in views: + _fail(ContractErrorCode.INVALID_VALUE, "$.standardized_views", "duplicate view_ref_id") + views[view_ref_id] = _ViewFacts(view_ref_id, schema_digest, content_digest, transformation_digest) + + readiness = _object( + root["readiness"], + "$.readiness", + ("evidence_scope", "contract_validation", "real_data_validation", "production_validation", "live_validation"), + ) + evidence_scope = _enum(readiness["evidence_scope"], "$.readiness.evidence_scope", {"synthetic_fixture", "real_data"}) + contract_level = _object(readiness["contract_validation"], "$.readiness.contract_validation", ("status", "evidence_digests")) + if contract_level["status"] != "validated": + _fail(ContractErrorCode.INVALID_VALUE, "$.readiness.contract_validation.status", "must be validated") + contract_evidence_raw = _array(contract_level["evidence_digests"], "$.readiness.contract_validation.evidence_digests", minimum=1, unique=True) + contract_evidence = tuple(_digest(value, f"$.readiness.contract_validation.evidence_digests[{index}]") for index, value in enumerate(contract_evidence_raw)) + used_evidence = set(contract_evidence) + level_status: dict[str, str] = {} + for name in ("real_data_validation", "production_validation", "live_validation"): + level = _object(readiness[name], f"$.readiness.{name}", ("status", "evidence_digests")) + status = _enum(level["status"], f"$.readiness.{name}.status", {"not_validated", "validated"}) + evidence_raw = _array(level["evidence_digests"], f"$.readiness.{name}.evidence_digests", unique=True) + evidence = {_digest(value, f"$.readiness.{name}.evidence_digests[{index}]") for index, value in enumerate(evidence_raw)} + if status == "validated" and not evidence: + _fail(ContractErrorCode.INVALID_VALUE, f"$.readiness.{name}", "validated level requires evidence") + if status == "not_validated" and evidence: + _fail(ContractErrorCode.INVALID_VALUE, f"$.readiness.{name}", "unvalidated level cannot have evidence") + if used_evidence.intersection(evidence): + _fail(ContractErrorCode.READINESS_ESCALATION, f"$.readiness.{name}", "evidence reused across levels") + used_evidence.update(evidence) + level_status[name] = status + if evidence_scope == "synthetic_fixture" and any(status == "validated" for status in level_status.values()): + _fail(ContractErrorCode.READINESS_ESCALATION, "$.readiness", "synthetic evidence cannot promote readiness") + if level_status["production_validation"] == "validated" and level_status["real_data_validation"] != "validated": + _fail(ContractErrorCode.READINESS_ESCALATION, "$.readiness.production_validation", "real-data prerequisite missing") + if level_status["live_validation"] == "validated" and level_status["production_validation"] != "validated": + _fail(ContractErrorCode.READINESS_ESCALATION, "$.readiness.live_validation", "production prerequisite missing") + if foundation_id != _content_address(document, "foundation_id", "rhdfv1:sha256:"): + _fail(ContractErrorCode.IDENTITY_MISMATCH, "$.foundation_id", "foundation envelope mismatch") + return _FoundationFacts( + foundation_id=foundation_id, + dataset_snapshot_id=snapshot_id, + pit_cutoff=pit_text, + views=MappingProxyType(dict(views)), + evidence_scope=evidence_scope, + contract_evidence_digests=contract_evidence, + real_data_status=level_status["real_data_validation"], + ) + + +@dataclass(frozen=True, slots=True, init=False) +class DataFoundationEnvelope: + foundation_id: str + dataset_snapshot_id: str + pit_cutoff: str + views: Mapping[str, _ViewFacts] + evidence_scope: str + contract_evidence_digests: tuple[str, ...] + real_data_status: str + _payload: Mapping[str, Any] = field(repr=False, compare=False) + + @classmethod + def from_dict(cls, value: Any) -> Self: + if type(value) is not dict: + _fail(ContractErrorCode.TYPE_ERROR, "$", "Data Foundation envelope must be an object") + detached = _thaw_json(_freeze_json(value)) + facts = _validate_data_foundation(detached) + instance = object.__new__(cls) + for fact_field in fields(facts): + object.__setattr__(instance, fact_field.name, getattr(facts, fact_field.name)) + object.__setattr__(instance, "_payload", _freeze_json(detached)) + return instance + + @classmethod + def from_json(cls, value: str | bytes) -> Self: + return cls.from_dict(_parse_json_object(value, "$")) + + def to_dict(self) -> dict[str, Any]: + return cast(dict[str, Any], _thaw_json(self._payload)) + + def to_json(self) -> str: + return canonical_json(self.to_dict()) + + +class AvailabilityMode(StrEnum): + AS_AVAILABLE = "as_available" + RETROSPECTIVE_REPLAY = "retrospective_replay" + + +class HistoricalAvailability(StrEnum): + DECLARED_AS_AVAILABLE = "declared_as_available" + NOT_ESTABLISHED = "not_established" + + +class PayloadValidation(StrEnum): + PAYLOAD_REVALIDATED = "payload_revalidated" + REFERENCE_ONLY = "reference_only" + + +@dataclass(frozen=True, slots=True) +class ActorIdentity: + kind: str + id: str + + def __post_init__(self) -> None: + object.__setattr__(self, "kind", _enum(self.kind, "$.actor.kind", {"user", "service"})) + object.__setattr__(self, "id", _logical_id(self.id, "$.actor.id")) + + def to_dict(self) -> dict[str, Any]: + return {"kind": self.kind, "id": self.id} + + @classmethod + def from_dict(cls, value: Any, path: str = "$.actor") -> Self: + item = _object(value, path, ("kind", "id")) + return cls(kind=_string(item["kind"], f"{path}.kind"), id=_string(item["id"], f"{path}.id")) + + +@dataclass(frozen=True, slots=True) +class Causation: + kind: str + id: str + + def __post_init__(self) -> None: + kind = _enum(self.kind, "$.causation.kind", {"factor_set", "foundation"}) + pattern = _FACTOR_SET_ID if kind == "factor_set" else _FOUNDATION_ID + object.__setattr__(self, "kind", kind) + object.__setattr__(self, "id", _string(self.id, "$.causation.id", pattern)) + + def to_dict(self) -> dict[str, Any]: + return {"kind": self.kind, "id": self.id} + + @classmethod + def from_dict(cls, value: Any, path: str = "$.causation") -> Self: + item = _object(value, path, ("kind", "id")) + return cls(kind=_string(item["kind"], f"{path}.kind"), id=_string(item["id"], f"{path}.id")) + + +@dataclass(frozen=True, slots=True) +class InputBinding: + definition_id: str + input_name: str + view_ref_id: str + schema_digest: str + + def __post_init__(self) -> None: + object.__setattr__(self, "definition_id", _string(self.definition_id, "$.input_bindings[].definition_id", _DEFINITION_ID)) + object.__setattr__(self, "input_name", _string(self.input_name, "$.input_bindings[].input_name", _FIELD_NAME)) + object.__setattr__(self, "view_ref_id", _string(self.view_ref_id, "$.input_bindings[].view_ref_id", _VIEW_REF_ID)) + object.__setattr__(self, "schema_digest", _digest(self.schema_digest, "$.input_bindings[].schema_digest")) + + def to_dict(self) -> dict[str, Any]: + return { + "definition_id": self.definition_id, + "input_name": self.input_name, + "view_ref_id": self.view_ref_id, + "schema_digest": self.schema_digest, + } + + @classmethod + def from_dict(cls, value: Any, path: str) -> Self: + item = _object(value, path, ("definition_id", "input_name", "view_ref_id", "schema_digest")) + return cls( + definition_id=_string(item["definition_id"], f"{path}.definition_id"), + input_name=_string(item["input_name"], f"{path}.input_name"), + view_ref_id=_string(item["view_ref_id"], f"{path}.view_ref_id"), + schema_digest=_string(item["schema_digest"], f"{path}.schema_digest"), + ) + + +@dataclass(frozen=True, slots=True) +class ViewAvailability: + view_ref_id: str + available_at: str + evidence_digest: str + + def __post_init__(self) -> None: + object.__setattr__(self, "view_ref_id", _string(self.view_ref_id, "$.view_availability[].view_ref_id", _VIEW_REF_ID)) + object.__setattr__(self, "available_at", _normalize_new_instant(self.available_at, "$.view_availability[].available_at")) + object.__setattr__(self, "evidence_digest", _digest(self.evidence_digest, "$.view_availability[].evidence_digest")) + + def to_dict(self) -> dict[str, Any]: + return { + "view_ref_id": self.view_ref_id, + "available_at": self.available_at, + "evidence_digest": self.evidence_digest, + } + + @classmethod + def from_dict(cls, value: Any, path: str) -> Self: + item = _object(value, path, ("view_ref_id", "available_at", "evidence_digest")) + return cls( + view_ref_id=_string(item["view_ref_id"], f"{path}.view_ref_id"), + available_at=_string(item["available_at"], f"{path}.available_at"), + evidence_digest=_string(item["evidence_digest"], f"{path}.evidence_digest"), + ) + + +@dataclass(frozen=True, slots=True) +class OutputQualityCheck: + check_id: str + status: str + evidence_digest: str + + def __post_init__(self) -> None: + object.__setattr__(self, "check_id", _string(self.check_id, "$.output_quality.checks[].check_id", _FIELD_NAME)) + object.__setattr__(self, "status", _enum(self.status, "$.output_quality.checks[].status", {"failed", "passed"})) + object.__setattr__(self, "evidence_digest", _digest(self.evidence_digest, "$.output_quality.checks[].evidence_digest")) + + def to_dict(self) -> dict[str, Any]: + return {"check_id": self.check_id, "status": self.status, "evidence_digest": self.evidence_digest} + + @classmethod + def from_dict(cls, value: Any, path: str) -> Self: + item = _object(value, path, ("check_id", "status", "evidence_digest")) + return cls( + check_id=_string(item["check_id"], f"{path}.check_id"), + status=_string(item["status"], f"{path}.status"), + evidence_digest=_string(item["evidence_digest"], f"{path}.evidence_digest"), + ) + + +@dataclass(frozen=True, slots=True) +class OutputQuality: + status: str + checks: tuple[OutputQualityCheck, ...] + + def __post_init__(self) -> None: + object.__setattr__(self, "status", _enum(self.status, "$.output_quality.status", {"failed", "passed"})) + if type(self.checks) not in {tuple, list} or not self.checks: + _fail(ContractErrorCode.INVALID_VALUE, "$.output_quality.checks", "must be a non-empty sequence") + checks: list[OutputQualityCheck] = [] + for index, check in enumerate(self.checks): + if not isinstance(check, OutputQualityCheck): + _fail(ContractErrorCode.TYPE_ERROR, f"$.output_quality.checks[{index}]", "must be OutputQualityCheck") + checks.append(check) + if len({check.check_id for check in checks}) != len(checks): + _fail(ContractErrorCode.INVALID_VALUE, "$.output_quality.checks", "duplicate check_id") + object.__setattr__(self, "checks", tuple(checks)) + + def to_dict(self) -> dict[str, Any]: + return {"status": self.status, "checks": [check.to_dict() for check in self.checks]} + + @classmethod + def from_dict(cls, value: Any, path: str = "$.output_quality") -> Self: + item = _object(value, path, ("status", "checks")) + raw_checks = _array(item["checks"], f"{path}.checks", minimum=1) + return cls( + status=_string(item["status"], f"{path}.status"), + checks=tuple( + OutputQualityCheck.from_dict(check, f"{path}.checks[{index}]") + for index, check in enumerate(raw_checks) + ), + ) + + +@dataclass(frozen=True, slots=True) +class OutputCoverage: + status: str + expected_count: int + observed_count: int + unit: str + domain: str + evidence_digest: str + + def __post_init__(self) -> None: + object.__setattr__(self, "status", _enum(self.status, "$.output_coverage.status", {"complete", "incomplete", "stale", "unavailable"})) + object.__setattr__(self, "expected_count", _safe_integer(self.expected_count, "$.output_coverage.expected_count", minimum=1)) + object.__setattr__(self, "observed_count", _safe_integer(self.observed_count, "$.output_coverage.observed_count", minimum=0)) + object.__setattr__(self, "unit", _string(self.unit, "$.output_coverage.unit", _FIELD_NAME)) + object.__setattr__(self, "domain", _logical_id(self.domain, "$.output_coverage.domain")) + object.__setattr__(self, "evidence_digest", _digest(self.evidence_digest, "$.output_coverage.evidence_digest")) + + def to_dict(self) -> dict[str, Any]: + return { + "status": self.status, + "expected_count": self.expected_count, + "observed_count": self.observed_count, + "unit": self.unit, + "domain": self.domain, + "evidence_digest": self.evidence_digest, + } + + @classmethod + def from_dict(cls, value: Any, path: str = "$.output_coverage") -> Self: + item = _object( + value, + path, + ("status", "expected_count", "observed_count", "unit", "domain", "evidence_digest"), + ) + return cls( + status=_string(item["status"], f"{path}.status"), + expected_count=item["expected_count"], + observed_count=item["observed_count"], + unit=_string(item["unit"], f"{path}.unit"), + domain=_string(item["domain"], f"{path}.domain"), + evidence_digest=_string(item["evidence_digest"], f"{path}.evidence_digest"), + ) + + +@dataclass(frozen=True, slots=True, init=False) +class OutputArtifactRef: + artifact_id: str + schema_digest: str + content_digest: str + + @classmethod + def create(cls, *, schema_digest: str, content_digest: str) -> Self: + normalized_schema = _digest(schema_digest, "$.output_artifact_ref.schema_digest") + normalized_content = _digest(content_digest, "$.output_artifact_ref.content_digest") + payload = {"schema_digest": normalized_schema, "content_digest": normalized_content} + artifact_id = f"rhfactoroutputv1:sha256:{hashlib.sha256(canonical_json_bytes(payload)).hexdigest()}" + instance = object.__new__(cls) + object.__setattr__(instance, "artifact_id", artifact_id) + object.__setattr__(instance, "schema_digest", normalized_schema) + object.__setattr__(instance, "content_digest", normalized_content) + return instance + + def to_dict(self) -> dict[str, Any]: + return { + "artifact_id": self.artifact_id, + "schema_digest": self.schema_digest, + "content_digest": self.content_digest, + } + + @classmethod + def from_dict(cls, value: Any, path: str = "$.output_artifact_ref") -> Self: + item = _object(value, path, ("artifact_id", "schema_digest", "content_digest")) + supplied = _string(item["artifact_id"], f"{path}.artifact_id", _OUTPUT_ARTIFACT_ID) + result = cls.create( + schema_digest=_string(item["schema_digest"], f"{path}.schema_digest"), + content_digest=_string(item["content_digest"], f"{path}.content_digest"), + ) + if supplied != result.artifact_id: + _fail(ContractErrorCode.IDENTITY_MISMATCH, f"{path}.artifact_id", "artifact digest binding mismatch") + return result + + +@dataclass(frozen=True, slots=True) +class UpstreamEvidence: + snapshot_id: str + snapshot_content_digest: str + snapshot_manifest_digest: str + quality_status: str + quality_evidence_digests: tuple[str, ...] + qualification_status: str + qualification_policy_id: str + qualification_policy_version: str + qualification_evaluated_at: str + qualification_evidence_digest: str + foundation_id: str + foundation_evidence_scope: str + foundation_contract_evidence_digests: tuple[str, ...] + + def to_dict(self) -> dict[str, Any]: + return { + "snapshot_id": self.snapshot_id, + "snapshot_content_digest": self.snapshot_content_digest, + "snapshot_manifest_digest": self.snapshot_manifest_digest, + "quality_status": self.quality_status, + "quality_evidence_digests": list(self.quality_evidence_digests), + "qualification_status": self.qualification_status, + "qualification_policy_id": self.qualification_policy_id, + "qualification_policy_version": self.qualification_policy_version, + "qualification_evaluated_at": self.qualification_evaluated_at, + "qualification_evidence_digest": self.qualification_evidence_digest, + "foundation_id": self.foundation_id, + "foundation_evidence_scope": self.foundation_evidence_scope, + "foundation_contract_evidence_digests": list(self.foundation_contract_evidence_digests), + } + + @classmethod + def derive(cls, snapshot: DatasetSnapshotEnvelope, foundation: DataFoundationEnvelope) -> Self: + return cls( + snapshot_id=snapshot.snapshot_id, + snapshot_content_digest=snapshot.content_digest, + snapshot_manifest_digest=snapshot.manifest_digest, + quality_status=snapshot.quality_status, + quality_evidence_digests=snapshot.quality_evidence_digests, + qualification_status=snapshot.qualification_status, + qualification_policy_id=snapshot.qualification_policy_id, + qualification_policy_version=snapshot.qualification_policy_version, + qualification_evaluated_at=snapshot.qualification_evaluated_at, + qualification_evidence_digest=snapshot.qualification_evidence_digest, + foundation_id=foundation.foundation_id, + foundation_evidence_scope=foundation.evidence_scope, + foundation_contract_evidence_digests=foundation.contract_evidence_digests, + ) + + @classmethod + def from_dict(cls, value: Any, path: str = "$.upstream_evidence") -> Self: + item = _object( + value, + path, + ( + "snapshot_id", + "snapshot_content_digest", + "snapshot_manifest_digest", + "quality_status", + "quality_evidence_digests", + "qualification_status", + "qualification_policy_id", + "qualification_policy_version", + "qualification_evaluated_at", + "qualification_evidence_digest", + "foundation_id", + "foundation_evidence_scope", + "foundation_contract_evidence_digests", + ), + ) + quality_evidence = _array(item["quality_evidence_digests"], f"{path}.quality_evidence_digests", minimum=1) + foundation_evidence = _array(item["foundation_contract_evidence_digests"], f"{path}.foundation_contract_evidence_digests", minimum=1) + return cls( + snapshot_id=_string(item["snapshot_id"], f"{path}.snapshot_id", _SNAPSHOT_ID), + snapshot_content_digest=_digest(item["snapshot_content_digest"], f"{path}.snapshot_content_digest"), + snapshot_manifest_digest=_digest(item["snapshot_manifest_digest"], f"{path}.snapshot_manifest_digest"), + quality_status=_enum(item["quality_status"], f"{path}.quality_status", {"failed", "passed"}), + quality_evidence_digests=tuple(_digest(digest, f"{path}.quality_evidence_digests[{index}]") for index, digest in enumerate(quality_evidence)), + qualification_status=_enum(item["qualification_status"], f"{path}.qualification_status", {"qualified", "rejected"}), + qualification_policy_id=_string(item["qualification_policy_id"], f"{path}.qualification_policy_id"), + qualification_policy_version=_semver(item["qualification_policy_version"], f"{path}.qualification_policy_version"), + qualification_evaluated_at=_string(item["qualification_evaluated_at"], f"{path}.qualification_evaluated_at", _UTC_INSTANT), + qualification_evidence_digest=_digest(item["qualification_evidence_digest"], f"{path}.qualification_evidence_digest"), + foundation_id=_string(item["foundation_id"], f"{path}.foundation_id", _FOUNDATION_ID), + foundation_evidence_scope=_enum(item["foundation_evidence_scope"], f"{path}.foundation_evidence_scope", {"real_data", "synthetic_fixture"}), + foundation_contract_evidence_digests=tuple(_digest(digest, f"{path}.foundation_contract_evidence_digests[{index}]") for index, digest in enumerate(foundation_evidence)), + ) + + +def _canonical_evidence_bytes(value: Any, path: str) -> bytes: + if type(value) is not bytes: + _fail(ContractErrorCode.TYPE_ERROR, path, "must be canonical JSON bytes") + try: + loaded = json.loads(value, object_pairs_hook=_duplicate_key_pairs) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise FactorContractError(ContractErrorCode.INVALID_FORMAT, path, "invalid canonical JSON bytes") from exc + _assert_canonical_profile(loaded, path) + if canonical_json_bytes(loaded) != value: + _fail(ContractErrorCode.INVALID_FORMAT, path, "bytes are not canonical JSON") + return value + + +def _normalize_bindings(bindings: Sequence[InputBinding]) -> tuple[InputBinding, ...]: + if type(bindings) not in {tuple, list}: + _fail(ContractErrorCode.TYPE_ERROR, "$.input_bindings", "must be a sequence") + normalized: list[InputBinding] = [] + for index, binding in enumerate(bindings): + if not isinstance(binding, InputBinding): + _fail(ContractErrorCode.TYPE_ERROR, f"$.input_bindings[{index}]", "must be InputBinding") + normalized.append(binding) + keys = [(binding.definition_id, binding.input_name) for binding in normalized] + if len(keys) != len(set(keys)): + _fail(ContractErrorCode.INPUT_CLOSURE_VIOLATION, "$.input_bindings", "duplicate factor input binding") + return tuple(sorted(normalized, key=lambda binding: (binding.definition_id, binding.input_name))) + + +def _normalize_view_availability(values: Sequence[ViewAvailability]) -> tuple[ViewAvailability, ...]: + if type(values) not in {tuple, list}: + _fail(ContractErrorCode.TYPE_ERROR, "$.view_availability", "must be a sequence") + normalized: list[ViewAvailability] = [] + for index, value in enumerate(values): + if not isinstance(value, ViewAvailability): + _fail(ContractErrorCode.TYPE_ERROR, f"$.view_availability[{index}]", "must be ViewAvailability") + normalized.append(value) + identities = [value.view_ref_id for value in normalized] + if len(identities) != len(set(identities)): + _fail(ContractErrorCode.INVALID_VALUE, "$.view_availability", "duplicate view_ref_id") + return tuple(sorted(normalized, key=lambda value: value.view_ref_id)) + + +@dataclass(frozen=True, slots=True, init=False) +class FactorSetRef: + contract_name: str + schema_version: str + factor_set_id: str + definition_ids: tuple[str, ...] + dataset_snapshot_id: str + foundation_id: str + pit_cutoff: str + selected_view_ref_ids: tuple[str, ...] + input_bindings: tuple[InputBinding, ...] + upstream_evidence: UpstreamEvidence + view_availability: tuple[ViewAvailability, ...] + output_quality: OutputQuality + output_coverage: OutputCoverage + output_schema_digest: str + output_content_digest: str + output_artifact_ref: OutputArtifactRef + availability_mode: AvailabilityMode + historical_availability: HistoricalAvailability + evaluation_at: str + computed_at: str + artifact_available_at: str + producer: ProducerIdentity + code_revision: str + actor: ActorIdentity + correlation_id: str + causation: Causation + evidence_scope: str + decision_eligible: bool + payload_validation: PayloadValidation = field(compare=False) + _definitions: tuple[FactorDefinition, ...] = field(repr=False, compare=False) + _dataset_snapshot: DatasetSnapshotEnvelope = field(repr=False, compare=False) + _foundation: DataFoundationEnvelope = field(repr=False, compare=False) + + @classmethod + def create( + cls, + *, + definitions: Sequence[FactorDefinition], + dataset_snapshot: DatasetSnapshotEnvelope, + foundation: DataFoundationEnvelope, + selected_view_ref_ids: Sequence[str], + input_bindings: Sequence[InputBinding], + view_availability: Sequence[ViewAvailability], + output_quality: OutputQuality, + output_coverage: OutputCoverage, + output_schema_bytes: bytes, + output_content_bytes: bytes, + output_artifact_ref: OutputArtifactRef, + availability_mode: AvailabilityMode | str, + evaluation_at: str, + computed_at: str, + artifact_available_at: str, + producer: ProducerIdentity, + code_revision: str, + actor: ActorIdentity, + correlation_id: str, + causation: Causation, + evidence_scope: str, + decision_eligible: bool, + parent: FactorSetRef | None = None, + ) -> Self: + schema_bytes = _canonical_evidence_bytes(output_schema_bytes, "$.output_schema_bytes") + content_bytes = _canonical_evidence_bytes(output_content_bytes, "$.output_content_bytes") + return cls._build( + definitions=definitions, + dataset_snapshot=dataset_snapshot, + foundation=foundation, + selected_view_ref_ids=selected_view_ref_ids, + input_bindings=input_bindings, + upstream_evidence=None, + view_availability=view_availability, + output_quality=output_quality, + output_coverage=output_coverage, + output_schema_digest=_digest_bytes(schema_bytes), + output_content_digest=_digest_bytes(content_bytes), + output_artifact_ref=output_artifact_ref, + availability_mode=availability_mode, + historical_availability=None, + evaluation_at=evaluation_at, + computed_at=computed_at, + artifact_available_at=artifact_available_at, + producer=producer, + code_revision=code_revision, + actor=actor, + correlation_id=correlation_id, + causation=causation, + evidence_scope=evidence_scope, + decision_eligible=decision_eligible, + parent=parent, + supplied_factor_set_id=None, + payload_validation=PayloadValidation.PAYLOAD_REVALIDATED, + ) + + @classmethod + def _build( + cls, + *, + definitions: Sequence[FactorDefinition], + dataset_snapshot: Any, + foundation: Any, + selected_view_ref_ids: Sequence[str], + input_bindings: Sequence[InputBinding], + upstream_evidence: UpstreamEvidence | None, + view_availability: Sequence[ViewAvailability], + output_quality: Any, + output_coverage: Any, + output_schema_digest: Any, + output_content_digest: Any, + output_artifact_ref: Any, + availability_mode: Any, + historical_availability: Any, + evaluation_at: Any, + computed_at: Any, + artifact_available_at: Any, + producer: Any, + code_revision: Any, + actor: Any, + correlation_id: Any, + causation: Any, + evidence_scope: Any, + decision_eligible: Any, + parent: FactorSetRef | None, + supplied_factor_set_id: Any, + payload_validation: PayloadValidation, + ) -> Self: + if not isinstance(dataset_snapshot, DatasetSnapshotEnvelope): + _fail(ContractErrorCode.TYPE_ERROR, "$.dataset_snapshot", "complete validated DatasetSnapshotEnvelope required") + if not isinstance(foundation, DataFoundationEnvelope): + _fail(ContractErrorCode.TYPE_ERROR, "$.foundation", "complete validated DataFoundationEnvelope required") + dataset_snapshot.require_qualified() + if foundation.dataset_snapshot_id != dataset_snapshot.snapshot_id: + _fail(ContractErrorCode.INPUT_CLOSURE_VIOLATION, "$.foundation.dataset_snapshot_id", "snapshot mismatch") + snapshot_pit = _parse_utc(dataset_snapshot.pit_cutoff, "$.dataset_snapshot.pit_cutoff") + foundation_pit = _parse_utc(foundation.pit_cutoff, "$.foundation.pit_cutoff") + if snapshot_pit > foundation_pit: + _fail(ContractErrorCode.TIME_ORDER_VIOLATION, "$.pit_cutoff", "snapshot PIT exceeds Foundation PIT") + normalized_definitions = validate_factor_catalog(definitions) + definition_by_id = {definition.definition_id: definition for definition in normalized_definitions} + if type(selected_view_ref_ids) not in {tuple, list}: + _fail(ContractErrorCode.TYPE_ERROR, "$.selected_view_ref_ids", "must be a sequence") + selected_views = tuple( + sorted( + _string(view_id, f"$.selected_view_ref_ids[{index}]", _VIEW_REF_ID) + for index, view_id in enumerate(selected_view_ref_ids) + ) + ) + if not selected_views or len(selected_views) != len(set(selected_views)): + _fail(ContractErrorCode.INVALID_VALUE, "$.selected_view_ref_ids", "must be non-empty and unique") + if not set(selected_views).issubset(foundation.views): + _fail(ContractErrorCode.INPUT_CLOSURE_VIOLATION, "$.selected_view_ref_ids", "view is not a Foundation member") + bindings = _normalize_bindings(input_bindings) + expected_binding_keys = { + (definition.definition_id, factor_input.input_name) + for definition in normalized_definitions + for factor_input in definition.inputs + } + actual_binding_keys = {(binding.definition_id, binding.input_name) for binding in bindings} + if actual_binding_keys != expected_binding_keys: + _fail(ContractErrorCode.INPUT_CLOSURE_VIOLATION, "$.input_bindings", "factor input closure mismatch") + consumed_views: set[str] = set() + for binding in bindings: + definition = definition_by_id[binding.definition_id] + declared_input = next(item for item in definition.inputs if item.input_name == binding.input_name) + if binding.view_ref_id not in selected_views: + _fail(ContractErrorCode.INPUT_CLOSURE_VIOLATION, "$.input_bindings[].view_ref_id", "binding uses an unselected view") + view = foundation.views[binding.view_ref_id] + if binding.schema_digest != declared_input.schema_digest or binding.schema_digest != view.schema_digest: + _fail(ContractErrorCode.INPUT_CLOSURE_VIOLATION, "$.input_bindings[].schema_digest", "definition/view schema mismatch") + consumed_views.add(binding.view_ref_id) + if consumed_views != set(selected_views): + _fail(ContractErrorCode.INPUT_CLOSURE_VIOLATION, "$.selected_view_ref_ids", "unused selected view") + availability = _normalize_view_availability(view_availability) + if {item.view_ref_id for item in availability} != set(selected_views): + _fail(ContractErrorCode.INPUT_CLOSURE_VIOLATION, "$.view_availability", "must cover selected views exactly") + expected_upstream = UpstreamEvidence.derive(dataset_snapshot, foundation) + if upstream_evidence is not None and upstream_evidence != expected_upstream: + _fail(ContractErrorCode.IDENTITY_MISMATCH, "$.upstream_evidence", "does not match admitted upstream envelopes") + if not isinstance(output_quality, OutputQuality): + _fail(ContractErrorCode.TYPE_ERROR, "$.output_quality", "must be OutputQuality") + if output_quality.status != "passed" or any(check.status != "passed" for check in output_quality.checks): + _fail(ContractErrorCode.INVALID_VALUE, "$.output_quality", "successful FactorSetRef requires every check passed") + if not isinstance(output_coverage, OutputCoverage): + _fail(ContractErrorCode.TYPE_ERROR, "$.output_coverage", "must be OutputCoverage") + if output_coverage.status != "complete" or output_coverage.observed_count != output_coverage.expected_count: + _fail(ContractErrorCode.INVALID_VALUE, "$.output_coverage", "successful FactorSetRef requires complete coverage") + schema_digest = _digest(output_schema_digest, "$.output_schema_digest") + content_digest = _digest(output_content_digest, "$.output_content_digest") + if not isinstance(output_artifact_ref, OutputArtifactRef): + _fail(ContractErrorCode.TYPE_ERROR, "$.output_artifact_ref", "immutable artifact reference is required") + if output_artifact_ref.schema_digest != schema_digest or output_artifact_ref.content_digest != content_digest: + _fail(ContractErrorCode.ARTIFACT_MISMATCH, "$.output_artifact_ref", "artifact digests do not match output bytes") + try: + mode = availability_mode if isinstance(availability_mode, AvailabilityMode) else AvailabilityMode(availability_mode) + except (TypeError, ValueError) as exc: + raise FactorContractError(ContractErrorCode.INVALID_VALUE, "$.availability_mode", "unsupported availability mode") from exc + expected_historical = ( + HistoricalAvailability.DECLARED_AS_AVAILABLE + if mode is AvailabilityMode.AS_AVAILABLE + else HistoricalAvailability.NOT_ESTABLISHED + ) + if historical_availability is not None: + try: + supplied_historical = ( + historical_availability + if isinstance(historical_availability, HistoricalAvailability) + else HistoricalAvailability(historical_availability) + ) + except (TypeError, ValueError) as exc: + raise FactorContractError(ContractErrorCode.INVALID_VALUE, "$.historical_availability", "unsupported historical claim") from exc + if supplied_historical is not expected_historical: + _fail(ContractErrorCode.READINESS_ESCALATION, "$.historical_availability", "claim is derived from availability_mode") + evaluation_text = _normalize_new_instant(evaluation_at, "$.evaluation_at") + computed_text = _normalize_new_instant(computed_at, "$.computed_at") + artifact_text = _normalize_new_instant(artifact_available_at, "$.artifact_available_at") + evaluation_time = _parse_utc(evaluation_text, "$.evaluation_at") + computed_time = _parse_utc(computed_text, "$.computed_at") + artifact_time = _parse_utc(artifact_text, "$.artifact_available_at") + if not dataset_snapshot.knowledge_start <= dataset_snapshot.knowledge_end <= snapshot_pit <= foundation_pit <= evaluation_time: + _fail(ContractErrorCode.TIME_ORDER_VIOLATION, "$.evaluation_at", "knowledge <= snapshot PIT <= Foundation PIT <= evaluation required") + for definition in normalized_definitions: + if not _parse_utc(definition.valid_from, "$.definitions[].valid_from") <= evaluation_time < _parse_utc( + definition.valid_until, "$.definitions[].valid_until" + ): + _fail(ContractErrorCode.TIME_ORDER_VIOLATION, "$.definitions[].validity", "definition is not valid at evaluation") + view_times = [_parse_utc(item.available_at, "$.view_availability[].available_at") for item in availability] + if mode is AvailabilityMode.AS_AVAILABLE: + if dataset_snapshot.published_at > foundation_pit: + _fail(ContractErrorCode.TIME_ORDER_VIOLATION, "$.dataset_snapshot.descriptor.published_at", "publication is after Foundation PIT") + if any(view_time > foundation_pit for view_time in view_times): + _fail(ContractErrorCode.TIME_ORDER_VIOLATION, "$.view_availability", "view availability is after Foundation PIT") + if not all(source_time <= computed_time for source_time in [dataset_snapshot.published_at, *view_times]): + _fail(ContractErrorCode.TIME_ORDER_VIOLATION, "$.computed_at", "computation precedes source/view availability") + if not computed_time <= artifact_time <= evaluation_time: + _fail(ContractErrorCode.TIME_ORDER_VIOLATION, "$.artifact_available_at", "computed <= artifact <= evaluation required") + else: + if dataset_snapshot.published_at > computed_time or any(view_time > computed_time for view_time in view_times): + _fail(ContractErrorCode.TIME_ORDER_VIOLATION, "$.computed_at", "replay computation precedes actual source/view availability") + if not evaluation_time <= computed_time <= artifact_time: + _fail(ContractErrorCode.TIME_ORDER_VIOLATION, "$.computed_at", "evaluation <= computed <= artifact required for replay") + if not isinstance(producer, ProducerIdentity): + _fail(ContractErrorCode.TYPE_ERROR, "$.producer", "must be ProducerIdentity") + if producer.id != "quant_engine": + _fail(ContractErrorCode.LINEAGE_VIOLATION, "$.producer.id", "FactorSet producer must be quant_engine") + normalized_revision = _git_revision(code_revision, "$.code_revision") + if not isinstance(actor, ActorIdentity): + _fail(ContractErrorCode.TYPE_ERROR, "$.actor", "must be ActorIdentity") + normalized_correlation = _logical_id(correlation_id, "$.correlation_id") + if not isinstance(causation, Causation): + _fail(ContractErrorCode.TYPE_ERROR, "$.causation", "must be Causation") + if causation.kind == "foundation": + if causation.id != foundation.foundation_id: + _fail(ContractErrorCode.LINEAGE_VIOLATION, "$.causation.id", "must bind exact input Foundation") + if parent is not None: + _fail(ContractErrorCode.LINEAGE_VIOLATION, "$.causation", "Foundation cause cannot carry a FactorSet parent") + else: + if parent is None: + _fail(ContractErrorCode.LINEAGE_VIOLATION, "$.causation", "FactorSet cause requires the exact parent object") + if causation.id != parent.factor_set_id: + _fail(ContractErrorCode.LINEAGE_VIOLATION, "$.causation.id", "parent factor_set_id mismatch") + if normalized_correlation != parent.correlation_id: + _fail(ContractErrorCode.LINEAGE_VIOLATION, "$.correlation_id", "parent correlation mismatch") + normalized_scope = _enum(evidence_scope, "$.evidence_scope", {"real_data", "synthetic_fixture"}) + if normalized_scope == "real_data" and ( + foundation.evidence_scope != "real_data" or foundation.real_data_status != "validated" + ): + _fail(ContractErrorCode.READINESS_ESCALATION, "$.evidence_scope", "real-data evidence is not established upstream") + if type(decision_eligible) is not bool: + _fail(ContractErrorCode.TYPE_ERROR, "$.decision_eligible", "must be boolean") + if decision_eligible: + _fail(ContractErrorCode.READINESS_ESCALATION, "$.decision_eligible", "S2 computation artifacts are never decision eligible") + payload = { + "contract_name": "researchhub.factor-set-ref", + "schema_version": FACTOR_CONTRACT_VERSION, + "definition_ids": [definition.definition_id for definition in normalized_definitions], + "dataset_snapshot_id": dataset_snapshot.snapshot_id, + "foundation_id": foundation.foundation_id, + "pit_cutoff": foundation.pit_cutoff, + "selected_view_ref_ids": list(selected_views), + "input_bindings": [binding.to_dict() for binding in bindings], + "upstream_evidence": expected_upstream.to_dict(), + "view_availability": [item.to_dict() for item in availability], + "output_quality": output_quality.to_dict(), + "output_coverage": output_coverage.to_dict(), + "output_schema_digest": schema_digest, + "output_content_digest": content_digest, + "output_artifact_ref": output_artifact_ref.to_dict(), + "availability_mode": mode.value, + "historical_availability": expected_historical.value, + "evaluation_at": evaluation_text, + "computed_at": computed_text, + "artifact_available_at": artifact_text, + "producer": producer.to_dict(), + "code_revision": normalized_revision, + "actor": actor.to_dict(), + "correlation_id": normalized_correlation, + "causation": causation.to_dict(), + "evidence_scope": normalized_scope, + "decision_eligible": False, + } + expected_id = f"rhfactorsetv1:sha256:{hashlib.sha256(canonical_json_bytes(payload)).hexdigest()}" + if causation.kind == "factor_set" and causation.id == expected_id: + _fail(ContractErrorCode.LINEAGE_VIOLATION, "$.causation.id", "self parent is forbidden") + if supplied_factor_set_id is not None: + supplied = _string(supplied_factor_set_id, "$.factor_set_id", _FACTOR_SET_ID) + if supplied != expected_id: + _fail(ContractErrorCode.IDENTITY_MISMATCH, "$.factor_set_id", "does not identify complete normalized FactorSetRef") + values: dict[str, Any] = { + **payload, + "factor_set_id": expected_id, + "definition_ids": tuple( + definition.definition_id for definition in normalized_definitions + ), + "selected_view_ref_ids": selected_views, + "input_bindings": bindings, + "upstream_evidence": expected_upstream, + "view_availability": availability, + "output_quality": output_quality, + "output_coverage": output_coverage, + "output_artifact_ref": output_artifact_ref, + "availability_mode": mode, + "historical_availability": expected_historical, + "producer": producer, + "actor": actor, + "causation": causation, + "payload_validation": payload_validation, + "_definitions": normalized_definitions, + "_dataset_snapshot": dataset_snapshot, + "_foundation": foundation, + } + instance = object.__new__(cls) + for name, value in values.items(): + object.__setattr__(instance, name, value) + return instance + + def to_dict(self) -> dict[str, Any]: + return { + "contract_name": self.contract_name, + "schema_version": self.schema_version, + "factor_set_id": self.factor_set_id, + "definition_ids": list(self.definition_ids), + "dataset_snapshot_id": self.dataset_snapshot_id, + "foundation_id": self.foundation_id, + "pit_cutoff": self.pit_cutoff, + "selected_view_ref_ids": list(self.selected_view_ref_ids), + "input_bindings": [binding.to_dict() for binding in self.input_bindings], + "upstream_evidence": self.upstream_evidence.to_dict(), + "view_availability": [item.to_dict() for item in self.view_availability], + "output_quality": self.output_quality.to_dict(), + "output_coverage": self.output_coverage.to_dict(), + "output_schema_digest": self.output_schema_digest, + "output_content_digest": self.output_content_digest, + "output_artifact_ref": self.output_artifact_ref.to_dict(), + "availability_mode": self.availability_mode.value, + "historical_availability": self.historical_availability.value, + "evaluation_at": self.evaluation_at, + "computed_at": self.computed_at, + "artifact_available_at": self.artifact_available_at, + "producer": self.producer.to_dict(), + "code_revision": self.code_revision, + "actor": self.actor.to_dict(), + "correlation_id": self.correlation_id, + "causation": self.causation.to_dict(), + "evidence_scope": self.evidence_scope, + "decision_eligible": self.decision_eligible, + } + + def to_json(self) -> str: + return canonical_json(self.to_dict()) + + @classmethod + def from_dict( + cls, + value: Any, + *, + definitions: Sequence[FactorDefinition], + dataset_snapshot: DatasetSnapshotEnvelope, + foundation: DataFoundationEnvelope, + parent: FactorSetRef | None = None, + output_schema_bytes: bytes | None = None, + output_content_bytes: bytes | None = None, + ) -> Self: + item = _object( + value, + "$", + ( + "contract_name", + "schema_version", + "factor_set_id", + "definition_ids", + "dataset_snapshot_id", + "foundation_id", + "pit_cutoff", + "selected_view_ref_ids", + "input_bindings", + "upstream_evidence", + "view_availability", + "output_quality", + "output_coverage", + "output_schema_digest", + "output_content_digest", + "output_artifact_ref", + "availability_mode", + "historical_availability", + "evaluation_at", + "computed_at", + "artifact_available_at", + "producer", + "code_revision", + "actor", + "correlation_id", + "causation", + "evidence_scope", + "decision_eligible", + ), + ) + if item["contract_name"] != "researchhub.factor-set-ref": + _fail(ContractErrorCode.INVALID_VALUE, "$.contract_name", "unsupported contract") + if item["schema_version"] != FACTOR_CONTRACT_VERSION: + _fail(ContractErrorCode.INVALID_VALUE, "$.schema_version", "unsupported version") + if item["dataset_snapshot_id"] != dataset_snapshot.snapshot_id: + _fail(ContractErrorCode.INPUT_CLOSURE_VIOLATION, "$.dataset_snapshot_id", "external envelope mismatch") + if item["foundation_id"] != foundation.foundation_id or item["pit_cutoff"] != foundation.pit_cutoff: + _fail(ContractErrorCode.INPUT_CLOSURE_VIOLATION, "$.foundation_id", "external Foundation mismatch") + raw_definition_ids = _array(item["definition_ids"], "$.definition_ids", minimum=1, unique=True) + if set(raw_definition_ids) != {definition.definition_id for definition in definitions}: + _fail(ContractErrorCode.INPUT_CLOSURE_VIOLATION, "$.definition_ids", "external definitions mismatch") + raw_views = _array(item["selected_view_ref_ids"], "$.selected_view_ref_ids", minimum=1, unique=True) + raw_bindings = _array(item["input_bindings"], "$.input_bindings", minimum=1, unique=True) + raw_availability = _array(item["view_availability"], "$.view_availability", minimum=1, unique=True) + if (output_schema_bytes is None) != (output_content_bytes is None): + _fail(ContractErrorCode.ARTIFACT_MISMATCH, "$.output_artifact_ref", "both payload byte sets are required together") + validation = PayloadValidation.REFERENCE_ONLY + output_schema_digest = item["output_schema_digest"] + output_content_digest = item["output_content_digest"] + if output_schema_bytes is not None and output_content_bytes is not None: + output_schema_digest = _digest_bytes(_canonical_evidence_bytes(output_schema_bytes, "$.output_schema_bytes")) + output_content_digest = _digest_bytes(_canonical_evidence_bytes(output_content_bytes, "$.output_content_bytes")) + if output_schema_digest != item["output_schema_digest"] or output_content_digest != item["output_content_digest"]: + _fail(ContractErrorCode.ARTIFACT_MISMATCH, "$.output_artifact_ref", "supplied payload bytes do not match references") + validation = PayloadValidation.PAYLOAD_REVALIDATED + return cls._build( + definitions=definitions, + dataset_snapshot=dataset_snapshot, + foundation=foundation, + selected_view_ref_ids=tuple(raw_views), + input_bindings=tuple(InputBinding.from_dict(binding, f"$.input_bindings[{index}]") for index, binding in enumerate(raw_bindings)), + upstream_evidence=UpstreamEvidence.from_dict(item["upstream_evidence"]), + view_availability=tuple(ViewAvailability.from_dict(availability, f"$.view_availability[{index}]") for index, availability in enumerate(raw_availability)), + output_quality=OutputQuality.from_dict(item["output_quality"]), + output_coverage=OutputCoverage.from_dict(item["output_coverage"]), + output_schema_digest=output_schema_digest, + output_content_digest=output_content_digest, + output_artifact_ref=OutputArtifactRef.from_dict(item["output_artifact_ref"]), + availability_mode=item["availability_mode"], + historical_availability=item["historical_availability"], + evaluation_at=item["evaluation_at"], + computed_at=item["computed_at"], + artifact_available_at=item["artifact_available_at"], + producer=ProducerIdentity.from_dict(item["producer"]), + code_revision=item["code_revision"], + actor=ActorIdentity.from_dict(item["actor"]), + correlation_id=item["correlation_id"], + causation=Causation.from_dict(item["causation"]), + evidence_scope=item["evidence_scope"], + decision_eligible=item["decision_eligible"], + parent=parent, + supplied_factor_set_id=item["factor_set_id"], + payload_validation=validation, + ) + + @classmethod + def from_json( + cls, + value: str | bytes, + **kwargs: Any, + ) -> Self: + return cls.from_dict(_parse_json_object(value, "$"), **kwargs) + + +@dataclass(frozen=True, slots=True, init=False) +class LegacyFactorBinding: + contract_name: str + schema_version: str + binding_id: str + definition_id: str + legacy_factor_id: str + legacy_version: str + legacy_definition_sha256: str + legacy_dataset_schema_version: str + canonical_input_schema_digest: str + correspondence_evidence_digest: str + + @classmethod + def create( + cls, + *, + definition: FactorDefinition, + legacy_factor_id: str, + legacy_version: str, + legacy_definition_sha256: str, + legacy_dataset_schema_version: str, + canonical_input_schema_digest: str, + correspondence_evidence_digest: str, + ) -> Self: + if not isinstance(definition, FactorDefinition): + _fail(ContractErrorCode.TYPE_ERROR, "$.definition", "complete FactorDefinition required") + normalized_canonical_digest = _digest(canonical_input_schema_digest, "$.canonical_input_schema_digest") + if normalized_canonical_digest != definition.input_schema_digest: + _fail(ContractErrorCode.LEGACY_BINDING_MISMATCH, "$.canonical_input_schema_digest", "definition input schema mismatch") + payload = { + "contract_name": "researchhub.legacy-factor-binding", + "schema_version": FACTOR_CONTRACT_VERSION, + "definition_id": definition.definition_id, + "legacy_factor_id": _logical_id(legacy_factor_id, "$.legacy_factor_id"), + "legacy_version": _semver(legacy_version, "$.legacy_version"), + "legacy_definition_sha256": _string(legacy_definition_sha256, "$.legacy_definition_sha256", _BARE_SHA256), + "legacy_dataset_schema_version": _semver(legacy_dataset_schema_version, "$.legacy_dataset_schema_version"), + "canonical_input_schema_digest": normalized_canonical_digest, + "correspondence_evidence_digest": _digest(correspondence_evidence_digest, "$.correspondence_evidence_digest"), + } + binding_id = f"rhlegacyfactorv1:sha256:{hashlib.sha256(canonical_json_bytes(payload)).hexdigest()}" + instance = object.__new__(cls) + for name, value in {**payload, "binding_id": binding_id}.items(): + object.__setattr__(instance, name, value) + return instance + + def to_dict(self) -> dict[str, Any]: + return { + "contract_name": self.contract_name, + "schema_version": self.schema_version, + "binding_id": self.binding_id, + "definition_id": self.definition_id, + "legacy_factor_id": self.legacy_factor_id, + "legacy_version": self.legacy_version, + "legacy_definition_sha256": self.legacy_definition_sha256, + "legacy_dataset_schema_version": self.legacy_dataset_schema_version, + "canonical_input_schema_digest": self.canonical_input_schema_digest, + "correspondence_evidence_digest": self.correspondence_evidence_digest, + } + + def to_json(self) -> str: + return canonical_json(self.to_dict()) + + @classmethod + def from_dict(cls, value: Any, *, definition: FactorDefinition) -> Self: + item = _object( + value, + "$", + ( + "contract_name", + "schema_version", + "binding_id", + "definition_id", + "legacy_factor_id", + "legacy_version", + "legacy_definition_sha256", + "legacy_dataset_schema_version", + "canonical_input_schema_digest", + "correspondence_evidence_digest", + ), + ) + if item["contract_name"] != "researchhub.legacy-factor-binding" or item["schema_version"] != FACTOR_CONTRACT_VERSION: + _fail(ContractErrorCode.INVALID_VALUE, "$.contract_name", "unsupported legacy binding contract") + if item["definition_id"] != definition.definition_id: + _fail(ContractErrorCode.LEGACY_BINDING_MISMATCH, "$.definition_id", "definition mismatch") + result = cls.create( + definition=definition, + legacy_factor_id=item["legacy_factor_id"], + legacy_version=item["legacy_version"], + legacy_definition_sha256=item["legacy_definition_sha256"], + legacy_dataset_schema_version=item["legacy_dataset_schema_version"], + canonical_input_schema_digest=item["canonical_input_schema_digest"], + correspondence_evidence_digest=item["correspondence_evidence_digest"], + ) + supplied = _string(item["binding_id"], "$.binding_id", _LEGACY_BINDING_ID) + if supplied != result.binding_id: + _fail(ContractErrorCode.IDENTITY_MISMATCH, "$.binding_id", "legacy binding digest mismatch") + return result + + @classmethod + def from_json(cls, value: str | bytes, *, definition: FactorDefinition) -> Self: + return cls.from_dict(_parse_json_object(value, "$"), definition=definition) + + +__all__ = [ + "ActorIdentity", + "AvailabilityMode", + "ContractErrorCode", + "Causation", + "DataFoundationEnvelope", + "DatasetSnapshotEnvelope", + "FACTOR_CONTRACT_VERSION", + "FactorContractError", + "FactorDefinition", + "FactorInput", + "FactorSetRef", + "HistoricalAvailability", + "InputBinding", + "LegacyFactorBinding", + "OutputArtifactRef", + "OutputCoverage", + "OutputQuality", + "OutputQualityCheck", + "PayloadValidation", + "ProducerIdentity", + "TypedParameter", + "UpstreamEvidence", + "ViewAvailability", + "canonical_json", + "canonical_json_bytes", + "factor_definition_from_alpha158", + "factor_input_schema_digest", + "validate_factor_catalog", +] diff --git a/src/quant_engine/governed_pipeline.py b/src/quant_engine/governed_pipeline.py index 28d1080..96e8433 100644 --- a/src/quant_engine/governed_pipeline.py +++ b/src/quant_engine/governed_pipeline.py @@ -20,6 +20,12 @@ from types import MappingProxyType import pandas as pd from quant_engine.execution import ExecutionConfig +from quant_engine.factor_contracts import ( + ContractErrorCode, + FactorContractError, + FactorDefinition, + LegacyFactorBinding, +) from quant_engine.research_pipeline import FactorBacktestResult, run_factor_backtest_research _SHA256 = re.compile(r"^[0-9a-f]{64}$") @@ -28,6 +34,8 @@ _GIT_SHA = re.compile(r"^[0-9a-f]{40}$") __all__ = [ "DatasetSnapshot", "FactorVersion", + "bind_legacy_factor", + "project_legacy_factor", "StrategyStage", "StrategyVersion", "BacktestRun", @@ -160,6 +168,80 @@ class FactorVersion: return f"{self.factor_id}@{self.version}" +def _validate_legacy_factor_binding( + legacy: FactorVersion, + definition: FactorDefinition, + binding: LegacyFactorBinding, +) -> None: + if not isinstance(legacy, FactorVersion): + raise FactorContractError( + ContractErrorCode.TYPE_ERROR, + "$.legacy", + "FactorVersion is required", + ) + if not isinstance(definition, FactorDefinition): + raise FactorContractError( + ContractErrorCode.TYPE_ERROR, + "$.definition", + "complete FactorDefinition is required", + ) + if not isinstance(binding, LegacyFactorBinding): + raise FactorContractError( + ContractErrorCode.TYPE_ERROR, + "$.binding", + "LegacyFactorBinding is required", + ) + expected = ( + definition.definition_id, + legacy.factor_id, + legacy.version, + legacy.definition_sha256, + legacy.dataset_schema_version, + definition.input_schema_digest, + ) + actual = ( + binding.definition_id, + binding.legacy_factor_id, + binding.legacy_version, + binding.legacy_definition_sha256, + binding.legacy_dataset_schema_version, + binding.canonical_input_schema_digest, + ) + if actual != expected: + raise FactorContractError( + ContractErrorCode.LEGACY_BINDING_MISMATCH, + "$.binding", + "binding does not exactly associate the supplied legacy and canonical identities", + ) + + +def bind_legacy_factor( + legacy: FactorVersion, + complete_definition: FactorDefinition, + binding: LegacyFactorBinding, +) -> FactorDefinition: + """Validate an explicit migration binding without manufacturing missing semantics.""" + + _validate_legacy_factor_binding(legacy, complete_definition, binding) + return complete_definition + + +def project_legacy_factor( + complete_definition: FactorDefinition, + binding: LegacyFactorBinding, +) -> FactorVersion: + """Project a canonical definition into its recorded, explicitly lossy legacy identity.""" + + legacy = FactorVersion( + factor_id=binding.legacy_factor_id, + version=binding.legacy_version, + definition_sha256=binding.legacy_definition_sha256, + dataset_schema_version=binding.legacy_dataset_schema_version, + ) + _validate_legacy_factor_binding(legacy, complete_definition, binding) + return legacy + + class StrategyStage(StrEnum): DRAFT = "Draft" RESEARCH = "Research" diff --git a/tests/fixtures/factor-contracts-v1.golden.json b/tests/fixtures/factor-contracts-v1.golden.json new file mode 100644 index 0000000..10ab781 --- /dev/null +++ b/tests/fixtures/factor-contracts-v1.golden.json @@ -0,0 +1,206 @@ +{ + "dataset_snapshot": { + "contract_name": "researchhub.dataset-snapshot", + "schema_version": "1.0.0", + "snapshot_id": "rhdsv1:sha256:f63a29b4795c63fb7d6b2d3b5544cee9274b633db77c75c63d50a340c0827d57", + "descriptor": { + "dataset": { + "dataset_id": "rhdataset:market:0123456789abcdef0123456789abcdef", + "dataset_kind": "market", + "record_schema_version": "1.0.0", + "dimensions": ["instrument_id", "effective_time"] + }, + "published_at": "2026-01-02T07:05:00Z", + "time_semantics": { + "effective_time": { + "start_inclusive": "2026-01-02T07:00:00Z", + "end_inclusive": "2026-01-02T07:00:00Z" + }, + "knowledge_time": { + "start_inclusive": "2026-01-02T07:01:00Z", + "end_inclusive": "2026-01-02T07:01:00Z" + }, + "pit_cutoff": "2026-01-02T07:01:00Z" + }, + "content": { + "digest_algorithm": "sha256", + "canonicalization": "RFC8785", + "record_order": "canonical-record-byte-order", + "content_digest": "sha256:44ea11ba64dc2e6fd55c6d8e038c5edc84ee38d6fd46e38b15a1d5409662a020", + "logical_manifest": { + "record_count": 2, + "chunks": [ + { + "chunk_index": 0, + "content_digest": "sha256:44ea11ba64dc2e6fd55c6d8e038c5edc84ee38d6fd46e38b15a1d5409662a020", + "record_count": 2 + } + ] + }, + "manifest_digest": "sha256:d991bb2f8f6b80525f93c51e0b371213a3ed4649dffb073ed4605bfbd32349bd", + "record_count": 2 + }, + "lineage": { + "publisher": {"id": "researchhub.data", "version": "1.0.0"}, + "transformation": { + "id": "rhtransform:00112233445566778899aabbccddeeff", + "version": "1.0.0" + }, + "upstream_snapshot_ids": [], + "upstream_content_digests": [] + }, + "quality": { + "status": "passed", + "checks": [ + { + "check_id": "completeness", + "status": "passed", + "severity": "blocking", + "evidence_digest": "sha256:876fc2fcc6414ddc3f824a47f475d34c82a53d2bda5dc72a234a3f3164e8e2ec" + }, + { + "check_id": "pit_time_integrity", + "status": "passed", + "severity": "blocking", + "evidence_digest": "sha256:90a6cc46b9f2ab317a1c6d14dc173784e19b5621cd7fe956e8f41338cbdc5944" + } + ] + }, + "qualification": { + "status": "qualified", + "policy_id": "researchhub.dataset-snapshot.pit", + "policy_version": "1.0.0", + "evaluated_at": "2026-01-02T07:04:00Z", + "evidence_digest": "sha256:e192462f9022f2b477f73cdbe9e6c9f891ebcfdc2b4ed4f8ddd7b1ff107ee6a6" + } + } + }, + "data_foundation": { + "contract_name": "researchhub.data-foundation", + "schema_version": "1.0.0", + "foundation_id": "rhdfv1:sha256:d848237ab753ee9432ae78ec1f93b6ac45c8072d6694023b7f288203daf9d838", + "dataset_snapshot_id": "rhdsv1:sha256:f63a29b4795c63fb7d6b2d3b5544cee9274b633db77c75c63d50a340c0827d57", + "pit_cutoff": "2026-01-03T00:00:00Z", + "instrument_routes": [ + { + "route_revision_id": "rhroutev1:sha256:ca67013250e28ab4cce16570607379a8792400e62415ee6cb71c75508e2f3d86", + "instrument_id": "rhinstrument:0123456789abcdef0123456789abcdef", + "revision_number": 1, + "symbol": "600000", + "mic": "XSHG", + "currency": "CNY", + "asset_class": "equity", + "instrument_type": "stock", + "calendar_id": "rhcalendar:11112222333344445555666677778888", + "effective_from": "2020-01-01T00:00:00Z", + "knowledge_time": "2026-01-01T07:00:00Z", + "evidence_digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "trading_calendar_revisions": [ + { + "calendar_revision_id": "rhcalv1:sha256:1f4ca22557063389badd669cf774bb35234066e847646682dccfe411e252a078", + "calendar_id": "rhcalendar:11112222333344445555666677778888", + "session_date": "2026-01-02", + "revision_number": 1, + "status": "open", + "sessions": [ + {"opens_at": "2026-01-02T01:30:00Z", "closes_at": "2026-01-02T07:00:00Z"} + ], + "knowledge_time": "2026-01-01T08:00:00Z", + "evidence_digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } + ], + "corporate_action_revisions": [ + { + "action_revision_id": "rhcav1:sha256:0f947df29f152bfa2c6ab0da7a0d464670c7ad2526cd10f2a7939b42fee5c275", + "action_id": "rhaction:99998888777766665555444433332222", + "instrument_id": "rhinstrument:0123456789abcdef0123456789abcdef", + "revision_number": 1, + "action_type": "cash_dividend", + "status": "confirmed", + "effective_time": "2026-01-02T00:00:00Z", + "knowledge_time": "2026-01-01T09:00:00Z", + "terms_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "evidence_digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + } + ], + "standardized_views": [ + { + "view_ref_id": "rhviewrefv1:sha256:bf776bcd26d940fafde1d650776a5505fb3fe8b5b068c351622bf2c42385629c", + "view_id": "rhview:abcdef0123456789abcdef0123456789", + "view_version": "1.0.0", + "dataset_snapshot_id": "rhdsv1:sha256:f63a29b4795c63fb7d6b2d3b5544cee9274b633db77c75c63d50a340c0827d57", + "pit_cutoff": "2026-01-03T00:00:00Z", + "schema_digest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "content_digest": "sha256:123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0", + "transformation_digest": "sha256:23456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef01", + "instrument_route_revision_ids": [ + "rhroutev1:sha256:ca67013250e28ab4cce16570607379a8792400e62415ee6cb71c75508e2f3d86" + ], + "trading_calendar_revision_ids": [ + "rhcalv1:sha256:1f4ca22557063389badd669cf774bb35234066e847646682dccfe411e252a078" + ], + "corporate_action_revision_ids": [ + "rhcav1:sha256:0f947df29f152bfa2c6ab0da7a0d464670c7ad2526cd10f2a7939b42fee5c275" + ] + } + ], + "revision_lineage": [ + { + "revision_kind": "instrument_route", + "revision_id": "rhroutev1:sha256:ca67013250e28ab4cce16570607379a8792400e62415ee6cb71c75508e2f3d86", + "revision_number": 1, + "knowledge_time": "2026-01-01T07:00:00Z", + "evidence_digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + { + "revision_kind": "trading_calendar", + "revision_id": "rhcalv1:sha256:1f4ca22557063389badd669cf774bb35234066e847646682dccfe411e252a078", + "revision_number": 1, + "knowledge_time": "2026-01-01T08:00:00Z", + "evidence_digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + { + "revision_kind": "corporate_action", + "revision_id": "rhcav1:sha256:0f947df29f152bfa2c6ab0da7a0d464670c7ad2526cd10f2a7939b42fee5c275", + "revision_number": 1, + "knowledge_time": "2026-01-01T09:00:00Z", + "evidence_digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + } + ], + "readiness": { + "evidence_scope": "synthetic_fixture", + "contract_validation": { + "status": "validated", + "evidence_digests": [ + "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + ] + }, + "real_data_validation": {"status": "not_validated", "evidence_digests": []}, + "production_validation": {"status": "not_validated", "evidence_digests": []}, + "live_validation": {"status": "not_validated", "evidence_digests": []} + } + }, + "output_schema": { + "columns": ["evaluation_at", "factor_id", "instrument_id", "value"], + "schema_version": "1.0.0" + }, + "output_content": { + "rows": [ + { + "evaluation_at": "2026-01-03T11:00:00Z", + "factor_id": "alpha_005", + "instrument_id": "rhinstrument:0123456789abcdef0123456789abcdef", + "value": "0.125" + } + ] + }, + "expected": { + "definition_id": "rhfactorv1:sha256:978fb8000d318373844a5e044ca14bf377e01ebe8d85964b826ecd2af9085ce9", + "input_schema_digest": "sha256:4501aeab99b4bcc25a1b8813ebe197fb498053fd710d73746bf20fc8eeb4bfa7", + "factor_set_id": "rhfactorsetv1:sha256:e9339581cf569e92459f672e8081337712e7bf98e58ad42d60d7ed13f9b5a021", + "output_artifact_id": "rhfactoroutputv1:sha256:a4803b5ff66d12d3a0e7e8e5b8cca953cbc137e2bf41514b7c4e5f05da5ee68b", + "legacy_binding_id": "rhlegacyfactorv1:sha256:541bc5a9469f9c8e4c2d696a9972fc5f2e6e2bef218b86f728823994b915dede" + } +} diff --git a/tests/governance/test_module_spec.py b/tests/governance/test_module_spec.py index e101743..ad189b2 100644 --- a/tests/governance/test_module_spec.py +++ b/tests/governance/test_module_spec.py @@ -1,32 +1,51 @@ from __future__ import annotations import json -import unittest from pathlib import Path ROOT = Path(__file__).resolve().parents[2] -class ModuleSpecTests(unittest.TestCase): - def test_module_spec_declares_pure_research_engine_boundary(self) -> None: - spec = json.loads((ROOT / "MODULE_SPEC.yaml").read_text(encoding="utf-8")) - self.assertEqual(spec["module_id"], "quant_engine") - self.assertEqual(spec["authority"]["subject"], spec["module_id"]) - self.assertEqual(spec["repository"]["type"], "research_engine") - self.assertEqual(spec["bounded_context"]["domain"], "quantitative-research-engine") - prohibited = " ".join(spec["bounded_context"]["prohibited_responsibilities"]).lower() - for term in ("investment advice", "live order", "credentials", "source facts"): - self.assertIn(term, prohibited) - self.assertEqual(spec["contracts"], {"provides": [], "consumes": []}) - self.assertEqual(spec["dependencies"], []) - self.assertTrue( - all( - command["required"] and not command["network"] - for command in spec["verification"]["commands"] - ) - ) +def test_module_spec_declares_pure_research_engine_boundary() -> None: + spec = json.loads((ROOT / "MODULE_SPEC.yaml").read_text(encoding="utf-8")) + assert spec["module_id"] == "quant_engine" + assert spec["authority"]["subject"] == spec["module_id"] + assert spec["repository"]["type"] == "research_engine" + assert spec["bounded_context"]["domain"] == "quantitative-research-engine" + prohibited = " ".join(spec["bounded_context"]["prohibited_responsibilities"]).lower() + for term in ("investment advice", "live order", "credentials", "source facts"): + assert term in prohibited + assert spec["authority"]["revision"] == 2 + assert { + (item["contract_id"], item["version"]) + for item in spec["contracts"]["provides"] + } == { + ("researchhub.factor-definition", "1.0.0"), + ("researchhub.factor-set-ref", "1.0.0"), + } + assert all( + item["authority"] == "quant_engine" + and item["path"] == "src/quant_engine/factor_contracts.py" + for item in spec["contracts"]["provides"] + ) + assert { + (item["contract_id"], item["version"]) + for item in spec["contracts"]["consumes"] + } == { + ("researchhub.dataset-snapshot", "1.0.0"), + ("researchhub.data-foundation", "1.0.0"), + } + assert all( + item["authority"] == "researchhub.data" + for item in spec["contracts"]["consumes"] + ) + assert spec["dependencies"] == [] + assert all( + command["required"] and not command["network"] + for command in spec["verification"]["commands"] + ) if __name__ == "__main__": - unittest.main() + test_module_spec_declares_pure_research_engine_boundary() diff --git a/tests/test_alpha_factors.py b/tests/test_alpha_factors.py index 1786bd6..2e479a3 100644 --- a/tests/test_alpha_factors.py +++ b/tests/test_alpha_factors.py @@ -7,6 +7,13 @@ import pandas as pd import pytest import quant_engine.alpha_factors as alpha_factors_module +from quant_engine.factor_contracts import ( + FactorContractError, + FactorInput, + ProducerIdentity, + factor_definition_from_alpha158, + factor_input_schema_digest, +) from quant_engine.alpha_factors import ( ALPHA158_REGISTRY, ALPHA158_PHASE1_OPERATOR_SPECS, @@ -434,6 +441,50 @@ def test_alpha_registry_required_fields(): assert required <= set(meta.keys()), f"{alpha_id} missing fields" +def test_alpha_registry_adapts_to_definition_without_copying_formula_or_inputs(): + factor_input = FactorInput( + "market", + "sha256:" + "1" * 64, + tuple(ALPHA158_REGISTRY["alpha_005"]["inputs"]), + ) + definition = factor_definition_from_alpha158( + "alpha_005", + version="1.0.0", + parameters={}, + inputs=(factor_input,), + implementation_digest="sha256:" + "2" * 64, + input_schema_digest=factor_input_schema_digest((factor_input,)), + valid_from="2026-01-01T00:00:00Z", + valid_until="2027-01-01T00:00:00Z", + warmup_sessions=10, + lag_sessions=1, + producer=ProducerIdentity("quant_engine", "1.0.0"), + code_revision="c" * 40, + ) + + assert definition.formula == ALPHA158_REGISTRY["alpha_005"]["formula"] + assert definition.inputs[0].required_columns == tuple( + ALPHA158_REGISTRY["alpha_005"]["inputs"] + ) + + incomplete = FactorInput("market", "sha256:" + "1" * 64, ("close",)) + with pytest.raises(FactorContractError, match="exactly correspond"): + factor_definition_from_alpha158( + "alpha_005", + version="1.0.0", + parameters={}, + inputs=(incomplete,), + implementation_digest="sha256:" + "2" * 64, + input_schema_digest=factor_input_schema_digest((incomplete,)), + valid_from="2026-01-01T00:00:00Z", + valid_until="2027-01-01T00:00:00Z", + warmup_sessions=10, + lag_sessions=1, + producer=ProducerIdentity("quant_engine", "1.0.0"), + code_revision="c" * 40, + ) + + def test_get_alpha_meta_success(): """已知 alpha_id 返回完整 meta。""" meta = get_alpha_meta("alpha_001") diff --git a/tests/test_factor_contracts.py b/tests/test_factor_contracts.py new file mode 100644 index 0000000..a3b41b7 --- /dev/null +++ b/tests/test_factor_contracts.py @@ -0,0 +1,837 @@ +"""Versioned factor-definition and factor-set contract conformance.""" + +from __future__ import annotations + +import copy +import hashlib +import json +from dataclasses import FrozenInstanceError +from pathlib import Path +from typing import Any, Callable + +import pytest + +from quant_engine.factor_contracts import ( + ActorIdentity, + AvailabilityMode, + ContractErrorCode, + Causation, + DataFoundationEnvelope, + DatasetSnapshotEnvelope, + FactorContractError, + FactorDefinition, + FactorInput, + FactorSetRef, + HistoricalAvailability, + InputBinding, + LegacyFactorBinding, + OutputArtifactRef, + OutputCoverage, + OutputQuality, + OutputQualityCheck, + PayloadValidation, + ProducerIdentity, + TypedParameter, + ViewAvailability, + canonical_json_bytes, + factor_definition_from_alpha158, + factor_input_schema_digest, + validate_factor_catalog, +) +from quant_engine.governed_pipeline import ( + FactorVersion, + bind_legacy_factor, + project_legacy_factor, +) + + +FIXTURE_PATH = Path(__file__).parent / "fixtures" / "factor-contracts-v1.golden.json" +VIEW_REF_ID = "rhviewrefv1:sha256:bf776bcd26d940fafde1d650776a5505fb3fe8b5b068c351622bf2c42385629c" +VIEW_SCHEMA_DIGEST = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + + +def _golden() -> dict[str, Any]: + loaded = json.loads(FIXTURE_PATH.read_text(encoding="utf-8")) + assert isinstance(loaded, dict) + return loaded + + +def _sha256(value: bytes) -> str: + return f"sha256:{hashlib.sha256(value).hexdigest()}" + + +def _reidentify(item: dict[str, Any], field: str, prefix: str) -> None: + payload = {key: value for key, value in item.items() if key != field} + item[field] = f"{prefix}{hashlib.sha256(canonical_json_bytes(payload)).hexdigest()}" + + +def _snapshot_and_foundation( + fixture: dict[str, Any] | None = None, +) -> tuple[DatasetSnapshotEnvelope, DataFoundationEnvelope]: + source = _golden() if fixture is None else fixture + return ( + DatasetSnapshotEnvelope.from_dict(source["dataset_snapshot"]), + DataFoundationEnvelope.from_dict(source["data_foundation"]), + ) + + +def _definition( + *, + inputs: tuple[FactorInput, ...] | None = None, + **overrides: Any, +) -> FactorDefinition: + factor_inputs = inputs or ( + FactorInput("market", VIEW_SCHEMA_DIGEST, ("close", "volume")), + ) + arguments: dict[str, Any] = { + "factor_id": "alpha_005", + "version": "1.0.0", + "formula": "correlation(close, volume, 10)", + "parameters": {}, + "implementation_digest": "sha256:" + "1" * 64, + "input_schema_digest": factor_input_schema_digest(factor_inputs), + "inputs": factor_inputs, + "valid_from": "2026-01-01T00:00:00.000000Z", + "valid_until": "2027-01-01T00:00:00Z", + "warmup_sessions": 10, + "lag_sessions": 1, + "producer": ProducerIdentity("quant_engine", "1.0.0"), + "code_revision": "c" * 40, + } + arguments.update(overrides) + return FactorDefinition.create(**arguments) + + +def _golden_definition() -> FactorDefinition: + factor_input = FactorInput("market", VIEW_SCHEMA_DIGEST, ("close", "volume")) + return factor_definition_from_alpha158( + "alpha_005", + version="1.0.0", + parameters={}, + inputs=(factor_input,), + implementation_digest="sha256:" + "1" * 64, + input_schema_digest=factor_input_schema_digest((factor_input,)), + valid_from="2026-01-01T00:00:00.000000Z", + valid_until="2027-01-01T00:00:00Z", + warmup_sessions=10, + lag_sessions=1, + producer=ProducerIdentity("quant_engine", "1.0.0"), + code_revision="c" * 40, + ) + + +def _factor_set_arguments( + *, + fixture: dict[str, Any] | None = None, + snapshot: DatasetSnapshotEnvelope | None = None, + foundation: DataFoundationEnvelope | None = None, + definition: FactorDefinition | None = None, +) -> dict[str, Any]: + source = _golden() if fixture is None else fixture + if snapshot is None or foundation is None: + parsed_snapshot, parsed_foundation = _snapshot_and_foundation(source) + snapshot = snapshot or parsed_snapshot + foundation = foundation or parsed_foundation + selected_definition = definition or _golden_definition() + output_schema_bytes = canonical_json_bytes(source["output_schema"]) + output_content_bytes = canonical_json_bytes(source["output_content"]) + artifact = OutputArtifactRef.create( + schema_digest=_sha256(output_schema_bytes), + content_digest=_sha256(output_content_bytes), + ) + return { + "definitions": (selected_definition,), + "dataset_snapshot": snapshot, + "foundation": foundation, + "selected_view_ref_ids": (VIEW_REF_ID,), + "input_bindings": ( + InputBinding( + selected_definition.definition_id, + "market", + VIEW_REF_ID, + VIEW_SCHEMA_DIGEST, + ), + ), + "view_availability": ( + ViewAvailability(VIEW_REF_ID, "2026-01-02T23:50:00Z", "sha256:" + "2" * 64), + ), + "output_quality": OutputQuality( + "passed", + (OutputQualityCheck("finite_values", "passed", "sha256:" + "3" * 64),), + ), + "output_coverage": OutputCoverage( + "complete", + 1, + 1, + "row", + "alpha_005.cn_a", + "sha256:" + "4" * 64, + ), + "output_schema_bytes": output_schema_bytes, + "output_content_bytes": output_content_bytes, + "output_artifact_ref": artifact, + "availability_mode": AvailabilityMode.AS_AVAILABLE, + "evaluation_at": "2026-01-03T11:00:00Z", + "computed_at": "2026-01-03T10:15:00Z", + "artifact_available_at": "2026-01-03T10:20:00Z", + "producer": ProducerIdentity("quant_engine", "1.0.0"), + "code_revision": "c" * 40, + "actor": ActorIdentity("service", "factor_worker_v1"), + "correlation_id": "research_run_001", + "causation": Causation("foundation", foundation.foundation_id), + "evidence_scope": "synthetic_fixture", + "decision_eligible": False, + } + + +def _factor_set(**overrides: Any) -> FactorSetRef: + arguments = _factor_set_arguments() + arguments.update(overrides) + return FactorSetRef.create(**arguments) + + +def _assert_error( + error: pytest.ExceptionInfo[FactorContractError], + code: ContractErrorCode, + path: str, +) -> None: + assert error.value.code is code + assert error.value.path == path + + +def _mutate_artifact_schema_binding(value: dict[str, Any]) -> None: + artifact = value["output_artifact_ref"] + artifact["schema_digest"] = "sha256:" + "0" * 64 + _reidentify(artifact, "artifact_id", "rhfactoroutputv1:sha256:") + + +def test_golden_contracts_are_content_addressed_round_trippable_and_deeply_immutable() -> None: + fixture = _golden() + original_snapshot = copy.deepcopy(fixture["dataset_snapshot"]) + original_foundation = copy.deepcopy(fixture["data_foundation"]) + snapshot, foundation = _snapshot_and_foundation(fixture) + definition = _golden_definition() + factor_set = FactorSetRef.create(**_factor_set_arguments(fixture=fixture, snapshot=snapshot, foundation=foundation, definition=definition)) + binding = LegacyFactorBinding.create( + definition=definition, + legacy_factor_id="factor:demo-momentum", + legacy_version="1.0.0", + legacy_definition_sha256="b" * 64, + legacy_dataset_schema_version="1.0.0", + canonical_input_schema_digest=definition.input_schema_digest, + correspondence_evidence_digest="sha256:" + "5" * 64, + ) + + assert snapshot.pit_cutoff == "2026-01-02T07:01:00Z" + assert foundation.pit_cutoff == factor_set.pit_cutoff == "2026-01-03T00:00:00Z" + assert snapshot.pit_cutoff != foundation.pit_cutoff + assert definition.definition_id == fixture["expected"]["definition_id"] + assert definition.input_schema_digest == fixture["expected"]["input_schema_digest"] + assert factor_set.factor_set_id == fixture["expected"]["factor_set_id"] + assert factor_set.output_artifact_ref.artifact_id == fixture["expected"]["output_artifact_id"] + assert binding.binding_id == fixture["expected"]["legacy_binding_id"] + assert not definition.to_json().endswith("\n") + assert not factor_set.to_json().endswith("\n") + assert FactorDefinition.from_json(definition.to_json()) == definition + + reparsed = FactorSetRef.from_json( + factor_set.to_json(), + definitions=(definition,), + dataset_snapshot=snapshot, + foundation=foundation, + output_schema_bytes=canonical_json_bytes(fixture["output_schema"]), + output_content_bytes=canonical_json_bytes(fixture["output_content"]), + ) + reference_only = FactorSetRef.from_json( + factor_set.to_json(), + definitions=(definition,), + dataset_snapshot=snapshot, + foundation=foundation, + ) + assert reparsed.factor_set_id == factor_set.factor_set_id + assert reparsed.payload_validation is PayloadValidation.PAYLOAD_REVALIDATED + assert reference_only.payload_validation is PayloadValidation.REFERENCE_ONLY + + fixture["dataset_snapshot"]["descriptor"]["dataset"]["dimensions"].append("forbidden") + fixture["data_foundation"]["standardized_views"][0]["schema_digest"] = "sha256:" + "0" * 64 + assert snapshot.to_dict() == original_snapshot + assert foundation.to_dict() == original_foundation + returned = snapshot.to_dict() + returned["descriptor"]["dataset"]["dimensions"].append("also_forbidden") + assert snapshot.to_dict() == original_snapshot + with pytest.raises(FrozenInstanceError): + snapshot.snapshot_id = "rhdsv1:sha256:" + "0" * 64 # type: ignore[misc] + + +def test_factor_definition_identity_is_order_independent_where_semantics_are_unordered() -> None: + first_input = FactorInput("prices", "sha256:" + "6" * 64, ("close",)) + second_input = FactorInput("volumes", "sha256:" + "7" * 64, ("volume",)) + inputs = (first_input, second_input) + parameters_a = { + "window": TypedParameter("integer", 10), + "weights": TypedParameter("json", {"fast": [1, 2], "slow": [3, 4]}), + } + parameters_b = { + "weights": TypedParameter("json", {"slow": [3, 4], "fast": [1, 2]}), + "window": TypedParameter("integer", 10), + } + first = _definition( + inputs=inputs, + parameters=parameters_a, + input_schema_digest=factor_input_schema_digest(inputs), + ) + second = _definition( + inputs=tuple(reversed(inputs)), + parameters=parameters_b, + input_schema_digest=factor_input_schema_digest(tuple(reversed(inputs))), + ) + assert first.definition_id == second.definition_id + assert first.to_json() == second.to_json() + + semantic_changes = ( + _definition(factor_id="alpha_006"), + _definition(version="1.0.1"), + _definition(formula="correlation(close, volume, 11)"), + _definition(parameters={"window": TypedParameter("integer", 10)}), + _definition(implementation_digest="sha256:" + "9" * 64), + _definition(valid_until="2027-01-02T00:00:00Z"), + _definition(warmup_sessions=11), + _definition(lag_sessions=2), + _definition(producer=ProducerIdentity("quant_engine", "1.0.1")), + _definition(code_revision="d" * 40), + ) + assert all(changed.definition_id != _golden_definition().definition_id for changed in semantic_changes) + assert len({changed.definition_id for changed in semantic_changes}) == len(semantic_changes) + + +def test_parameter_types_decimal_profile_and_detached_nested_values_are_strict() -> None: + nested = {"ordered": [1, {"flag": True}]} + parameter = TypedParameter("json", nested) + nested["ordered"].append(2) + definition = _definition(parameters={"payload": parameter}) + assert definition.to_dict()["parameters"]["payload"]["value"] == { + "ordered": [1, {"flag": True}] + } + integer_definition = _definition(parameters={"value": TypedParameter("integer", 1)}) + string_definition = _definition(parameters={"value": TypedParameter("string", "1")}) + assert integer_definition.definition_id != string_definition.definition_id + + for parameter_type, value, code in ( + ("decimal", "1.0", ContractErrorCode.INVALID_FORMAT), + ("decimal", "1e3", ContractErrorCode.INVALID_FORMAT), + ("decimal", "-0", ContractErrorCode.INVALID_FORMAT), + ("integer", True, ContractErrorCode.TYPE_ERROR), + ("json", 1.5, ContractErrorCode.TYPE_ERROR), + ("json", {"é": "bad-key"}, ContractErrorCode.INVALID_FORMAT), + ("json", 9_007_199_254_740_992, ContractErrorCode.INVALID_VALUE), + ): + with pytest.raises(FactorContractError) as error: + TypedParameter(parameter_type, value) + assert error.value.code is code + assert TypedParameter("decimal", "10.25").to_dict()["value"] == "10.25" + + +def test_catalog_rejects_duplicate_and_overlapping_logical_validity_but_allows_adjacency() -> None: + base = _golden_definition() + adjacent = _definition(valid_from="2027-01-01T00:00:00Z", valid_until="2028-01-01T00:00:00Z") + assert len(validate_factor_catalog((adjacent, base))) == 2 + with pytest.raises(FactorContractError) as duplicate: + validate_factor_catalog((base, base)) + _assert_error(duplicate, ContractErrorCode.INVALID_VALUE, "$.definitions") + overlapping = _definition(valid_from="2026-06-01T00:00:00Z", valid_until="2028-01-01T00:00:00Z") + with pytest.raises(FactorContractError) as overlap: + validate_factor_catalog((base, overlapping)) + _assert_error(overlap, ContractErrorCode.TIME_ORDER_VIOLATION, "$.definitions") + + +def test_upstream_contracts_reject_unknown_fields_identity_forgery_and_unqualified_input() -> None: + unknown = _golden()["dataset_snapshot"] + unknown["provider"] = "forbidden" + with pytest.raises(FactorContractError) as unknown_error: + DatasetSnapshotEnvelope.from_dict(unknown) + _assert_error(unknown_error, ContractErrorCode.UNKNOWN_FIELD, "$.provider") + + forged = _golden()["data_foundation"] + forged["standardized_views"][0]["schema_digest"] = "sha256:" + "0" * 64 + with pytest.raises(FactorContractError) as forged_error: + DataFoundationEnvelope.from_dict(forged) + assert forged_error.value.code is ContractErrorCode.IDENTITY_MISMATCH + assert forged_error.value.path.endswith("view_ref_id") + + rejected_source = _golden() + rejected_source["dataset_snapshot"]["descriptor"]["qualification"]["status"] = "rejected" + _reidentify(rejected_source["dataset_snapshot"], "snapshot_id", "rhdsv1:sha256:") + rejected_snapshot = DatasetSnapshotEnvelope.from_dict(rejected_source["dataset_snapshot"]) + _, foundation = _snapshot_and_foundation() + with pytest.raises(FactorContractError) as rejected_error: + FactorSetRef.create( + **_factor_set_arguments(snapshot=rejected_snapshot, foundation=foundation) + ) + _assert_error( + rejected_error, + ContractErrorCode.QUALIFICATION_REJECTED, + "$.dataset_snapshot.descriptor.qualification", + ) + + +def test_foundation_rejects_future_knowledge_and_per_view_calendar_borrowing() -> None: + future = _golden()["data_foundation"] + action = future["corporate_action_revisions"][0] + old_action_id = action["action_revision_id"] + action["knowledge_time"] = "2026-01-03T00:00:01Z" + _reidentify(action, "action_revision_id", "rhcav1:sha256:") + future["standardized_views"][0]["corporate_action_revision_ids"] = [action["action_revision_id"]] + lineage = next(item for item in future["revision_lineage"] if item["revision_id"] == old_action_id) + lineage["revision_id"] = action["action_revision_id"] + lineage["knowledge_time"] = action["knowledge_time"] + _reidentify(future["standardized_views"][0], "view_ref_id", "rhviewrefv1:sha256:") + _reidentify(future, "foundation_id", "rhdfv1:sha256:") + with pytest.raises(FactorContractError) as future_error: + DataFoundationEnvelope.from_dict(future) + _assert_error( + future_error, + ContractErrorCode.TIME_ORDER_VIOLATION, + "$.revision_lineage.knowledge_time", + ) + + uncovered = _golden()["data_foundation"] + original_route_id = uncovered["instrument_routes"][0]["route_revision_id"] + second_calendar = copy.deepcopy(uncovered["trading_calendar_revisions"][0]) + second_calendar["calendar_id"] = "rhcalendar:99990000111122223333444455556666" + _reidentify(second_calendar, "calendar_revision_id", "rhcalv1:sha256:") + uncovered["trading_calendar_revisions"].append(second_calendar) + route = uncovered["instrument_routes"][0] + route["calendar_id"] = second_calendar["calendar_id"] + _reidentify(route, "route_revision_id", "rhroutev1:sha256:") + route_lineage = next(item for item in uncovered["revision_lineage"] if item["revision_id"] == original_route_id) + route_lineage["revision_id"] = route["route_revision_id"] + uncovered["revision_lineage"].append( + { + "revision_kind": "trading_calendar", + "revision_id": second_calendar["calendar_revision_id"], + "revision_number": 1, + "knowledge_time": second_calendar["knowledge_time"], + "evidence_digest": second_calendar["evidence_digest"], + } + ) + view = uncovered["standardized_views"][0] + view["instrument_route_revision_ids"] = [route["route_revision_id"]] + _reidentify(view, "view_ref_id", "rhviewrefv1:sha256:") + _reidentify(uncovered, "foundation_id", "rhdfv1:sha256:") + with pytest.raises(FactorContractError) as calendar_error: + DataFoundationEnvelope.from_dict(uncovered) + assert calendar_error.value.code is ContractErrorCode.INPUT_CLOSURE_VIOLATION + assert "selected route calendar" in calendar_error.value.detail + + +def _replay_fixture() -> dict[str, Any]: + fixture = _golden() + snapshot = fixture["dataset_snapshot"] + snapshot["descriptor"]["published_at"] = "2026-01-04T00:00:00Z" + _reidentify(snapshot, "snapshot_id", "rhdsv1:sha256:") + foundation = fixture["data_foundation"] + foundation["dataset_snapshot_id"] = snapshot["snapshot_id"] + for view in foundation["standardized_views"]: + view["dataset_snapshot_id"] = snapshot["snapshot_id"] + _reidentify(view, "view_ref_id", "rhviewrefv1:sha256:") + _reidentify(foundation, "foundation_id", "rhdfv1:sha256:") + return fixture + + +def test_as_available_and_retrospective_replay_keep_distinct_time_claims() -> None: + as_available = _factor_set() + assert as_available.historical_availability is HistoricalAvailability.DECLARED_AS_AVAILABLE + + replay_source = _replay_fixture() + snapshot, foundation = _snapshot_and_foundation(replay_source) + replay_view_id = next(iter(foundation.views)) + arguments = _factor_set_arguments( + fixture=replay_source, + snapshot=snapshot, + foundation=foundation, + ) + arguments.update( + selected_view_ref_ids=(replay_view_id,), + input_bindings=( + InputBinding( + arguments["definitions"][0].definition_id, + "market", + replay_view_id, + VIEW_SCHEMA_DIGEST, + ), + ), + view_availability=( + ViewAvailability(replay_view_id, "2026-01-04T00:10:00Z", "sha256:" + "2" * 64), + ), + availability_mode=AvailabilityMode.RETROSPECTIVE_REPLAY, + computed_at="2026-01-04T00:20:00Z", + artifact_available_at="2026-01-04T00:25:00Z", + causation=Causation("foundation", foundation.foundation_id), + ) + replay = FactorSetRef.create(**arguments) + assert replay.evaluation_at == "2026-01-03T11:00:00Z" + assert replay.computed_at == "2026-01-04T00:20:00Z" + assert replay.historical_availability is HistoricalAvailability.NOT_ESTABLISHED + + replay_source_args = _factor_set_arguments( + fixture=replay_source, + snapshot=snapshot, + foundation=foundation, + ) + replay_source_args.update( + selected_view_ref_ids=(replay_view_id,), + input_bindings=( + InputBinding( + replay_source_args["definitions"][0].definition_id, + "market", + replay_view_id, + VIEW_SCHEMA_DIGEST, + ), + ), + view_availability=( + ViewAvailability(replay_view_id, "2026-01-02T23:50:00Z", "sha256:" + "2" * 64), + ), + causation=Causation("foundation", foundation.foundation_id), + ) + with pytest.raises(FactorContractError) as late_publication: + FactorSetRef.create(**replay_source_args) + _assert_error( + late_publication, + ContractErrorCode.TIME_ORDER_VIOLATION, + "$.dataset_snapshot.descriptor.published_at", + ) + + +@pytest.mark.parametrize( + ("overrides", "path"), + [ + ({"view_availability": (ViewAvailability(VIEW_REF_ID, "2026-01-03T00:00:01Z", "sha256:" + "2" * 64),)}, "$.view_availability"), + ({"computed_at": "2026-01-02T23:40:00Z"}, "$.computed_at"), + ({"artifact_available_at": "2026-01-03T10:14:00Z"}, "$.artifact_available_at"), + ({"artifact_available_at": "2026-01-03T11:00:01Z"}, "$.artifact_available_at"), + ({"evaluation_at": "2026-01-03T11:00:00"}, "$.evaluation_at"), + ], +) +def test_as_available_time_failures_are_typed(overrides: dict[str, Any], path: str) -> None: + with pytest.raises(FactorContractError) as error: + _factor_set(**overrides) + assert error.value.code in { + ContractErrorCode.INVALID_FORMAT, + ContractErrorCode.TIME_ORDER_VIOLATION, + } + assert error.value.path == path + + +def test_replay_rejects_backdating_and_historical_availability_promotion() -> None: + source = _replay_fixture() + snapshot, foundation = _snapshot_and_foundation(source) + view_id = next(iter(foundation.views)) + arguments = _factor_set_arguments(fixture=source, snapshot=snapshot, foundation=foundation) + definition = arguments["definitions"][0] + arguments.update( + selected_view_ref_ids=(view_id,), + input_bindings=(InputBinding(definition.definition_id, "market", view_id, VIEW_SCHEMA_DIGEST),), + view_availability=(ViewAvailability(view_id, "2026-01-04T00:10:00Z", "sha256:" + "2" * 64),), + availability_mode=AvailabilityMode.RETROSPECTIVE_REPLAY, + computed_at="2026-01-04T00:20:00Z", + artifact_available_at="2026-01-04T00:25:00Z", + causation=Causation("foundation", foundation.foundation_id), + ) + replay = FactorSetRef.create(**arguments) + promoted = replay.to_dict() + promoted["historical_availability"] = "declared_as_available" + _reidentify(promoted, "factor_set_id", "rhfactorsetv1:sha256:") + with pytest.raises(FactorContractError) as promotion_error: + FactorSetRef.from_dict( + promoted, + definitions=(definition,), + dataset_snapshot=snapshot, + foundation=foundation, + ) + _assert_error( + promotion_error, + ContractErrorCode.READINESS_ESCALATION, + "$.historical_availability", + ) + arguments["computed_at"] = "2026-01-03T11:30:00Z" + with pytest.raises(FactorContractError) as backdated_error: + FactorSetRef.create(**arguments) + _assert_error(backdated_error, ContractErrorCode.TIME_ORDER_VIOLATION, "$.computed_at") + + +def _multi_view_fixture() -> tuple[dict[str, Any], str]: + fixture = _golden() + foundation = fixture["data_foundation"] + second = copy.deepcopy(foundation["standardized_views"][0]) + second["view_id"] = "rhview:11111111222222223333333344444444" + second["schema_digest"] = "sha256:" + "6" * 64 + second["content_digest"] = "sha256:" + "7" * 64 + second["transformation_digest"] = "sha256:" + "8" * 64 + _reidentify(second, "view_ref_id", "rhviewrefv1:sha256:") + foundation["standardized_views"].append(second) + _reidentify(foundation, "foundation_id", "rhdfv1:sha256:") + return fixture, second["view_ref_id"] + + +def test_multi_input_mapping_requires_exact_consumption_closure_and_is_order_independent() -> None: + fixture, second_view_id = _multi_view_fixture() + snapshot, foundation = _snapshot_and_foundation(fixture) + inputs = ( + FactorInput("prices", VIEW_SCHEMA_DIGEST, ("close",)), + FactorInput("volumes", "sha256:" + "6" * 64, ("volume",)), + ) + definition = _definition( + inputs=inputs, + formula="correlation(close, volume, 10)", + input_schema_digest=factor_input_schema_digest(inputs), + ) + first_binding = InputBinding(definition.definition_id, "prices", VIEW_REF_ID, VIEW_SCHEMA_DIGEST) + second_binding = InputBinding(definition.definition_id, "volumes", second_view_id, "sha256:" + "6" * 64) + first_availability = ViewAvailability(VIEW_REF_ID, "2026-01-02T23:40:00Z", "sha256:" + "2" * 64) + second_availability = ViewAvailability(second_view_id, "2026-01-02T23:50:00Z", "sha256:" + "6" * 64) + base = _factor_set_arguments(fixture=fixture, snapshot=snapshot, foundation=foundation, definition=definition) + base.update( + selected_view_ref_ids=(VIEW_REF_ID, second_view_id), + input_bindings=(first_binding, second_binding), + view_availability=(first_availability, second_availability), + causation=Causation("foundation", foundation.foundation_id), + ) + first = FactorSetRef.create(**base) + reordered = dict(base) + reordered.update( + selected_view_ref_ids=(second_view_id, VIEW_REF_ID), + input_bindings=(second_binding, first_binding), + view_availability=(second_availability, first_availability), + ) + assert FactorSetRef.create(**reordered).factor_set_id == first.factor_set_id + + for invalid_bindings, invalid_views in ( + ((first_binding,), (VIEW_REF_ID, second_view_id)), + ((first_binding, second_binding), (VIEW_REF_ID,)), + ((first_binding, second_binding), (VIEW_REF_ID, second_view_id, VIEW_REF_ID)), + ): + invalid = dict(base) + invalid.update(input_bindings=invalid_bindings, selected_view_ref_ids=invalid_views) + with pytest.raises(FactorContractError) as error: + FactorSetRef.create(**invalid) + assert error.value.code in { + ContractErrorCode.INPUT_CLOSURE_VIOLATION, + ContractErrorCode.INVALID_VALUE, + } + + +@pytest.mark.parametrize( + ("mutate", "code", "path"), + [ + (lambda value: value["producer"].pop("id"), ContractErrorCode.MISSING_FIELD, "$.producer.id"), + (lambda value: value["producer"].pop("version"), ContractErrorCode.MISSING_FIELD, "$.producer.version"), + (lambda value: value["producer"].update(id="other_engine"), ContractErrorCode.LINEAGE_VIOLATION, "$.producer.id"), + (lambda value: value["producer"].update(version="latest"), ContractErrorCode.INVALID_FORMAT, "$.producer.version"), + (lambda value: value.update(code_revision="bad"), ContractErrorCode.INVALID_FORMAT, "$.code_revision"), + (lambda value: value["actor"].pop("kind"), ContractErrorCode.MISSING_FIELD, "$.actor.kind"), + (lambda value: value["actor"].pop("id"), ContractErrorCode.MISSING_FIELD, "$.actor.id"), + (lambda value: value["actor"].update(kind="robot"), ContractErrorCode.INVALID_VALUE, "$.actor.kind"), + (lambda value: value["actor"].update(id="latest"), ContractErrorCode.INVALID_VALUE, "$.actor.id"), + (lambda value: value.pop("correlation_id"), ContractErrorCode.MISSING_FIELD, "$.correlation_id"), + (lambda value: value.update(correlation_id="latest"), ContractErrorCode.INVALID_VALUE, "$.correlation_id"), + (lambda value: value["causation"].pop("kind"), ContractErrorCode.MISSING_FIELD, "$.causation.kind"), + (lambda value: value["causation"].update(kind="run"), ContractErrorCode.INVALID_VALUE, "$.causation.kind"), + (lambda value: value["causation"].update(id="rhdfv1:sha256:" + "0" * 64), ContractErrorCode.LINEAGE_VIOLATION, "$.causation.id"), + (lambda value: value.pop("output_artifact_ref"), ContractErrorCode.MISSING_FIELD, "$.output_artifact_ref"), + (lambda value: value["output_artifact_ref"].update(artifact_id="rhfactoroutputv1:sha256:" + "0" * 64), ContractErrorCode.IDENTITY_MISMATCH, "$.output_artifact_ref.artifact_id"), + (_mutate_artifact_schema_binding, ContractErrorCode.ARTIFACT_MISMATCH, "$.output_artifact_ref"), + (lambda value: value.update(availability_mode="implicit_fallback"), ContractErrorCode.INVALID_VALUE, "$.availability_mode"), + (lambda value: value.pop("computed_at"), ContractErrorCode.MISSING_FIELD, "$.computed_at"), + (lambda value: value.update(decision_eligible=True), ContractErrorCode.READINESS_ESCALATION, "$.decision_eligible"), + (lambda value: value.update(evidence_scope="real_data"), ContractErrorCode.READINESS_ESCALATION, "$.evidence_scope"), + (lambda value: value["upstream_evidence"].update(qualification_evidence_digest="sha256:" + "0" * 64), ContractErrorCode.IDENTITY_MISMATCH, "$.upstream_evidence"), + ], +) +def test_lineage_artifact_and_readiness_fields_have_independent_typed_negatives( + mutate: Callable[[dict[str, Any]], Any], + code: ContractErrorCode, + path: str, +) -> None: + factor_set = _factor_set() + value = factor_set.to_dict() + mutate(value) + if "factor_set_id" in value: + _reidentify(value, "factor_set_id", "rhfactorsetv1:sha256:") + snapshot, foundation = _snapshot_and_foundation() + with pytest.raises(FactorContractError) as error: + FactorSetRef.from_dict( + value, + definitions=(_golden_definition(),), + dataset_snapshot=snapshot, + foundation=foundation, + ) + _assert_error(error, code, path) + + +def test_output_schema_content_bytes_cannot_be_swapped_or_forged() -> None: + factor_set = _factor_set() + fixture = _golden() + snapshot, foundation = _snapshot_and_foundation() + schema_bytes = canonical_json_bytes(fixture["output_schema"]) + content_bytes = canonical_json_bytes(fixture["output_content"]) + with pytest.raises(FactorContractError) as swapped: + FactorSetRef.from_dict( + factor_set.to_dict(), + definitions=(_golden_definition(),), + dataset_snapshot=snapshot, + foundation=foundation, + output_schema_bytes=content_bytes, + output_content_bytes=schema_bytes, + ) + _assert_error(swapped, ContractErrorCode.ARTIFACT_MISMATCH, "$.output_artifact_ref") + with pytest.raises(FactorContractError) as noncanonical: + FactorSetRef.create( + **{ + **_factor_set_arguments(), + "output_schema_bytes": json.dumps(fixture["output_schema"], indent=2).encode(), + } + ) + _assert_error(noncanonical, ContractErrorCode.INVALID_FORMAT, "$.output_schema_bytes") + + +def test_unsuccessful_output_quality_or_coverage_cannot_form_a_factor_set() -> None: + with pytest.raises(FactorContractError) as failed_quality: + _factor_set( + output_quality=OutputQuality( + "failed", + (OutputQualityCheck("finite_values", "failed", "sha256:" + "3" * 64),), + ) + ) + _assert_error(failed_quality, ContractErrorCode.INVALID_VALUE, "$.output_quality") + + for coverage in ( + OutputCoverage("incomplete", 2, 1, "row", "alpha_005.cn_a", "sha256:" + "4" * 64), + OutputCoverage("complete", 2, 1, "row", "alpha_005.cn_a", "sha256:" + "4" * 64), + ): + with pytest.raises(FactorContractError) as incomplete: + _factor_set(output_coverage=coverage) + _assert_error(incomplete, ContractErrorCode.INVALID_VALUE, "$.output_coverage") + + +def test_external_snapshot_definition_and_view_references_cannot_be_substituted() -> None: + factor_set = _factor_set() + snapshot, foundation = _snapshot_and_foundation() + value = factor_set.to_dict() + value["dataset_snapshot_id"] = "rhdsv1:sha256:" + "0" * 64 + _reidentify(value, "factor_set_id", "rhfactorsetv1:sha256:") + with pytest.raises(FactorContractError) as snapshot_error: + FactorSetRef.from_dict( + value, + definitions=(_golden_definition(),), + dataset_snapshot=snapshot, + foundation=foundation, + ) + _assert_error( + snapshot_error, + ContractErrorCode.INPUT_CLOSURE_VIOLATION, + "$.dataset_snapshot_id", + ) + + value = factor_set.to_dict() + value["definition_ids"] = ["rhfactorv1:sha256:" + "0" * 64] + _reidentify(value, "factor_set_id", "rhfactorsetv1:sha256:") + with pytest.raises(FactorContractError) as definition_error: + FactorSetRef.from_dict( + value, + definitions=(_golden_definition(),), + dataset_snapshot=snapshot, + foundation=foundation, + ) + _assert_error( + definition_error, + ContractErrorCode.INPUT_CLOSURE_VIOLATION, + "$.definition_ids", + ) + + arguments = _factor_set_arguments() + arguments["selected_view_ref_ids"] = ("rhviewrefv1:sha256:" + "0" * 64,) + with pytest.raises(FactorContractError) as view_error: + FactorSetRef.create(**arguments) + _assert_error( + view_error, + ContractErrorCode.INPUT_CLOSURE_VIOLATION, + "$.selected_view_ref_ids", + ) + + +def test_factor_set_parent_requires_exact_identity_and_correlation() -> None: + parent = _factor_set() + child_arguments = _factor_set_arguments() + child_arguments.update( + output_content_bytes=canonical_json_bytes({"rows": [{"value": "0.250"}]}), + causation=Causation("factor_set", parent.factor_set_id), + parent=parent, + ) + child_arguments["output_artifact_ref"] = OutputArtifactRef.create( + schema_digest=_sha256(child_arguments["output_schema_bytes"]), + content_digest=_sha256(child_arguments["output_content_bytes"]), + ) + child = FactorSetRef.create(**child_arguments) + assert child.causation.id == parent.factor_set_id + missing_parent = child.to_dict() + snapshot, foundation = _snapshot_and_foundation() + with pytest.raises(FactorContractError) as missing_error: + FactorSetRef.from_dict( + missing_parent, + definitions=(_golden_definition(),), + dataset_snapshot=snapshot, + foundation=foundation, + ) + _assert_error(missing_error, ContractErrorCode.LINEAGE_VIOLATION, "$.causation") + wrong_correlation = dict(child_arguments) + wrong_correlation["correlation_id"] = "different_run" + with pytest.raises(FactorContractError) as correlation_error: + FactorSetRef.create(**wrong_correlation) + _assert_error(correlation_error, ContractErrorCode.LINEAGE_VIOLATION, "$.correlation_id") + + +def test_legacy_bridge_is_explicit_lossy_and_preserves_all_four_historical_fields() -> None: + definition = _golden_definition() + legacy = FactorVersion( + factor_id="factor:demo-momentum", + version="1.0.0", + definition_sha256="b" * 64, + dataset_schema_version="1.0.0", + ) + binding = LegacyFactorBinding.create( + definition=definition, + legacy_factor_id=legacy.factor_id, + legacy_version=legacy.version, + legacy_definition_sha256=legacy.definition_sha256, + legacy_dataset_schema_version=legacy.dataset_schema_version, + canonical_input_schema_digest=definition.input_schema_digest, + correspondence_evidence_digest="sha256:" + "5" * 64, + ) + assert bind_legacy_factor(legacy, definition, binding) is definition + assert project_legacy_factor(definition, binding) == legacy + assert legacy.version_id == "factor:demo-momentum@1.0.0" + assert legacy.definition_sha256 != definition.definition_id.rsplit(":", maxsplit=1)[-1] + assert LegacyFactorBinding.from_json(binding.to_json(), definition=definition) == binding + + mismatched = FactorVersion( + factor_id="factor:different", + version=legacy.version, + definition_sha256=legacy.definition_sha256, + dataset_schema_version=legacy.dataset_schema_version, + ) + with pytest.raises(FactorContractError) as mismatch_error: + bind_legacy_factor(mismatched, definition, binding) + _assert_error(mismatch_error, ContractErrorCode.LEGACY_BINDING_MISMATCH, "$.binding") + + +def test_bare_legacy_factor_or_id_cannot_enter_factor_set_contract() -> None: + legacy = FactorVersion("factor:demo-momentum", "1.0.0", "b" * 64, "1.0.0") + arguments = _factor_set_arguments() + arguments["definitions"] = (legacy,) + with pytest.raises(FactorContractError) as legacy_error: + FactorSetRef.create(**arguments) + _assert_error(legacy_error, ContractErrorCode.TYPE_ERROR, "$.definitions[0]") + arguments["definitions"] = (legacy.version_id,) + with pytest.raises(FactorContractError) as id_error: + FactorSetRef.create(**arguments) + _assert_error(id_error, ContractErrorCode.TYPE_ERROR, "$.definitions[0]") diff --git a/tests/test_governed_pipeline.py b/tests/test_governed_pipeline.py index 466cf8b..e098233 100644 --- a/tests/test_governed_pipeline.py +++ b/tests/test_governed_pipeline.py @@ -119,6 +119,24 @@ def test_governed_slice_is_reproducible_and_creates_only_paper_intent() -> None: assert result.order_intent.environment == "paper" assert result.order_intent.risk_decision_id == result.risk_decision.decision_id assert result.order_intent.portfolio_target_id == result.portfolio_target.target_id + assert result.factor_version.version_id == "factor:demo-momentum@1.0.0" + assert result.factor_version.definition_sha256 == "b" * 64 + assert result.strategy_version.version_id == "strategy:demo-top2@1.0.0" + assert result.backtest_run.run_id == ( + "backtest-run:e74403571f6a73c98b380220748957a422518c0bed883fabc4b93ebe13f05a37" + ) + assert result.backtest_run.config_hash == ( + "40a3c804a2dc940161a626d1a5d25817c13463e685005e37fc48c41d1e20b87b" + ) + assert result.portfolio_target.target_id == ( + "portfolio-target:ab2d398489aa9a292ee155a1098e9340beac0a924874cdaeb5b7b4d379ac9ce8" + ) + assert result.risk_decision.decision_id == ( + "risk-decision:95924926bd327e44beeb15a63f47d14c5e77b97533fc3227fba2eda68e9b423d" + ) + assert result.order_intent.intent_id == ( + "order-intent:73c349086c2c05b68424ace9286896eb21507b9080da5ff9b96b58c88d8f6ac1" + ) repeated = run_governed_factor_slice( factor_scores=_scores(),