# QC Élection Forecast — Plateforme de prévision électorale du Québec 2026 # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # https://www.qc-election.com """Tests des couches v2 « au-delà des sondages » (fonctions pures).""" from datetime import date import numpy as np from app.config import settings from app.modeling import ensemble as ENS from app.modeling import fundamentals as FUND from app.modeling.compositions import alr, close, inv_alr def test_fundamentals_prior_is_valid_composition(): prior = FUND.compute_prior(date(2026, 8, 29)) assert prior.mean_shares.shape == (6,) assert abs(prior.mean_shares.sum() - 1.0) < 1e-9 assert (prior.mean_shares > 0).all() # covariance symétrique définie positive assert np.allclose(prior.P, prior.P.T) assert (np.linalg.eigvalsh(prior.P) > 0).all() # le prior doit être diffus (sd alr large) assert prior.detail["sd_alr"] >= 0.3 def test_fundamentals_regression_sane(): prior = FUND.compute_prior(date(2026, 8, 29), satisfaction=35.0) reg = prior.detail["regression"] # la satisfaction doit prédire positivement le vote du sortant assert reg["b_satisfaction"] > 0 assert reg["n_elections"] == 8 # erreur LOO honnête, ni nulle ni délirante assert 1.5 < reg["loo_rmse_pp"] < 12.0 # sortant plausible pour satisfaction 35 % après 2 mandats assert 15.0 < prior.detail["prior_pct"]["CAQ"] < 45.0 def test_blend_weight_schedule(): assert FUND.blend_weight(0) == 0.0 assert FUND.blend_weight(-3) == 0.0 w30, w365, w540 = (FUND.blend_weight(d) for d in (30, 365, 540)) assert 0 < w30 < w365 < w540 <= settings.fundamentals_max_weight assert w30 < 0.05 # à J-30, les fondamentaux ne pèsent presque plus def test_blend_limits(): prior = FUND.compute_prior(date(2026, 8, 29)) x_p = alr(close(np.array([0.15, 0.10, 0.20, 0.35, 0.15, 0.05]))) P_p = np.eye(5) * 0.01 # jour du vote : le blend est l'identité x0, P0, d0 = FUND.blend(x_p, P_p, prior, 0) assert np.allclose(x0, x_p) and np.allclose(P0, P_p) # loin du vote : tiré vers le prior, variance réduite, poids publié x1, P1, d1 = FUND.blend(x_p, P_p, prior, 540) assert 0 < d1["precision_share"] < 1 assert np.trace(P1) < np.trace(P_p) dist_before = np.linalg.norm(x_p - prior.x) assert np.linalg.norm(x1 - prior.x) < dist_before def test_byelection_implied_national_roundtrip(): """Si la partielle reproduit exactement le résultat local 2022, l'observation implicite doit être le national 2022 (swing nul).""" from app.ingest.byelections import RESULT_2022, _riding_2022 base = _riding_2022("Jean-Talon") assert base is not None and abs(sum(base.values()) - 100) < 2 nat = close(np.array([RESULT_2022[p] for p in settings.parties]) / 100.0) b = close(np.array([base[p] for p in settings.parties]) / 100.0) implied = inv_alr(alr(nat) + settings.byelection_swing_shrink * (alr(b) - alr(b))) assert np.allclose(implied, nat, atol=1e-9) def test_byelection_swing_shrunk(): """Un balayage local doit produire un swing national rétréci, pas intégral.""" from app.ingest.byelections import RESULT_2022, _riding_2022 base = _riding_2022("Terrebonne") nat = close(np.array([RESULT_2022[p] for p in settings.parties]) / 100.0) b = close(np.array([base[p] for p in settings.parties]) / 100.0) surge = b.copy() i_pq = settings.parties.index("PQ") surge[i_pq] *= 2.5 # le PQ explose localement surge = close(surge) full = inv_alr(alr(nat) + (alr(surge) - alr(b))) shrunk = inv_alr(alr(nat) + settings.byelection_swing_shrink * (alr(surge) - alr(b))) assert nat[i_pq] < shrunk[i_pq] < full[i_pq] def test_media_nudge_cap_and_sum(): x = alr(close(np.array([0.15, 0.10, 0.20, 0.35, 0.15, 0.05]))) delta = {"PQ": settings.media_nudge_pp_max, "CAQ": -settings.media_nudge_pp_max} x2 = ENS.apply_nudge(x, delta) p2 = inv_alr(x2) assert abs(p2.sum() - 1.0) < 1e-9 moved = (p2 - inv_alr(x)) * 100 assert abs(moved).max() <= settings.media_nudge_pp_max + 0.15 # délta nul → état inchangé (aucun bruit numérique injecté) assert np.allclose(ENS.apply_nudge(x, {"PQ": 0.0}), x) def test_market_blend(): model = {"PQ": 0.70, "CAQ": 0.10, "PLQ": 0.12, "QS": 0.04, "PCQ": 0.04, "AUT": 0.0} market = {"PQ": 0.80, "CAQ": 0.05, "PLQ": 0.10, "QS": 0.03, "PCQ": 0.02} out = ENS.market_blend(model, market) assert out["weight_market"] == settings.market_blend_weight assert abs(sum(out["blended"].values()) - 1.0) < 1e-6 assert model["PQ"] < out["blended"]["PQ"] < market["PQ"] # sans marché : identité same = ENS.market_blend(model, None) assert same["blended"] == model and same["weight_market"] == 0.0 def test_synthetic_poststratify(): from app.modeling.synthetic_poll import poststratify, strata st = strata() assert len(st) == 96 assert abs(sum(s["weight"] for s in st) - 1.0) < 1e-9 cells = [{**s, "shares": {"CAQ": 20, "PLQ": 20, "PQ": 30, "QS": 10, "PCQ": 15, "AUT": 5}} for s in st] agg = poststratify(cells) assert abs(sum(agg.values()) - 100.0) < 0.5 assert abs(agg["PQ"] - 30.0) < 0.2 # strates non normalisées → renormalisées avant agrégation cells[0]["shares"] = {"CAQ": 40, "PLQ": 40, "PQ": 60, "QS": 20, "PCQ": 30, "AUT": 10} agg2 = poststratify(cells) assert abs(sum(agg2.values()) - 100.0) < 0.5 def test_forecast_drift_multiplier_widens_only(): from datetime import date from app.modeling.forecast import forecast from app.modeling.trend import TrendResult x = np.zeros(5); P = np.eye(5) * 0.005 tr = TrendResult(dates=[date(2026, 8, 1)], share_mean=np.zeros((1, 6)), share_lo=np.zeros((1, 6)), share_hi=np.zeros((1, 6)), x=x, P=P, q=1e-4, loglik=0.0, n_polls=10) f1 = forecast(tr, date(2026, 8, 30), date(2026, 10, 5)) f2 = forecast(tr, date(2026, 8, 30), date(2026, 10, 5), drift_multiplier=1.45) assert np.allclose(f1.x, f2.x) # moyenne intacte assert np.trace(f2.P) > np.trace(f1.P) # variance élargie def test_scoring_functions(): from app.modeling.validation import scoring as SC parties = ["CAQ", "PLQ", "PQ"] fc = {"CAQ": 40.0, "PLQ": 15.0, "PQ": 15.0} actual = {"CAQ": 41.0, "PLQ": 14.4, "PQ": 14.6} ve = SC.vote_errors(fc, actual, parties) assert 0 < ve["mae_pp"] < 1.0 and ve["bias_pp"]["CAQ"] == -1.0 summary = {p: {"p25": fc[p] - 1, "p75": fc[p] + 1, "p05": fc[p] - 2, "p95": fc[p] + 2, "lo95": fc[p] - 3, "hi95": fc[p] + 3, "mean": fc[p]} for p in parties} cov = SC.interval_coverage(summary, actual, parties) assert cov["coverage_95"] == 1.0 probs = np.array([0.1] * 20 + [0.9] * 20) outs = np.array([0] * 18 + [1] * 2 + [1] * 18 + [0] * 2) bins = SC.calibration_bins(probs, outs) assert len(bins) == 2 and abs(bins[0]["observed"] - 0.1) < 0.01 def test_volatility_bounds(): from app.modeling.signals import volatility as V from app.config import settings # sans DB sociale : seul wiki agit; z énorme → plafonné à la borne class FakeDB: pass saved = settings.enable_social_volatility object.__setattr__(settings, "enable_social_volatility", False) try: out = V.compute(None, {"z_max": 50.0}) assert 1.0 <= out["multiplier"] <= settings.volatility_multiplier_max assert out["multiplier"] == settings.volatility_multiplier_max calm = V.compute(None, {"z_max": 0.5}) assert calm["multiplier"] == 1.0 finally: object.__setattr__(settings, "enable_social_volatility", saved) def test_tipping_points_math(): from app.modeling.simulate import tipping_points parties = ["A", "B"] # 3 circonscriptions, majorité à 3 : le 3e siège le plus sûr (le plus # serré, c2) est TOUJOURS celui qui donne la majorité → pivot certain. S, D = 200, 3 rng = np.random.default_rng(7) shares = np.zeros((S, D, 2)) shares[..., 0] = 0.55 + rng.normal(0, 0.008, (S, D)) shares[:, 2, 0] = 0.51 shares[..., 1] = 1 - shares[..., 0] winners = shares.argmax(axis=-1) tips = tipping_points(winners, shares, ["c0", "c1", "c2"], 3, parties) assert tips["n_sims_with_majority"] == S top = tips["majority_tipping"][0] assert top["district"] == "c2" and top["prob_majority_tipping"] > 0.9 # majorité à 2 : pivot = 2e marge la plus sûre → c0 ou c1, jamais c2 tips2 = tipping_points(winners, shares, ["c0", "c1", "c2"], 2, parties) names = {t["district"] for t in tips2["majority_tipping"]} assert "c2" not in names and names <= {"c0", "c1"} def test_ablation_overrides_restore(): from app.modeling.validation.ablation import _overrides from app.config import settings before = settings.fundamentals_enabled with _overrides({"fundamentals_enabled": not before}): assert settings.fundamentals_enabled == (not before) assert settings.fundamentals_enabled == before def test_coordination_filter(): from app.modeling.signals.social import coordination_filter from datetime import datetime, timezone class P: def __init__(self, text): self.text = text self.fetched_at = datetime(2026, 8, 30, 12, tzinfo=timezone.utc) organic = [P(f"opinion unique numéro {i} sur la campagne") for i in range(10)] spam = [P("Votez maintenant contre le gouvernement corrompu!") for _ in range(6)] org, diag = coordination_filter(organic + spam) assert diag["n_coordinated"] == 6 and diag["n_organic"] == 10 def test_crps_and_log_score(): from app.modeling.validation.scoring import crps_from_draws, log_score_alr rng = np.random.default_rng(3) actual = np.array([30.0, 25.0, 20.0]) tight = rng.normal(actual, 1.0, (4000, 3)) wide = rng.normal(actual, 6.0, (4000, 3)) assert crps_from_draws(tight, actual) < crps_from_draws(wide, actual) biased = rng.normal(actual + 8.0, 1.0, (4000, 3)) assert crps_from_draws(tight, actual) < crps_from_draws(biased, actual) x = np.zeros(3); P = np.eye(3) * 0.02 assert log_score_alr(x, P, np.zeros(3)) > log_score_alr(x, P, np.full(3, 0.5)) def test_industry_sigma_bounds(): import pathlib from app.config import DATA_DIR if not (DATA_DIR / "historical" / "polls_2018.csv").exists(): return # cache absent (premier déploiement) — le pipeline le créera from app.modeling.national.pollster_error import (SIGMA_CAP, SIGMA_FLOOR, industry_sigma_loeo) for yr in (None, 2018, 2022): s = industry_sigma_loeo(yr) assert SIGMA_FLOOR <= s <= SIGMA_CAP def test_replay_smoke_2018(): from app.config import DATA_DIR if not (DATA_DIR / "historical" / "polls_2018.csv").exists(): return from app.modeling.validation.historical_replay import replay_election rep = replay_election(2018, horizons=[30, 7]) assert rep["avg"] and rep["avg"]["mae_pp"] < 12 for r in rep["horizons"]: assert "error" not in r fcst = r["forecast"] assert abs(sum(fcst.values()) - 100) < 3 # composition ≈ 100 % # LOEO : le prior de fondamentaux exclut 2018 (pas de fuite) # l'ère 2018 n'a pas de PCQ : le modèle tourne à 5 partis assert "PCQ" not in rep["parties"] def test_era_parties_trend(): """Le cœur alr fonctionne avec une ère à 5 partis (ADQ, sans CAQ/PCQ).""" from datetime import date, timedelta from app.modeling.trend import fit_trend parties = ["ADQ", "PLQ", "PQ", "QS", "AUT"] polls = [{"pollster": f"Maison{i%3}", "field_end": date(2007, 1, 1) + timedelta(days=i * 7), "sample_size": 1000, "mode": "unknown", "shares": {"ADQ": 30.0 + i * 0.1, "PLQ": 33.0, "PQ": 28.0, "QS": 4.0, "AUT": 5.0}} for i in range(8)] tr = fit_trend(polls, {}, date(2007, 3, 1), parties=parties) assert tr is not None and tr.x.shape == (4,) assert abs(tr.share_mean[-1].sum() - 1.0) < 1e-9 def test_primary_report_extraction(): from app.modeling.validation.scoring import vote_errors # noqa (import sanity) from app.ingest.pollster_reports import extract_toplines md = """Sondage Léger — Intentions de vote au Québec Réalisé du 22 au 26 septembre 2026 auprès de 1024 répondants. Intentions de vote : le PQ obtient 31 %, la CAQ 22 %, le Parti libéral 21 %, le Parti conservateur du Québec 14 % et Québec solidaire 9 %.""" ext = extract_toplines(md) assert ext["shares"] == {"PQ": 31.0, "CAQ": 22.0, "PLQ": 21.0, "PCQ": 14.0, "QS": 9.0} assert str(ext["field_end"]) == "2026-09-26" assert ext["sample_size"] == 1024 assert ext["confidence"] >= 0.9 # rapport pauvre → confiance basse, jamais de création silencieuse bad = extract_toplines("Communiqué sans chiffres pertinents.") assert bad["confidence"] < 0.7 and not bad["shares"] def test_invariants_simulation(): """Invariants §qualité : 127 sièges/simulation, probs [0,1], somme=1.""" from app.modeling.simulate import SimulationInput, run_simulation D, K = 127, 6 rng = np.random.default_rng(11) baselines = rng.dirichlet(np.ones(K) * 8, D) inp = SimulationInput( x_mean=np.zeros(K - 1), P=np.eye(K - 1) * 0.02, baseline_national=np.full(K, 1 / K), district_names=[f"c{i}" for i in range(D)], district_baselines=baselines, district_regions=[f"r{i % 13}" for i in range(D)], retirement_flags=np.zeros(D, dtype=int), incumbent_party_idx=np.full(D, -1), majority_seats=64) sim = run_simulation(inp, n_sims=2000, keep_raw=True) counts = np.zeros(2000) for k in range(K): counts += (sim.raw_winners == k).sum(axis=1) * 0 + 0 # noqa assert sim.raw_winners.shape == (2000, D) per_sim = np.ones((2000, D)).sum(axis=1) assert (per_sim == D).all() # 127 sièges par simulation for d in sim.districts: s = sum(d["win_probs"].values()) assert abs(s - 1.0) < 1e-6 # P(victoire) somme à 1 assert all(0.0 <= v <= 1.0 for v in d["win_probs"].values()) assert abs(sum(sim.national_vote.values()) - 100.0) < 0.5 def test_election_night_infers_national_shift(): """Un vrai décalage national doit être retrouvé par la décomposition hiérarchique, et un décalage nul ne doit rien inventer.""" from app.modeling.election_night import infer_shifts from app.modeling.simulate import SimulationInput from app.modeling.compositions import alr, close, inv_alr rng = np.random.default_rng(5) D, K = 60, 6 baselines = rng.dirichlet(np.ones(K) * 10, D) nat = np.full(K, 1 / K) inp = SimulationInput( x_mean=alr(close(nat)), P=np.eye(K - 1) * 0.01, baseline_national=nat, district_names=[f"c{i}" for i in range(D)], district_baselines=baselines, district_regions=[f"r{i % 6}" for i in range(D)], retirement_flags=np.zeros(D, dtype=int), incumbent_party_idx=np.full(D, -1), majority_seats=31) # vérité : PQ (idx 3) +3 pp national — rapporté dans 30 circonscriptions true_shift_pp = np.zeros(K); true_shift_pp[3] = 3.0 reported = [] for i in range(30): obs = close(np.clip(baselines[i] + true_shift_pp / 100.0, 0.001, None)) reported.append({"district": f"c{i}", "pct_reported": 100.0, "results": {p: float(obs[j] * 100) for j, p in enumerate( ["CAQ", "PCQ", "PLQ", "PQ", "QS", "AUT"])}}) from app.config import settings upd = infer_shifts(inp, reported, industry_sd=0.22) shifted = inv_alr(inp.x_mean + upd.national_shift) dpq = (shifted[3] - nat[3]) * 100 assert 1.5 < dpq < 4.5 # décalage PQ retrouvé (~3 pp, rétréci OK) assert upd.national_var < 0.22 ** 2 # l'incertitude nationale a diminué # décalage nul → rien d'inventé rep0 = [{**r, "results": {p: float(baselines[i][j] * 100) for j, p in enumerate( ["CAQ", "PCQ", "PLQ", "PQ", "QS", "AUT"])}} for i, r in enumerate(reported)] upd0 = infer_shifts(inp, rep0, industry_sd=0.22) assert np.abs(inv_alr(inp.x_mean + upd0.national_shift) - nat).max() * 100 < 0.5 # sous le seuil de dépouillement : aucune information updm = infer_shifts(inp, [{**reported[0], "pct_reported": 2.0}], 0.22) assert updm.n_informative == 0 def test_demographics_feature_store(): from app.config import DATA_DIR from app.ingest.eq_socioeconomic import slug_of assert slug_of("Anjou–Louis-Riel") == "anjou-louis-riel" assert slug_of("Sainte-Marie–Saint-Jacques") == "sainte-marie-saint-jacques" assert slug_of("D'Arcy-McGee") == "darcy-mcgee" f = DATA_DIR / "population" / "riding_2026_demographics.csv" if not f.exists(): return # premier déploiement — construit par admin/demographics import pandas as pd df = pd.read_csv(f) assert len(df) == 127 assert df["population"].between(10000, 130000).all() assert df["pct_francais"].between(1, 100).all() assert df["pct_locataires"].between(5, 95).all()