parity_check.py 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. #!/usr/bin/env python3
  2. """Phase 3 KPI parity gate: compares legacy/*.html DATA blocks against
  3. data/119/members/<id>.json metrics blocks for 8 sample members."""
  4. import json
  5. import re
  6. import sys
  7. from pathlib import Path
  8. ROOT = Path("/home/user/polisci")
  9. LEGACY = ROOT / "legacy"
  10. MEMBERS = ROOT / "data" / "119" / "members"
  11. PAIRS = [
  12. ("ThomasMassie119.html", "M001184", "Thomas Massie"),
  13. ("RoKhanna119.html", "K000389", "Ro Khanna"),
  14. ("AlexandriaOcasioCortez119.html", "O000172", "Alexandria Ocasio-Cortez"),
  15. ("IlhanOmar119.html", "O000173", "Ilhan Omar"),
  16. ("MarjorieTaylorGreene119.html", "G000596", "Marjorie Taylor Greene"),
  17. ("JimJordan119.html", "J000289", "Jim Jordan"),
  18. ("ByronDonalds119.html", "D000032", "Byron Donalds"),
  19. ("LindseyGraham119.html", "S293", "Lindsey Graham"),
  20. ]
  21. SCALAR_KPIS = [
  22. "total", "voting", "yeas", "nays", "nv", "present",
  23. "blocked_dem_count", "blocked_rep_count",
  24. "voted_with_gop", "voted_against_gop",
  25. "voted_with_dem", "voted_against_dem",
  26. ]
  27. CANONICAL_ALIGN = ["Helped Republicans", "Helped Democrats", "Helped Both", "Helped Neither"]
  28. def extract_legacy(path):
  29. text = path.read_text(encoding="utf-8")
  30. m = re.search(r"const DATA\s*=\s*(\{.+?\});", text)
  31. if not m:
  32. raise RuntimeError(f"No DATA block in {path}")
  33. return json.loads(m.group(1))
  34. def cmp_member(legacy_file, mid, display):
  35. out_lines = []
  36. fails = []
  37. legacy_path = LEGACY / legacy_file
  38. new_path = MEMBERS / f"{mid}.json"
  39. legacy = extract_legacy(legacy_path)
  40. new_full = json.loads(new_path.read_text(encoding="utf-8"))
  41. new = new_full["metrics"]
  42. # scalar KPIs
  43. for k in SCALAR_KPIS:
  44. lv = legacy.get(k)
  45. nv = new.get(k)
  46. if lv != nv:
  47. fails.append(f" {k}: legacy={lv} new={nv} (delta {(nv if nv is not None else 0) - (lv if lv is not None else 0)})")
  48. # lone_wolf (only if legacy has it)
  49. if "lone_wolf" in legacy:
  50. if legacy["lone_wolf"] != new.get("lone_wolf"):
  51. fails.append(f" lone_wolf: legacy={legacy['lone_wolf']} new={new.get('lone_wolf')}")
  52. # alignment canonical 4
  53. la = legacy.get("alignment", {})
  54. na = new.get("alignment", {})
  55. for k in CANONICAL_ALIGN:
  56. lv, nv = la.get(k, 0), na.get(k, 0)
  57. if lv != nv:
  58. fails.append(f" alignment[{k}]: legacy={lv} new={nv} (delta {nv - lv})")
  59. # N/A sums
  60. la_na = sum(v for k, v in la.items() if k.startswith("N/A"))
  61. na_na = sum(v for k, v in na.items() if k.startswith("N/A"))
  62. if la_na != na_na:
  63. fails.append(f" alignment[N/A sum]: legacy={la_na} new={na_na} (delta {na_na - la_na})")
  64. # blocked
  65. lb = legacy.get("blocked", {})
  66. nb = new.get("blocked", {})
  67. for k in ("Democrat", "Republican"):
  68. if lb.get(k, 0) != nb.get(k, 0):
  69. fails.append(f" blocked[{k}]: legacy={lb.get(k,0)} new={nb.get(k,0)}")
  70. # monthly: align by month string
  71. l_months = legacy.get("months", [])
  72. n_months = new.get("months", [])
  73. l_monthly = legacy.get("monthly", {})
  74. n_monthly = new.get("monthly", {})
  75. all_months = sorted(set(l_months) | set(n_months))
  76. for cat in CANONICAL_ALIGN:
  77. l_arr = l_monthly.get(cat, [])
  78. n_arr = n_monthly.get(cat, [])
  79. l_map = dict(zip(l_months, l_arr))
  80. n_map = dict(zip(n_months, n_arr))
  81. for mo in all_months:
  82. lv = l_map.get(mo, 0)
  83. nv = n_map.get(mo, 0)
  84. if lv != nv:
  85. fails.append(f" monthly[{cat}][{mo}]: legacy={lv} new={nv} (delta {nv - lv})")
  86. # rows length must equal total
  87. l_rows = len(legacy.get("rows", []))
  88. n_rows = len(new.get("rows", []))
  89. if l_rows != legacy.get("total"):
  90. fails.append(f" legacy rows length {l_rows} != legacy total {legacy.get('total')}")
  91. if n_rows != new.get("total"):
  92. fails.append(f" new rows length {n_rows} != new total {new.get('total')}")
  93. if l_rows != n_rows:
  94. fails.append(f" rows length: legacy={l_rows} new={n_rows} (delta {n_rows - l_rows})")
  95. if not fails:
  96. out_lines.append(f"{mid} {display} — PASS (all {len(SCALAR_KPIS)}+1 scalar KPIs + alignment4 + blocked + monthly + rows)")
  97. return True, out_lines
  98. else:
  99. out_lines.append(f"{mid} {display} — FAIL")
  100. out_lines.extend(fails)
  101. return False, out_lines
  102. def main():
  103. report = []
  104. report.append("# Phase 3 — KPI Parity Gate Report")
  105. report.append("")
  106. report.append("Compared legacy/*.html DATA blocks vs data/119/members/<id>.json metrics.")
  107. report.append("")
  108. report.append("## Per-member results")
  109. report.append("")
  110. passes = 0
  111. for legacy_file, mid, display in PAIRS:
  112. ok, lines = cmp_member(legacy_file, mid, display)
  113. if ok:
  114. passes += 1
  115. report.extend(lines)
  116. report.append("")
  117. summary = f"Phase 3 gate: {passes}/{len(PAIRS)} members PASS"
  118. report.append("## Summary")
  119. report.append("")
  120. report.append(summary)
  121. report.append("")
  122. # MTG banner check
  123. mtg = json.loads((MEMBERS / "G000596.json").read_text(encoding="utf-8"))
  124. report.append("## MTG (G000596) banner check")
  125. report.append("")
  126. report.append(f"- served_partial: {mtg.get('served_partial')}")
  127. report.append(f"- metrics.total: {mtg['metrics'].get('total')}")
  128. report.append(f"- served_from: {mtg.get('served_from')}, served_to: {mtg.get('served_to')}")
  129. if mtg.get("served_partial") or mtg["metrics"].get("total") == 0:
  130. report.append("- Banner SHOULD trigger.")
  131. else:
  132. report.append("- No banner needed (full term, has votes).")
  133. report.append("")
  134. # deep-link in template/app.js
  135. appjs = (ROOT / "template" / "app.js").read_text(encoding="utf-8")
  136. has_pushstate = "pushState" in appjs
  137. has_popstate = "popstate" in appjs
  138. report.append("## Deep-link URL behavior (template/app.js)")
  139. report.append("")
  140. report.append(f"- pushState present: {has_pushstate}")
  141. report.append(f"- popstate present: {has_popstate}")
  142. report.append("")
  143. # CDN check
  144. import subprocess
  145. cdn = subprocess.run(
  146. ["grep", "-RE", r"cdn\.|cdnjs|jsdelivr|unpkg", str(ROOT / "template")],
  147. capture_output=True, text=True,
  148. )
  149. report.append("## CDN traffic check (template/)")
  150. report.append("")
  151. # Filter out comment lines inside vendored files (documentation references, not runtime URLs)
  152. real_hits = []
  153. for line in cdn.stdout.strip().splitlines():
  154. if "/vendor/" in line and ("More information" in line or "Do NOT use SRI" in line):
  155. continue # vendored chart.js header comment
  156. real_hits.append(line)
  157. if real_hits:
  158. report.append("- FAIL — CDN references found:")
  159. for line in real_hits:
  160. report.append(f" {line}")
  161. else:
  162. report.append("- PASS — no runtime CDN references (vendored-file documentation comments ignored).")
  163. if cdn.stdout.strip():
  164. report.append(" Note: documentation/comment-only mentions inside template/vendor/ were ignored:")
  165. for line in cdn.stdout.strip().splitlines():
  166. report.append(f" {line}")
  167. report.append("")
  168. # decision
  169. report.append("## Decision")
  170. report.append("")
  171. if passes == len(PAIRS):
  172. report.append("**GATE PASSED — safe to proceed to Phase 4.**")
  173. else:
  174. report.append("**GATE FAILED — investigate the delta(s) before proceeding.**")
  175. report.append("")
  176. text = "\n".join(report)
  177. print(text)
  178. (ROOT / "research" / "PHASE3_PARITY.md").write_text(text, encoding="utf-8")
  179. return 0 if passes == len(PAIRS) else 1
  180. if __name__ == "__main__":
  181. sys.exit(main())