Committing the prior uncommitted working-tree state that predates this session's data-source work (was already modified/untracked at session start) so the tree is clean before push. Includes: event-study + research report integrity/forward observation work, prices tests, research hash migration script, and the 2026-08-23/24 engineering-log + test-evidence notes. Verified green as part of the full 362-test suite.
623 lines
29 KiB
Python
623 lines
29 KiB
Python
import copy
|
|
import hashlib
|
|
import json
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
from typing import Any, cast
|
|
|
|
from app.prices import PriceSnapshotStore, PriceSourceError
|
|
from app.research import ResearchRunError, ResearchRunStore, _manifest_entry_hash, _report_hash, _validate_config, run_tourism_research
|
|
from app.vintages import VintageStore
|
|
|
|
|
|
def tourism_snapshot(vintage_id: str, published_at: str) -> dict:
|
|
return {
|
|
"as_of": "2026-01-01",
|
|
"data_quality": "provisional",
|
|
"theme": "tourism",
|
|
"strategy_version": "tourism-v0.2-bot",
|
|
"observations": [
|
|
{"metric_key": "foreign_arrivals_yoy", "value": 1.0, "expected": 0.0, "scale": 1.0, "period": "2025-12", "history_points": 12, "unit": "percent", "provisional": True}
|
|
],
|
|
"exposures": [
|
|
{"symbol": "AOT", "coefficient": 1.0, "confidence": 1.0, "evidence": "airport"},
|
|
{"symbol": "PTT", "coefficient": -0.5, "confidence": 1.0, "evidence": "control"},
|
|
],
|
|
"source": {
|
|
"source_id": "test.bot",
|
|
"source_url": "https://example.test/bot",
|
|
"vintage_id": vintage_id,
|
|
"published_at": published_at,
|
|
"retrieved_at": "2026-02-02T00:00:00+00:00",
|
|
"release_status": "provisional",
|
|
"parser_version": "test-v1",
|
|
},
|
|
}
|
|
|
|
|
|
def price_snapshot(point_in_time: bool) -> tuple[dict, dict[str, bytes]]:
|
|
def bar(session_date: str, close: float) -> dict:
|
|
if not point_in_time:
|
|
return {"date": session_date, "close": close}
|
|
return {
|
|
"date": session_date,
|
|
"session_date": session_date,
|
|
"open": close,
|
|
"high": close + 1,
|
|
"low": close - 1,
|
|
"close": close,
|
|
"adjusted_close": close,
|
|
"volume": 1000,
|
|
"known_at": f"{session_date}T17:00:00+07:00",
|
|
}
|
|
|
|
pit_source = {
|
|
"source_url": "https://example.test/prices",
|
|
"parser_version": "pit-test-v1",
|
|
"archive_contract": "pit-daily-v1",
|
|
"provider_release_id": "release-2026-02-01",
|
|
"point_in_time_evidence": {
|
|
"known_at_field": "known_at",
|
|
"known_at_semantics": "provider_release_time",
|
|
"provider_release_id": "release-2026-02-01",
|
|
"release_published_at": "2026-02-01T17:00:00+07:00",
|
|
},
|
|
} if point_in_time else {}
|
|
return (
|
|
{
|
|
"schema_version": 1,
|
|
"source": {
|
|
"source_id": "test.prices",
|
|
"snapshot_id": "prices-test",
|
|
"retrieved_at": "2026-02-02T00:00:00+00:00",
|
|
"period_start": "2026-01-01",
|
|
"period_end": "2026-01-06",
|
|
"quality": "point_in_time_archive" if point_in_time else "revised_vendor_history",
|
|
"point_in_time": point_in_time,
|
|
"adjusted_prices": False,
|
|
**pit_source,
|
|
},
|
|
"series": {
|
|
"AOT": {"canonical_symbol": "AOT", "provider_symbol": "AOT.BK", "raw_payload_hash": hashlib.sha256(b"aot").hexdigest(), "timezone": "Asia/Bangkok", "bars": [bar("2026-01-01", 100.0), bar("2026-01-02", 102.0), bar("2026-01-05", 104.0), bar("2026-01-06", 106.0)]},
|
|
"PTT": {"canonical_symbol": "PTT", "provider_symbol": "PTT.BK", "raw_payload_hash": hashlib.sha256(b"ptt").hexdigest(), "timezone": "Asia/Bangkok", "bars": [bar("2026-01-01", 100.0), bar("2026-01-02", 99.0), bar("2026-01-05", 98.0), bar("2026-01-06", 97.0)]},
|
|
"SET50": {"canonical_symbol": "SET50", "provider_symbol": "^SET.BK", "raw_payload_hash": hashlib.sha256(b"set").hexdigest(), "timezone": "Asia/Bangkok", "bars": [bar("2026-01-01", 100.0), bar("2026-01-02", 101.0), bar("2026-01-05", 102.0), bar("2026-01-06", 103.0)]},
|
|
},
|
|
"benchmark_symbol": "SET50",
|
|
},
|
|
{"AOT.BK": b"aot", "PTT.BK": b"ptt", "^SET.BK": b"set"},
|
|
)
|
|
|
|
|
|
class ResearchRunTests(unittest.TestCase):
|
|
def test_research_config_rejects_malformed_values_with_domain_error(self):
|
|
cases = (
|
|
((None,), 20.0, 1, 1),
|
|
((float("nan"),), 20.0, 1, 1),
|
|
((1,), float("inf"), 1, 1),
|
|
((1,), 20.0, None, 1),
|
|
((1,), 20.0, 1, float("inf")),
|
|
)
|
|
for windows, cost_bps, min_events, execution_lag in cases:
|
|
with self.subTest(windows=windows, cost_bps=cost_bps, min_events=min_events, execution_lag=execution_lag):
|
|
with self.assertRaises(ResearchRunError):
|
|
_validate_config(
|
|
cast(Any, windows),
|
|
cast(Any, cost_bps),
|
|
cast(Any, min_events),
|
|
cast(Any, execution_lag),
|
|
)
|
|
|
|
def test_research_run_store_binds_report_hash_and_rejects_tampering(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
root = Path(temp_dir)
|
|
store = ResearchRunStore(root)
|
|
report = {
|
|
"schema_version": 1,
|
|
"run_id": "tourism-run-test",
|
|
"theme": "tourism",
|
|
"status": "blocked",
|
|
"reason": "test",
|
|
"generated_at": "2026-02-01T00:00:00+00:00",
|
|
"config": {},
|
|
"gates": {},
|
|
"inputs": {},
|
|
}
|
|
|
|
stored = store.persist(report)
|
|
|
|
self.assertRegex(stored["report_hash"], r"^[a-f0-9]{64}$")
|
|
report_path = root / "reports" / "tourism-run-test.json"
|
|
tampered = json.loads(report_path.read_text(encoding="utf-8"))
|
|
tampered["reason"] = "tampered"
|
|
report_path.write_text(json.dumps(tampered), encoding="utf-8")
|
|
with self.assertRaisesRegex(ResearchRunError, "integrity"):
|
|
store.load("tourism-run-test")
|
|
|
|
def test_research_run_store_rejects_manifest_hash_tampering(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
root = Path(temp_dir)
|
|
store = ResearchRunStore(root)
|
|
report = {
|
|
"schema_version": 1,
|
|
"run_id": "tourism-run-manifest-test",
|
|
"theme": "tourism",
|
|
"status": "blocked",
|
|
"reason": "test",
|
|
"generated_at": "2026-02-01T00:00:00+00:00",
|
|
"config": {},
|
|
"gates": {},
|
|
"inputs": {},
|
|
}
|
|
store.persist(report)
|
|
|
|
manifest_path = root / "manifest.json"
|
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
manifest["runs"]["tourism-run-manifest-test"]["report_hash"] = "0" * 64
|
|
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
|
with self.assertRaisesRegex(ResearchRunError, "integrity"):
|
|
store.load("tourism-run-manifest-test")
|
|
|
|
def test_research_run_store_migrates_legacy_reports_explicitly(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
root = Path(temp_dir)
|
|
store = ResearchRunStore(root)
|
|
report = {
|
|
"schema_version": 1,
|
|
"run_id": "tourism-run-legacy-test",
|
|
"theme": "tourism",
|
|
"status": "blocked",
|
|
"reason": "test",
|
|
"generated_at": "2026-02-01T00:00:00+00:00",
|
|
"config": {},
|
|
"gates": {},
|
|
"inputs": {},
|
|
}
|
|
legacy_manifest = {
|
|
"schema_version": 1,
|
|
"runs": {
|
|
"tourism-run-legacy-test": {
|
|
"run_id": "tourism-run-legacy-test",
|
|
"status": "blocked",
|
|
"reason": "test",
|
|
"generated_at": "2026-02-01T00:00:00+00:00",
|
|
"report_file": "tourism-run-legacy-test.json",
|
|
}
|
|
},
|
|
}
|
|
(root / "reports").mkdir(parents=True)
|
|
(root / "reports" / "tourism-run-legacy-test.json").write_text(json.dumps(report), encoding="utf-8")
|
|
(root / "manifest.json").write_text(json.dumps(legacy_manifest), encoding="utf-8")
|
|
|
|
with self.assertRaisesRegex(ResearchRunError, "integrity"):
|
|
store.load("tourism-run-legacy-test")
|
|
|
|
self.assertEqual(store.migrate_legacy(), 1)
|
|
migrated = store.load("tourism-run-legacy-test")
|
|
manifest_entry = store.load_manifest()["runs"]["tourism-run-legacy-test"]
|
|
self.assertEqual(migrated["report_hash_algorithm"], "sha256-json-canonical-v1")
|
|
self.assertRegex(migrated["report_hash"], r"^[a-f0-9]{64}$")
|
|
self.assertRegex(manifest_entry["manifest_entry_hash"], r"^[a-f0-9]{64}$")
|
|
self.assertEqual(store.migrate_legacy(), 0)
|
|
|
|
def test_research_run_store_completes_interrupted_legacy_migration(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
root = Path(temp_dir)
|
|
store = ResearchRunStore(root)
|
|
report = {
|
|
"schema_version": 1,
|
|
"run_id": "tourism-run-partial-migration-test",
|
|
"theme": "tourism",
|
|
"status": "blocked",
|
|
"reason": "test",
|
|
"generated_at": "2026-02-01T00:00:00+00:00",
|
|
"config": {},
|
|
"gates": {},
|
|
"inputs": {},
|
|
}
|
|
partial_report = copy.deepcopy(report)
|
|
partial_report["report_hash_algorithm"] = "sha256-json-canonical-v1"
|
|
partial_report["report_hash"] = _report_hash(partial_report)
|
|
legacy_entry = {
|
|
"run_id": report["run_id"],
|
|
"status": report["status"],
|
|
"reason": report["reason"],
|
|
"generated_at": report["generated_at"],
|
|
"report_file": f"{report['run_id']}.json",
|
|
}
|
|
(root / "reports").mkdir(parents=True)
|
|
(root / "reports" / f"{report['run_id']}.json").write_text(json.dumps(partial_report), encoding="utf-8")
|
|
(root / "manifest.json").write_text(json.dumps({"schema_version": 1, "runs": {report["run_id"]: legacy_entry}}), encoding="utf-8")
|
|
|
|
self.assertEqual(store.migrate_legacy(), 1)
|
|
self.assertEqual(store.load(report["run_id"])["report_hash"], partial_report["report_hash"])
|
|
self.assertEqual(store.migrate_legacy(), 0)
|
|
|
|
def test_research_run_store_rejects_manifest_identity_tampering(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
root = Path(temp_dir)
|
|
store = ResearchRunStore(root)
|
|
report = {
|
|
"schema_version": 1,
|
|
"run_id": "tourism-run-identity-test",
|
|
"theme": "tourism",
|
|
"status": "blocked",
|
|
"reason": "test",
|
|
"generated_at": "2026-02-01T00:00:00+00:00",
|
|
"config": {},
|
|
"gates": {},
|
|
"inputs": {},
|
|
}
|
|
store.persist(report)
|
|
|
|
manifest_path = root / "manifest.json"
|
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
entry = manifest["runs"]["tourism-run-identity-test"]
|
|
entry["run_id"] = "tourism-run-other"
|
|
entry["manifest_entry_hash"] = _manifest_entry_hash(entry)
|
|
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
|
|
|
with self.assertRaisesRegex(ResearchRunError, "integrity"):
|
|
store.load("tourism-run-identity-test")
|
|
|
|
def test_research_run_store_rejects_manifest_metadata_tampering(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
root = Path(temp_dir)
|
|
store = ResearchRunStore(root)
|
|
report = {
|
|
"schema_version": 1,
|
|
"run_id": "tourism-run-metadata-test",
|
|
"theme": "tourism",
|
|
"status": "blocked",
|
|
"reason": "test",
|
|
"generated_at": "2026-02-01T00:00:00+00:00",
|
|
"config": {},
|
|
"gates": {},
|
|
"inputs": {},
|
|
}
|
|
store.persist(report)
|
|
|
|
manifest_path = root / "manifest.json"
|
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
manifest["runs"]["tourism-run-metadata-test"]["generated_at"] = "2099-02-01T00:00:00+00:00"
|
|
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
|
with self.assertRaisesRegex(ResearchRunError, "integrity"):
|
|
store.load("tourism-run-metadata-test")
|
|
|
|
def test_research_run_store_rejects_non_object_report(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
root = Path(temp_dir)
|
|
store = ResearchRunStore(root)
|
|
report = {
|
|
"schema_version": 1,
|
|
"run_id": "tourism-run-shape-test",
|
|
"theme": "tourism",
|
|
"status": "blocked",
|
|
"reason": "test",
|
|
"generated_at": "2026-02-01T00:00:00+00:00",
|
|
"config": {},
|
|
"gates": {},
|
|
"inputs": {},
|
|
}
|
|
store.persist(report)
|
|
report_path = root / "reports" / "tourism-run-shape-test.json"
|
|
report_path.write_text("[\"tampered\"]", encoding="utf-8")
|
|
with self.assertRaisesRegex(ResearchRunError, "invalid"):
|
|
store.load("tourism-run-shape-test")
|
|
|
|
def test_research_run_store_rejects_non_object_manifest(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
root = Path(temp_dir)
|
|
store = ResearchRunStore(root)
|
|
(root / "manifest.json").write_text("[]", encoding="utf-8")
|
|
|
|
with self.assertRaisesRegex(ResearchRunError, "schema"):
|
|
store.load_manifest()
|
|
|
|
def test_research_run_store_rejects_unreadable_manifest_bytes(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
root = Path(temp_dir)
|
|
store = ResearchRunStore(root)
|
|
(root / "manifest.json").write_bytes(b"\xff")
|
|
|
|
with self.assertRaisesRegex(ResearchRunError, "unreadable"):
|
|
store.load_manifest()
|
|
|
|
def test_research_run_store_rejects_unreadable_report_bytes(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
root = Path(temp_dir)
|
|
store = ResearchRunStore(root)
|
|
report = {
|
|
"schema_version": 1,
|
|
"run_id": "tourism-run-encoding-test",
|
|
"theme": "tourism",
|
|
"status": "blocked",
|
|
"reason": "test",
|
|
"generated_at": "2026-02-01T00:00:00+00:00",
|
|
"config": {},
|
|
"gates": {},
|
|
"inputs": {},
|
|
}
|
|
store.persist(report)
|
|
(root / "reports" / "tourism-run-encoding-test.json").write_bytes(b"\xff")
|
|
|
|
with self.assertRaisesRegex(ResearchRunError, "unreadable"):
|
|
store.load("tourism-run-encoding-test")
|
|
|
|
def test_research_run_store_list_runs_rejects_manifest_metadata_tampering(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
root = Path(temp_dir)
|
|
store = ResearchRunStore(root)
|
|
report = {
|
|
"schema_version": 1,
|
|
"run_id": "tourism-run-list-test",
|
|
"theme": "tourism",
|
|
"status": "blocked",
|
|
"reason": "test",
|
|
"generated_at": "2026-02-01T00:00:00+00:00",
|
|
"config": {},
|
|
"gates": {},
|
|
"inputs": {},
|
|
}
|
|
store.persist(report)
|
|
manifest_path = root / "manifest.json"
|
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
manifest["runs"]["tourism-run-list-test"]["generated_at"] = "2099-02-01T00:00:00+00:00"
|
|
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
|
|
|
with self.assertRaisesRegex(ResearchRunError, "integrity"):
|
|
store.list_runs()
|
|
|
|
def test_research_run_store_latest_and_list_reject_malformed_manifest_entry(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
root = Path(temp_dir)
|
|
store = ResearchRunStore(root)
|
|
report = {
|
|
"schema_version": 1,
|
|
"run_id": "tourism-run-entry-shape-test",
|
|
"theme": "tourism",
|
|
"status": "blocked",
|
|
"reason": "test",
|
|
"generated_at": "2026-02-01T00:00:00+00:00",
|
|
"config": {},
|
|
"gates": {},
|
|
"inputs": {},
|
|
}
|
|
store.persist(report)
|
|
manifest_path = root / "manifest.json"
|
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
manifest["runs"]["tourism-run-entry-shape-test"] = []
|
|
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
|
|
|
with self.assertRaisesRegex(ResearchRunError, "entry"):
|
|
store.list_runs()
|
|
with self.assertRaisesRegex(ResearchRunError, "entry"):
|
|
store.latest()
|
|
|
|
def test_research_run_store_persist_rejects_malformed_existing_manifest_entry(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
root = Path(temp_dir)
|
|
store = ResearchRunStore(root)
|
|
report = {
|
|
"schema_version": 1,
|
|
"run_id": "tourism-run-persist-entry-test",
|
|
"theme": "tourism",
|
|
"status": "blocked",
|
|
"reason": "test",
|
|
"generated_at": "2026-02-01T00:00:00+00:00",
|
|
"config": {},
|
|
"gates": {},
|
|
"inputs": {},
|
|
}
|
|
store.persist(report)
|
|
manifest_path = root / "manifest.json"
|
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
manifest["runs"]["tourism-run-persist-entry-test"] = []
|
|
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
|
|
|
with self.assertRaisesRegex(ResearchRunError, "entry"):
|
|
store.persist(report)
|
|
|
|
def test_research_run_store_persist_validates_unrelated_manifest_entries_before_write(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
root = Path(temp_dir)
|
|
store = ResearchRunStore(root)
|
|
existing_report = {
|
|
"schema_version": 1,
|
|
"run_id": "tourism-run-persist-unrelated-existing",
|
|
"theme": "tourism",
|
|
"status": "blocked",
|
|
"reason": "test",
|
|
"generated_at": "2026-02-01T00:00:00+00:00",
|
|
"config": {},
|
|
"gates": {},
|
|
"inputs": {},
|
|
}
|
|
new_report = {
|
|
**existing_report,
|
|
"run_id": "tourism-run-persist-unrelated-new",
|
|
}
|
|
store.persist(existing_report)
|
|
|
|
manifest_path = root / "manifest.json"
|
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
manifest["runs"]["tourism-run-persist-unrelated-malformed"] = []
|
|
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
|
|
|
with self.assertRaisesRegex(ResearchRunError, "entry"):
|
|
store.persist(new_report)
|
|
self.assertFalse((root / "reports" / "tourism-run-persist-unrelated-new.json").exists())
|
|
|
|
def test_price_store_loads_snapshot_and_verifies_raw_payload(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
store = PriceSnapshotStore(Path(temp_dir))
|
|
snapshot, raw = price_snapshot(point_in_time=False)
|
|
store.persist(snapshot, raw)
|
|
loaded = store.load_snapshot("prices-test")
|
|
self.assertEqual(loaded["source"]["snapshot_id"], "prices-test")
|
|
self.assertEqual(loaded["source"]["raw_payload_hash"], store.load_manifest()["snapshots"]["prices-test"]["raw_payload_hash"])
|
|
|
|
def test_price_store_rejects_manifest_metadata_tampering(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
root = Path(temp_dir)
|
|
store = PriceSnapshotStore(root)
|
|
snapshot, raw = price_snapshot(point_in_time=False)
|
|
store.persist(snapshot, raw)
|
|
manifest_path = root / "manifest.json"
|
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
manifest["snapshots"]["prices-test"]["point_in_time"] = True
|
|
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
|
with self.assertRaisesRegex(PriceSourceError, "metadata"):
|
|
store.load_snapshot("prices-test")
|
|
|
|
def test_runner_persists_blocked_report_when_vintage_count_is_insufficient(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
root = Path(temp_dir)
|
|
vintage_store = VintageStore(root / "tourism")
|
|
vintage_store.persist(b"tourism", tourism_snapshot("v1", "2026-01-01T08:00:00+07:00"))
|
|
price_store = PriceSnapshotStore(root / "prices")
|
|
snapshot, raw = price_snapshot(point_in_time=False)
|
|
price_store.persist(snapshot, raw)
|
|
run_store = ResearchRunStore(root / "runs")
|
|
|
|
report = run_tourism_research(vintage_store, price_store, run_store, min_events=2, windows=(1,))
|
|
|
|
self.assertEqual(report["status"], "blocked")
|
|
self.assertEqual(report["reason"], "insufficient_vintages")
|
|
self.assertEqual(report["gates"]["vintages"]["available_events"], 1)
|
|
self.assertNotIn("result", report)
|
|
self.assertEqual(run_store.latest()["run_id"], report["run_id"])
|
|
|
|
def test_runner_allows_explicit_exploratory_mode_with_revised_prices(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
root = Path(temp_dir)
|
|
vintage_store = VintageStore(root / "tourism")
|
|
vintage_store.persist(b"tourism", tourism_snapshot("v1", "2026-01-01T08:00:00+07:00"))
|
|
price_store = PriceSnapshotStore(root / "prices")
|
|
snapshot, raw = price_snapshot(point_in_time=False)
|
|
price_store.persist(snapshot, raw)
|
|
run_store = ResearchRunStore(root / "runs")
|
|
|
|
report = run_tourism_research(
|
|
vintage_store,
|
|
price_store,
|
|
run_store,
|
|
mode="exploratory",
|
|
min_events=1,
|
|
windows=(1,),
|
|
)
|
|
|
|
self.assertEqual(report["status"], "descriptive_only")
|
|
self.assertEqual(report["research_mode"], "exploratory")
|
|
self.assertEqual(report["result_scope"], "non_pit_descriptive_only")
|
|
self.assertEqual(report["gates"]["prices"]["status"], "descriptive_only")
|
|
self.assertEqual(report["gates"]["prices"]["reason"], "price_series_not_point_in_time")
|
|
self.assertEqual(report["result"]["event_count"], 1)
|
|
|
|
def test_runner_keeps_exploratory_pit_inputs_descriptive_only(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
root = Path(temp_dir)
|
|
vintage_store = VintageStore(root / "tourism")
|
|
vintage_store.persist(b"tourism", tourism_snapshot("v1", "2026-01-01T08:00:00+07:00"))
|
|
price_store = PriceSnapshotStore(root / "prices")
|
|
snapshot, raw = price_snapshot(point_in_time=True)
|
|
price_store.persist(snapshot, raw)
|
|
run_store = ResearchRunStore(root / "runs")
|
|
|
|
report = run_tourism_research(
|
|
vintage_store,
|
|
price_store,
|
|
run_store,
|
|
mode="exploratory",
|
|
min_events=1,
|
|
windows=(1,),
|
|
)
|
|
|
|
self.assertEqual(report["status"], "descriptive_only")
|
|
self.assertEqual(report["result_scope"], "non_pit_descriptive_only")
|
|
self.assertEqual(report["gates"]["prices"]["status"], "descriptive_only")
|
|
self.assertFalse(report["gates"]["prices"]["validated"])
|
|
self.assertTrue(report["gates"]["prices"]["point_in_time"])
|
|
self.assertEqual(report["gates"]["prices"]["reason"], "exploratory_mode_non_validated")
|
|
|
|
def test_runner_reuses_same_run_when_only_observation_time_changes(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
root = Path(temp_dir)
|
|
vintage_store = VintageStore(root / "tourism")
|
|
vintage_store.persist(b"tourism", tourism_snapshot("v1", "2026-01-01T08:00:00+07:00"))
|
|
price_store = PriceSnapshotStore(root / "prices")
|
|
snapshot, raw = price_snapshot(point_in_time=False)
|
|
price_store.persist(snapshot, raw)
|
|
run_store = ResearchRunStore(root / "runs")
|
|
|
|
first = run_tourism_research(vintage_store, price_store, run_store, mode="exploratory", min_events=1, windows=(1,))
|
|
observed_again = copy.deepcopy(snapshot)
|
|
observed_again["source"]["retrieved_at"] = "2026-02-03T00:00:00+00:00"
|
|
price_store.persist(observed_again, raw)
|
|
second = run_tourism_research(vintage_store, price_store, run_store, mode="exploratory", min_events=1, windows=(1,))
|
|
|
|
self.assertEqual(second["run_id"], first["run_id"])
|
|
self.assertEqual(second, first)
|
|
self.assertEqual(len(price_store.list_observations()), 2)
|
|
|
|
def test_runner_computes_replayable_result_when_both_gates_pass(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
root = Path(temp_dir)
|
|
vintage_store = VintageStore(root / "tourism")
|
|
for index, published_at in ((1, "2026-01-01T08:00:00+07:00"), (2, "2026-01-02T08:00:00+07:00")):
|
|
snapshot = tourism_snapshot(f"v{index}", published_at)
|
|
vintage_store.persist(f"tourism-{index}".encode(), snapshot)
|
|
price_store = PriceSnapshotStore(root / "prices")
|
|
snapshot, raw = price_snapshot(point_in_time=True)
|
|
price_store.persist(snapshot, raw)
|
|
run_store = ResearchRunStore(root / "runs")
|
|
|
|
report = run_tourism_research(vintage_store, price_store, run_store, min_events=2, windows=(1,), cost_bps=20)
|
|
replay = run_tourism_research(vintage_store, price_store, run_store, min_events=2, windows=(1,), cost_bps=20)
|
|
|
|
self.assertEqual(report["status"], "ready")
|
|
self.assertEqual(report["result"]["event_count"], 2)
|
|
self.assertEqual(report["result"]["windows"]["1"]["cost_bps"], 20.0)
|
|
self.assertEqual(replay, report)
|
|
self.assertEqual(report["inputs"]["price_snapshot"]["point_in_time"], True)
|
|
|
|
def test_runner_uses_latest_revision_once(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
root = Path(temp_dir)
|
|
vintage_store = VintageStore(root / "tourism")
|
|
vintage_store.persist(b"initial", tourism_snapshot("v1", "2026-01-01T08:00:00+07:00"))
|
|
revised = tourism_snapshot("v1-revised", "2026-01-01T01:00:00Z")
|
|
revised["source"]["source_id"] = "test.bot"
|
|
vintage_store.persist(b"revised", revised)
|
|
vintage_store.persist(b"next", tourism_snapshot("v2", "2026-01-02T08:00:00+07:00"))
|
|
price_store = PriceSnapshotStore(root / "prices")
|
|
snapshot, raw = price_snapshot(point_in_time=True)
|
|
price_store.persist(snapshot, raw)
|
|
report = run_tourism_research(vintage_store, price_store, ResearchRunStore(root / "runs"), min_events=2, windows=(1,))
|
|
self.assertEqual(report["status"], "ready")
|
|
self.assertEqual(len(report["inputs"]["vintages"]), 2)
|
|
self.assertIn("v1-revised", {entry["vintage_id"] for entry in report["inputs"]["vintages"]})
|
|
|
|
def test_runner_does_not_replay_cached_ready_report_after_price_tampering(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
root = Path(temp_dir)
|
|
vintage_store = VintageStore(root / "tourism")
|
|
for index, published_at in ((1, "2026-01-01T08:00:00+07:00"), (2, "2026-01-02T08:00:00+07:00")):
|
|
vintage_store.persist(f"tourism-{index}".encode(), tourism_snapshot(f"v{index}", published_at))
|
|
price_store = PriceSnapshotStore(root / "prices")
|
|
snapshot, raw = price_snapshot(point_in_time=True)
|
|
price_store.persist(snapshot, raw)
|
|
run_store = ResearchRunStore(root / "runs")
|
|
report = run_tourism_research(vintage_store, price_store, run_store, min_events=2, windows=(1,))
|
|
self.assertEqual(report["status"], "ready")
|
|
manifest_path = root / "prices" / "manifest.json"
|
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
manifest["snapshots"]["prices-test"]["point_in_time"] = False
|
|
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
|
with self.assertRaisesRegex(ResearchRunError, "integrity"):
|
|
run_tourism_research(vintage_store, price_store, run_store, min_events=2, windows=(1,))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|