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
2 changes: 2 additions & 0 deletions src/research_signal_context_pipelines/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from .overlay_backtest import OverlayPolicy, backtest_overlay
from .context_bundle import DEFAULT_UNIVERSE, build_context_bundle, build_context_from_source
from .latest_linkage import validate_latest_signal
from .price_history import PriceExtractionSummary, write_filtered_price_history
from .schema import SignalValidationError, validate_signal
from .theme_momentum import (
Expand All @@ -24,6 +25,7 @@
"load_symbol_theme_exposure",
"load_theme_taxonomy",
"validate_signal",
"validate_latest_signal",
"validate_theme_momentum_snapshot",
"write_filtered_price_history",
"write_theme_momentum_snapshot",
Expand Down
198 changes: 198 additions & 0 deletions src/research_signal_context_pipelines/latest_linkage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
from __future__ import annotations

import hashlib
import json
import datetime as dt
import errno
import os
import re
import stat
from pathlib import Path
from typing import Any

from .schema import SignalValidationError, validate_signal
from .theme_momentum import validate_theme_momentum_snapshot


_SHA256_RE = re.compile(r"^[0-9a-fA-F]{64}$")
_OPEN_SUPPORTS_DIR_FD = os.open in getattr(os, "supports_dir_fd", set())
MAX_JSON_ARTIFACT_BYTES = 4 * 1024 * 1024


def validate_latest_signal(
payload: dict[str, Any],
*,
signal_base_dir: str | Path | None = None,
source_override: str | Path | None = None,
) -> None:
"""Validate a v2 signal and its single, point-in-time theme snapshot."""
try:
validate_signal(payload)
except SignalValidationError:
raise
except (TypeError, ValueError, KeyError) as exc:
raise SignalValidationError(f"invalid signal: {exc}") from exc

if payload.get("schema_version") != "2":
raise SignalValidationError("latest signal schema_version must be '2'")

evidence = payload["evidence"]
sources = evidence["sources"]
if len(sources) != 1:
raise SignalValidationError("latest signal evidence.sources must declare exactly one source")
declared_source = sources[0]
if not isinstance(declared_source, str) or not declared_source.strip():
raise SignalValidationError("latest signal evidence.sources must contain a path")

try:
base = Path(signal_base_dir).resolve() if signal_base_dir is not None else None
except OSError as exc:
raise SignalValidationError("invalid signal_base_dir") from exc
source_path = _resolve_source_path(declared_source, base)
if source_override is not None:
override_path = _resolve_source_path(str(source_override), base)
if override_path != source_path:
raise SignalValidationError("source override must be the same declaration file")

raw = _read_declaration(source_path, base, declared_source)
_validate_source_hash(evidence, payload, declared_source, source_path, raw)
snapshot = _decode_declaration(raw)
try:
validate_theme_momentum_snapshot(snapshot)

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 executable theme snapshot policies

When the linked v2 snapshot sets policy.execution_allowed or policy.portfolio_allocation_allowed to true, this still accepts it because validate_theme_momentum_snapshot() only checks that policy exists and the strict linkage adds no policy validation, even though the generated theme snapshot contract emits those flags as false for research-only context. That lets a latest signal validate against a source artifact that advertises execution/allocation permission, weakening the strict linkage guarantee for downstream consumers that trust validated artifacts.

Useful? React with 👍 / 👎.

except SignalValidationError:
raise
except (TypeError, ValueError, KeyError) as exc:
raise SignalValidationError(f"invalid theme momentum snapshot: {exc}") from exc

if snapshot.get("schema_version") != "2":
raise SignalValidationError("theme momentum snapshot schema_version must be '2'")
if snapshot.get("mode") != "theme_momentum_snapshot":
raise SignalValidationError("source must declare theme_momentum_snapshot")
Comment on lines +69 to +70

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 Require the linked snapshot artifact type

When a source JSON keeps mode as theme_momentum_snapshot but sets another artifact_type, it passes here because the strict linkage only checks mode; the existing snapshot validator only checks that artifact_type is present. That lets a non-medium-horizon artifact be linked to a latest signal and pollute replay context; compare artifact_type to medium_horizon_theme_context before accepting the source.

Useful? React with 👍 / 👎.

if snapshot.get("artifact_type") != "medium_horizon_theme_context":
raise SignalValidationError("theme snapshot artifact_type must be 'medium_horizon_theme_context'")
if snapshot.get("as_of") != payload.get("as_of"):
raise SignalValidationError("signal and theme snapshot as_of must match")
Comment on lines +73 to +74

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 future-generated snapshots in strict linkage

When replaying or backfilling a saved signal, a theme snapshot with the same as_of but a generated_at later than the signal's generated_at still passes here because the linkage check only compares as_of. That admits a source artifact that did not exist when the signal was generated, which breaks the point-in-time guarantee and can introduce look-ahead into experiments; parse both timestamps and reject snapshots generated after the signal.

Useful? React with 👍 / 👎.

if _parse_datetime(snapshot["generated_at"], "theme snapshot generated_at") > _parse_datetime(
payload["generated_at"], "signal generated_at"
):
raise SignalValidationError("theme snapshot generated_at must not be later than signal generated_at")
Comment on lines +75 to +78

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 artifacts generated before their as_of date

When the snapshot claims as_of data from a future date relative to its own generated_at (for example as_of=2026-06-26 with snapshot generated_at=2026-06-25T00:00:00Z), this still passes as long as the signal uses the same as_of and was generated later. That permits a point-in-time replay source to claim data that could not have been available when the snapshot was generated, so strict linkage should compare the parsed as_of date against the generated timestamps, not only snapshot-vs-signal ordering.

Useful? React with 👍 / 👎.

Comment on lines +75 to +78

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 snapshots expired before signal generation

When a v2 snapshot has expires_at after as_of but before the signal's generated_at (for example a 2026-06-26 snapshot expiring 2026-06-28 linked to a signal generated 2026-07-27), this still validates because strict linkage only checks matching as_of and generation ordering. That allows a latest signal to be built from stale medium-horizon context even though the snapshot declares it was no longer valid at signal generation time.

Useful? React with 👍 / 👎.



def _resolve_source_path(source: str, base: Path | None) -> Path:
raw_path = Path(source)
if not raw_path.is_absolute() and base is None:
raise SignalValidationError("relative source requires signal_base_dir")
try:
resolved = raw_path.resolve() if raw_path.is_absolute() else (base / raw_path).resolve()
except OSError as exc:
raise SignalValidationError("unable to resolve source path") from exc
if base is not None:
try:
resolved.relative_to(base)
except ValueError as exc:
raise SignalValidationError("source must remain within signal_base_dir") from exc
return resolved


def _read_declaration(path: Path, base: Path | None, declared_source: str) -> bytes:
if (
getattr(os, "O_NOFOLLOW", None) is None
or getattr(os, "O_DIRECTORY", None) is None
or getattr(os, "O_NONBLOCK", None) is None
):
raise SignalValidationError("secure descriptor-based source reading is unavailable")
if not _OPEN_SUPPORTS_DIR_FD:
raise SignalValidationError("secure openat source reading is unavailable")
fd = -1
try:
if base is None:
fd = os.open(Path(declared_source), os.O_RDONLY | os.O_NOFOLLOW | getattr(os, "O_NONBLOCK", 0))
else:
raw_path = Path(declared_source)
try:
relative = raw_path.relative_to(base) if raw_path.is_absolute() else raw_path
except ValueError as exc:
raise SignalValidationError("absolute source must be beneath signal_base_dir") from exc
parts = relative.parts
if not parts or any(part in {"", ".", ".."} for part in parts):
raise SignalValidationError("source path must be a file beneath signal_base_dir")
fd = os.open(base, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
for part in parts[:-1]:
next_fd = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd)
os.close(fd)
fd = next_fd
final_fd = -1
try:
final_fd = os.open(
parts[-1], os.O_RDONLY | os.O_NOFOLLOW | getattr(os, "O_NONBLOCK", 0), dir_fd=fd
)
finally:
parent_fd = fd
fd = final_fd
os.close(parent_fd)
metadata = os.fstat(fd)
if not stat.S_ISREG(metadata.st_mode):
raise SignalValidationError("declaration source must be a regular file")
if metadata.st_size > MAX_JSON_ARTIFACT_BYTES:
raise SignalValidationError("declaration source exceeds maximum size")
with os.fdopen(fd, "rb") as stream:
fd = -1
return stream.read()
except SignalValidationError:
raise
except OSError as exc:
if exc.errno == errno.ELOOP:
raise SignalValidationError("no-follow source read rejected a symlink") from exc
raise SignalValidationError(f"unable to securely read declaration file: {path}") from exc
finally:
if fd >= 0:
os.close(fd)


def _decode_declaration(raw: bytes) -> dict[str, Any]:
try:
value = json.loads(raw)
except (json.JSONDecodeError, UnicodeDecodeError, TypeError) as exc:
raise SignalValidationError("declaration file must contain valid JSON") from exc
if not isinstance(value, dict):
raise SignalValidationError("declaration file must contain a JSON object")
return value


def _validate_source_hash(
evidence: dict[str, Any],
payload: dict[str, Any],
declared_source: str,
source_path: Path,
raw: bytes,
) -> None:
locations = [container["source_hashes"] for container in (evidence, payload) if "source_hashes" in container]
if not locations:
return
if len(locations) != 1:
raise SignalValidationError("source_hashes must be declared in exactly one location")
hashes = locations[0]
if not isinstance(hashes, dict):
raise SignalValidationError("source_hashes must be an object")
allowed_keys = {declared_source, str(source_path)}
if len(hashes) != 1 or not set(hashes).issubset(allowed_keys):
raise SignalValidationError("source_hashes must cover only the declared source")
expected = next(iter(hashes.values()))
if not isinstance(expected, str) or not _SHA256_RE.fullmatch(expected):
raise SignalValidationError("source_hashes must contain a SHA-256 digest")
actual = hashlib.sha256(raw).hexdigest()
if actual.lower() != expected.lower():
raise SignalValidationError("source SHA-256 hash mismatch")


def _parse_datetime(value: Any, name: str) -> dt.datetime:
if not isinstance(value, str) or not value.strip():
raise SignalValidationError(f"{name} must be an ISO datetime")
normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
try:
parsed = dt.datetime.fromisoformat(normalized)
Comment on lines +192 to +193

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 date-only generated_at values

When either the signal or snapshot supplies generated_at as a bare date such as 2026-06-27, datetime.fromisoformat() accepts it and this strict linkage check treats it as midnight UTC. That lets artifacts without an actual generation time pass the point-in-time ordering check, so same-day replay can be accepted even though the metadata cannot prove the snapshot existed when the signal was generated; require a time component before parsing.

Useful? React with 👍 / 👎.

except ValueError as exc:
raise SignalValidationError(f"{name} must be an ISO datetime") from exc
if parsed.tzinfo is None:
raise SignalValidationError(f"{name} must be an ISO datetime with an explicit timezone")
return parsed.astimezone(dt.timezone.utc)
Loading
Loading