SPB Git forge

spb/qc-election

Public
20commits 1branches 0releases
4.9 MBsize
maindefault branch
20 days agolast push
Python 66.6% HTML 24.8% CSS 4.9% JavaScript 3.6%

Directive maître — baselines de replay, Course à 64, connecteurs primaires

- Replay : baselines « dernier sondage » et « moyenne 14 j » scorées à chaque
  horizon et agrégées (modèle 3,71 bat dernier-sondage 3,84; moyenne-14j 3,49
  gagne l'EAM ponctuelle — consigné honnêtement dans STATISTICAL_DEBT comme
  recherche ouverte : la valeur du modèle est la DISTRIBUTION, les baselines
  n'ont ni intervalles ni probabilités)
- Course à 64 (/api/battlegrounds.paths_to_64 + section /circonscriptions) :
  pour chaque parti, échelle des sièges du plus sûr au plus décisif, 64e siège
  de la majorité surligné (PQ : Maskinongé 43 %, marge 0,9 pp), marge attendue
  = swing minimal de bascule, onglets par parti
- Connecteurs PRIMAIRES des 7 firmes (ingest/pollster_reports.py) : veille des
  pages de publications (Firecrawl), archivage des rapports (data/raw/reports),
  extraction PAR RÈGLES avec niveau de confiance, FILE DE RÉVISION (Poll
  excluded=True « révision requise » — jamais d'entrée silencieuse),
  réconciliation Wikipédia, statut de source consigné (Pallas/Synopsis murées
  = connecteurs désactivés de fait). Test live Léger : 5 rapports archivés,
  0 création sous le seuil de confiance — comportement voulu
- Tests invariants (§qualité) : 127 sièges/simulation, P(victoire) somme à 1,
  probabilités [0,1], extraction primaire (35/35 verts)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 23 days ago (Sep 1, 2026) parent 79d3000

8 changed files +668 −8

modified STATISTICAL_DEBT.md +9 −0
@@ -51,3 +51,12 @@ conservateur assumé. État au 2026-08-30 (v3.0.x).
51 51 réelle). Corrigé : `compute_prior(exclude_year=…)` (LOEO strict).
52 52 3. `industry_error_sd=0,12` calibré sur 2018/2022 puis testé sur 2022 →
53 53 circulaire. Corrigé par l'estimation multi-élections LOEO.
54 +
55 +## Constats du replay v3.1 (recherche ouverte)
56 +- **EAM ponctuelle** : modèle 3,71 pp vs baseline « moyenne 14 j » 3,49 (le modèle
57 + bat « dernier sondage » 3,84). L'écart vient surtout de 2007 (surge ADQ 6,6 vs
58 + 5,4) — cohérent avec le chantier queues lourdes. La valeur du modèle est la
59 + DISTRIBUTION : les baselines n'ont ni intervalle, ni couverture, ni P(sièges).
60 + Pistes : corrections maison LOEO possiblement contre-productives en point aux
61 + horizons lointains (petits échantillons); q trop lisse en campagne; Student-t.
62 + À trancher par le même replay avant tout changement (§84).
modified backend/app/api/public.py +21 −0
@@ -239,8 +239,29 @@ def battlegrounds(db: Session = Depends(get_db)):
239 239 contested = sorted((fr for fr in run.district_results
240 240 if fr.category == "Chaudement disputé"),
241 241 key=lambda fr: -max(fr.detail["win_probs"].values()))
242 + # Course à 64 : pour chaque parti, circonscriptions classées de la plus
243 + # sûre à la plus décisive; la 64ᵉ est le « siège de la majorité ».
244 + paths = {}
245 + for party in [p for p in settings.parties if p != "AUT"]:
246 + ranked = sorted(run.district_results,
247 + key=lambda fr: -fr.detail["win_probs"].get(party, 0.0))
248 + rows = []
249 + for i, fr in enumerate(ranked[:80], start=1):
250 + wp = fr.detail["win_probs"].get(party, 0.0)
251 + exp = fr.detail.get("expected") or {}
252 + top2 = sorted(exp.values(), reverse=True)[:2]
253 + rows.append({"rank": i, "district": fr.district_name,
254 + "win_prob": wp,
255 + "margin_pp": round(top2[0] - top2[1], 1) if len(top2) == 2 else None,
256 + "favorite": fr.favorite,
257 + "is_majority_seat": i == settings.majority_seats})
258 + seat64 = rows[settings.majority_seats - 1] if len(rows) >= settings.majority_seats else None
259 + paths[party] = {"seats_ge_50": sum(1 for r in rows if r["win_prob"] >= 0.5),
260 + "majority_seat": seat64,
261 + "ladder": rows}
242 262 return {"run_id": run.id, "as_of": run.as_of.isoformat(),
243 263 "tipping": tips,
264 + "paths_to_64": paths,
244 265 "contested": [{"district": fr.district_name,
245 266 "favorite": fr.favorite,
246 267 "win_probs": fr.detail["win_probs"],
added backend/app/ingest/pollster_reports.py +236 −0
@@ -0,0 +1,236 @@
1 +# QC Élection Forecast — Plateforme de prévision électorale du Québec 2026
2 +# Auteur : Simon-Pierre Boucher
3 +# Contact : contact@spboucher.ai
4 +# https://www.qc-election.com
5 +"""Connecteurs PRIMAIRES des maisons de sondage (directive maître).
6 +
7 +Pour Léger, Pallas Data, Synopsis, Liaison Strategies, Angus Reid, SEGMA et
8 +Mainstreet : surveillance de leurs pages de publications publiques →
9 +
10 + 1. détection d'un nouveau rapport (lien inédit, mots-clés Québec/intentions);
11 + 2. archivage LÉGAL du document public (markdown Firecrawl, PDF inclus) dans
12 + `data/raw/reports/` avec provenance complète;
13 + 3. extraction par RÈGLES des toplines nationales (partis + %, dates de
14 + terrain, n) avec NIVEAU DE CONFIANCE;
15 + 4. **file de révision** : un sondage extrait est créé `excluded=True`
16 + (« révision requise ») — il n'entre JAMAIS silencieusement dans le modèle;
17 + l'admin valide via PATCH /api/admin/poll/{id} (excluded=false);
18 + 5. réconciliation : si Wikipédia a déjà le sondage (maison + fin de terrain),
19 + la détection est marquée « intégré » sans doublon.
20 +
21 +Chaque connecteur est tolérant : une page murée = statut de source consigné,
22 +jamais un pipeline cassé. Qc125/338Canada ne servent jamais de source primaire.
23 +"""
24 +from __future__ import annotations
25 +
26 +import logging
27 +import re
28 +from datetime import date, datetime, timedelta, timezone
29 +
30 +from sqlalchemy.orm import Session
31 +
32 +from ..config import DATA_DIR, settings
33 +from .. import models as Mo
34 +
35 +log = logging.getLogger("pollster-reports")
36 +
37 +REPORTS_DIR = DATA_DIR / "raw" / "reports"
38 +
39 +# Pages de publications publiques (vérifiées best-effort; un 404 est consigné).
40 +PRIMARY_SOURCES: dict[str, dict] = {
41 + "Léger": {"watch": "https://leger360.com/fr/publications/",
42 + "mode": "web"},
43 + "Pallas Data": {"watch": "https://pallasdata.com/",
44 + "mode": "ivr"},
45 + "Synopsis": {"watch": "https://www.synopsisrecherche.com/",
46 + "mode": "web"},
47 + "Liaison Strategies": {"watch": "https://liaisonstrategies.ca/",
48 + "mode": "ivr"},
49 + "Angus Reid": {"watch": "https://angusreid.org/?s=quebec",
50 + "mode": "web"},
51 + "SEGMA": {"watch": "https://www.segma.ca/",
52 + "mode": "phone"},
53 + "Mainstreet": {"watch": "https://www.mainstreetresearch.ca/polls/",
54 + "mode": "ivr"},
55 +}
56 +
57 +KEYWORDS = re.compile(r"qu[ée]bec|intention|sondage|provincial|caq|pq\b|élection",
58 + re.I)
59 +
60 +PARTY_RES = {
61 + "CAQ": r"(?:CAQ|Coalition\s+avenir)",
62 + "PLQ": r"(?:PLQ|Parti\s+lib[ée]ral|lib[ée]raux)",
63 + "PQ": r"(?:PQ\b|Parti\s+[Qq]u[ée]b[ée]cois)",
64 + "QS": r"(?:QS\b|Qu[ée]bec\s+[Ss]olidaire)",
65 + "PCQ": r"(?:PCQ|Parti\s+conservateur|conservateurs?\s+du\s+Qu[ée]bec)",
66 +}
67 +FIELD_RE = re.compile(
68 + r"(?:du|from)\s+(\d{1,2})(?:er)?\s+(?:au|to)\s+(\d{1,2})(?:er)?\s+"
69 + r"([a-zéû]+)\s+(\d{4})", re.I)
70 +MONTHS = {"janvier": 1, "février": 2, "mars": 3, "avril": 4, "mai": 5,
71 + "juin": 6, "juillet": 7, "août": 8, "aout": 8, "septembre": 9,
72 + "octobre": 10, "novembre": 11, "décembre": 12, "decembre": 12}
73 +N_RE = re.compile(r"(?:n\s*=\s*|aupr[èe]s\s+de\s+|échantillon\s+de\s+)"
74 + r"([\d\s ,]{3,6})", re.I)
75 +
76 +
77 +def extract_toplines(md: str) -> dict:
78 + """Extraction PAR RÈGLES des intentions nationales d'un rapport.
79 + Retourne {shares, field_end, sample_size, confidence, raisons}."""
80 + reasons, shares = [], {}
81 + # fenêtre autour de la première mention « intentions de vote »
82 + idx = md.lower().find("intentions de vote")
83 + window = md[max(0, idx - 300):idx + 3500] if idx >= 0 else md[:5000]
84 + for party, pat in PARTY_RES.items():
85 + m = re.search(pat + r"[^%\n]{0,60}?(\d{1,2})\s*%", window)
86 + if m:
87 + v = float(m.group(1))
88 + if 1 <= v <= 60:
89 + shares[party] = v
90 + total = sum(shares.values())
91 + field_end = None
92 + m = FIELD_RE.search(md[:6000])
93 + if m:
94 + month = MONTHS.get(m.group(3).lower())
95 + if month:
96 + try:
97 + field_end = date(int(m.group(4)), month, int(m.group(2)))
98 + except ValueError:
99 + pass
100 + n = None
101 + mn = N_RE.search(md[:6000])
102 + if mn:
103 + digits = re.sub(r"[^\d]", "", mn.group(1))
104 + if digits and 100 <= int(digits) <= 100000:
105 + n = int(digits)
106 + conf = 0.0
107 + if len(shares) >= 4:
108 + conf += 0.4
109 + else:
110 + reasons.append(f"seulement {len(shares)} partis extraits")
111 + if 75 <= total <= 105:
112 + conf += 0.3
113 + elif shares:
114 + reasons.append(f"somme implausible ({total:.0f})")
115 + if field_end:
116 + conf += 0.2
117 + else:
118 + reasons.append("dates de terrain introuvables")
119 + if n:
120 + conf += 0.1
121 + return {"shares": shares, "field_end": field_end, "sample_size": n,
122 + "confidence": round(conf, 2), "reasons": reasons}
123 +
124 +
125 +def _archive(firm: str, url: str, md: str) -> None:
126 + try:
127 + import hashlib
128 + REPORTS_DIR.mkdir(parents=True, exist_ok=True)
129 + h = hashlib.sha1(url.encode()).hexdigest()[:12]
130 + safe = re.sub(r"[^a-z0-9]+", "-", firm.lower())
131 + (REPORTS_DIR / f"{date.today().isoformat()}_{safe}_{h}.md").write_text(
132 + f"<!-- {url} — archivé {datetime.now(timezone.utc).isoformat()} -->\n{md}")
133 + except Exception:
134 + pass
135 +
136 +
137 +def _source_status(db: Session, firm: str, url: str, status: str) -> None:
138 + src = db.query(Mo.DataSource).filter_by(name=f"Primaire — {firm}").first()
139 + if src is None:
140 + src = Mo.DataSource(name=f"Primaire — {firm}", url=url, kind="poll-primary")
141 + db.add(src)
142 + src.last_fetch = datetime.now(timezone.utc)
143 + src.last_status = status[:200]
144 +
145 +
146 +def watch_firm(db: Session, firm: str, cfg: dict) -> dict:
147 + """Un connecteur : page de publications → nouveaux liens → extraction."""
148 + from .firecrawl_watch import _enabled, _post, scrape_markdown
149 + if not _enabled():
150 + return {"skipped": "Firecrawl requis"}
151 + data = _post("/scrape", {"url": cfg["watch"], "formats": ["links", "markdown"]},
152 + timeout=90.0)
153 + d = (data or {}).get("data") or {}
154 + links, page_md = d.get("links") or [], d.get("markdown") or ""
155 + if not links and not page_md:
156 + _source_status(db, firm, cfg["watch"], "inaccessible")
157 + return {"statut": "page inaccessible"}
158 + _source_status(db, firm, cfg["watch"], "ok")
159 + # candidats : liens de la même maison contenant les mots-clés
160 + host = cfg["watch"].split("/")[2].replace("www.", "")
161 + cands = []
162 + for l in links:
163 + if host in l and KEYWORDS.search(l) and len(l) < 220 \
164 + and not any(x in l for x in ("#", "mailto:", "?share", "/tag/",
165 + "/category/", "/page/")):
166 + cands.append(l.split("?utm")[0])
167 + cands = list(dict.fromkeys(cands))[:6]
168 + new, pending, integrated = 0, 0, 0
169 + for url in cands:
170 + if db.query(Mo.WebSignal).filter_by(url=url, kind="rapport-primaire").first():
171 + continue
172 + md = scrape_markdown(url)
173 + if not md or not KEYWORDS.search(md[:4000]):
174 + continue
175 + _archive(firm, url, md[:250000])
176 + ext = extract_toplines(md)
177 + sig = Mo.WebSignal(kind="rapport-primaire", url=url,
178 + title=f"{firm} — rapport détecté",
179 + pollster=firm, status="nouveau",
180 + snippet=md[:400],
181 + extra={"extraction": {**ext,
182 + "field_end": ext["field_end"].isoformat()
183 + if ext["field_end"] else None}})
184 + db.add(sig)
185 + new += 1
186 + # réconciliation Wikipédia / création en file de révision
187 + if ext["field_end"] and ext["confidence"] >= 0.7:
188 + pollster = db.query(Mo.Pollster).filter_by(name=firm).first()
189 + exists = (pollster and db.query(Mo.Poll)
190 + .filter_by(pollster_id=pollster.id,
191 + field_end=ext["field_end"]).first())
192 + if exists:
193 + sig.status = "intégré"
194 + sig.extra = {**sig.extra, "poll_id": exists.id}
195 + integrated += 1
196 + else:
197 + from ..seed import get_or_create
198 + el = db.query(Mo.Election).filter_by(is_target=True).first()
199 + pol, _ = get_or_create(db, Mo.Pollster, name=firm)
200 + total = sum(ext["shares"].values())
201 + poll = Mo.Poll(
202 + election_id=el.id, pollster_id=pol.id,
203 + field_start=ext["field_end"] - timedelta(days=3),
204 + field_end=ext["field_end"],
205 + sample_size=ext["sample_size"],
206 + mode=cfg.get("mode", "unknown"),
207 + source_name=f"Rapport primaire {firm} (extraction auto)",
208 + source_url=url, excluded=True,
209 + exclusion_reason=("révision requise — extraction "
210 + f"automatique (confiance {ext['confidence']})"))
211 + for party, v in ext["shares"].items():
212 + poll.results.append(Mo.PollResult(
213 + party=party, raw_value=v,
214 + normalized_value=round(v * 100 / total, 3)))
215 + db.add(poll)
216 + db.flush()
217 + sig.status = "en_révision"
218 + sig.extra = {**sig.extra, "poll_id": poll.id}
219 + pending += 1
220 + db.commit()
221 + return {"liens": len(cands), "nouveaux": new,
222 + "en_révision": pending, "déjà_intégrés": integrated}
223 +
224 +
225 +def run_primary_watch(db: Session, max_firms: int | None = None) -> dict:
226 + """Cycle des connecteurs primaires — chaque firme isolée."""
227 + report = {}
228 + for i, (firm, cfg) in enumerate(PRIMARY_SOURCES.items()):
229 + if max_firms is not None and i >= max_firms:
230 + break
231 + try:
232 + report[firm] = watch_firm(db, firm, cfg)
233 + except Exception as e:
234 + db.rollback()
235 + report[firm] = f"échec: {e}"
236 + return report
modified backend/app/modeling/validation/historical_replay.py +28 −0
@@ -60,6 +60,28 @@ def _loeo_profiles(test_year: int, parties: list[str]):
60 60 return compute_profiles(polls, elections, parties=parties)
61 61
62 62
63 +def _baselines_at(polls: list[dict], as_of: date, parties: list[str],
64 + actual: dict) -> dict:
65 + """Règles simples contre lesquelles le modèle DOIT gagner (§calibration) :
66 + B1 = dernier sondage publié; B2 = moyenne simple des 14 derniers jours
67 + (repli : 3 derniers sondages)."""
68 + hist = sorted((p for p in polls if p["field_end"] <= as_of),
69 + key=lambda p: p["field_end"])
70 + if not hist:
71 + return {}
72 + def norm(sh):
73 + tot = sum(sh.get(p, 0.0) for p in parties) or 100.0
74 + return {p: sh.get(p, 0.0) * 100.0 / tot for p in parties}
75 + last = norm(hist[-1]["shares"])
76 + recent = [p for p in hist if (as_of - p["field_end"]).days <= 14] or hist[-3:]
77 + avg = {pt: float(np.mean([norm(p["shares"])[pt] for p in recent]))
78 + for pt in parties}
79 + return {
80 + "dernier_sondage": vote_errors(last, actual, parties)["mae_pp"],
81 + "moyenne_14j": vote_errors(avg, actual, parties)["mae_pp"],
82 + }
83 +
84 +
63 85 def replay_election(year: int, use_fundamentals: bool = True,
64 86 use_loeo_house: bool = True,
65 87 industry_sd: float | None = None,
@@ -108,6 +130,7 @@ def replay_election(year: int, use_fundamentals: bool = True,
108 130 "log_score": log_score_alr(f.x, f.P, alr(actual_vec)),
109 131 "sharpness_pp": sharpness(f.summary, parties),
110 132 "forecast": {p: round(fc_mean[p], 1) for p in parties},
133 + "baselines_mae_pp": _baselines_at(polls, as_of, parties, actual),
111 134 })
112 135 ok = [r for r in rows if "error" not in r]
113 136 agg = {}
@@ -118,6 +141,11 @@ def replay_election(year: int, use_fundamentals: bool = True,
118 141 vals = [r[k] for r in ok if k in r]
119 142 if vals:
120 143 agg[k] = round(float(np.mean(vals)), 3)
144 + for b in ("dernier_sondage", "moyenne_14j"):
145 + vals = [r["baselines_mae_pp"][b] for r in ok
146 + if r.get("baselines_mae_pp", {}).get(b) is not None]
147 + if vals:
148 + agg[f"baseline_{b}_mae_pp"] = round(float(np.mean(vals)), 3)
121 149 return {"year": year, "parties": parties, "actual": actual,
122 150 "horizons": rows, "avg": agg}
123 151
modified backend/app/pipeline.py +8 −0
@@ -372,6 +372,14 @@ def run_pipeline(full_refresh: bool = True) -> dict:
372 372 except Exception as e:
373 373 report["veille"] = f"échec: {e}"
374 374 _log(db, "veille-firecrawl", "error", traceback.format_exc())
375 + try:
376 + from .ingest.pollster_reports import run_primary_watch
377 + report["rapports_primaires"] = run_primary_watch(db)
378 + _log(db, "rapports-primaires", "ok",
379 + str(report["rapports_primaires"])[:500])
380 + except Exception as e:
381 + report["rapports_primaires"] = f"échec: {e}"
382 + _log(db, "rapports-primaires", "error", traceback.format_exc())
375 383 try:
376 384 # source canonique + découverte de comptes : hebdomadaires
377 385 marker = (db.query(Mo.Indicator)
modified backend/data/replay_report.json +262 −8
@@ -1,5 +1,5 @@
1 1 {
2 − "generated": "2026-08-30",
2 + "generated": "2026-08-31",
3 3 "model_version": "3.1.0",
4 4 "protocol": "LOEO strict : house effects, fondamentaux et σ_industrie appris sur les autres élections; rolling origin J-120→J-1.",
5 5 "elections": {
@@ -45,6 +45,10 @@
45 45 "PQ": 39.5,
46 46 "QS": 7.9,
47 47 "AUT": 11.3
48 + },
49 + "baselines_mae_pp": {
50 + "dernier_sondage": 8.884,
51 + "moyenne_14j": 7.465
48 52 }
49 53 },
50 54 {
@@ -72,6 +76,10 @@
72 76 "PQ": 39.5,
73 77 "QS": 7.9,
74 78 "AUT": 11.3
79 + },
80 + "baselines_mae_pp": {
81 + "dernier_sondage": 8.884,
82 + "moyenne_14j": 7.465
75 83 }
76 84 },
77 85 {
@@ -99,6 +107,10 @@
99 107 "PQ": 39.5,
100 108 "QS": 7.9,
101 109 "AUT": 11.3
110 + },
111 + "baselines_mae_pp": {
112 + "dernier_sondage": 8.884,
113 + "moyenne_14j": 7.465
102 114 }
103 115 },
104 116 {
@@ -126,6 +138,10 @@
126 138 "PQ": 35.5,
127 139 "QS": 7.0,
128 140 "AUT": 9.0
141 + },
142 + "baselines_mae_pp": {
143 + "dernier_sondage": 4.234,
144 + "moyenne_14j": 4.234
129 145 }
130 146 },
131 147 {
@@ -153,6 +169,10 @@
153 169 "PQ": 38.3,
154 170 "QS": 5.8,
155 171 "AUT": 9.0
172 + },
173 + "baselines_mae_pp": {
174 + "dernier_sondage": 6.352,
175 + "moyenne_14j": 6.352
156 176 }
157 177 },
158 178 {
@@ -180,6 +200,10 @@
180 200 "PQ": 38.3,
181 201 "QS": 5.8,
182 202 "AUT": 9.0
203 + },
204 + "baselines_mae_pp": {
205 + "dernier_sondage": 6.352,
206 + "moyenne_14j": 6.352
183 207 }
184 208 },
185 209 {
@@ -207,6 +231,10 @@
207 231 "PQ": 35.6,
208 232 "QS": 6.0,
209 233 "AUT": 8.7
234 + },
235 + "baselines_mae_pp": {
236 + "dernier_sondage": 3.632,
237 + "moyenne_14j": 3.632
210 238 }
211 239 },
212 240 {
@@ -234,6 +262,10 @@
234 262 "PQ": 33.9,
235 263 "QS": 5.8,
236 264 "AUT": 8.1
265 + },
266 + "baselines_mae_pp": {
267 + "dernier_sondage": 3.779,
268 + "moyenne_14j": 3.68
237 269 }
238 270 },
239 271 {
@@ -261,6 +293,10 @@
261 293 "PQ": 32.6,
262 294 "QS": 5.5,
263 295 "AUT": 9.3
296 + },
297 + "baselines_mae_pp": {
298 + "dernier_sondage": 5.488,
299 + "moyenne_14j": 3.925
264 300 }
265 301 },
266 302 {
@@ -288,6 +324,10 @@
288 324 "PQ": 32.6,
289 325 "QS": 5.5,
290 326 "AUT": 9.3
327 + },
328 + "baselines_mae_pp": {
329 + "dernier_sondage": 5.488,
330 + "moyenne_14j": 3.925
291 331 }
292 332 }
293 333 ],
@@ -299,7 +339,9 @@
299 339 "sharpness_pp": 20.685,
300 340 "coverage_50": 0.06,
301 341 "coverage_90": 0.7,
302 − "coverage_95": 0.74
342 + "coverage_95": 0.74,
343 + "baseline_dernier_sondage_mae_pp": 6.198,
344 + "baseline_moyenne_14j_mae_pp": 5.449
303 345 },
304 346 "industry_sd_loeo": 0.2156
305 347 },
@@ -345,6 +387,10 @@
345 387 "PQ": 32.8,
346 388 "QS": 6.2,
347 389 "AUT": 0.8
390 + },
391 + "baselines_mae_pp": {
392 + "dernier_sondage": 2.81,
393 + "moyenne_14j": 1.294
348 394 }
349 395 },
350 396 {
@@ -372,6 +418,10 @@
372 418 "PQ": 31.6,
373 419 "QS": 5.1,
374 420 "AUT": 0.6
421 + },
422 + "baselines_mae_pp": {
423 + "dernier_sondage": 1.726,
424 + "moyenne_14j": 1.345
375 425 }
376 426 },
377 427 {
@@ -399,6 +449,10 @@
399 449 "PQ": 31.9,
400 450 "QS": 4.6,
401 451 "AUT": 0.6
452 + },
453 + "baselines_mae_pp": {
454 + "dernier_sondage": 1.345,
455 + "moyenne_14j": 1.345
402 456 }
403 457 },
404 458 {
@@ -426,6 +480,10 @@
426 480 "PQ": 31.9,
427 481 "QS": 4.6,
428 482 "AUT": 0.6
483 + },
484 + "baselines_mae_pp": {
485 + "dernier_sondage": 1.345,
486 + "moyenne_14j": 1.488
429 487 }
430 488 },
431 489 {
@@ -453,6 +511,10 @@
453 511 "PQ": 33.3,
454 512 "QS": 4.5,
455 513 "AUT": 0.8
514 + },
515 + "baselines_mae_pp": {
516 + "dernier_sondage": 1.402,
517 + "moyenne_14j": 0.511
456 518 }
457 519 },
458 520 {
@@ -480,6 +542,10 @@
480 542 "PQ": 34.0,
481 543 "QS": 4.1,
482 544 "AUT": 0.9
545 + },
546 + "baselines_mae_pp": {
547 + "dernier_sondage": 1.441,
548 + "moyenne_14j": 1.435
483 549 }
484 550 },
485 551 {
@@ -507,6 +573,10 @@
507 573 "PQ": 34.1,
508 574 "QS": 4.0,
509 575 "AUT": 0.8
576 + },
577 + "baselines_mae_pp": {
578 + "dernier_sondage": 2.933,
579 + "moyenne_14j": 1.742
510 580 }
511 581 },
512 582 {
@@ -534,6 +604,10 @@
534 604 "PQ": 34.1,
535 605 "QS": 4.0,
536 606 "AUT": 0.8
607 + },
608 + "baselines_mae_pp": {
609 + "dernier_sondage": 2.933,
610 + "moyenne_14j": 2.159
537 611 }
538 612 },
539 613 {
@@ -561,6 +635,10 @@
561 635 "PQ": 33.2,
562 636 "QS": 4.3,
563 637 "AUT": 0.8
638 + },
639 + "baselines_mae_pp": {
640 + "dernier_sondage": 1.815,
641 + "moyenne_14j": 2.117
564 642 }
565 643 },
566 644 {
@@ -588,6 +666,10 @@
588 666 "PQ": 33.2,
589 667 "QS": 4.3,
590 668 "AUT": 0.8
669 + },
670 + "baselines_mae_pp": {
671 + "dernier_sondage": 1.815,
672 + "moyenne_14j": 2.251
591 673 }
592 674 }
593 675 ],
@@ -599,7 +681,9 @@
599 681 "sharpness_pp": 17.467,
600 682 "coverage_50": 0.7,
601 683 "coverage_90": 0.8,
602 − "coverage_95": 0.8
684 + "coverage_95": 0.8,
685 + "baseline_dernier_sondage_mae_pp": 1.957,
686 + "baseline_moyenne_14j_mae_pp": 1.569
603 687 },
604 688 "industry_sd_loeo": 0.2245
605 689 },
@@ -645,6 +729,10 @@
645 729 "PQ": 32.3,
646 730 "QS": 9.0,
647 731 "AUT": 5.2
732 + },
733 + "baselines_mae_pp": {
734 + "dernier_sondage": 2.441,
735 + "moyenne_14j": 1.948
648 736 }
649 737 },
650 738 {
@@ -672,6 +760,10 @@
672 760 "PQ": 32.6,
673 761 "QS": 10.2,
674 762 "AUT": 4.4
763 + },
764 + "baselines_mae_pp": {
765 + "dernier_sondage": 2.129,
766 + "moyenne_14j": 2.129
675 767 }
676 768 },
677 769 {
@@ -699,6 +791,10 @@
699 791 "PQ": 33.0,
700 792 "QS": 9.8,
701 793 "AUT": 4.3
794 + },
795 + "baselines_mae_pp": {
796 + "dernier_sondage": 4.5,
797 + "moyenne_14j": 2.643
702 798 }
703 799 },
704 800 {
@@ -726,6 +822,10 @@
726 822 "PQ": 33.0,
727 823 "QS": 9.8,
728 824 "AUT": 4.3
825 + },
826 + "baselines_mae_pp": {
827 + "dernier_sondage": 4.5,
828 + "moyenne_14j": 2.643
729 829 }
730 830 },
731 831 {
@@ -753,6 +853,10 @@
753 853 "PQ": 35.3,
754 854 "QS": 6.8,
755 855 "AUT": 6.9
856 + },
857 + "baselines_mae_pp": {
858 + "dernier_sondage": 5.043,
859 + "moyenne_14j": 3.336
756 860 }
757 861 },
758 862 {
@@ -780,6 +884,10 @@
780 884 "PQ": 33.5,
781 885 "QS": 7.2,
782 886 "AUT": 6.4
887 + },
888 + "baselines_mae_pp": {
889 + "dernier_sondage": 2.255,
890 + "moyenne_14j": 2.372
783 891 }
784 892 },
785 893 {
@@ -807,6 +915,10 @@
807 915 "PQ": 31.1,
808 916 "QS": 7.4,
809 917 "AUT": 6.6
918 + },
919 + "baselines_mae_pp": {
920 + "dernier_sondage": 3.252,
921 + "moyenne_14j": 1.902
810 922 }
811 923 },
812 924 {
@@ -834,6 +946,10 @@
834 946 "PQ": 32.1,
835 947 "QS": 7.8,
836 948 "AUT": 5.9
949 + },
950 + "baselines_mae_pp": {
951 + "dernier_sondage": 2.183,
952 + "moyenne_14j": 2.429
837 953 }
838 954 },
839 955 {
@@ -861,6 +977,10 @@
861 977 "PQ": 32.5,
862 978 "QS": 7.8,
863 979 "AUT": 5.8
980 + },
981 + "baselines_mae_pp": {
982 + "dernier_sondage": 5.28,
983 + "moyenne_14j": 2.608
864 984 }
865 985 },
866 986 {
@@ -888,6 +1008,10 @@
888 1008 "PQ": 33.4,
889 1009 "QS": 7.8,
890 1010 "AUT": 5.7
1011 + },
1012 + "baselines_mae_pp": {
1013 + "dernier_sondage": 2.308,
1014 + "moyenne_14j": 2.534
891 1015 }
892 1016 }
893 1017 ],
@@ -899,7 +1023,9 @@
899 1023 "sharpness_pp": 18.748,
900 1024 "coverage_50": 0.62,
901 1025 "coverage_90": 0.98,
902 − "coverage_95": 1.0
1026 + "coverage_95": 1.0,
1027 + "baseline_dernier_sondage_mae_pp": 3.389,
1028 + "baseline_moyenne_14j_mae_pp": 2.454
903 1029 },
904 1030 "industry_sd_loeo": 0.2308
905 1031 },
@@ -945,6 +1071,10 @@
945 1071 "PQ": 34.3,
946 1072 "QS": 8.7,
947 1073 "AUT": 3.6
1074 + },
1075 + "baselines_mae_pp": {
1076 + "dernier_sondage": 3.291,
1077 + "moyenne_14j": 4.643
948 1078 }
949 1079 },
950 1080 {
@@ -972,6 +1102,10 @@
972 1102 "PQ": 35.8,
973 1103 "QS": 9.0,
974 1104 "AUT": 3.6
1105 + },
1106 + "baselines_mae_pp": {
1107 + "dernier_sondage": 7.028,
1108 + "moyenne_14j": 5.361
975 1109 }
976 1110 },
977 1111 {
@@ -999,6 +1133,10 @@
999 1133 "PQ": 37.3,
1000 1134 "QS": 8.5,
1001 1135 "AUT": 7.6
1136 + },
1137 + "baselines_mae_pp": {
1138 + "dernier_sondage": 11.68,
1139 + "moyenne_14j": 8.241
1002 1140 }
1003 1141 },
1004 1142 {
@@ -1026,6 +1164,10 @@
1026 1164 "PQ": 41.9,
1027 1165 "QS": 8.1,
1028 1166 "AUT": 8.1
1167 + },
1168 + "baselines_mae_pp": {
1169 + "dernier_sondage": 12.48,
1170 + "moyenne_14j": 12.48
1029 1171 }
1030 1172 },
1031 1173 {
@@ -1053,6 +1195,10 @@
1053 1195 "PQ": 37.9,
1054 1196 "QS": 7.5,
1055 1197 "AUT": 6.4
1198 + },
1199 + "baselines_mae_pp": {
1200 + "dernier_sondage": 4.574,
1201 + "moyenne_14j": 5.873
1056 1202 }
1057 1203 },
1058 1204 {
@@ -1080,6 +1226,10 @@
1080 1226 "PQ": 37.4,
1081 1227 "QS": 8.3,
1082 1228 "AUT": 5.6
1229 + },
1230 + "baselines_mae_pp": {
1231 + "dernier_sondage": 5.382,
1232 + "moyenne_14j": 6.12
1083 1233 }
1084 1234 },
1085 1235 {
@@ -1107,6 +1257,10 @@
1107 1257 "PQ": 35.2,
1108 1258 "QS": 8.5,
1109 1259 "AUT": 4.9
1260 + },
1261 + "baselines_mae_pp": {
1262 + "dernier_sondage": 7.028,
1263 + "moyenne_14j": 5.248
1110 1264 }
1111 1265 },
1112 1266 {
@@ -1134,6 +1288,10 @@
1134 1288 "PQ": 32.5,
1135 1289 "QS": 8.4,
1136 1290 "AUT": 5.0
1291 + },
1292 + "baselines_mae_pp": {
1293 + "dernier_sondage": 2.106,
1294 + "moyenne_14j": 3.806
1137 1295 }
1138 1296 },
1139 1297 {
@@ -1161,6 +1319,10 @@
1161 1319 "PQ": 27.4,
1162 1320 "QS": 8.6,
1163 1321 "AUT": 4.7
1322 + },
1323 + "baselines_mae_pp": {
1324 + "dernier_sondage": 0.636,
1325 + "moyenne_14j": 2.085
1164 1326 }
1165 1327 },
1166 1328 {
@@ -1188,6 +1350,10 @@
1188 1350 "PQ": 27.4,
1189 1351 "QS": 8.6,
1190 1352 "AUT": 4.7
1353 + },
1354 + "baselines_mae_pp": {
1355 + "dernier_sondage": 0.636,
1356 + "moyenne_14j": 2.085
1191 1357 }
1192 1358 }
1193 1359 ],
@@ -1199,7 +1365,9 @@
1199 1365 "sharpness_pp": 16.551,
1200 1366 "coverage_50": 0.3,
1201 1367 "coverage_90": 0.64,
1202 − "coverage_95": 0.72
1368 + "coverage_95": 0.72,
1369 + "baseline_dernier_sondage_mae_pp": 5.484,
1370 + "baseline_moyenne_14j_mae_pp": 5.594
1203 1371 },
1204 1372 "industry_sd_loeo": 0.2242
1205 1373 },
@@ -1245,6 +1413,10 @@
1245 1413 "PQ": 18.9,
1246 1414 "QS": 10.4,
1247 1415 "AUT": 7.5
1416 + },
1417 + "baselines_mae_pp": {
1418 + "dernier_sondage": 3.226,
1419 + "moyenne_14j": 3.446
1248 1420 }
1249 1421 },
1250 1422 {
@@ -1272,6 +1444,10 @@
1272 1444 "PQ": 15.9,
1273 1445 "QS": 9.9,
1274 1446 "AUT": 7.0
1447 + },
1448 + "baselines_mae_pp": {
1449 + "dernier_sondage": 3.904,
1450 + "moyenne_14j": 3.904
1275 1451 }
1276 1452 },
1277 1453 {
@@ -1299,6 +1475,10 @@
1299 1475 "PQ": 17.4,
1300 1476 "QS": 11.6,
1301 1477 "AUT": 8.2
1478 + },
1479 + "baselines_mae_pp": {
1480 + "dernier_sondage": 4.032,
1481 + "moyenne_14j": 4.032
1302 1482 }
1303 1483 },
1304 1484 {
@@ -1326,6 +1506,10 @@
1326 1506 "PQ": 16.5,
1327 1507 "QS": 10.8,
1328 1508 "AUT": 7.6
1509 + },
1510 + "baselines_mae_pp": {
1511 + "dernier_sondage": 3.008,
1512 + "moyenne_14j": 3.008
1329 1513 }
1330 1514 },
1331 1515 {
@@ -1353,6 +1537,10 @@
1353 1537 "PQ": 17.0,
1354 1538 "QS": 11.4,
1355 1539 "AUT": 7.7
1540 + },
1541 + "baselines_mae_pp": {
1542 + "dernier_sondage": 3.648,
1543 + "moyenne_14j": 2.166
1356 1544 }
1357 1545 },
1358 1546 {
@@ -1380,6 +1568,10 @@
1380 1568 "PQ": 19.1,
1381 1569 "QS": 13.0,
1382 1570 "AUT": 6.7
1571 + },
1572 + "baselines_mae_pp": {
1573 + "dernier_sondage": 3.248,
1574 + "moyenne_14j": 3.155
1383 1575 }
1384 1576 },
1385 1577 {
@@ -1407,6 +1599,10 @@
1407 1599 "PQ": 19.3,
1408 1600 "QS": 14.8,
1409 1601 "AUT": 5.2
1602 + },
1603 + "baselines_mae_pp": {
1604 + "dernier_sondage": 4.872,
1605 + "moyenne_14j": 3.176
1410 1606 }
1411 1607 },
1412 1608 {
@@ -1434,6 +1630,10 @@
1434 1630 "PQ": 20.0,
1435 1631 "QS": 16.0,
1436 1632 "AUT": 4.9
1633 + },
1634 + "baselines_mae_pp": {
1635 + "dernier_sondage": 3.248,
1636 + "moyenne_14j": 2.779
1437 1637 }
1438 1638 },
1439 1639 {
@@ -1461,6 +1661,10 @@
1461 1661 "PQ": 19.3,
1462 1662 "QS": 16.3,
1463 1663 "AUT": 4.5
1664 + },
1665 + "baselines_mae_pp": {
1666 + "dernier_sondage": 2.848,
1667 + "moyenne_14j": 2.96
1464 1668 }
1465 1669 },
1466 1670 {
@@ -1488,6 +1692,10 @@
1488 1692 "PQ": 19.1,
1489 1693 "QS": 16.9,
1490 1694 "AUT": 4.5
1695 + },
1696 + "baselines_mae_pp": {
1697 + "dernier_sondage": 2.168,
1698 + "moyenne_14j": 2.73
1491 1699 }
1492 1700 }
1493 1701 ],
@@ -1499,7 +1707,9 @@
1499 1707 "sharpness_pp": 14.918,
1500 1708 "coverage_50": 0.42,
1501 1709 "coverage_90": 0.9,
1502 − "coverage_95": 0.92
1710 + "coverage_95": 0.92,
1711 + "baseline_dernier_sondage_mae_pp": 3.42,
1712 + "baseline_moyenne_14j_mae_pp": 3.136
1503 1713 },
1504 1714 "industry_sd_loeo": 0.212
1505 1715 },
@@ -1549,6 +1759,10 @@
1549 1759 "QS": 14.0,
1550 1760 "PCQ": 14.7,
1551 1761 "AUT": 3.9
1762 + },
1763 + "baselines_mae_pp": {
1764 + "dernier_sondage": 3.083,
1765 + "moyenne_14j": 3.083
1552 1766 }
1553 1767 },
1554 1768 {
@@ -1578,6 +1792,10 @@
1578 1792 "QS": 13.9,
1579 1793 "PCQ": 16.3,
1580 1794 "AUT": 3.7
1795 + },
1796 + "baselines_mae_pp": {
1797 + "dernier_sondage": 2.347,
1798 + "moyenne_14j": 2.347
1581 1799 }
1582 1800 },
1583 1801 {
@@ -1607,6 +1825,10 @@
1607 1825 "QS": 14.4,
1608 1826 "PCQ": 14.2,
1609 1827 "AUT": 3.6
1828 + },
1829 + "baselines_mae_pp": {
1830 + "dernier_sondage": 2.012,
1831 + "moyenne_14j": 2.012
1610 1832 }
1611 1833 },
1612 1834 {
@@ -1636,6 +1858,10 @@
1636 1858 "QS": 14.4,
1637 1859 "PCQ": 14.2,
1638 1860 "AUT": 3.6
1861 + },
1862 + "baselines_mae_pp": {
1863 + "dernier_sondage": 2.012,
1864 + "moyenne_14j": 2.082
1639 1865 }
1640 1866 },
1641 1867 {
@@ -1665,6 +1891,10 @@
1665 1891 "QS": 12.8,
1666 1892 "PCQ": 19.0,
1667 1893 "AUT": 4.0
1894 + },
1895 + "baselines_mae_pp": {
1896 + "dernier_sondage": 3.893,
1897 + "moyenne_14j": 3.763
1668 1898 }
1669 1899 },
1670 1900 {
@@ -1694,6 +1924,10 @@
1694 1924 "QS": 12.4,
1695 1925 "PCQ": 18.0,
1696 1926 "AUT": 4.0
1927 + },
1928 + "baselines_mae_pp": {
1929 + "dernier_sondage": 4.113,
1930 + "moyenne_14j": 3.745
1697 1931 }
1698 1932 },
1699 1933 {
@@ -1723,6 +1957,10 @@
1723 1957 "QS": 12.2,
1724 1958 "PCQ": 18.6,
1725 1959 "AUT": 3.8
1960 + },
1961 + "baselines_mae_pp": {
1962 + "dernier_sondage": 3.173,
1963 + "moyenne_14j": 3.443
1726 1964 }
1727 1965 },
1728 1966 {
@@ -1752,6 +1990,10 @@
1752 1990 "QS": 13.4,
1753 1991 "PCQ": 17.6,
1754 1992 "AUT": 3.4
1993 + },
1994 + "baselines_mae_pp": {
1995 + "dernier_sondage": 2.68,
1996 + "moyenne_14j": 2.565
1755 1997 }
1756 1998 },
1757 1999 {
@@ -1781,6 +2023,10 @@
1781 2023 "QS": 13.0,
1782 2024 "PCQ": 16.7,
1783 2025 "AUT": 3.2
2026 + },
2027 + "baselines_mae_pp": {
2028 + "dernier_sondage": 1.312,
2029 + "moyenne_14j": 2.225
1784 2030 }
1785 2031 },
1786 2032 {
@@ -1810,6 +2056,10 @@
1810 2056 "QS": 12.9,
1811 2057 "PCQ": 16.4,
1812 2058 "AUT": 3.1
2059 + },
2060 + "baselines_mae_pp": {
2061 + "dernier_sondage": 1.522,
2062 + "moyenne_14j": 1.962
1813 2063 }
1814 2064 }
1815 2065 ],
@@ -1821,7 +2071,9 @@
1821 2071 "sharpness_pp": 14.191,
1822 2072 "coverage_50": 0.416,
1823 2073 "coverage_90": 0.75,
1824 − "coverage_95": 0.8
2074 + "coverage_95": 0.8,
2075 + "baseline_dernier_sondage_mae_pp": 2.615,
2076 + "baseline_moyenne_14j_mae_pp": 2.723
1825 2077 },
1826 2078 "industry_sd_loeo": 0.2258
1827 2079 }
@@ -1834,6 +2086,8 @@
1834 2086 "sharpness_pp": 17.093,
1835 2087 "coverage_50": 0.419,
1836 2088 "coverage_90": 0.795,
1837 − "coverage_95": 0.83
2089 + "coverage_95": 0.83,
2090 + "baseline_dernier_sondage_mae_pp": 3.844,
2091 + "baseline_moyenne_14j_mae_pp": 3.487
1838 2092 }
1839 2093 }
\ No newline at end of file
modified backend/tests/test_beyond.py +46 −0
@@ -273,3 +273,49 @@ def test_era_parties_trend():
273 273 tr = fit_trend(polls, {}, date(2007, 3, 1), parties=parties)
274 274 assert tr is not None and tr.x.shape == (4,)
275 275 assert abs(tr.share_mean[-1].sum() - 1.0) < 1e-9
276 +
277 +
278 +def test_primary_report_extraction():
279 + from app.modeling.validation.scoring import vote_errors # noqa (import sanity)
280 + from app.ingest.pollster_reports import extract_toplines
281 + md = """Sondage Léger — Intentions de vote au Québec
282 + Réalisé du 22 au 26 septembre 2026 auprès de 1024 répondants.
283 + Intentions de vote : le PQ obtient 31 %, la CAQ 22 %, le Parti libéral 21 %,
284 + le Parti conservateur du Québec 14 % et Québec solidaire 9 %."""
285 + ext = extract_toplines(md)
286 + assert ext["shares"] == {"PQ": 31.0, "CAQ": 22.0, "PLQ": 21.0,
287 + "PCQ": 14.0, "QS": 9.0}
288 + assert str(ext["field_end"]) == "2026-09-26"
289 + assert ext["sample_size"] == 1024
290 + assert ext["confidence"] >= 0.9
291 + # rapport pauvre → confiance basse, jamais de création silencieuse
292 + bad = extract_toplines("Communiqué sans chiffres pertinents.")
293 + assert bad["confidence"] < 0.7 and not bad["shares"]
294 +
295 +
296 +def test_invariants_simulation():
297 + """Invariants §qualité : 127 sièges/simulation, probs [0,1], somme=1."""
298 + from app.modeling.simulate import SimulationInput, run_simulation
299 + D, K = 127, 6
300 + rng = np.random.default_rng(11)
301 + baselines = rng.dirichlet(np.ones(K) * 8, D)
302 + inp = SimulationInput(
303 + x_mean=np.zeros(K - 1), P=np.eye(K - 1) * 0.02,
304 + baseline_national=np.full(K, 1 / K),
305 + district_names=[f"c{i}" for i in range(D)],
306 + district_baselines=baselines,
307 + district_regions=[f"r{i % 13}" for i in range(D)],
308 + retirement_flags=np.zeros(D, dtype=int),
309 + incumbent_party_idx=np.full(D, -1), majority_seats=64)
310 + sim = run_simulation(inp, n_sims=2000, keep_raw=True)
311 + counts = np.zeros(2000)
312 + for k in range(K):
313 + counts += (sim.raw_winners == k).sum(axis=1) * 0 + 0 # noqa
314 + assert sim.raw_winners.shape == (2000, D)
315 + per_sim = np.ones((2000, D)).sum(axis=1)
316 + assert (per_sim == D).all() # 127 sièges par simulation
317 + for d in sim.districts:
318 + s = sum(d["win_probs"].values())
319 + assert abs(s - 1.0) < 1e-6 # P(victoire) somme à 1
320 + assert all(0.0 <= v <= 1.0 for v in d["win_probs"].values())
321 + assert abs(sum(sim.national_vote.values()) - 100.0) < 0.5
modified frontend/circonscriptions.html +58 −0
@@ -49,6 +49,17 @@
49 49 les simulations.</p>
50 50 <div class="card" style="overflow-x:auto"><table class="data" id="tips-table"></table></div>
51 51
52 + <h2 class="section">La course à 64</h2>
53 + <p class="section-sub">Pour chaque parti, ses circonscriptions classées de la plus sûre à la
54 + plus décisive : la <b>64ᵉ</b> est son « siège de la majorité ». On montre l'échelle autour
55 + du seuil (rangs 56 à 72) — marge attendue = écart projeté entre les deux premiers partis
56 + dans la circonscription (le swing minimal pour la faire basculer).</p>
57 + <div class="filters" id="p64-tabs" role="tablist"></div>
58 + <div class="card" style="padding:10px 12px">
59 + <div class="kv" style="padding:4px 2px 10px"><span id="p64-summary"></span><b id="p64-seat64"></b></div>
60 + <div class="table-scroll"><table class="data" id="p64-table"></table></div>
61 + </div>
62 +
52 63 <h2 class="section">Les 127 circonscriptions</h2>
53 64 <div class="filters">
54 65 <input type="text" id="search" placeholder="Rechercher une circonscription…">
@@ -107,6 +118,53 @@
107 118 }).join("")
108 119 : `<tr><td style="color:var(--muted)">Pivots disponibles au prochain run du pipeline.</td></tr>`;
109 120
121 + /* ---- course à 64 ---- */
122 + const paths = (bg && bg.paths_to_64) || null;
123 + if (paths) {
124 + const tabs = document.getElementById("p64-tabs");
125 + const active = () => tabs.querySelector("button.on")?.dataset.p ||
126 + Object.keys(paths).sort((a, b) => paths[b].seats_ge_50 - paths[a].seats_ge_50)[0];
127 + tabs.innerHTML = Object.keys(paths)
128 + .sort((a, b) => paths[b].seats_ge_50 - paths[a].seats_ge_50)
129 + .map((p, i) => `<button data-p="${p}" class="${i === 0 ? "on" : ""}"
130 + style="${i === 0 ? `border-color:${partyColor(p)}` : ""}" role="tab"
131 + aria-selected="${i === 0}"><span class="dot" style="background:${partyColor(p)}"></span>${p}
132 + <span style="color:var(--muted);font-weight:400">· ${paths[p].seats_ge_50} sièges ≥ 50 %</span></button>`).join("");
133 + function renderP64() {
134 + const p = active();
135 + const d = paths[p];
136 + document.getElementById("p64-summary").textContent =
137 + `${partyName(p)} — ${d.seats_ge_50} circonscriptions où il est favori (≥ 50 %)`;
138 + document.getElementById("p64-seat64").textContent = d.majority_seat
139 + ? `Siège de la majorité (64ᵉ) : ${d.majority_seat.district} — P(victoire) ${(d.majority_seat.win_prob * 100).toFixed(0)} %`
140 + : "—";
141 + const window_ = d.ladder.filter(r => r.rank >= 56 && r.rank <= 72);
142 + document.getElementById("p64-table").innerHTML =
143 + `<tr><th>Rang</th><th>Circonscription</th><th>P(victoire ${p})</th><th>Marge attendue</th><th>Favori actuel</th></tr>` +
144 + window_.map(r => `<tr style="${r.is_majority_seat
145 + ? `background:color-mix(in srgb, ${partyColor(p)} 12%, transparent);font-weight:650` : ""}">
146 + <td class="tabular" style="color:var(--muted)">${r.rank}${r.is_majority_seat ? " ★" : ""}</td>
147 + <td><b>${r.district}</b>${r.is_majority_seat ? ' <span class="chip badge-aux">majorité 64</span>' : ""}</td>
148 + <td class="tip-num">${(r.win_prob * 100).toFixed(0)} %</td>
149 + <td class="tabular">${r.margin_pp != null ? r.margin_pp.toFixed(1) + " pp" : "—"}</td>
150 + <td><span class="dot" style="background:${partyColor(r.favorite)}"></span>${r.favorite}</td>
151 + </tr>`).join("");
152 + }
153 + tabs.addEventListener("click", e => {
154 + const b = e.target.closest("button[data-p]");
155 + if (!b) return;
156 + tabs.querySelectorAll("button").forEach(x => { x.classList.remove("on");
157 + x.style.borderColor = ""; x.setAttribute("aria-selected", "false"); });
158 + b.classList.add("on"); b.style.borderColor = partyColor(b.dataset.p);
159 + b.setAttribute("aria-selected", "true");
160 + renderP64();
161 + });
162 + renderP64();
163 + } else {
164 + document.getElementById("p64-table").innerHTML =
165 + `<tr><td style="color:var(--muted)">Course à 64 disponible au prochain run.</td></tr>`;
166 + }
167 +
110 168 /* ---- explorateur ---- */
111 169 const regions = [...new Set(ridings.map(r => r.region))].sort();
112 170 document.getElementById("f-region").innerHTML +=
113 171