diff --git a/src/quant_platform_kit/data/research_input.py b/src/quant_platform_kit/data/research_input.py new file mode 100644 index 0000000..ee513b2 --- /dev/null +++ b/src/quant_platform_kit/data/research_input.py @@ -0,0 +1,140 @@ +"""Pure validation for immutable research-input manifest metadata. + +This S0 contract deliberately does not acquire, read, materialize, replay, or +otherwise act on the referenced inputs. It only validates the metadata that +binds a research consumer to already-produced immutable artifacts. +""" + +from __future__ import annotations + +import json +import re +from collections.abc import Mapping +from datetime import datetime +from typing import Any + + +RESEARCH_INPUT_MANIFEST_SCHEMA_VERSION = "research_input_manifest.v1" + +_INPUT_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") +_KIND = re.compile(r"^[a-z][a-z0-9_]*$") +_SHA256 = re.compile(r"^[a-f0-9]{64}$") +_RFC3339_DATETIME = re.compile( + r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|[+-]\d{2}:\d{2})$" +) +_REQUIRED_TOP_LEVEL = frozenset({"schema_version", "manifest_id", "created_at", "as_of", "inputs"}) +_REQUIRED_INPUT = frozenset({"input_id", "kind", "artifact_uri", "sha256", "as_of"}) + + +class ResearchInputManifestValidationError(ValueError): + """Raised when a research-input manifest violates the S0 contract.""" + + def __init__(self) -> None: + super().__init__("invalid research input manifest") + + +def validate_research_input_manifest(payload: Mapping[str, Any]) -> dict[str, Any]: + """Validate and return immutable metadata without dereferencing input artifacts.""" + + if not isinstance(payload, Mapping) or set(payload) != _REQUIRED_TOP_LEVEL: + raise ResearchInputManifestValidationError() + if payload.get("schema_version") != RESEARCH_INPUT_MANIFEST_SCHEMA_VERSION: + raise ResearchInputManifestValidationError() + + _require_identifier(payload.get("manifest_id")) + created_at = _parse_datetime(payload.get("created_at")) + manifest_as_of = _parse_datetime(payload.get("as_of")) + if created_at < manifest_as_of: + raise ResearchInputManifestValidationError() + + inputs = payload.get("inputs") + if not isinstance(inputs, list) or not inputs: + raise ResearchInputManifestValidationError() + + input_ids: set[str] = set() + for input_metadata in inputs: + _validate_input(input_metadata, manifest_as_of, input_ids) + + return dict(payload) + + +def parse_research_input_manifest(value: bytes) -> dict[str, Any]: + """Strictly read canonical manifest bytes without dereferencing artifacts.""" + + if not isinstance(value, bytes): + raise ResearchInputManifestValidationError() + try: + parsed = json.loads( + value.decode("utf-8"), + object_pairs_hook=_reject_duplicate_object_keys, + parse_constant=_reject_non_json_constant, + ) + except (UnicodeDecodeError, ValueError, json.JSONDecodeError) as exc: + raise ResearchInputManifestValidationError() from exc + if type(parsed) is not dict: + raise ResearchInputManifestValidationError() + + manifest = validate_research_input_manifest(parsed) + if _canonical_json(manifest) != value: + raise ResearchInputManifestValidationError() + return manifest + + +def _validate_input(value: object, manifest_as_of: datetime, input_ids: set[str]) -> None: + if not isinstance(value, Mapping) or set(value) != _REQUIRED_INPUT: + raise ResearchInputManifestValidationError() + + input_id = value.get("input_id") + _require_identifier(input_id) + if input_id in input_ids: + raise ResearchInputManifestValidationError() + input_ids.add(input_id) + + kind = value.get("kind") + if not isinstance(kind, str) or not _KIND.fullmatch(kind): + raise ResearchInputManifestValidationError() + + artifact_uri = value.get("artifact_uri") + if not isinstance(artifact_uri, str) or not artifact_uri or any(char.isspace() for char in artifact_uri): + raise ResearchInputManifestValidationError() + + sha256 = value.get("sha256") + if not isinstance(sha256, str) or not _SHA256.fullmatch(sha256): + raise ResearchInputManifestValidationError() + + if _parse_datetime(value.get("as_of")) > manifest_as_of: + raise ResearchInputManifestValidationError() + + +def _require_identifier(value: object) -> None: + if not isinstance(value, str) or not _INPUT_ID.fullmatch(value): + raise ResearchInputManifestValidationError() + + +def _parse_datetime(value: object) -> datetime: + if not isinstance(value, str) or not _RFC3339_DATETIME.fullmatch(value): + raise ResearchInputManifestValidationError() + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise ResearchInputManifestValidationError() from exc + if parsed.tzinfo is None or parsed.utcoffset() is None: + raise ResearchInputManifestValidationError() + return parsed + + +def _canonical_json(value: Mapping[str, Any]) -> bytes: + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8") + + +def _reject_duplicate_object_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ValueError("duplicate JSON object key") + result[key] = value + return result + + +def _reject_non_json_constant(value: str) -> None: + raise ValueError(f"non-JSON constant: {value}") diff --git a/src/quant_platform_kit/schemas/research-input-manifest.v1.schema.json b/src/quant_platform_kit/schemas/research-input-manifest.v1.schema.json new file mode 100644 index 0000000..22ee480 --- /dev/null +++ b/src/quant_platform_kit/schemas/research-input-manifest.v1.schema.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://quantplatformkit.local/schemas/research-input-manifest.v1.schema.json", + "title": "Research Input Manifest v1", + "type": "object", + "x-qpk-canonical-json": true, + "x-qpk-pit-cutoff": { + "allow_equal": true, + "cutoff": "/as_of", + "entries": "/inputs", + "timestamp_field": "as_of" + }, + "required": ["schema_version", "manifest_id", "created_at", "as_of", "inputs"], + "properties": { + "schema_version": { "const": "research_input_manifest.v1" }, + "manifest_id": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" }, + "created_at": { "$ref": "#/$defs/date_time" }, + "as_of": { "$ref": "#/$defs/date_time" }, + "inputs": { + "type": "array", + "minItems": 1, + "x-qpk-unique-by": "input_id", + "items": { "$ref": "#/$defs/input" } + } + }, + "$defs": { + "date_time": { + "type": "string", + "format": "date-time", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,6})?(Z|[+-][0-9]{2}:[0-9]{2})$" + }, + "input": { + "type": "object", + "required": ["input_id", "kind", "artifact_uri", "sha256", "as_of"], + "properties": { + "input_id": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" }, + "kind": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" }, + "artifact_uri": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }, + "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "as_of": { "$ref": "#/$defs/date_time" } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/tests/test_research_input_manifest_contract.py b/tests/test_research_input_manifest_contract.py new file mode 100644 index 0000000..4e069ec --- /dev/null +++ b/tests/test_research_input_manifest_contract.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from quant_platform_kit.data.research_input import ( + RESEARCH_INPUT_MANIFEST_SCHEMA_VERSION, + ResearchInputManifestValidationError, + parse_research_input_manifest, + validate_research_input_manifest, +) + + +def _valid_manifest() -> dict[str, object]: + return { + "schema_version": RESEARCH_INPUT_MANIFEST_SCHEMA_VERSION, + "manifest_id": "tqqq-research-inputs-2026-07-30", + "created_at": "2026-07-30T00:00:00Z", + "as_of": "2026-07-29T20:00:00Z", + "inputs": [ + { + "input_id": "daily-prices", + "kind": "market_data", + "artifact_uri": "s3://research-fixtures/tqqq/prices.csv", + "sha256": "a" * 64, + "as_of": "2026-07-29T20:00:00Z", + } + ], + } + + +def test_manifest_contract_accepts_complete_immutable_input_metadata() -> None: + manifest = _valid_manifest() + + assert validate_research_input_manifest(manifest) == manifest + + +def test_manifest_contract_rejects_missing_or_mutable_input_identity() -> None: + manifest = _valid_manifest() + manifest["inputs"] = [ + { + "input_id": "daily-prices", + "kind": "market_data", + "artifact_uri": "s3://research-fixtures/tqqq/prices.csv", + "sha256": "not-a-digest", + "as_of": "2026-07-29T20:00:00Z", + } + ] + + with pytest.raises(ResearchInputManifestValidationError): + validate_research_input_manifest(manifest) + + +def test_manifest_contract_rejects_duplicate_input_ids_and_future_implementation_fields() -> None: + manifest = _valid_manifest() + manifest["inputs"] = [*manifest["inputs"], dict(manifest["inputs"][0])] + manifest["provider"] = {"name": "must-not-be-added-in-s0"} + + with pytest.raises(ResearchInputManifestValidationError): + validate_research_input_manifest(manifest) + + +def test_schema_declares_the_same_closed_s0_contract() -> None: + root = Path(__file__).resolve().parents[1] + schema = json.loads( + (root / "src/quant_platform_kit/schemas/research-input-manifest.v1.schema.json").read_text(encoding="utf-8") + ) + + assert schema["properties"]["schema_version"]["const"] == RESEARCH_INPUT_MANIFEST_SCHEMA_VERSION + assert schema["additionalProperties"] is False + assert schema["required"] == ["schema_version", "manifest_id", "created_at", "as_of", "inputs"] + assert schema["$defs"]["input"]["additionalProperties"] is False + + +def _canonical_json(value: object) -> bytes: + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8") + + +def test_canonical_readback_rejects_noncanonical_and_duplicate_key_bytes() -> None: + expected = _valid_manifest() + + assert parse_research_input_manifest(_canonical_json(expected)) == expected + + with pytest.raises(ResearchInputManifestValidationError): + parse_research_input_manifest(json.dumps(expected).encode("utf-8")) + with pytest.raises(ResearchInputManifestValidationError): + parse_research_input_manifest( + b'{"as_of":"2026-07-29T20:00:00Z","as_of":"2026-07-29T20:00:00Z"}' + ) + + +def test_manifest_contract_rejects_submicrosecond_timestamps() -> None: + manifest = _valid_manifest() + manifest["as_of"] = "2026-07-29T20:00:00.0000001Z" + + with pytest.raises(ResearchInputManifestValidationError): + validate_research_input_manifest(manifest) + + +def test_schema_encodes_pit_cutoff_and_input_id_uniqueness() -> None: + root = Path(__file__).resolve().parents[1] + schema = json.loads( + (root / "src/quant_platform_kit/schemas/research-input-manifest.v1.schema.json").read_text(encoding="utf-8") + ) + + assert schema["x-qpk-pit-cutoff"] == { + "allow_equal": True, + "cutoff": "/as_of", + "entries": "/inputs", + "timestamp_field": "as_of", + } + assert schema["properties"]["inputs"]["x-qpk-unique-by"] == "input_id" + assert "{1,6}" in schema["$defs"]["date_time"]["pattern"]