Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions src/quant_platform_kit/data/research_input.py
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()
Comment on lines +77 to +79

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject lone surrogates before canonical encoding

When otherwise valid JSON contains an unpaired UTF-16 escape in artifact_uri (for example \ud800), json.loads accepts the resulting string and metadata validation permits it, but _canonical_json(manifest) raises an uncaught UnicodeEncodeError. Callers catching ResearchInputManifestValidationError can 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 👍 / 👎.

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"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve sub-microsecond precision in cutoff comparisons

When timestamps contain more than six fractional digits—which the schema and _RFC3339_DATETIME explicitly allow—datetime.fromisoformat truncates the excess precision. For example, a manifest cutoff ending in .0000001Z and an input timestamp ending in .0000002Z both become the same Python datetime, so the future input passes the check at _validate_input; either constrain accepted precision to microseconds or compare without discarding the additional digits.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Declare the creation-time ordering in the schema

When a consumer validates only against the published schema, a manifest with created_at earlier than its own as_of cutoff is accepted, even though validate_research_input_manifest rejects it at created_at < manifest_as_of. This leaves schema-based ingestion with a weaker provenance contract than the Python validator; declare the same sibling timestamp-ordering invariant at the schema root.

Useful? React with 👍 / 👎.

"inputs": {
"type": "array",
"minItems": 1,
"x-qpk-unique-by": "input_id",
"items": { "$ref": "#/$defs/input" }
Comment on lines +19 to +23

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enforce unique input IDs in the schema

When a manifest is validated through the packaged JSON Schema, inputs may contain multiple records with the same input_id—including conflicting URIs or hashes—because the array has no uniqueness-by-ID constraint. validate_research_input_manifest rejects these records, so the two advertised representations of the contract disagree and schema consumers can receive an ambiguous artifact binding; add the corresponding uniqueness invariant to the array.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make calendar validation assertive in the schema

When a Draft 2020-12 consumer does not explicitly enable the optional format assertion vocabulary, format: date-time is only an annotation and the accompanying pattern accepts impossible values such as 2026-99-99T99:99:99+99:99. The Python validator rejects those values through datetime.fromisoformat, so schema-only interchange validation can admit manifests that the packaged validator refuses; make date-time validity an asserted part of the schema contract rather than relying on the default format annotation.

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" }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Encode the manifest cutoff for every input

When an interchange consumer validates only against this schema, an input whose as_of is later than the manifest's as_of is accepted because this property has no cross-field ordering constraint. The Python validator rejects the same payload in _validate_input, so schema-validated research can inadvertently include post-cutoff data and produce look-ahead-biased results; encode this invariant in the published contract as well.

Useful? React with 👍 / 👎.

},
"additionalProperties": false
}
},
"additionalProperties": false
}
115 changes: 115 additions & 0 deletions tests/test_research_input_manifest_contract.py
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"]