1
+"""Tests for shared run-context schema and fan-in validation contract.
2
+
3
+Covers acceptance criteria from issue #333:
4
+- Shared run-context schema prevents independent wall-clock computation
5
+- Fan-in validation contract: schema/checksum/window consistency,
6
+ deterministic ordering, duplicate handling, stale cache rejection,
7
+ source status metadata, required-vs-optional failure behavior
8
+- Fixture-based byte-stability checks (same inputs → same output)
9
+"""
10
+
11
+from __future__ import annotations
12
+
13
+import hashlib
14
+import json
15
+from datetime import UTC, datetime, timedelta
16
+from pathlib import Path
17
+from typing import Any
18
+
19
+import pytest
20
+
21
+from scripts.run_context import (
22
+ SCHEMA_VERSION,
23
+ RunContext,
24
+ build_run_context,
25
+ compute_code_sha,
26
+ contexts_compatible,
27
+ validate_run_context,
28
+)
29
+from scripts.fan_in_validator import (
30
+ DEFAULT_MAX_ARTIFACT_AGE,
31
+ ValidationResult,
32
+ detect_duplicate_repos,
33
+ detect_duplicate_urls,
34
+ run_full_validation,
35
+ validate_artifact_schema,
36
+ validate_checksum_integrity,
37
+ validate_deterministic_ordering,
38
+ validate_stale_artifacts,
39
+ validate_source_status,
40
+ validate_window_consistency,
41
+ verify_byte_stability,
42
+)
43
+
44
+
45
+# --- Fixtures ---
46
+
47
+WEEK = "2026-W24"
48
+SINCE = datetime(2026, 6, 8, 0, 0, 0, tzinfo=UTC)
49
+UNTIL = datetime(2026, 6, 15, 0, 0, 0, tzinfo=UTC)
50
+NOW = datetime(2026, 6, 14, 12, 0, 0, tzinfo=UTC)
51
+SOURCE_CHECKSUM = "a" * 64
52
+TOPIC_CHECKSUM = "b" * 64
53
+CODE_SHA = "c" * 64
54
+
55
+
56
+def _make_run_context(**overrides: Any) -> RunContext:
57
+ defaults = dict(
58
+ week=WEEK,
59
+ since=SINCE,
60
+ until=UNTIL,
61
+ source_config_checksum=SOURCE_CHECKSUM,
62
+ topic_config_checksum=TOPIC_CHECKSUM,
63
+ code_sha=CODE_SHA,
64
+ created_at=NOW,
65
+ )
66
+ defaults.update(overrides)
67
+ return build_run_context(**defaults)
68
+
69
+
70
+def _make_rss_artifact(
71
+ source_id: str = "techcrunch",
72
+ articles: list[dict[str, Any]] | None = None,
73
+ run_context: RunContext | None = None,
74
+ crawled_at: str | None = None,
75
+ status_success: bool = True,
76
+) -> dict[str, Any]:
77
+ ctx = run_context or _make_run_context()
78
+ arts = articles or [
79
+ {"url": f"https://example.com/{source_id}/1", "title": "Article 1", "source": source_id},
80
+ {"url": f"https://example.com/{source_id}/2", "title": "Article 2", "source": source_id},
81
+ ]
82
+ artifact = {
83
+ "source_artifact_schema_version": "1",
84
+ "source_id": source_id,
85
+ "crawled_at": crawled_at or NOW.strftime("%Y-%m-%dT%H:%M:%SZ"),
86
+ "run_context": {
87
+ "week": ctx.week,
88
+ "since": ctx.since,
89
+ "until": ctx.until,
90
+ "crawl_window": {"since": ctx.since, "until": ctx.until},
91
+ "source_config_checksum": ctx.source_config_checksum,
92
+ "schema_checksum": "1",
93
+ },
94
+ "status": {"success": status_success, "error_message": "" if status_success else "timeout"},
95
+ "metrics": {
96
+ "total_articles": len(arts),
97
+ "relevant_articles": len(arts),
98
+ "content_checksum": hashlib.sha256(
99
+ json.dumps(arts, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
100
+ ).hexdigest(),
101
+ },
102
+ "articles": arts,
103
+ }
104
+ # Compute artifact_checksum
105
+ payload = {
106
+ "source_id": artifact["source_id"],
107
+ "run_context": artifact["run_context"],
108
+ "articles": artifact["articles"],
109
+ }
110
+ serialized = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
111
+ artifact["artifact_checksum"] = hashlib.sha256(serialized.encode("utf-8")).hexdigest()
112
+ return artifact
113
+
114
+
115
+# --- Run Context Tests ---
116
+
117
+
118
+class TestRunContext:
119
+ def test_build_run_context_deterministic_run_id(self):
120
+ ctx1 = _make_run_context()
121
+ ctx2 = _make_run_context()
122
+ assert ctx1.run_id == ctx2.run_id
123
+
124
+ def test_build_run_context_different_inputs_different_id(self):
125
+ ctx1 = _make_run_context()
126
+ ctx2 = _make_run_context(source_config_checksum="d" * 64)
127
+ assert ctx1.run_id != ctx2.run_id
128
+
129
+ def test_schema_version_is_set(self):
130
+ ctx = _make_run_context()
131
+ assert ctx.schema_version == SCHEMA_VERSION
132
+
133
+ def test_run_context_serialization_roundtrip(self):
134
+ ctx = _make_run_context()
135
+ json_str = ctx.to_json()
136
+ restored = RunContext.from_json(json_str)
137
+ assert ctx == restored
138
+
139
+ def test_validate_run_context_valid(self):
140
+ ctx = _make_run_context()
141
+ errors = validate_run_context(ctx)
142
+ assert errors == []
143
+
144
+ def test_validate_run_context_missing_field(self):
145
+ ctx = _make_run_context()
146
+ data = ctx.to_dict()
147
+ del data["week"]
148
+ errors = validate_run_context(data)
149
+ assert any("week" in e for e in errors)
150
+
151
+ def test_validate_run_context_bad_week_format(self):
152
+ ctx = _make_run_context()
153
+ data = ctx.to_dict()
154
+ data["week"] = "2026-24" # Missing W prefix
155
+ errors = validate_run_context(data)
156
+ assert any("week" in e for e in errors)
157
+
158
+ def test_validate_run_context_bad_timestamp(self):
159
+ ctx = _make_run_context()
160
+ data = ctx.to_dict()
161
+ data["since"] = "not-a-timestamp"
162
+ errors = validate_run_context(data)
163
+ assert any("since" in e for e in errors)
164
+
165
+ def test_contexts_compatible_identical(self):
166
+ ctx = _make_run_context()
167
+ assert contexts_compatible(ctx, ctx) == []
168
+
169
+ def test_contexts_compatible_different_week(self):
170
+ ctx1 = _make_run_context()
171
+ ctx2 = _make_run_context(week="2026-W25")
172
+ mismatches = contexts_compatible(ctx1, ctx2)
173
+ assert any("week" in m for m in mismatches)
174
+
175
+ def test_contexts_compatible_different_checksum(self):
176
+ ctx1 = _make_run_context()
177
+ ctx2 = _make_run_context(source_config_checksum="f" * 64)
178
+ mismatches = contexts_compatible(ctx1, ctx2)
179
+ assert len(mismatches) > 0
180
+
181
+ def test_prevents_wall_clock_computation(self):
182
+ """Run context enforces that legs cannot compute their own window."""
183
+ ctx = _make_run_context()
184
+ # The since/until are fixed at build time, not computed from wall clock
185
+ assert ctx.since == "2026-06-08T00:00:00Z"
186
+ assert ctx.until == "2026-06-15T00:00:00Z"
187
+ # Even if built at a different time, same inputs yield same window
188
+ ctx2 = build_run_context(
189
+ week=WEEK,
190
+ since=SINCE,
191
+ until=UNTIL,
192
+ source_config_checksum=SOURCE_CHECKSUM,
193
+ topic_config_checksum=TOPIC_CHECKSUM,
194
+ code_sha=CODE_SHA,
195
+ created_at=datetime(2026, 6, 20, 0, 0, 0, tzinfo=UTC),
196
+ )
197
+ assert ctx.since == ctx2.since
198
+ assert ctx.until == ctx2.until
199
+
200
+
201
+# --- Fan-In Validation Tests ---
202
+
203
+
204
+class TestFanInValidation:
205
+ def test_validate_artifact_schema_valid(self):
206
+ artifact = _make_rss_artifact()
207
+ errors = validate_artifact_schema(artifact, "1")
208
+ assert errors == []
209
+
210
+ def test_validate_artifact_schema_mismatch(self):
211
+ artifact = _make_rss_artifact()
212
+ errors = validate_artifact_schema(artifact, "2")
213
+ assert any("mismatch" in e for e in errors)
214
+
215
+ def test_validate_checksum_integrity_valid(self):
216
+ artifact = _make_rss_artifact()
217
+ errors = validate_checksum_integrity(artifact)
218
+ assert errors == []
219
+
220
+ def test_validate_checksum_integrity_tampered(self):
221
+ artifact = _make_rss_artifact()
222
+ artifact["artifact_checksum"] = "0" * 64
223
+ errors = validate_checksum_integrity(artifact)
224
+ assert len(errors) > 0
225
+
226
+ def test_window_consistency_valid(self):
227
+ ctx = _make_run_context()
228
+ artifacts = [_make_rss_artifact(run_context=ctx)]
229
+ errors = validate_window_consistency(artifacts, ctx)
230
+ assert errors == []
231
+
232
+ def test_window_consistency_mismatch(self):
233
+ ctx = _make_run_context()
234
+ artifact = _make_rss_artifact(run_context=ctx)
235
+ artifact["run_context"]["week"] = "2026-W99"
236
+ errors = validate_window_consistency([artifact], ctx)
237
+ assert len(errors) > 0
238
+
239
+ def test_deterministic_ordering_sorted(self):
240
+ articles = [
241
+ {"source": "a", "url": "https://a.com/1"},
242
+ {"source": "a", "url": "https://a.com/2"},
243
+ {"source": "b", "url": "https://b.com/1"},
244
+ ]
245
+ errors = validate_deterministic_ordering(articles)
246
+ assert errors == []
247
+
248
+ def test_deterministic_ordering_unsorted(self):
249
+ articles = [
250
+ {"source": "b", "url": "https://b.com/1"},
251
+ {"source": "a", "url": "https://a.com/1"},
252
+ ]
253
+ errors = validate_deterministic_ordering(articles)
254
+ assert len(errors) > 0
255
+
256
+ def test_detect_duplicate_urls(self):
257
+ articles = [
258
+ {"url": "https://example.com/1"},
259
+ {"url": "https://example.com/1"},
260
+ {"url": "https://example.com/2"},
261
+ ]
262
+ dupes = detect_duplicate_urls(articles)
263
+ assert len(dupes) == 1
264
+
265
+ def test_detect_duplicate_urls_normalized(self):
266
+ articles = [
267
+ {"url": "https://example.com/path?query=1"},
268
+ {"url": "https://example.com/path?query=2"},
269
+ ]
270
+ dupes = detect_duplicate_urls(articles)
271
+ # Same path, different query → treated as same after normalization
272
+ assert len(dupes) == 1
273
+
274
+ def test_detect_duplicate_repos(self):
275
+ repos = [
276
+ {"full_name": "owner/repo1"},
277
+ {"full_name": "owner/repo1"},
278
+ {"full_name": "owner/repo2"},
279
+ ]
280
+ dupes = detect_duplicate_repos(repos)
281
+ assert dupes == ["owner/repo1"]
282
+
283
+ def test_stale_artifact_rejected(self):
284
+ artifact = _make_rss_artifact(
285
+ crawled_at="2026-06-12T00:00:00Z" # >24h before NOW
286
+ )
287
+ stale = validate_stale_artifacts([artifact], reference_time=NOW)
288
+ assert len(stale) > 0
289
+
290
+ def test_fresh_artifact_accepted(self):
291
+ artifact = _make_rss_artifact(
292
+ crawled_at=NOW.strftime("%Y-%m-%dT%H:%M:%SZ")
293
+ )
294
+ stale = validate_stale_artifacts([artifact], reference_time=NOW)
295
+ assert stale == []
296
+
297
+ def test_source_status_required_missing(self):
298
+ artifacts = [_make_rss_artifact(source_id="techcrunch")]
299
+ errors, warnings = validate_source_status(
300
+ artifacts, required_sources=["techcrunch", "nvidia_blog"]
301
+ )
302
+ assert any("nvidia_blog" in e for e in errors)
303
+
304
+ def test_source_status_required_failed(self):
305
+ artifact = _make_rss_artifact(source_id="techcrunch", status_success=False)
306
+ errors, warnings = validate_source_status(
307
+ [artifact], required_sources=["techcrunch"]
308
+ )
309
+ assert any("techcrunch" in e for e in errors)
310
+
311
+ def test_source_status_optional_missing_is_warning(self):
312
+ artifacts = [_make_rss_artifact(source_id="techcrunch")]
313
+ errors, warnings = validate_source_status(
314
+ artifacts,
315
+ required_sources=["techcrunch"],
316
+ optional_sources=["huggingface"],
317
+ )
318
+ assert errors == []
319
+ assert any("huggingface" in w for w in warnings)
320
+
321
+
322
+# --- Byte Stability Tests ---
323
+
324
+
325
+class TestByteStability:
326
+ def test_same_inputs_same_output(self):
327
+ """Fixture check: same inputs produce byte-identical output."""
328
+ output1 = {
329
+ "articles": [
330
+ {"url": "https://a.com/1", "source": "a", "title": "A1"},
331
+ {"url": "https://b.com/1", "source": "b", "title": "B1"},
332
+ ],
333
+ "merged_at": "2026-06-14T12:00:00Z",
334
+ "checksum": "abc",
335
+ }
336
+ output2 = {
337
+ "articles": [
338
+ {"url": "https://a.com/1", "source": "a", "title": "A1"},
339
+ {"url": "https://b.com/1", "source": "b", "title": "B1"},
340
+ ],
341
+ "merged_at": "2026-06-14T13:00:00Z", # Different timestamp
342
+ "checksum": "abc",
343
+ }
344
+ errors = verify_byte_stability(output1, output2)
345
+ assert errors == [] # Timestamps excluded
346
+
347
+ def test_different_content_detected(self):
348
+ output1 = {"articles": [{"url": "https://a.com/1"}], "merged_at": "t1"}
349
+ output2 = {"articles": [{"url": "https://b.com/1"}], "merged_at": "t1"}
350
+ errors = verify_byte_stability(output1, output2)
351
+ assert len(errors) > 0
352
+
353
+ def test_deterministic_merge_fixture(self):
354
+ """Prove that merging the same artifacts twice yields identical output."""
355
+ ctx = _make_run_context()
356
+ a1 = _make_rss_artifact(source_id="alpha", run_context=ctx)
357
+ a2 = _make_rss_artifact(source_id="beta", run_context=ctx)
358
+
359
+ def merge(artifacts: list[dict[str, Any]]) -> dict[str, Any]:
360
+ sorted_arts = sorted(artifacts, key=lambda a: a["source_id"])
361
+ all_articles = []
362
+ for art in sorted_arts:
363
+ all_articles.extend(art.get("articles", []))
364
+ # Deterministic sort
365
+ all_articles.sort(key=lambda a: (a.get("source", ""), a.get("url", "")))
366
+ return {
367
+ "articles": all_articles,
368
+ "sources": [a["source_id"] for a in sorted_arts],
369
+ "checksum": hashlib.sha256(
370
+ json.dumps(all_articles, sort_keys=True).encode()
371
+ ).hexdigest(),
372
+ }
373
+
374
+ result1 = merge([a1, a2])
375
+ result2 = merge([a2, a1]) # Different input order
376
+ errors = verify_byte_stability(result1, result2)
377
+ assert errors == [], "Same artifacts in different order must produce identical output"
378
+
379
+
380
+# --- Full Validation Integration Tests ---
381
+
382
+
383
+class TestFullValidation:
384
+ def test_valid_artifacts_pass(self):
385
+ ctx = _make_run_context()
386
+ artifacts = [
387
+ _make_rss_artifact(source_id="techcrunch", run_context=ctx),
388
+ _make_rss_artifact(source_id="nvidia_blog", run_context=ctx),
389
+ ]
390
+ result = run_full_validation(
391
+ artifacts,
392
+ ctx,
393
+ required_sources=["techcrunch", "nvidia_blog"],
394
+ expected_schema_version="1",
395
+ reference_time=NOW,
396
+ )
397
+ assert result.valid
398
+ assert result.errors == []
399
+
400
+ def test_empty_artifacts_fail(self):
401
+ ctx = _make_run_context()
402
+ result = run_full_validation([], ctx)
403
+ assert not result.valid
404
+ assert "no artifacts" in result.errors[0]
405
+
406
+ def test_schema_mismatch_fails(self):
407
+ ctx = _make_run_context()
408
+ artifact = _make_rss_artifact(run_context=ctx)
409
+ result = run_full_validation(
410
+ [artifact], ctx, expected_schema_version="99"
411
+ )
412
+ assert not result.valid
413
+
414
+ def test_missing_required_source_fails(self):
415
+ ctx = _make_run_context()
416
+ artifact = _make_rss_artifact(source_id="techcrunch", run_context=ctx)
417
+ result = run_full_validation(
418
+ [artifact],
419
+ ctx,
420
+ required_sources=["techcrunch", "missing_source"],
421
+ expected_schema_version="1",
422
+ reference_time=NOW,
423
+ )
424
+ assert not result.valid
425
+ assert "missing_source" in str(result.errors)
426
+
427
+ def test_optional_missing_source_warns(self):
428
+ ctx = _make_run_context()
429
+ artifact = _make_rss_artifact(source_id="techcrunch", run_context=ctx)
430
+ result = run_full_validation(
431
+ [artifact],
432
+ ctx,
433
+ required_sources=["techcrunch"],
434
+ optional_sources=["huggingface"],
435
+ expected_schema_version="1",
436
+ reference_time=NOW,
437
+ )
438
+ assert result.valid # Optional missing doesn't fail
439
+ assert "huggingface" in str(result.warnings)