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