-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add research input manifest contract #271
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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")) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When timestamps contain more than six fractional digits—which the schema and Useful? React with 👍 / 👎. |
||
| 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}") | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" }, | ||
|
Comment on lines
+17
to
+18
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a consumer validates only against the published schema, a manifest with Useful? React with 👍 / 👎. |
||
| "inputs": { | ||
| "type": "array", | ||
| "minItems": 1, | ||
| "x-qpk-unique-by": "input_id", | ||
| "items": { "$ref": "#/$defs/input" } | ||
|
Comment on lines
+19
to
+23
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a manifest is validated through the packaged JSON Schema, Useful? React with 👍 / 👎. |
||
| } | ||
| }, | ||
| "$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})$" | ||
|
Comment on lines
+29
to
+30
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a Draft 2020-12 consumer does not explicitly enable the optional Useful? React with 👍 / 👎. |
||
| }, | ||
| "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" } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an interchange consumer validates only against this schema, an input whose Useful? React with 👍 / 👎. |
||
| }, | ||
| "additionalProperties": false | ||
| } | ||
| }, | ||
| "additionalProperties": false | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When otherwise valid JSON contains an unpaired UTF-16 escape in
artifact_uri(for example\ud800),json.loadsaccepts the resulting string and metadata validation permits it, but_canonical_json(manifest)raises an uncaughtUnicodeEncodeError. Callers catchingResearchInputManifestValidationErrorcan therefore crash on malformed manifest bytes instead of receiving the advertised validation failure; reject surrogate code points or translate this encoding failure.Useful? React with 👍 / 👎.