1
+#!/usr/bin/env python3
2
+"""Map/reduce vs. single-pass comparison framework.
3
+
4
+Generates a structured comparison report that tracks quality deltas between
5
+the map/reduce candidate and the current single-pass analyzer output. This
6
+report feeds the promotion criteria defined in docs/map-reduce-promotion-path.md.
7
+
8
+Never publishes content. Produces comparison-report.json alongside the
9
+candidate artifacts for QA analysis.
10
+"""
11
+
12
+from __future__ import annotations
13
+
14
+import argparse
15
+import hashlib
16
+import json
17
+import os
18
+import re
19
+import sys
20
+from dataclasses import asdict, dataclass
21
+from datetime import UTC, datetime
22
+from pathlib import Path
23
+from typing import Any
24
+
25
+try:
26
+ from scripts.analysis_gate import validate_analysis, validate_publish_quality
27
+except ModuleNotFoundError: # pragma: no cover
28
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
29
+ from scripts.analysis_gate import validate_analysis, validate_publish_quality
30
+
31
+COMPARISON_SCHEMA = "comparison_report_v1"
32
+PROMOTION_MIN_QUALITY = int(os.environ.get("MAPREDUCE_MIN_QUALITY_SCORE", "60"))
33
+PROMOTION_MIN_COVERAGE = float(os.environ.get("MAPREDUCE_MIN_COVERAGE", "0.85"))
34
+PROMOTION_HARD_FLOOR_QUALITY = 55
35
+PROMOTION_HARD_FLOOR_COVERAGE = 0.70
36
+TIME_BUDGET_SECONDS = int(os.environ.get("MAPREDUCE_TIME_BUDGET_SECONDS", "300"))
37
+MARKDOWN_LINK_PATTERN = re.compile(r"\[[^\]]+\]\((https?://[^)]+)\)")
38
+WEEK_PATTERN = re.compile(r"^(?P<year>\d{4})-W(?P<week>\d{2})$")
39
+VALID_REJECTION_REASONS = {
40
+ "duplicate",
41
+ "malformed_finding",
42
+ "unresolved_contradiction",
43
+ "weak_citation",
44
+}
45
+
46
+
47
+@dataclass(frozen=True)
48
+class ArtifactInfo:
49
+ path: str
50
+ sha256: str
51
+ quality_score: int
52
+ gate_passed: bool
53
+ evidence_coverage: float
54
+ citation_count: int
55
+ word_count: int
56
+
57
+
58
+def sha256_file(path: Path) -> str:
59
+ return hashlib.sha256(path.read_bytes()).hexdigest()
60
+
61
+
62
+def count_citations(text: str) -> int:
63
+ """Count markdown repo/article links as citations."""
64
+ import re
65
+
66
+ return len(re.findall(r"\[[^\]]+\]\(https?://[^)]+\)", text))
67
+
68
+
69
+def extract_quality_score(text: str) -> int:
70
+ """Extract quality_score from frontmatter."""
71
+ import re
72
+
73
+ match = re.search(r"^quality_score:\s*(\d+)", text, re.MULTILINE)
74
+ return int(match.group(1)) if match else 0
75
+
76
+
77
+def compute_evidence_coverage(qa_report: dict[str, Any]) -> float:
78
+ """Compute evidence coverage from QA report checks."""
79
+ checks = qa_report.get("checks", {})
80
+ ref_count = checks.get("reference_count", {})
81
+ selected = ref_count.get("selected", 0)
82
+ # Use mapper contracts to estimate input count
83
+ mapper_contracts = checks.get("mapper_contracts", {})
84
+ errors_by_mapper = mapper_contracts.get("errors_by_mapper", {})
85
+ # If no errors, assume good coverage
86
+ if not any(errors_by_mapper.values()):
87
+ return 0.90
88
+ # Degrade based on mapper failures
89
+ failed_mappers = sum(1 for errs in errors_by_mapper.values() if errs)
90
+ total_mappers = max(len(errors_by_mapper), 1)
91
+ return max(0.0, 1.0 - (failed_mappers / total_mappers) * 0.5)
92
+
93
+
94
+def compute_evidence_coverage_from_ledgers(
95
+ candidate_dir: Path,
96
+) -> float:
97
+ """Compute evidence coverage from mapper ledger files."""
98
+ total_input = 0
99
+ total_mapped = 0
100
+ for ledger_path in sorted((candidate_dir / "maps").glob("*.json")):
101
+ try:
102
+ ledger = json.loads(ledger_path.read_text(encoding="utf-8"))
103
+ except (json.JSONDecodeError, OSError):
104
+ continue
105
+ coverage = ledger.get("coverage", {})
106
+ total_input += int(coverage.get("repo_count_input", 0)) + int(
107
+ coverage.get("article_count_input", 0)
108
+ )
109
+ total_mapped += int(coverage.get("repo_count_mapped", 0)) + int(
110
+ coverage.get("article_count_mapped", 0)
111
+ )
112
+ if total_input == 0:
113
+ return 0.0
114
+ return total_mapped / total_input
115
+
116
+
117
+def analyze_single_pass(
118
+ summary_path: Path, raw_payload: dict[str, Any], current_datetime: str
119
+) -> ArtifactInfo:
120
+ """Analyze the single-pass baseline artifact."""
121
+ text = summary_path.read_text(encoding="utf-8")
122
+ structural_errors, word_count = validate_analysis(text, raw_payload, current_datetime)
123
+ publish_errors, _gates = validate_publish_quality(
124
+ text, raw_payload, source="copilot-cli", model="claude-sonnet-4.6"
125
+ )
126
+ non_provenance = [e for e in publish_errors if not e.startswith("AI provenance")]
127
+ gate_passed = not structural_errors and not non_provenance
128
+ return ArtifactInfo(
129
+ path=summary_path.as_posix(),
130
+ sha256=sha256_file(summary_path),
131
+ quality_score=extract_quality_score(text),
132
+ gate_passed=gate_passed,
133
+ evidence_coverage=0.92, # Single-pass baseline assumed high coverage
134
+ citation_count=count_citations(text),
135
+ word_count=word_count,
136
+ )
137
+
138
+
139
+def analyze_map_reduce(
140
+ candidate_dir: Path, raw_payload: dict[str, Any], current_datetime: str
141
+) -> tuple[ArtifactInfo, dict[str, Any]]:
142
+ """Analyze the map/reduce candidate artifact and QA report."""
143
+ week = str(raw_payload.get("week") or "").strip()
144
+ candidate_path = resolve_candidate_path(candidate_dir, week)
145
+ qa_path = resolve_sidecar_path(
146
+ candidate_dir,
147
+ preferred_name="qa-comparison-report.json",
148
+ legacy_name="qa-report.json",
149
+ )
150
+ plan_path = candidate_dir / "editorial-plan.json"
151
+ contradictions_path = candidate_dir / "sidecars" / "contradictions.json"
152
+ rejected_claims_path = candidate_dir / "sidecars" / "rejected-claims.json"
153
+
154
+ if not candidate_path.exists():
155
+ raise FileNotFoundError(f"Candidate summary not found: {candidate_path}")
156
+
157
+ text = candidate_path.read_text(encoding="utf-8")
158
+ structural_errors, word_count = validate_analysis(text, raw_payload, current_datetime)
159
+ publish_errors, _gates = validate_publish_quality(
160
+ text, raw_payload, source="map-reduce-dry-run", model="local-deterministic"
161
+ )
162
+ non_provenance = [e for e in publish_errors if not e.startswith("AI provenance")]
163
+ gate_passed = not structural_errors and not non_provenance
164
+
165
+ artifact_errors: list[str] = []
166
+ qa_report = load_optional_json(
167
+ qa_path,
168
+ artifact_name="QA comparison report",
169
+ errors=artifact_errors,
170
+ )
171
+ editorial_plan = load_optional_json(
172
+ plan_path,
173
+ artifact_name="editorial plan",
174
+ errors=artifact_errors,
175
+ )
176
+ contradictions_sidecar = load_optional_json(
177
+ contradictions_path,
178
+ artifact_name="contradictions sidecar",
179
+ errors=artifact_errors,
180
+ )
181
+ rejected_claims_sidecar = load_optional_json(
182
+ rejected_claims_path,
183
+ artifact_name="rejected claims sidecar",
184
+ errors=artifact_errors,
185
+ )
186
+
187
+ evidence_coverage = compute_evidence_coverage_from_ledgers(candidate_dir)
188
+ orphaned_citations = compute_orphaned_citations(
189
+ text=text,
190
+ editorial_plan=editorial_plan,
191
+ )
192
+ unresolved_contradictions = count_unresolved_contradictions(contradictions_sidecar)
193
+ invalid_rejected_claims = count_invalid_rejected_claims(
194
+ rejected_claims_sidecar,
195
+ contradictions_sidecar=contradictions_sidecar,
196
+ )
197
+ checks = qa_report.get("checks", {}) if isinstance(qa_report, dict) else {}
198
+ sidecars_present = checks.get("sidecars_present", {}) if isinstance(checks, dict) else {}
199
+
200
+ extra = {
201
+ "mapper_errors": checks
202
+ .get("mapper_contracts", {})
203
+ .get("errors_by_mapper", {}),
204
+ "claims_rejected": sidecars_present.get(
205
+ "rejected_count",
206
+ len(rejected_claims_sidecar.get("rejected_claims", [])),
207
+ ),
208
+ "unresolved_contradictions": sidecars_present.get(
209
+ "contradiction_count",
210
+ unresolved_contradictions,
211
+ ),
212
+ "orphaned_citations": orphaned_citations,
213
+ "invalid_rejected_claims": invalid_rejected_claims,
214
+ "artifact_errors": artifact_errors,
215
+ }
216
+
217
+ info = ArtifactInfo(
218
+ path=candidate_path.as_posix(),
219
+ sha256=sha256_file(candidate_path),
220
+ quality_score=extract_quality_score(text),
221
+ gate_passed=gate_passed,
222
+ evidence_coverage=evidence_coverage,
223
+ citation_count=count_citations(text),
224
+ word_count=word_count,
225
+ )
226
+ return info, extra
227
+
228
+
229
+def compute_verdict(
230
+ single_pass: ArtifactInfo,
231
+ map_reduce: ArtifactInfo,
232
+ deltas: dict[str, Any],
233
+) -> tuple[str, list[str]]:
234
+ """Determine pass/fail verdict and list blockers."""
235
+ blockers: list[str] = []
236
+
237
+ # Gate regression check
238
+ if deltas.get("gate_regression"):
239
+ blockers.append("Gate regression: single-pass passes but map/reduce fails.")
240
+
241
+ # Quality floor
242
+ if map_reduce.quality_score < PROMOTION_MIN_QUALITY:
243
+ blockers.append(
244
+ f"Quality score {map_reduce.quality_score} below minimum {PROMOTION_MIN_QUALITY}."
245
+ )
246
+
247
+ # Hard quality floor (rollback trigger)
248
+ if map_reduce.quality_score < PROMOTION_HARD_FLOOR_QUALITY:
249
+ blockers.append(
250
+ f"Quality score {map_reduce.quality_score} below hard floor "
251
+ f"{PROMOTION_HARD_FLOOR_QUALITY} (rollback trigger)."
252
+ )
253
+
254
+ # Evidence coverage
255
+ if map_reduce.evidence_coverage < PROMOTION_MIN_COVERAGE:
256
+ blockers.append(
257
+ f"Evidence coverage {map_reduce.evidence_coverage:.2f} below minimum "
258
+ f"{PROMOTION_MIN_COVERAGE}."
259
+ )
260
+
261
+ # Hard coverage floor (rollback trigger)
262
+ if map_reduce.evidence_coverage < PROMOTION_HARD_FLOOR_COVERAGE:
263
+ blockers.append(
264
+ f"Evidence coverage {map_reduce.evidence_coverage:.2f} below hard floor "
265
+ f"{PROMOTION_HARD_FLOOR_COVERAGE} (rollback trigger)."
266
+ )
267
+
268
+ if deltas.get("orphaned_citations", 0) > 0:
269
+ blockers.append(
270
+ "Citation integrity failed: "
271
+ f"{deltas['orphaned_citations']} orphaned rendered citation(s) were not "
272
+ "bound in the editorial plan."
273
+ )
274
+
275
+ if deltas.get("unresolved_contradictions", 0) > 0:
276
+ blockers.append(
277
+ "Contradiction handling failed: "
278
+ f"{deltas['unresolved_contradictions']} unresolved contradiction(s) remain "
279
+ "in the map/reduce sidecars."
280
+ )
281
+
282
+ if deltas.get("invalid_rejected_claims", 0) > 0:
283
+ blockers.append(
284
+ "Claim rejection audit failed: "
285
+ f"{deltas['invalid_rejected_claims']} rejected claim(s) were missing a "
286
+ "supported audit reason."
287
+ )
288
+
289
+ for artifact_error in deltas.get("artifact_errors", []):
290
+ blockers.append(f"Comparison artifact unavailable: {artifact_error}")
291
+
292
+ verdict = "pass" if not blockers else "fail"
293
+ return verdict, blockers
294
+
295
+
296
+def generate_comparison_report(
297
+ *,
298
+ week: str,
299
+ single_pass: ArtifactInfo,
300
+ map_reduce: ArtifactInfo,
301
+ map_reduce_extra: dict[str, Any],
302
+ run_datetime: str,
303
+) -> dict[str, Any]:
304
+ """Generate the full comparison report."""
305
+ deltas = {
306
+ "quality_score": map_reduce.quality_score - single_pass.quality_score,
307
+ "evidence_coverage": round(
308
+ map_reduce.evidence_coverage - single_pass.evidence_coverage, 4
309
+ ),
310
+ "citation_count": map_reduce.citation_count - single_pass.citation_count,
311
+ "word_count": map_reduce.word_count - single_pass.word_count,
312
+ "gate_regression": single_pass.gate_passed and not map_reduce.gate_passed,
313
+ "orphaned_citations": int(map_reduce_extra.get("orphaned_citations", 0)),
314
+ "unresolved_contradictions": int(
315
+ map_reduce_extra.get("unresolved_contradictions", 0)
316
+ ),
317
+ "invalid_rejected_claims": int(
318
+ map_reduce_extra.get("invalid_rejected_claims", 0)
319
+ ),
320
+ "artifact_errors": list(map_reduce_extra.get("artifact_errors", [])),
321
+ }
322
+
323
+ verdict, blockers = compute_verdict(single_pass, map_reduce, deltas)
324
+
325
+ return {
326
+ "schema_version": COMPARISON_SCHEMA,
327
+ "week": week,
328
+ "run_datetime": run_datetime,
329
+ "single_pass": {
330
+ "artifact_path": single_pass.path,
331
+ "sha256": single_pass.sha256,
332
+ "quality_score": single_pass.quality_score,
333
+ "gate_passed": single_pass.gate_passed,
334
+ "evidence_coverage": single_pass.evidence_coverage,
335
+ "citation_count": single_pass.citation_count,
336
+ "word_count": single_pass.word_count,
337
+ },
338
+ "map_reduce": {
339
+ "artifact_path": map_reduce.path,
340
+ "sha256": map_reduce.sha256,
341
+ "quality_score": map_reduce.quality_score,
342
+ "gate_passed": map_reduce.gate_passed,
343
+ "evidence_coverage": map_reduce.evidence_coverage,
344
+ "citation_count": map_reduce.citation_count,
345
+ "word_count": map_reduce.word_count,
346
+ **map_reduce_extra,
347
+ },
348
+ "deltas": deltas,
349
+ "verdict": verdict,
350
+ "blockers": blockers,
351
+ }
352
+
353
+
354
+def check_promotion_eligibility(reports: list[dict[str, Any]]) -> dict[str, Any]:
355
+ """Check whether a set of comparison reports meets promotion criteria.
356
+
357
+ Returns a promotion status object indicating readiness and any blockers.
358
+ """
359
+ if len(reports) < 3:
360
+ return {
361
+ "eligible": False,
362
+ "reason": f"Only {len(reports)} comparison runs available; need ≥ 3.",
363
+ "runs_passing": len([r for r in reports if r.get("verdict") == "pass"]),
364
+ "runs_total": len(reports),
365
+ }
366
+
367
+ ordered_reports = sorted(reports, key=promotion_report_sort_key)
368
+ recent_reports = ordered_reports[-3:]
369
+ passing = [r for r in recent_reports if r.get("verdict") == "pass"]
370
+ if len(passing) < 3:
371
+ return {
372
+ "eligible": False,
373
+ "reason": (
374
+ f"Only {len(passing)}/{len(recent_reports)} most recent runs passed; "
375
+ "need ≥ 3 consecutive."
376
+ ),
377
+ "runs_passing": len(passing),
378
+ "runs_total": len(reports),
379
+ }
380
+
381
+ # Check average quality across the required consecutive window.
382
+ avg_quality = sum(
383
+ r["map_reduce"]["quality_score"] for r in recent_reports
384
+ ) / len(recent_reports)
385
+ if avg_quality < 65:
386
+ return {
387
+ "eligible": False,
388
+ "reason": f"Average quality score {avg_quality:.1f} below 65 threshold.",
389
+ "runs_passing": len(passing),
390
+ "runs_total": len(reports),
391
+ }
392
+
393
+ # Check staleness (28 days)
394
+ now = datetime.now(UTC)
395
+ for report in recent_reports:
396
+ run_dt = report.get("run_datetime", "")
397
+ try:
398
+ report_time = datetime.fromisoformat(run_dt.replace("Z", "+00:00"))
399
+ if (now - report_time).days > 28:
400
+ return {
401
+ "eligible": False,
402
+ "reason": f"Comparison run from {run_dt} is older than 28 days.",
403
+ "runs_passing": len(passing),
404
+ "runs_total": len(reports),
405
+ }
406
+ except (ValueError, TypeError):
407
+ pass
408
+
409
+ return {
410
+ "eligible": True,
411
+ "reason": "All promotion criteria met. Awaiting operator opt-in and team sign-off.",
412
+ "runs_passing": len(passing),
413
+ "runs_total": len(reports),
414
+ "average_quality": round(avg_quality, 1),
415
+ }
416
+
417
+
418
+def should_rollback(report: dict[str, Any]) -> tuple[bool, str]:
419
+ """Determine if automatic rollback should trigger based on a comparison report.
420
+
421
+ Returns (should_rollback, reason).
422
+ """
423
+ mr = report.get("map_reduce", {})
424
+
425
+ # Gate failure when single-pass passes
426
+ if report.get("deltas", {}).get("gate_regression"):
427
+ return True, "Gate regression: map/reduce fails gates that single-pass passes."
428
+
429
+ # Hard quality floor
430
+ quality = mr.get("quality_score", 0)
431
+ if quality < PROMOTION_HARD_FLOOR_QUALITY:
432
+ return True, f"Quality score {quality} below hard floor {PROMOTION_HARD_FLOOR_QUALITY}."
433
+
434
+ # Hard coverage floor
435
+ coverage = mr.get("evidence_coverage", 0.0)
436
+ if coverage < PROMOTION_HARD_FLOOR_COVERAGE:
437
+ return True, f"Evidence coverage {coverage:.2f} below hard floor {PROMOTION_HARD_FLOOR_COVERAGE}."
438
+
439
+ # Mapper failure
440
+ mapper_errors = mr.get("mapper_errors", {})
441
+ if any(
442
+ errs
443
+ for errs in mapper_errors.values()
444
+ if isinstance(errs, list) and errs
445
+ ):
446
+ failed = [k for k, v in mapper_errors.items() if v]
447
+ return True, f"Mapper failures in: {', '.join(failed)}."
448
+
449
+ return False, ""
450
+
451
+
452
+def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
453
+ parser = argparse.ArgumentParser(
454
+ description="Compare map/reduce candidate against single-pass baseline."
455
+ )
456
+ parser.add_argument(
457
+ "--raw-json",
458
+ required=True,
459
+ type=Path,
460
+ help="Weekly raw crawl payload.",
461
+ )
462
+ parser.add_argument(
463
+ "--single-pass-summary",
464
+ required=True,
465
+ type=Path,
466
+ help="Current single-pass analyzed summary.",
467
+ )
468
+ parser.add_argument(
469
+ "--candidate-dir",
470
+ required=True,
471
+ type=Path,
472
+ help="Map/reduce candidate output directory.",
473
+ )
474
+ parser.add_argument(
475
+ "--current-datetime",
476
+ default=datetime.now(UTC).isoformat(),
477
+ help="ISO-8601 timestamp for the comparison run.",
478
+ )
479
+ parser.add_argument(
480
+ "--output",
481
+ type=Path,
482
+ help="Output path for comparison report (default: candidate-dir/comparison-report.json).",
483
+ )
484
+ parser.add_argument(
485
+ "--check-promotion",
486
+ action="store_true",
487
+ help="Also check promotion eligibility across all available comparison reports.",
488
+ )
489
+ return parser.parse_args(argv)
490
+
491
+
492
+def run(args: argparse.Namespace) -> dict[str, Any]:
493
+ """Execute comparison and return the report."""
494
+ raw_payload = json.loads(args.raw_json.read_text(encoding="utf-8"))
495
+ week = raw_payload.get("week", "unknown")
496
+
497
+ single_pass = analyze_single_pass(
498
+ args.single_pass_summary, raw_payload, args.current_datetime
499
+ )
500
+ map_reduce, mr_extra = analyze_map_reduce(
501
+ args.candidate_dir, raw_payload, args.current_datetime
502
+ )
503
+
504
+ report = generate_comparison_report(
505
+ week=week,
506
+ single_pass=single_pass,
507
+ map_reduce=map_reduce,
508
+ map_reduce_extra=mr_extra,
509
+ run_datetime=args.current_datetime,
510
+ )
511
+
512
+ # Check rollback
513
+ rollback, rollback_reason = should_rollback(report)
514
+ if rollback:
515
+ report["rollback"] = True
516
+ report["rollback_reason"] = rollback_reason
517
+
518
+ # Write report
519
+ output_path = args.output or (args.candidate_dir / "comparison-report.json")
520
+ output_path.parent.mkdir(parents=True, exist_ok=True)
521
+ output_path.write_text(
522
+ json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
523
+ encoding="utf-8",
524
+ )
525
+
526
+ # Optional promotion check
527
+ if args.check_promotion:
528
+ reports_dir = args.candidate_dir.parent
529
+ all_reports = []
530
+ for rdir in sorted(reports_dir.iterdir()):
531
+ rpath = rdir / "comparison-report.json"
532
+ if rpath.exists():
533
+ try:
534
+ all_reports.append(
535
+ json.loads(rpath.read_text(encoding="utf-8"))
536
+ )
537
+ except (json.JSONDecodeError, OSError):
538
+ pass
539
+ promotion = check_promotion_eligibility(all_reports)
540
+ report["promotion_status"] = promotion
541
+
542
+ # Re-write with promotion status
543
+ output_path.write_text(
544
+ json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
545
+ encoding="utf-8",
546
+ )
547
+
548
+ return report
549
+
550
+
551
+def main(argv: list[str] | None = None) -> int:
552
+ args = parse_args(argv)
553
+ try:
554
+ report = run(args)
555
+ except FileNotFoundError as exc:
556
+ print(f"ERROR: {exc}", file=sys.stderr)
557
+ return 1
558
+
559
+ verdict = report.get("verdict", "unknown")
560
+ rollback = report.get("rollback", False)
561
+ print(f"Comparison complete: week={report.get('week')} verdict={verdict}", flush=True)
562
+ if rollback:
563
+ print(f"⚠️ ROLLBACK TRIGGERED: {report.get('rollback_reason')}", flush=True)
564
+ if report.get("promotion_status"):
565
+ status = report["promotion_status"]
566
+ if status.get("eligible"):
567
+ print("✅ Promotion eligible — awaiting operator opt-in.", flush=True)
568
+ else:
569
+ print(f"⏳ Not yet eligible: {status.get('reason')}", flush=True)
570
+
571
+ return 1 if rollback or verdict != "pass" else 0
572
+
573
+
574
+def resolve_candidate_path(candidate_dir: Path, week: str) -> Path:
575
+ preferred = candidate_dir / f"{week}-map-reduce-candidate.md"
576
+ if preferred.exists():
577
+ return preferred
578
+ legacy = candidate_dir / "candidate-summary.md"
579
+ if legacy.exists():
580
+ return legacy
581
+ return preferred
582
+
583
+
584
+def resolve_sidecar_path(
585
+ candidate_dir: Path,
586
+ *,
587
+ preferred_name: str,
588
+ legacy_name: str,
589
+) -> Path:
590
+ preferred = candidate_dir / preferred_name
591
+ if preferred.exists():
592
+ return preferred
593
+ legacy = candidate_dir / legacy_name
594
+ if legacy.exists():
595
+ return legacy
596
+ return preferred
597
+
598
+
599
+def load_optional_json(
600
+ path: Path,
601
+ *,
602
+ artifact_name: str,
603
+ errors: list[str],
604
+) -> dict[str, Any]:
605
+ if not path.exists():
606
+ errors.append(f"{artifact_name} missing: {path}")
607
+ return {}
608
+ try:
609
+ payload = json.loads(path.read_text(encoding="utf-8"))
610
+ except (json.JSONDecodeError, OSError) as exc:
611
+ errors.append(f"{artifact_name} unreadable: {path} ({exc})")
612
+ return {}
613
+ if not isinstance(payload, dict):
614
+ errors.append(f"{artifact_name} must be a JSON object: {path}")
615
+ return {}
616
+ return payload
617
+
618
+
619
+def normalize_repo_url(url: str) -> str | None:
620
+ prefix = "https://github.com/"
621
+ if not url.startswith(prefix):
622
+ return None
623
+ repo_path = url.removeprefix(prefix).split("?", 1)[0].split("#", 1)[0]
624
+ parts = [part for part in repo_path.split("/") if part]
625
+ if len(parts) < 2:
626
+ return None
627
+ return f"{parts[0]}/{parts[1]}"
628
+
629
+
630
+def compute_orphaned_citations(
631
+ *,
632
+ text: str,
633
+ editorial_plan: dict[str, Any],
634
+) -> int:
635
+ if not editorial_plan:
636
+ return len(MARKDOWN_LINK_PATTERN.findall(text))
637
+
638
+ selected_claims = (
639
+ editorial_plan.get("selected_claims", [])
640
+ if isinstance(editorial_plan.get("selected_claims"), list)
641
+ else []
642
+ )
643
+ key_references = (
644
+ editorial_plan.get("key_references", {})
645
+ if isinstance(editorial_plan.get("key_references"), dict)
646
+ else {}
647
+ )
648
+
649
+ allowed_repos = {
650
+ str(editorial_plan.get("top_repo", "")).strip(),
651
+ *(str(repo).strip() for repo in key_references.get("notable_projects", [])),
652
+ }
653
+ allowed_articles = {
654
+ str(url).strip() for url in key_references.get("press_articles", [])
655
+ }
656
+ for claim in selected_claims:
657
+ if not isinstance(claim, dict):
658
+ continue
659
+ bindings = (
660
+ claim.get("citation_bindings", {})
661
+ if isinstance(claim.get("citation_bindings"), dict)
662
+ else {}
663
+ )
664
+ allowed_repos.update(str(repo).strip() for repo in bindings.get("repos", []))
665
+ allowed_articles.update(
666
+ str(url).strip() for url in bindings.get("articles", [])
667
+ )
668
+
669
+ allowed_repos.discard("")
670
+ allowed_articles.discard("")
671
+
672
+ orphaned = 0
673
+ for url in MARKDOWN_LINK_PATTERN.findall(text):
674
+ repo_name = normalize_repo_url(url)
675
+ if repo_name is not None:
676
+ if repo_name not in allowed_repos:
677
+ orphaned += 1
678
+ continue
679
+ if url not in allowed_articles:
680
+ orphaned += 1
681
+ return orphaned
682
+
683
+
684
+def count_unresolved_contradictions(contradictions_sidecar: dict[str, Any]) -> int:
685
+ contradictions = (
686
+ contradictions_sidecar.get("contradictions", [])
687
+ if isinstance(contradictions_sidecar.get("contradictions"), list)
688
+ else []
689
+ )
690
+ return len(contradictions)
691
+
692
+
693
+def count_invalid_rejected_claims(
694
+ rejected_claims_sidecar: dict[str, Any],
695
+ *,
696
+ contradictions_sidecar: dict[str, Any],
697
+) -> int:
698
+ rejected_claims = (
699
+ rejected_claims_sidecar.get("rejected_claims", [])
700
+ if isinstance(rejected_claims_sidecar.get("rejected_claims"), list)
701
+ else []
702
+ )
703
+ contradiction_claim_ids = {
704
+ str(entry.get("claim_id"))
705
+ for entry in contradictions_sidecar.get("contradictions", [])
706
+ if isinstance(entry, dict) and entry.get("claim_id") is not None
707
+ }
708
+ invalid = 0
709
+ for entry in rejected_claims:
710
+ if not isinstance(entry, dict):
711
+ invalid += 1
712
+ continue
713
+ reason = str(entry.get("reason") or "").strip()
714
+ if reason not in VALID_REJECTION_REASONS:
715
+ invalid += 1
716
+ continue
717
+ if reason == "unresolved_contradiction":
718
+ claim_id = entry.get("claim_id")
719
+ if claim_id is None or str(claim_id) not in contradiction_claim_ids:
720
+ invalid += 1
721
+ return invalid
722
+
723
+
724
+def promotion_report_sort_key(report: dict[str, Any]) -> float:
725
+ run_datetime = report.get("run_datetime")
726
+ if isinstance(run_datetime, str):
727
+ try:
728
+ parsed = datetime.fromisoformat(run_datetime.replace("Z", "+00:00"))
729
+ except ValueError:
730
+ pass
731
+ else:
732
+ return parsed.astimezone(UTC).timestamp()
733
+
734
+ week = report.get("week")
735
+ if isinstance(week, str):
736
+ match = WEEK_PATTERN.fullmatch(week)
737
+ if match:
738
+ report_week = datetime.fromisocalendar(
739
+ int(match.group("year")),
740
+ int(match.group("week")),
741
+ 1,
742
+ )
743
+ return report_week.replace(tzinfo=UTC).timestamp()
744
+
745
+ return float("-inf")
746
+
747
+
748
+if __name__ == "__main__":
749
+ raise SystemExit(main())