Weighted aggregator · probabilistic model · 2026 presidential election

Placar 2026

Part 3 of 3 · Under the hood. How the model works, the list of polls and weights, the method, each pollster's biases, a glossary, the full datasets and the code. The results are on the home page; the analyses, in What drives it.
Updated
Oct 25, 2026, 1:23 p.m.
1st round Oct 4 · 2nd round Oct 25
Daniel Pessini Sobreira · model, data and dashboard
3
Under the hood
For those who want to check: how the model works, the list of polls and weights, the method, each pollster's biases and a glossary.
Model evaluation

The model missed the margin by 1.2 points, vs. 4.6 for the polls: what it got right and what failed

In one sentence: the model missed the margin by 1.2 points, vs. 4.6 for the polls it aggregated, and the result fell within the central interval; the part that helped most was the historical-error discount, and the part that hurt most was the house-effect correction anchored on the traditional pollsters.

Forecast frozen on the eve × official result

The final forecast was saved on the night of Oct 3 in previsoes_1turno_congeladas.json and not touched again. Below, each of its numbers against the TSE count: the bar is the range in which the model placed an 80% chance, the tick is the forecast median and the dot is the result (dark if it fell within the range, terracotta if it fell outside).
Eve-of-election questions
4 of 4
outcomes the model rated most likely that happened · mean Brier 0.108 (coin flip: 0.250)
Within the 80% range
7 of 8
margin and 7 candidates; if the uncertainty were well calibrated, the expected count would be 6 or 7 of 8
Mean error per candidate
0.35 p.p.
undiscounted polls: 1.22 · the model missed by less in 7 of 7
Gain over a coin flip
32%
reduction in the Brier score for 'who finishes 1st' vs. 50%: the model gave 59% to the right outcome, not 99%
LULA − FLÁVIO MARGIN (PP) −8 −6 −4 −2 0 +2 +4 +6 Margin Margin: 80% range of the frozen forecast, −5.3 to 3.8 forecast median: −0.7 Margin: counted −1.87 (forecast −0.7, error +1.2) −1.87inside LULA AND FLÁVIO (% OF VALID VOTES) 41 42 43 44 45 46 47 48 49 Lula (PT) Lula (PT): 80% range of the frozen forecast, 42.3 to 47.6 forecast median: 45.0 Lula (PT): counted 45.16 (forecast 45.0, error −0.2) 45.16inside Flávio Bolsonaro (PL) Flávio Bolsonaro (PL): 80% range of the frozen forecast, 42.9 to 48.5 forecast median: 45.7 Flávio Bolsonaro (PL): counted 47.03 (forecast 45.7, error −1.3) 47.03inside OTHER CANDIDATES (% OF VALID VOTES) 0 1 2 3 4 5 Augusto Cury (Avante) Augusto Cury (Avante): 80% range of the frozen forecast, 1.7 to 4.5 forecast median: 2.8 Augusto Cury (Avante): counted 2.89 (forecast 2.8, error −0.1) 2.89inside Renan Santos (Missão) Renan Santos (Missão): 80% range of the frozen forecast, 1.4 to 3.9 forecast median: 2.4 Renan Santos (Missão): counted 2.24 (forecast 2.4, error +0.2) 2.24inside Ronaldo Caiado (PSD) Ronaldo Caiado (PSD): 80% range of the frozen forecast, 1.3 to 3.7 forecast median: 2.3 Ronaldo Caiado (PSD): counted 2.18 (forecast 2.3, error +0.1) 2.18inside Romeu Zema (Novo) Romeu Zema (Novo): 80% range of the frozen forecast, 0.1 to 1.1 forecast median: 0.4 Romeu Zema (Novo): counted 0.27 (forecast 0.4, error +0.1) 0.27inside Others (6 candidates) Others (6 candidates): 80% range of the frozen forecast, 0.3 to 1.6 forecast median: 0.7 Others (6 candidates): counted 0.22 (forecast 0.7, error +0.5) 0.22outside
forecast 80% rangeforecast medianresult within the rangeoutside the rangefor the margin, the darker band is the 50% range
Eve-of-election questionWhat the model saidWhat happenedProb. of what happenedBrier
Who finishes 1st?Flávio, at 59%Flávio, by 1.87 points59%0.171
Will there be a runoff?yes, at 98.7%yes98.7%0.000
Does Flávio make the runoff?yes, at 100%yes100%0.000
Who finishes 3rd?Cury, at 49% (the most likely)Augusto Cury, with 2.89%49%0.263
CandidateCountedModel (median)Model errorPolls (undiscounted average)Poll errorWho came closest
Lula (PT)45.1645.0−0.245.5+0.3model
Flávio Bolsonaro (PL)47.0345.7−1.342.8−4.2model
Augusto Cury (Avante)2.892.8−0.13.8+0.9model
Renan Santos (Missão)2.242.4+0.23.2+1.0model
Ronaldo Caiado (PSD)2.182.3+0.13.0+0.8model
Romeu Zema (Novo)0.270.4+0.10.7+0.4model
Others (6 candidates)0.220.7+0.51.1+0.9model
Mean absolute error0.351.22model

Error = forecast minus counted, in points of valid votes. Brier = (1 − probability assigned to what happened)²; 0 is a correct call with total certainty and 0.25 is what you get by guessing 50% on a yes-or-no question; for the third-place question, with four names, a uniform guess (25% each) would give 0.563. A single election is not enough to say whether the model's uncertainty is calibrated: the numbers above say whether it was useful this time, not whether it will be right next time.

Read the full analysis≈ 4 min read

The margin and alternative methods

Margin error
+1.2 model
undiscounted polls: +4.6 · simple average of the latest: +3.5
Percentile of the result
37º
in the frozen margin distribution; the 50th would be the exact median, and the 25th to 75th is the 50% interval
Brier for 'Lula 1st'
0.171
0 = correct certainty; 0.25 = coin flip; undiscounted it would be 0.834
Candidates within the 80%
6 of 7
only "others" fell outside (0.22 vs. a floor of 0.3)
ForecastForecast marginErrorP(Lula 1st)BrierLog loss
Undiscounted polls (model aggregate)+2.7+4.691%0.8342.44
Simple average of the latest polls+1.6+3.5———
Published model (discount +3.4, 60% of the historical)−0.7+1.241%0.1710.53
Full historical discount (+5.7)−3.0−1.122%0.0480.25

Brier = (probability assigned to Lula leading − 0)²; log loss = −ln(probability assigned to the actual outcome). Lower is better on both. Actual margin: −1.87.

Component by component

ComponentAssessmentEvidence
Discount for the historical first-round errorworkedMoved the margin from +2.7 to −0.7, in the right direction, and eliminated 74% of the polling error.
Size of the discount (60% of the historical average)conservativeThe anchor's actual error (+4.7) fell between the discount used (+3.4) and the full historical figure (+5.7). With the full historical figure, the median would have been −3.0 and the Brier score lower.
Width of the uncertaintyadequateThe result landed at the 37th percentile, inside the 50% interval; 6 of the 7 candidates fell within the 80% band.
House effects anchored on the traditional pollstersfailedThe correction pushed 11 of the last 14 polls away from the result: it penalized the pollsters that got it right (Palver, Gerp, Futura) for diverging from the anchor, and the anchor missed as a bloc. The bias discount offset the level, but the information from those pollsters was thrown away.
Lula's levelgot it rightMedian 45.0 vs. 45.16 counted.
Flávio's level and the collapse of the third wayunderestimatedMedian 45.7 vs. 47.03: the simulated collapse only redistributed votes without shifting the margin, and the actual collapse was larger and went entirely to Flávio.
3rd place and a first-round wingot it rightCury in 3rd was the most likely outcome (49%); the chance of the race being settled in the first round was 1.3%.
State-level readingfailed in the SoutheastState polls projected a Lula gain in the Southeast, and instead he lost 4.3 points; the layer carried 6% of the level and was dropped from the model in the post-first-round regime.
Rolling-origin validationcould not detect the problem85% coverage of the 80% interval, but measured against the polling average itself: an error shared by every pollster is invisible to it by construction.
Post-first-round regime (runoff)failure fixed todayWith the result in hand and no new polls at all, the discount on runoff polls fell from +3.4 to +2.0 and Lula's chance rose from 28% to 37%. Waves fielded before Oct 4 now receive the observed error (+4.7); with that, Lula's chance is 17%, and polls and counted votes converge.

How the forecast moved in the final days

RunVersionLula 1stFlávio 1st
Sep 30v3358%42%
Oct 1, afternoonv3650%50%
Oct 1, eveningv3852%48%
Oct 2v3946%54%
Oct 3, morningv4044%56%
Oct 3, afternoon (audit)v4156%44%
Oct 3, afternoon (MDA, Palver)v4238%62%
Oct 3, evening (2nd audit)v4345%55%
Oct 3, evening · frozenv4441%59%

Lula's chance swung between 38% and 58% with the polls and method corrections of the final week, never giving Flávio less than 42%. The noise between runs was as large as the effect of the audits: a sign that the forecast was, correctly, close to 50%.

What changes in the model for the runoff

(1) Runoff polls fielded before Oct 4 receive the anchor's observed first-round error (+4.7), and those fielded afterward receive the discount from the final runoff rounds plus 25% of that error; the mix follows the weight the new polls gain in the filter. (2) Counted votes enter the margin with the polls' total error, not just the sampling error, and currently weigh 21%. (3) Each pollster's first-round error, measured against the anchor, becomes the prior for its runoff house effect. (4) The market again weighs 25%, with prices from after the result. Pending: revisit the anchor and the shrinkage of the discount with the updated first-round series (2010–2026: +5.2; +10.5; +2.4; +5.8; +4.7).

Technical summary

Every number on the dashboard — updated Oct 25, 2026, 1:23 p.m.

  1. First round (Oct 4). Across the 75 first-round polls since August (17 pollsters), anchored on the calibrated pollsters: Lula 45.5% × Flávio 42.8% of valid votes; Cury 3.8, Renan 3.2, Caiado 3.0, Zema 0.7. After discounting the historical first-round error (+3.4 ± 3.8 on the PT − main rival margin; 60% of the 2010/14/18/22 average), expected margin +2.7 (80% between +0.1 and +5.2): Lula 1st in 91% of simulations, Flávio in 9%. Decided in the first round: 1.3%. Lula × Flávio runoff: 100%.
  2. Runoff, today's margin. Single state-space model (221 polls from 22 pollsters; campaign window since Jun 1 with 165), house effects anchored on AtlasIntel, Datafolha, Ipespe, CNT/MDA, Paraná Pesquisas, PoderData, Quaest: Lula − Flávio = -1.3 pp in valid votes (±2.3); two weeks ago, -2.0 in the polling series; last week's trend favoring Lula, but slowing. Under the old identification (average of all pollsters) the level would be -2.6: switching the anchor is worth +1.7 pp.
  3. Chance of winning. Polling model with a discount of +0.8 ± 2.0 (the polls' observed first-round error for waves before Oct 4, and the final runoff rounds of 2006–2022 plus 25% of that error for new waves): Lula 17% · Flávio 83%; without the discount, Lula 27%; with a +2.1 discount, Lula 6%. Markets (Polymarket Oct 25, 8 a.m.): Lula 20%. Fundamentals (reference only, weight 0): Lula 53%. Ensemble (75%/25%/0%): Lula 18% · Flávio 82%; 30% chance of a margin under 1.5 pp.
  4. Break-even point. A +0.8 Lula margin in the final polls gives each candidate 50%; +1.9 gives Lula 70%, +3.5 gives him 90%; the same 70% and 90% for Flávio come at -0.3 and -1.9.
  5. Four scenarios (from the ensemble): Lula by >4 pp 2% · Lula by 0–4 16% · Flávio by 0–4 57% · Flávio by >4 25%.
  6. Validation. Rolling origin (last 60 days, 7-day horizon, 233 forecasts): 85% coverage of the 80% interval and 94% of the 95% interval; mean absolute error 1.9 pp; mean bias -0.3. σ_rw = 0.31 pp/day (MAP; ML 0.32); total drift 0.0 pp through Oct 25 (between rounds: 0.95 pp/day, calibrated on 2014/18/22).
  7. Polling bias. The typical error of a single poll this cycle is 3.9 pp on the margin, versus 2.3 pp expected from sampling — a ratio of 1.72×. Raw margins vary by 2.0 pp across pollsters because of real house effects (showing Flávio stronger than the anchor: Gerp (-7.7), Veritá (-5.3), Palver (-4.4), Futura (-3.8), PoderData (-2.4), Paraná Pesquisas (-2.0); showing Lula stronger: CNT/MDA (+3.9)); with the house effect removed, the spread is 0.78× what sampling error would predict. See the "Bias" section.
  8. Structural indicators. High rejection on both sides, varying by pollster (Rejection section); pool of undecided/blank/spoiled varying by pollster (Undecided section); government rated great + good at 34.0% (Datafolha Sep 22–23).
Contents
  1. 1 · The results
  2. First round · October 4
  3. Runoff · October 25
  4. Live count: TSE tally and projection
  5. Today's numbers
  6. In four sentences
  7. How to read this dashboard
  8. Simulation history: Lula's chance in each model run
  9. Polls since the first round: trend and summary line
  10. Trend pointer: where the runoff polls are heading
  11. Change panel: differences within each pollster
  12. Votes up for grabs: who is still deciding and how each candidate fares by segment
  13. Four scenarios for October 25
  14. 2 · What drives the race
  15. What drives the runoff: issues, news and rallies
  16. First-round issues: why Flávio pulled ahead, and polls vs. ballots
  17. How the margin has evolved since December
  18. The states: who leads where and what it says about the national margin
  19. The municipalities: what changed between the 2022 runoff and the 2026 first round
  20. The vote abroad: cities, polling places and where to act
  21. Generations: is there a new, more right-leaning generation of voters?
  22. Decisive variables: what moves the margin
  23. Tracking negatives: Lula × Flávio Bolsonaro
  24. The pool of undecided, blank and spoiled votes over time
  25. Abstention: who stays home and whom it costs
  26. Migration of non-voters, third-way and blank/spoiled votes: Markov chain + Monte Carlo
  27. Scandals and late-campaign events: STF, Master, Dark Horse, betting, Aparecida and Carbono Oculto
  28. Other opponents tested
  29. 3 · Under the hood
  30. Accuracy of the first-round forecast
  31. Probabilistic model: who wins and under what conditions
  32. Individual polls and weights
  33. Aggregation methodology
  34. Change and adjustment log
  35. Bias, sampling flaws and what the margin of error doesn't cover
  36. Glossary
  37. Transparency and electoral compliance
  38. Fundamentals: approval, economy and sentiment
  39. Prediction markets and forecasters
  40. Political events and risks in 2026
  41. Historical drift: September polls × result
  42. Calibration by pollster (2006 to 2022)
  43. Appendix A — full poll database
  44. Appendix B — full first-round poll database
  45. Appendix C — code, data and reproduction
  46. References
Simulation

Lula needs +0.8 in the final polls to have a 50% chance; today the model gives him 17%

In one sentence: the model simulates the election 400,000 times from today's trend, adding the uncertainty about what can still change through Oct 25 (drift) and the historical polling error.

How to read it. In the chart, the horizontal axis is the final margin (Lula − Flávio). The height of each bar shows how many simulations landed in that range: slate (right of zero) = Lula wins; terracotta = Flávio wins. The wider the "mountain," the greater the uncertainty. The break-even table further down answers: "if the last poll shows Lula +X, what is his real chance?"
Simulated distribution of the final margin (polling model with the historical-error discount). The break-even point table turns the result into a rule of thumb: given an adjusted margin in the last week's polls, what is each candidate's chance — considering that the final polls overestimated the PT candidate in 3 of the last 5 runoffs and underestimated him in 2.
margin -14.0 to -13.0 pp · Flávio wins in this range · density 0% of peak margin -13.0 to -12.0 pp · Flávio wins in this range · density 0% of peak margin -12.0 to -11.0 pp · Flávio wins in this range · density 0% of peak margin -11.0 to -10.0 pp · Flávio wins in this range · density 1% of peak margin -10.0 to -9.0 pp · Flávio wins in this range · density 1% of peak margin -9.0 to -8.0 pp · Flávio wins in this range · density 2% of peak margin -8.0 to -7.0 pp · Flávio wins in this range · density 5% of peak margin -7.0 to -6.0 pp · Flávio wins in this range · density 12% of peak margin -6.0 to -5.0 pp · Flávio wins in this range · density 25% of peak margin -5.0 to -4.0 pp · Flávio wins in this range · density 49% of peak margin -4.0 to -3.0 pp · Flávio wins in this range · density 78% of peak margin -3.0 to -2.0 pp · Flávio wins in this range · density 100% of peak margin -2.0 to -1.0 pp · Flávio wins in this range · density 99% of peak margin -1.0 to 0.0 pp · Flávio wins in this range · density 76% of peak margin 0.0 to 1.0 pp · Lula wins in this range · density 46% of peak margin 1.0 to 2.0 pp · Lula wins in this range · density 23% of peak margin 2.0 to 3.0 pp · Lula wins in this range · density 11% of peak margin 3.0 to 4.0 pp · Lula wins in this range · density 5% of peak margin 4.0 to 5.0 pp · Lula wins in this range · density 2% of peak margin 5.0 to 6.0 pp · Lula wins in this range · density 1% of peak margin 6.0 to 7.0 pp · Lula wins in this range · density 0% of peak margin 7.0 to 8.0 pp · Lula wins in this range · density 0% of peak margin 8.0 to 9.0 pp · Lula wins in this range · density 0% of peak margin 9.0 to 10.0 pp · Lula wins in this range · density 0% of peak margin 10.0 to 11.0 pp · Lula wins in this range · density 0% of peak margin 11.0 to 12.0 pp · Lula wins in this range · density 0% of peak margin 12.0 to 13.0 pp · Lula wins in this range · density 0% of peak margin 13.0 to 14.0 pp · Lula wins in this range · density 0% of peak margin 14.0 to 15.0 pp · Lula wins in this range · density 0% of peak margin 15.0 to 16.0 pp · Lula wins in this range · density 0% of peak margin 16.0 to 17.0 pp · Lula wins in this range · density 0% of peak margin 17.0 to 18.0 pp · Lula wins in this range · density 0% of peak -12 -8 -4 0 +4 +8 +12 +16 Lula − Flávio margin on Oct 25 (pp of valid votes) Lula wins · 17% Flávio wins · 83% light band = |margin| < 1.5 pp (contestable result): 34%
Polling model (75%)
Lula 17% · Flávio 83%
median margin -2.0 · 80% between -4.8 and +0.7
Prediction markets (25%)
Lula 20% · Flávio 80%
Polymarket Oct 25, 8 a.m.: Flávio 79.5% × Lula 20.5% (US$197 million). Since Sep 8: 59% → 40% (Sep 21) → 41% (Sep 30) → 38% (Oct 1) → 36% (Oct 3) → 15% (Oct 4, after the result) → 16% (Oct 5, evening) → 12% (Oct 10, after Datafolha, AtlasIntel and PoderData) → 12% (Oct 11, steady after Vox Brasil and Ipespe). → 17% (Oct 15) → 22% (Oct 16, after Datafolha 47 x 45, Quaest 46 x 44 and PoderData 50 x 50). → 23% (Oct 17, evening) → 20% (Oct 18, before the Band debate). → 23.5% (Oct 19, after PoderData, AtlasIntel, BTG/Nexus and Futura). → 21.5% (Oct 20, 1 p.m., after Real Time Big Data, Alfa and França). → 20.5% (Oct 21, 7:20 a.m., after Meio/Ideia; 19.5% overnight). → 21.5% (Oct 21, 4:34 p.m., after Quaest, Palver and Futura; 22.5% at 12:03 p.m.). → 21.5% (Oct 22, 9 a.m., after Gerp, Veritá and PoderData 51 x 49; 20.5% on the evening of Oct 21). → 22.5% (Oct 22, 5 p.m., after Indexa 50.6 x 49.4; 21.5% to 22.5% over the day). → 22.5% (Oct 23, 3 a.m., after Datafolha 50 x 50 and Vox 50.6 x 49.4; steady since the afternoon of Oct 22). → 23.5% (Oct 23, 11 a.m., after Real Time Big Data 52 x 48, Alfa 52 x 48 and Ipespe 50.5 x 49.5). → 23.5% (Oct 23, 3 p.m., steady through the afternoon, after Vox, Real Time Big Data, Alfa, Ipespe and Veritá). → 20.5% (Oct 24, 3 a.m., after the Globo debate and Veritá 51.3 x 48.7). → 18% (Oct 24, 11 a.m., before Datafolha, AtlasIntel and Quaest; only Instituto França 51.4 x 48.6 and DataTrends 51.9 x 48.1 in valid votes). → 17.5% (Oct 24, 6 p.m., after CNT/MDA 50.7 x 49.3, Gerp 52 x 48 and Palver 50 x 49; 83.7% for Flávio at 5 p.m.; still without Datafolha, AtlasIntel and Quaest). → 20.5% (Oct 25, 8 a.m., Election Day, after Datafolha 51 x 49, Quaest 51 x 49, AtlasIntel 50.8 x 49.2, BTG/Nexus 50.5 x 49.5, Futura 50.1 x 49.9, Veritá 51.6 x 48.4, PoderData 51 x 49 and Colectta 49.7 x 50.3; 83.7% for Flávio on the afternoon of Oct 24).
Fundamentals (0%)
Lula 53% · Flávio 47%
Government rated great + good today: 34% (Datafolha Sep 22–23). Same measure for the last three incumbent presidents running (Datafolha, during the campaign): the tie point sat around 33.7% great + good (2006: 47.0 → +21.7; 2014: 39.0 → +3.3; 2022: 31.0 → -1.8). Expected margin: +0.4.
Ensemble (central)
Lula 18% · Flávio 82%
median -2.3 · 80% between -6.1 and +1.0 · |margin| < 1.5: 30%
Two models in parallel: Monte Carlo and MCMC. The main model estimates each parameter at its most likely value (how fast the margin moves, each pollster's deviation and noise) and then draws the drift and polling error separately. The parallel model uses the same polls, the same assumptions and the same sources of uncertainty, but draws everything jointly via MCMC (4 chains × 4,000 draws), so that uncertainty about the parameters themselves also reaches the forecast. If the two agree, the headline does not depend on the main model's shortcut; if they diverge, the table shows where the difference comes from. The headline remains the main model.
margin -20.0 to -19.0 pp · main 0.0% · MCMC 0.0% of simulations margin -19.0 to -18.0 pp · main 0.0% · MCMC 0.0% of simulations margin -18.0 to -17.0 pp · main 0.0% · MCMC 0.0% of simulations margin -17.0 to -16.0 pp · main 0.0% · MCMC 0.0% of simulations margin -16.0 to -15.0 pp · main 0.0% · MCMC 0.0% of simulations margin -15.0 to -14.0 pp · main 0.0% · MCMC 0.0% of simulations margin -14.0 to -13.0 pp · main 0.0% · MCMC 0.0% of simulations margin -13.0 to -12.0 pp · main 0.0% · MCMC 0.0% of simulations margin -12.0 to -11.0 pp · main 0.1% · MCMC 0.0% of simulations margin -11.0 to -10.0 pp · main 0.1% · MCMC 0.0% of simulations margin -10.0 to -9.0 pp · main 0.2% · MCMC 0.0% of simulations margin -9.0 to -8.0 pp · main 0.4% · MCMC 0.1% of simulations margin -8.0 to -7.0 pp · main 0.9% · MCMC 0.2% of simulations margin -7.0 to -6.0 pp · main 2.2% · MCMC 0.6% of simulations margin -6.0 to -5.0 pp · main 4.7% · MCMC 1.8% of simulations margin -5.0 to -4.0 pp · main 9.2% · MCMC 4.9% of simulations margin -4.0 to -3.0 pp · main 14.6% · MCMC 11.2% of simulations margin -3.0 to -2.0 pp · main 18.6% · MCMC 18.1% of simulations margin -2.0 to -1.0 pp · main 18.4% · MCMC 21.5% of simulations margin -1.0 to 0.0 pp · main 14.1% · MCMC 18.5% of simulations margin 0.0 to 1.0 pp · main 8.6% · MCMC 12.3% of simulations margin 1.0 to 2.0 pp · main 4.3% · MCMC 6.5% of simulations margin 2.0 to 3.0 pp · main 2.0% · MCMC 2.8% of simulations margin 3.0 to 4.0 pp · main 0.9% · MCMC 1.1% of simulations margin 4.0 to 5.0 pp · main 0.4% · MCMC 0.5% of simulations margin 5.0 to 6.0 pp · main 0.2% · MCMC 0.1% of simulations margin 6.0 to 7.0 pp · main 0.1% · MCMC 0.0% of simulations margin 7.0 to 8.0 pp · main 0.0% · MCMC 0.0% of simulations margin 8.0 to 9.0 pp · main 0.0% · MCMC 0.0% of simulations margin 9.0 to 10.0 pp · main 0.0% · MCMC 0.0% of simulations margin 10.0 to 11.0 pp · main 0.0% · MCMC 0.0% of simulations margin 11.0 to 12.0 pp · main 0.0% · MCMC 0.0% of simulations margin 12.0 to 13.0 pp · main 0.0% · MCMC 0.0% of simulations margin 13.0 to 14.0 pp · main 0.0% · MCMC 0.0% of simulations margin 14.0 to 15.0 pp · main 0.0% · MCMC 0.0% of simulations margin 15.0 to 16.0 pp · main 0.0% · MCMC 0.0% of simulations margin 16.0 to 17.0 pp · main 0.0% · MCMC 0.0% of simulations margin 17.0 to 18.0 pp · main 0.0% · MCMC 0.0% of simulations margin 18.0 to 19.0 pp · main 0.0% · MCMC 0.0% of simulations margin 19.0 to 20.0 pp · main 0.0% · MCMC 0.0% of simulations -20 -16 -12 -8 -4 0 +4 +8 +12 +16 +20 Lula − Flávio margin on Oct 25 (pp of valid votes; tails beyond ±20 added to the end bins) main (Monte Carlo): Lula 17% · median -2.0 · 80% from -4.8 to 0.7 MCMC (full posterior): Lula 23% · median -1.4 · 80% from -3.7 to 1.1
QuantityMain (Monte Carlo)MCMC (full posterior)
Lula's chance · polls only17%23%
Lula's chance · ensemble18%23%
Median margin on Oct 25-2.0-1.4
80% range of the final margin-4.8 to +0.7-3.7 to +1.1
True margin today (net of polling error)-2.0 ± 4.5-1.3 ± 3.8
Drift through Oct 25 (SD)0.00.0
σrw: daily change until the first round (pp)0.31 (fixed)0.31 (80%: 0.22 to 0.43)
σbetween: daily change between rounds (pp)0.95 (fixed)0.63 (80%: 0.39 to 0.95)
Common polling error β, beyond the historical discountdrawn: t5(0; 2.5)-0.6 (80%: -2.7 to 1.5)
Convergence—max R̂ 1.014 · min ESS 589

Difference in Lula's polls-only chance: +6.7 pp (MCMC minus main). Today's true margin differs by +0.7 pp between the two models. With new polls, the two readings tend to converge.

What changed in the model (v3, Sep 30 audit). The table starts from the previous model and switches on one correction at a time, showing each candidate's polls-only chance after each step. All use the same random seed, so the differences come from the factor, not from chance. The biggest change is how house effects are identified: before, the "consensus" was the average of all pollsters (including the biggest outliers); now it is the average of pollsters with a verified track record, the same set in which the historical error is measured, which eliminates double counting.
StepLula · polls onlyFlávio · polls onlyEffect of the step
Previous model (v2): anchor on all pollsters, bias +2.1, drift 4.321.6%78.4%
+ anchor on calibrated pollsters (house-effect identification)30.4%69.6%+8.8 pp for Lula
+ recalibrated bias (+0.9 ± 2.0)38.9%61.1%+8.5 pp for Lula
+ calibrated two-phase drift (0.0)24.8%75.2%-14.1 pp for Lula
+ P(Flávio in the runoff) from the first-round model (1.00)15.7%84.3%-9.1 pp for Lula
+ bias of runoff polls fielded before the first round (+0.8 ± 1.98)16.5%83.5%+0.8 pp for Lula
Adjusted polling margin in the last week (Lula − Flávio, pp of valid votes)-2-10+1+2+3+4+5+6+7+8
Lula's chance10%20%36%54%72%85%94%98%99%100%100%
Flávio's chance90%80%64%46%28%15%6%2%1%0%0%

Break-even point (50% each): a +0.8 Lula margin in the final polls, which equals the historical error being discounted. Lula reaches 70% at +1.9 and 90% at +3.5; Flávio reaches the same 70% and 90% at margins of -0.3 and -1.9 (symmetric around break-even). Today the adjusted margin is -1.3.

Lula wins if…

  • He takes at least 62% of the 7.8% who voted for the other candidates (Quaest's second-choice question gave him about 25%; the post-first-round PoderData, 30% to 43% depending on the candidate), or makes up the gap by bringing in voters who abstained in the first round
  • He wins back lost ground in the Southeast (40.7% of valid votes, where he lost 4.3 points of margin vs. 2022): every 3 points there are worth about 1.2 nationally
  • Polls fielded after Oct 4 show Lula ahead by more than the anchor's first-round error (+4.7)
  • Flávio's rejection overtakes Lula's; today they are tied

Flávio wins if…

  • He takes at least 38% of the other candidates' votes (Quaest's second-choice question gave him 36% to 59%; the post-first-round PoderData, 38% to 57%)
  • He holds Minas Gerais and widens his lead in the South and Center-West
  • Turnout repeats the first round's, with no extra mobilization in the Northeast
  • The polls repeat their first-round error in the runoff
Database

Individual polls and weights

In one sentence: each pollster's latest poll, the weight it gets in the calculation and the typical deviation (house effect) that the dashboard corrects.

Latest wave from each pollster in the Lula × Flávio scenario, all from professional pollsters with a sampling plan and polls registered with the Electoral Court — no informal straw poll (a survey without statistical methodology, banned since Aug 16, 2026) enters the database. The margin is calculated on valid votes (Lula ÷ (Lula + Flávio)), as in the official result. "Sponsor" is whoever commissioned the poll; "Margin of error" is the standard sampling margin at 95% confidence (p=0.5), calculated from n — for the official PesqEle/TSE registration of each round, see each poll's source (links in the full version). "House effect" is the pollster's systematic deviation in 2026 relative to the anchor, the pollsters with a verified presidential track record (positive = shows Lula stronger than the anchor; negative = shows Flávio stronger); σp is the pollster's extra variance beyond sampling error, estimated from the data, and "weight" is each poll's relative precision in the model (1 = the most precise); "MAE 2006–22" is the mean absolute error on the margin in the final polls before each round of the 2006 to 2022 elections in which the pollster appears.
PollsterSponsorFieldworknMethodMargin of error (95%)Lula × FlávioValid-vote marginHouse effectAdjusted marginExtra variance and weightMAE 2006–22
Futura (education from PNAD) · 21 waves Apex/100% Cidades 24/10 2,000 phone ±2.2 pp 47.9 × 48.2 -0.3 -0.4 +0.1 σp = 0.8 · weight 0.55 2.4
PoderData · 17 waves Aya 24/10 4,000 phone ±1.5 pp 47.0 × 49.0 -2.1 -1.2 -0.9 σp = 0.8 · weight 1.00 · anchor 4.5
BTG/Nexus · 19 waves BTG Pactual 24/10 2,000 phone ±2.2 pp 46.0 × 47.0 -1.1 -0.6 -0.5 σp = 0.8 · weight 0.54 —
Quaest · 19 waves Globo/O Globo 24/10 3,702 in-person ±1.6 pp 45.0 × 46.0 -1.1 -0.5 -0.6 σp = 0.8 · weight 0.90 · anchor 4.0
Datafolha · 18 waves Folha/Globo 24/10 4,050 in-person ±1.5 pp 47.0 × 48.0 -1.1 0.0 -1.1 σp = 0.8 · weight 1.00 · anchor 3.4
Palver (v3 calibration) · 9 waves self-funded 24/10 5,000 online ±2.3 pp 49.0 × 50.0 -1.0 -0.3 -0.7 σp = 0.8 · weight 0.51 —
Gerp · 20 waves self-funded 24/10 2,400 phone ±2.0 pp 46.0 × 49.0 -3.2 -3.8 +0.7 σp = 0.8 · weight 0.64 —
Instituto França · 3 waves self-funded 23/10 1,537 not reported ±2.5 pp 43.1 × 45.6 -2.8 -0.2 -2.6 σp = 0.8 · weight 0.40 —
Colectta · 1 wave Brazil Economy 23/10 3,000 in-person ±1.8 pp 46.5 × 46.0 +0.5 +0.2 +0.3 σp = 0.8 · weight 0.76 —
CNT/MDA (weighted by the first round) · 7 waves CNT 23/10 2,032 in-person ±2.2 pp 45.0 × 46.3 -1.4 -0.3 -1.1 σp = 0.8 · weight 0.54 3.9
Veritá · 9 waves self-funded 23/10 15,554 phone ±1.4 pp 46.5 × 49.4 -3.1 -2.7 -0.4 σp = 1.3 · weight 0.83 3.8
AtlasIntel · 17 waves Bloomberg 23/10 4,990 online ±2.0 pp 48.4 × 49.9 -1.5 -0.2 -1.3 σp = 0.8 · weight 0.69 · anchor 4.5
Ipespe · 3 waves Abrapel 22/10 1,500 phone ±2.5 pp 47.0 × 48.0 -1.1 -0.9 -0.2 σp = 0.8 · weight 0.42 · anchor 4.9
Alfa Inteligência · 6 waves self-funded 22/10 2,700 in-person ±1.9 pp 46.0 × 49.9 -4.1 +0.1 -4.2 σp = 1.0 · weight 0.65 —
Real Time Big Data · 9 waves self-funded 22/10 2,000 phone ±2.2 pp 45.0 × 49.0 -4.3 -0.8 -3.5 σp = 0.8 · weight 0.54 —
Vox Brasil · 11 waves self-funded 22/10 2,100 in-person ±2.1 pp 45.4 × 44.3 +1.2 -0.7 +1.9 σp = 1.4 · weight 0.45 —
Indexa · 8 waves Broadcast/Estadão 21/10 2,000 phone ±2.2 pp 44.1 × 45.2 -1.2 +0.5 -1.8 σp = 0.8 · weight 0.52 —
DataTrends · 5 waves self-funded 21/10 2,000 phone ±2.2 pp 46.0 × 50.0 -4.2 -0.9 -3.2 σp = 0.8 · weight 0.55 —
Meio/Ideia · 11 waves Canal Meio 19/10 1,500 phone ±2.5 pp 46.0 × 48.0 -2.1 -0.4 -1.7 σp = 0.8 · weight 0.42 —
American Analytics · 2 waves Times Brasil/CNBC 20/09 2,000 not reported ±2.2 pp 43.0 × 43.0 0.0 0.0 0.0 σp = 0.8 · weight 0.50 —
Vetor/Arrow · 2 waves self-funded 18/05 9,000 not reported ±1.4 pp 50.7 × 38.3 +13.9 -0.4 +14.3 σp = 6.0 · weight 0.08 —
Paraná Pesquisas · 4 waves self-funded 28/03 2,080 in-person ±2.1 pp 44.1 × 45.2 -1.2 -2.0 +0.8 σp = 0.8 · weight 0.54 · anchor 3.8
Methodology

Aggregation methodology (version 3)

In one sentence: a single statistical model simultaneously estimates the trend, each pollster's deviation and how much each one errs beyond its stated margin; the trend is anchored on pollsters with a verified track record, and that same set's historical error is discounted in the simulation.

Read the full analysis≈ 20 min read
Four layers, each correcting a distinct source of error: pollster error (house effects and extra variance), timing error (daily trend), systematic error of the polls as a whole (historical calibration 2006–2022) and what can still change before the election (drift). Everything is reproducible in the attached Python script. The Sep 30, 2026 audit replaced the previous version; the "what changed" table in the Model section shows the effect of each correction.

1 · Trend, house effects and variance by pollster: a single model

yi = xt(i) + hp(i) + εi · εi ~ N(0, SEsampling,i² + σp(i)²)
xt = xt−1 + ηt, η ~ N(0, σrw²) · hp ~ N(0, 1.5²) · Σp ∈ anchor qp·hp = 0

This is the core of the benchmark aggregators (Jackman 2005; Linzer 2013; Heidemanns, Gelman and Morris for The Economist): the true margin xt moves a little each day and each poll is a noisy measurement of it, shifted by the pollster's house effect hp. The three blocks are estimated jointly, by iteration: a Kalman smoother for xt, a shrunken posterior mean for hp and, for σp, the variance left in each pollster's residuals beyond sampling error, shrunk toward the median. Erratic pollsters (Veritá, Vetor, Gerp, CNT/MDA) get a high σp and weigh less, with no arbitrary weights. σrw = 0.31 pp/day in the campaign window (since Jun 1, 165 polls), estimated with a prior centered on 0.25 (without the prior: 0.32).

2 · The anchor: who sets the "zero" for house effects

A house effect is a deviation from some reference, and the choice of reference sets the level of the whole curve. The previous version used the average of all pollsters, weighted by number of waves, which left the "consensus" in the hands of whoever publishes most (Gerp, Futura, Palver and Veritá, the biggest outliers toward Flávio). Now the reference is the anchor: AtlasIntel, Datafolha, Ipespe, CNT/MDA, Paraná Pesquisas, PoderData, Quaest, the pollsters with a verified presidential track record in 2014–2022, weighted by historical quality. Only their average is fixed at zero; each one can deviate. The reason is consistency: the historical error discounted in step 3 was measured on these same pollsters, so level and discount refer to the same thing. Switching the anchor shifts today's level by +1.7 pp (see "what changed").

2b · State polls

swinguf = margin2026,uf − margin2022,uf ~ N(μ, τ²) · national = +1.80 + Σ weightuf·swinguf

Each recent state poll becomes a swing relative to the state's 2022 runoff, corrected for the pollster's national house effect and for movement in the margin since the end of fieldwork. Two-level shrinkage, state → region → country (DerSimonian-Laird, τ = 7.2); the electorate-weighted sum gives a second reading of the national margin (+3.6), combined with the national one assuming a 0.3 correlation between the errors (current weight: 0%), provided the states with polls add up to at least 30% of the electorate.

2c · Validating the model itself (rolling origin)

For each of the last 60 days, the model was refit using only polls up to that day and predicted the polls of the following 7 days. Across 233 forecasts: the 80% interval covered 85% of cases and the 95% interval covered 94% (adequate calibration); mean absolute error of 1.9 pp; mean bias of -0.3 pp (polls came in, on average, more favorable to Flávio than predicted a week earlier, reflecting the August–September slide, which a random walk does not extrapolate). Broken down: -0.3 pp for pollsters the fit already knew (217 forecasts) and -0.8 pp for those that joined the database later (16), which debut with a zero house effect. This bias is information and is not corrected: extrapolating the trend was the flaw of the previous version.

Where the movement comes from. How much today's margin would change if each poll from the last 7 days were removed (positive = the poll pulls toward Lula). In a series that ends today, the most recent poll weighs the most.

PollFieldwork throughEffect on the runoff marginEffect on the first-round margin
Alfa Inteligência22/10-0.09—
Gerp24/10+0.09—
PoderData21/10+0.06—
Vox Brasil22/10+0.06—
Real Time Big Data22/10-0.06—
Colectta23/10+0.06—
Veritá22/10+0.05—
DataTrends21/10-0.05—

3 · Systematic error: the historical discount

bias ~ t5(+0.8, 2.0) · waves before Oct 4: the anchor's observed first-round error; waves after Oct 4: final runoff rounds (no longer inheriting the first-round error since Oct 22, because it was tactical voting); mixed by the share of the margin that comes from new waves, applied only to the portion of the margin that comes from polls

In the five runoffs from 2006 to 2022, the national pollsters' final polls missed the PT − right margin by +0.3, -0.1, +1.1, -1.2 and +2.9 points (table below; positive = overestimated the PT). The recency-weighted average is +0.9; the simple average, +0.6. The previous version used +2.1, with 2.5× weight on the 2022 cycle. The deviation between cycles (σ = 2.0) enters as uncertainty. After the first round. The anchor's polls missed the first-round Lula − Flávio margin by +4.7 pp (positive = overestimated Lula). Runoff waves fielded before Oct 4 are the same ones that carried that error and receive the observed discount (+4.7); those fielded after receive only the discount from the final runoff rounds (+0.9). Until Oct 22 they also received 25% of the first-round error; the Oct 22 revision (v56) removed that inheritance because the first-round error had the signature of tactical voting: the final polling average missed Lula by -0.3 pp and Flávio by +4.2, while the other candidates lost 4.0 points, nearly all of which went to Flávio, and that mechanism does not exist in a two-candidate runoff. The runoff's own error remains in the discount: in the final rounds from 2006 to 2022 it was +0.3 (2006), -0.1 (2010), +1.1 (2014), -1.2 (2018), +2.9 (2022), with more weight on recent cycles. The inheritance took about 8 points off Lula's chance in the main model (12.8% vs. 20.6% with the Oct 22 data). Today 57 post-first-round waves account for 100% of the polling margin. The official result, with the vote transfer from the other candidates, gives a starting margin of -2.7 ± 2.5 on Oct 4, which becomes ± 5.0 today once the electorate's drift since then is added; the polls give -0.9 ± 0.7 before the discount. The dashboard combines the two by each one's total error (for the polls, sampling plus systematic): today the official result weighs 21% and loses weight every day, because the Oct 4 snapshot ages as new polls arrive. (Oct 4 revision: the previous rule applied the new-poll discount to the old waves and compared counted votes only with the polls' sampling error; with the result in hand, that took Lula's chance from 28% to 37%.) The sensitivity to this assumption is in the adjacent table: it is the choice that changes the result most, which is why it is kept in view. After the first round, runoff polls already incorporate the result and the final-poll discount applies again; each pollster's first-round error becomes a prior, at half weight, for its runoff house effect.

4 · Simulation through Oct 25

marginfinal = xtoday + N(0, 1.2) + drift t5(0, 0.0) − polling error t5(+0.8, 2.0)
with P(opponent = Flávio) = 100% (first-round model); otherwise margin ~ N(+7, 4)

400,000 simulations. Two-phase drift: until Oct 4, σrw = 0.31 pp/day; between rounds, 0.95 pp/day, calibrated on the movement of Datafolha and Ibope/Ipec runoff polls between the first and last rounds in 2014, 2018 and 2022 (table below; net of sampling error and shrunk 50% toward σrw, since there are only three cycles). Total: 0.0 pp through Oct 25. Drift and polling error have Student's t tails (5 degrees of freedom, same SD): with three to five calibration cycles, the normal underestimates surprises. Regional shocks were removed from the simulation in the Oct 3 audit: the historical error was already measured on the national result, which includes the regional errors. P(opponent = Flávio) comes from the first-round simulation, with a 0% risk of disqualification.

5 · Ensemble

P = 75% · model + 25% · markets (Polymarket Oct 25, 8 a.m.: Lula 20% × Flávio 80%) + 0% · fundamentals (great + good 34.0% · margin = 1.47·(great + good − 33.7))

A mixture of distributions, not just of probabilities, so the scenarios are consistent with the ensemble. The fundamentals (a line fit on three elections, with forecast error above 8 points) have had zero weight since the Oct 3 audit. The market only enters with prices up to 5 days old and, after the first round, from after Oct 4; otherwise, the ensemble is the model itself. After the first round, the official result informs the model directly. Markets react quickly to news, but they are not independent of the polls.

5b · Alternative readings of the hero: only polls fielded after the first round

The buttons at the top of the page swap the headline and the chance bar for extra runs of the same model in which the runoff database keeps only the 57 polls with fieldwork after Oct 4 (19 pollsters; 19 waves from 5 pollsters with a calibrated track record: AtlasIntel, Datafolha, Ipespe, PoderData, Quaest), so that each pollster's deviation is estimated on them alone, without carrying over half the deviation measured before the first round (λ = 0.5). Everything else is the same: anchor, house-effect prior from each pollster's first-round error, drift through Oct 25 and ensemble with the markets. Full model today: margin -1.3 ± 1.17, Lula's chance 17% on polls alone and 18% in the ensemble, with 21% of the level coming from counted votes (-2.7). In the post-first-round polls + counted votes reading, the level is combined with the starting margin as in the full model (weight 21%): margin -1.9 ± 1.19, Lula's chance 11% on polls alone and 13% in the ensemble; the difference from the full model isolates the effect of estimating pollster deviations on the new waves only. In the post-first-round polls only, without counted votes reading, the level is not combined with the starting margin and the historical-error discount falls entirely on the polls (+0.9 ± 2.5, vs. an effective +0.8 in the full model): margin -1.7 ± 0.69, Lula's chance 13% on polls alone and 15% in the ensemble.

What these readings measure well: they do not depend on the assumption that half of each pollster's pre-first-round deviation persists afterward (poorly identified in the Oct 16 audit) and, in the version without counted votes, nor on the stated vote transfer. What they measure poorly: with 18 days of data the margin's daily speed is weakly identified (σrw 0.29 vs. 0.31 in the long series); pollsters with a single wave stay close to the prior; the uncertainty of the anchor's average rises (0.17 vs. 0.10); and no rolling-origin validation is possible (they inherit the main model's calibration). That is why the whole page, sharing and the no-JavaScript version follow the full model; the variants are sensitivity readings, saved in resultados_pos1t_estrut.json and resultados_pos1t.json.

5c · Reading with the direction of the latest waves (momentum)

The fourth button keeps the full model intact (level, house effects, counted votes, discount) and changes one thing: the drift through Oct 25, which in the main model is drawn with zero mean, gets a mean. It comes from the change panel: the aggregate of differences between consecutive waves from the same pollster, in pp per day, weighted by each difference's error and by recency (3-day half-life), currently stands at +0.40 ± 0.93 pp per week for Lula. The model uses half of it (50% shrinkage, because the difference between two waves adds up the sampling error of both and there is no historical validation of this reading) times the 0 days remaining (zero on voting day, when the variant coincides with the main model), with an SD equal to the aggregate's standard error (floor of 0.1 pp per day): mean drift 0.00 ± 0.00 pp. The parallel MCMC receives the same prior (drawn at each iteration, with no likelihood of its own). Result: Lula's chance 17% on polls alone and 18% in the ensemble (MCMC 23% and 23%), vs. 17% and 18% in the full model. Limitations: the aggregate is dominated by the 3 or 4 newest differences, changes with every published poll and cannot tell real movement from noise when pollsters disagree; that is why it is a sensitivity reading, saved in resultados_momentum.json, and not the headline.

6 · First round

states: Lula − Flávio margin, sum of the two and one per minor candidate (valid votes) · house effects by pollster · bias ~ t5(+3.4; 3.8) on the margin · late third-way collapse ~ Beta(2, 8), with zero mean effect on the margin

Same model as in step 1, applied to the Lula − Flávio margin, to the sum of the two and to each minor candidate across the 75 first-round polls since August, with the same anchor. Until Oct 3, Lula and Flávio were two independent series, which ignored that they compete for the same votes and left the margin overly sensitive to the latest poll. In the simulation (200,000), candidates are drawn and renormalized to 100%. The historical first-round discount comes from the four verifiable cycles (+5.2, +10.5, +2.4, +5.8: the final polls overestimated the PT's margin over its main rival), shrunk to 60% of the recency-weighted average, with the predictive deviation between cycles (± 3.8) and Student's t tails. That error already contains the late third-way migration to the right-wing candidate (2014, 2022), so the simulated collapse only redistributes votes and adds uncertainty, without shifting the mean margin again (it would shift it by -0.5 pp). Minor candidates use a common random walk (0.17 pp/day) and only waves from the last 4 weeks: with each one's own random walk, Cury's drop in early September made the level copy the latest poll and the chance of 3rd place swing with every wave. The forecast is frozen at the last run before Oct 4 and compared with the official result.

7 · Parallel MCMC model

yi = xt(i) + μregime + β + hp(i) + εi · β ~ t5(0, 2.5) · structural level = xOct 5 + N(0, 2.5)
log σrw ~ N(log 0.25; 0.6) · log σbetween ~ N(log 0.95; 0.5) · log σp ~ N(m, 0.5²), σp ≥ 0.8 · marginfinal = xOct 25

The same model as steps 1 to 4, written as a single Bayesian model and estimated by MCMC (mcmc_2turno.py, with no libraries beyond numpy and scipy). Each poll measures the true margin plus its regime's historical discount (μ: waves through Oct 4 +4.7; later waves +0.9) and a common error β; the official first-round result with the transfer enters as a measurement of the margin without polling error. At each step of the chain, σrw and σbetween are drawn with the trajectory integrated out (given the rest, the model is linear and Gaussian), and the trajectory, β and the house effects come out together from an exact joint normal; σp and the center m come via Metropolis. Diagnostics: split R̂ and multichain effective sample size. A check in the script itself shows that, with the parameters fixed, the joint normal matches the Kalman smoother from step 1. Both models' forecasts are frozen on the eve of Oct 25 in previsoes_2turno_congeladas.json.

8 · Migration via Markov chain

destinationsu = nu · Mu · Mu[o, ·] ~ Dirichlet(κo · mo) · mthird way = 2nd choice + decided residual (d3, s3) · mabsent = (rinπu, rin(1−πu), ·, 1−rin)
campaign: xt+1 = xt · Pp,t over S0…S4 · reach Dp = Pp(1 − e−N/Pp) · conversion qp per contact, guide phases · margin = 100(ΣL − ΣF)/(ΣL + ΣF)

Parallel analysis (migracao_markov.py), conditional and with no effect on the headline: starting from the official result by state, a Markov chain takes each first-round voter (Lula, Flávio, each third-way candidate, blank/spoiled, absent) to one of the four runoff destinations via a matrix drawn row by row (Dirichlet around Quaest's stated second choice, the gross turnout flows and a profile tilt). A second chain, daily and non-homogeneous, follows the person-to-person outreach campaign from Oct 5 to 24 (reach via volunteers and chain recruitment; per-contact conversion taken from the experiments of Gerber & Green, Nickerson & Rogers and Kalla & Broockman; reversion; confirmation) and applies the converted stock as a flow between destinations, capped at the available mass. 20,000 draws per cell, across 4 reach scenarios × 2 Flávio responses, plus the "guide targets met" benchmark. Without a targeted campaign the median margin is −2.7; with the strong one, −2.7. The "with drift" chance adds the same t5 drift from step 4, only for comparison with the main model.

Cycle2nd roundFinal polls from the reference pollsters (valid votes)ResultMean error on the PT − right margin
2006Lula × AlckminDatafolha 61×3960.8 × 39.2+0.3 (weight 0.25)
2010Dilma × SerraDatafolha 56×4456.0 × 44.0-0.1 (weight 0.35)
2014Dilma × AécioDatafolha 52×48 · Ibope 53×47 · CNT/MDA 49.7×50.3 · Vox Populi 54×4651.6 × 48.4+1.1 (weight 0.50)
2018Haddad × BolsonaroDatafolha 45×55 · Ibope 46×54 · CNT/MDA 43.2×56.8 · Paraná Pesquisas 39.4×60.6 · Vox Populi 50×50 · Ipespe 42×5844.9 × 55.1-1.2 (weight 0.71)
2022Lula × BolsonaroDatafolha 52×48 · Ipec 54×46 · Quaest 52×48 · AtlasIntel 53.4×46.6 · PoderData 53×47 · Ipespe 53×47 · CNT/MDA 51.1×48.9 · Paraná Pesquisas 50.4×49.650.9 × 49.1+2.9 (weight 1.00)
2026 priorper-cycle average of the reference pollsters; cycles weighted by recency (two-cycle half-life); Veritá, Futura and Brasmarket excluded from the reference+0.9 ± 2.0
Discount after the first roundpre-first-round waves: observed error +4.7; post waves: +0.9; weight of post waves: 100%; on the polling share (79%)+0.8 ± 2.0
Cycle1st roundPT − right margin in the final polls (valid votes)ResultMean error
2010Dilma × SerraDatafolha +19.0 · Ibope +20.0+14.3+5.2
2014Dilma × AécioDatafolha +18.0 · Ibope +19.0 · CNT/MDA +18.6+8.0+10.5
2018Haddad × BolsonaroDatafolha -15.0 · Ibope -16.0 · CNT/MDA -14.8 · Paraná Pesquisas -15.3 · Vox Populi -9.0 · Ipespe -16.0-16.8+2.4
2022Lula × BolsonaroDatafolha +14.0 · Ipec +14.0 · Quaest +11.0 · AtlasIntel +9.2 · PoderData +10.0 · Ipespe +14.0 · CNT/MDA +8.6 · Paraná Pesquisas +7.1+5.2+5.8
2026 prior (first round)60% of the recency-weighted average (+5.7)+3.4 ± 3.8
CyclePollsterFirst runoff poll (left × right)Last before the voteMargin movement
2014Datafolha49 × 51 (09/10)52 × 48 (25/10)+6 in 16 days
2014Ibope49 × 51 (08/10)53 × 47 (25/10)+8 in 17 days
2018Datafolha42 × 58 (10/10)45 × 55 (27/10)+6 in 17 days
2018Ibope41 × 59 (14/10)46 × 54 (27/10)+10 in 13 days
2022Datafolha53 × 47 (07/10)52 × 48 (29/10)-2 in 22 days
2022Ipec55 × 45 (05/10)54 × 46 (29/10)-2 in 24 days

Movement between rounds: root mean square = 6.4 pp; net of two sampling errors per pair of polls and divided by the days, 1.31 pp/day, shrunk to 0.95 pp/day.

Uncertainty breakdown (variance of the final margin)

Systematic polling error74%
Today's aggregate (sampling+houses)26%
Drift through Oct 250%

Today, systematic polling error accounts for 74% of the uncertainty about the final margin and the imprecision of today's aggregate (sample and house effects) for 26%; what can still change through Oct 25 weighs 0%. On voting day the drift is zero: what remains is what the polls get wrong as a bloc, the only portion that does not fade with more polls.

Sensitivity: each candidate's chance under alternative assumptions

LevelHistorical errorLulaFlávio
combined nowcastrecalibrated bias17%83%
combined nowcastno bias correction27%73%
weighted average of latestrecalibrated bias20%80%
weighted average of latestno bias correction31%69%
Discount appliedLulaFlávio
+0.0 p.p.27%73%
+0.8 p.p.17%83%
+0.9 p.p.15%85%
+2.1 p.p.6%94%

The reading most favorable to Lula (no discount) reaches 27%; the most favorable to Flávio (a +2.1 discount) gives Lula 6%. The markets (Lula 20%) fall within that range.

Methodology

History of changes and adjustments

In one sentence: all 60 versions of the dashboard, from Sep 8 to today, with what changed in the method, the data and the presentation and, when measured, the effect of each change on each candidate's chance.

Rules from the start: every method change is logged with its date and effect; the first-round forecast was frozen before the vote and never rewritten; corrections made after the result are flagged as such. The log lives in historico_versoes.json, in the repository, and the dashboard is generated from it.
Read the full analysis≈ 56 min read

Method milestones (18)

VersionDateChangeMeasured effect
v56Oct 22, 2026The discount on runoff polls no longer inherits the first-round error, which was tactical voting; only the historical error of the final runoff rounds remainsLula headline from 15% to 21% (main model 12.8% → 20.6%; MCMC 17.7% → 27.1%), with the same Oct 22 data; discount applied from +1.65 to +0.74; hero variants: post-first-round polls only 11.7% → 16.9%, with counted votes 12.1% → 16.4%, with the direction of the latest waves 16.7% → 23.4% (MCMC 21.2% → 30.5%)
v55Oct 21, 2026Change panel (differences within each pollster), fixed-effects trend gauge, hero reading with the direction of the latest waves, and consistency fixesnone on the headline (12.3% / 14.4%, margin −1.73); the fourth hero reading gives Lula 14.7% (ensemble 16.2%; MCMC 17.1% / 17.9%) with mean drift +0.33 ± 0.31; gauge from +1.1 to +1.9 pp/week when switching the pooled line for fixed effects; Futura Oct 19–20 as a new series (v2) since commit 5d0551f
v53.3Oct 18, 2026Weight of counted votes decays with drift; λ only on series with pre-Oct 4 waves; validation runs the dashboard's modelweight of the structural level from ~53% to 27% (declines on its own); 80% validation coverage from 82% to 85% (commit ec672a3, logged on Oct 21)
v53.2Oct 16, 2026λ = 0.5 on the post-first-round house effect; stated effective n; Palver v3 as a new series; 2,500 cap on AtlasIntelLula's chance 14.6% (model), 16.5% (ensemble); with λ = 1 it would be 18.5%, with λ = 0, 15.0% (commit 5f3d59c, logged on Oct 21)
v53Oct 6, 2026Migration of absent voters, third way and blank/spoiled: Markov chain + Monte Carlo, alongside the main modelnone on the headline; conditional analysis: strong campaign nets +130,000 votes; the guide's targets would require conversion 7 to 29× that in the literature
v51Oct 5, 2026Parallel runoff model estimated by MCMC (full posterior), alongside the main modelnone on the headline; MCMC: Lula 21% vs. 20% in the main model
v45Oct 4, 2026Official result; correction of the post-first-round discount; analysis of the comeback, the polls and the model's accuracyLula (polls only) 28.4% on the eve → 36.9% under the old rule → 18.1% corrected; ensemble 17.4%
v44Oct 3, 2026First-round forecast frozen before the vote—
v43Oct 3, 2026Second audit: post-first-round regime, t tails, fundamentals at zero weight, herding testensemble 34.2% → 31.9%; Lula 1st in the first round 38% → 45%
v41Oct 3, 2026Audit: third way without double counting, pre-first-round discount in the runoff, homogeneous fundamentalsLula (polls only) 50% → 31%; ensemble 48% → 35%
v39Oct 2, 2026State-level reading: map by state and state polls in the national margin—
v37Oct 1, 2026Abstention enters the model (turnout scenarios by state and profile)—
v33Sep 30, 2026Audit and model v3: anchored state space, recalibrated bias, first-round forecastLula (ensemble) 37.7% → 47%
v28Sep 28, 2026Trend thermometer for the last two weeks—
v25Sep 24, 2026Model v2: state polls, Kalman filter and new factorssee the "What changed in the model" section
v16Sep 17, 2026Bias section: sample flaws, margin of error and historical calibration—
v2Sep 8, 2026Weighting by recency, sample size and quality; database of 41 waves—
v1Sep 8, 2026First version: simple average of the latest polls from five pollsters—
Full log, from the most recent version to the first (18 method, 32 data, 10 dashboard)
  • v56 — Oct 22, 2026 · discount without first-round inheritance [method] External critique (Oct 22): the first-round error was a preemptive tactical vote and should not be discounted in the runoff. The Oct 4 rule added to the discount from the final runoff rounds (+0.95, recency-weighted average of the 2006–2022 cycles) 25% of the anchor's common first-round error (0.25 × 4.74 = +1.19), applied to the portion of the margin that comes from polls. Breaking down the 2026 error by candidate confirms the tactical-voting reading: the final polling average had Lula at 45.8 (ballot box 45.16, −0.6), Flávio 43.1 (ballot box 47.03, +3.9) and the others 11.0 (ballot box 7.8, −3.2). The polls got Lula right; what the minor candidates lost went almost entirely to Flávio, a mechanism that does not exist in a two-candidate runoff. The 0.25 coefficient came from three cycles in which the runoff error tracked the first-round error, without separating out the tactical-voting part. Now RHO_1T_2T = 0: post-first-round waves receive only the discount from the final runoff rounds, whose sign varies (2018 −1.2; 2022 +2.9), with σ = 2.5 kept. Runoff waves fielded before Oct 4 keep the observed error, but weigh less than 1% of the margin. Unchanged: momentum remains only a variant (fourth hero button, drift with half the aggregate of within-pollster differences), because the pace fell from +3.0 to +1.8 pp/week and, in 2014 and 2018, extending the trend beyond the last poll would have erred more. In the same critique, full momentum would give 26.3% (MCMC 35.0%). Caveat: this is a method change three days before the election, prompted by a critique made after seeing the data. The justification is the breakdown of the first-round error, which predates this week and does not depend on the direction of the runoff polls.
  • v55 — Oct 21, 2026 · change panel and momentum [method] Critical review of the aggregator (analise-modelo-agregacao-2026-10-21.md) prompted by the critique that the model carries inertia from the past and should read the "present-day drive" in within-pollster differences. (1) New #mudanca section (mudanca_2turno.py): for each series with two consecutive waves after Oct 4, the change in the valid-vote margin, weighted by the error of the difference (sum of the two sampling errors) and by recency (3-day half-life); the literal version (only each house's latest difference) alongside; v2/v3 series do not pair with the old one; Gerp tracking only at 5 days or more; rejection and available room (100 − vote − rejection) in the same machinery when there are post-first-round waves in rejeicao_2026.csv. Today: +1.17 ± 1.08 pp/week for Lula, slowing (long window +1.55). (2) Gauge: slope with a per-series intercept (fixed effects), which cancels the house effect without estimating it and stops confusing composition with trend; weights using σinst instead of the residuals' RMS (which already contained the sampling error); pace needed to overturn based on the model's polling level; "Comeback under way" only when the pace catches up. (3) ELEICAO_MOMENTUM=1 variant (resultados_momentum.json, fourth hero button, step 5c): the drift through Oct 25 gets a mean = half the aggregate × days remaining, with the SD shrunk in the same proportion (floor 0.1/day); the MCMC receives the same prior. (4) Consistency: the error of the thermometer series and of serie_pos1t from the polls only (previously the merged level's); bias_prior_sd is the prior again; the frozen file stores nivel_pesquisas and nivel_ajustado; MCMC formula with the Oct 4 structural SD (2.5); notes for Palver v3, MDA v2 and Futura v2; first-round error prior explicitly not applied to the new series. Also logged on this date: versions v53.1 to v53.4 (Oct 13–19), which existed only in the commits.
  • v54 — Oct 19, 2026 · hero toggle [dashboard] Extra runs of modelo_eleicao_2026.py (variable ELEICAO_SO_POS1T: estrut writes resultados_pos1t_estrut.json, with counted votes combined as in the full model; 1 writes resultados_pos1t.json, without them): the runoff database keeps only waves with fieldwork after Oct 4, each pollster's house effect is estimated on them alone (without carrying over 50% of the deviation measured before the first round) and, in the version without counted votes, the level is not combined with the starting margin and the historical-error discount falls entirely on the polls. Anchor, first-round error priors, drift and ensemble are the same. The hero gets three buttons; the JS swaps the headline, subtitle, bar and caption, and a note explains where the difference comes from. Step 5b of the methodology records what the reading measures well (independent of λ and of the stated transfer) and poorly (12 days do not identify σrw; single-wave pollsters stay at the prior; no rolling-origin validation). The main run does not change: zero difference in resultados.json other than timestamps.
  • v53.4 — Oct 19, 2026 · evolution chart [dashboard] New #evolucao section (evolucao_pesquisas.py): Lula and Flávio points per poll, reading by point and by day, table with sources; summary line = the model's adjusted margin day by day (does not change with the selector); with a partial selection, a dashed simple average of the selected ones (4-day half-life, 14-day window).
  • v53.3 — Oct 18, 2026 · logic audit [method] The Oct 4 snapshot (result + transfer) was treated as a measurement of today with SD 2.5 and stayed stuck at ~53% of the level; it now adds the σbetween drift per day, as the MCMC already did. λ applies only to series with waves before Oct 4 (Palver v3 and MDA v2 identify the full h). The rolling-origin validation now runs the same model as the dashboard (first-round error prior and λ). Gauge: house effect applied (λ·h) and effective n, as in the model.
  • v53.2 — Oct 16, 2026 · audit of post-first-round polls [method] Audit of the post-Oct 4 waves against the technical reports: Palver changed its calibration (v3, not comparable) and becomes a new series; all waves now use the stated effective n (column n_efetivo); Ipespe is CATI. In the model, the house effect measured before the first round counts at half (λ = 0.5) in the new waves, because the observed slope was +0.58 ± 0.68 and the spread across pollsters grew with the full effect; AtlasIntel with a maximum effective n of 2,500.
  • v53.1 — Oct 13, 2026 · trend gauge [dashboard] New #ponteiro section (ponteiro_tendencia.py): slope of the Lula − Flávio margin in polls with fieldwork after Oct 4, corrected for house effect, with a ±3 pp/week prior while there are few polls; gauge with today's needle, the one from 3 days ago and a mark for the pace needed to overturn.
  • v53 — Oct 6, 2026 · migration via Markov chain [method] New script migracao_markov.py and Migration section in Part 2, between Abstention and Scandals. Starting from the official result by state, a Markov chain takes each first-round voter (Lula, Flávio, each third-way candidate, blank/spoiled, absent) to one of the four runoff destinations via an 8 × 4 matrix drawn row by row (Dirichlet around Quaest's stated second choice, the gross turnout flows and a profile tilt); a second chain, daily and non-homogeneous, follows the person-to-person outreach campaign from Oct 5 to 24 (volunteers and chain recruitment, per-contact conversion taken from Gerber & Green, Nickerson & Rogers and Kalla & Broockman, reversion, confirmation), with a symmetric response from Flávio and an open call that backfires in his states. 20,000 draws per cell, 4 reach scenarios × 2 responses plus the "guide targets met" benchmark. Result: without a targeted campaign the margin goes to −3.85 (80% between −4.7 and −3.0; reproduces the transfer model); the strong campaign (500,000 initial volunteers, 1.3 million by the end, 3.5 million names) nets +130,000 votes and moves the margin 0.12 point; the maximum one, +250,000; with a symmetric response from Flávio, +50,000. The guide's targets (+5.1 million) would take the margin to +0.4, but would require, at that reach, per-contact conversion 7× (absent voters) to 29× (Flávio voters) that in the literature. Lula's chance "with drift" (same t5 drift as the main model, only for comparison): 14.6% without a campaign, 15.2% with the strong one. Self-checks in the script: mass conserved, rows sum to 1, chain in [0, 1], reproduction of the structural model (−3.85 = −3.85), monotonic ladder, fixed seed. Step 8 of the methodology and two new glossary terms (Markov chain, Dirichlet). The headline remains the main model.
  • v52 — Oct 5, 2026 · day after the first round [data] Sweep on the day after the first round: no national runoff poll with fieldwork after Oct 4 had been released (Datafolha and PoderData come out Oct 8; AtlasIntel and Vox on Oct 9; Ipespe on Oct 10). Polymarket read on three mirrors on the evening of Oct 5: Lula 16% (it was 15% on election night). New box in Scandals with runoff endorsements (Zema declares his vote for Flávio; Cury and Renan neutral; PSD frees its members and Caiado decides with Kassab), the polling calendar and the debates (Band/Estadão and Record on Oct 11, Globo on Oct 23). Re-collection of municipalities from the TSE to complete those left with partial files on the night of Oct 4.
  • v51 — Oct 5, 2026 · parallel MCMC model [method] New script mcmc_2turno.py: the same runoff model (polls in the window, house effects anchored with a prior from each pollster's first-round error, the Oct 4 break, the structural level of the counted vote, regime-based bias discount with t tails, two-phase drift), written as a single Bayesian model and estimated by MCMC, with 4 chains of 4,000 draws. σrw and σbetween are drawn with the trajectory integrated out; trajectory, common error and house effects come from an exact joint normal; σp comes from Metropolis. Diagnostics: R̂ and effective sample size. At launch, today's true margin is the same in both (−3.9); Lula's chance is 21% in the MCMC versus 20% in the main model, because the speed of change between rounds is no longer fixed and widens the drift (deviation 5.4 × 4.2). Both forecasts will be frozen on the eve of Oct 25 in previsoes_2turno_congeladas.json. The headline is still the main model; none of its numbers changed.
  • v50 — Oct 5, 2026 · Generations section [dashboard] New section Generations (#geracoes): is there a new, more right-leaning generation of voters? It combines eve-of-election Datafolha polls broken down by age and profile (2010–2022; for 2026, the average of the Lula × Flávio simulations in the rounds from Sep 17 to Oct 3, plus Quaest and AtlasIntel), the TSE voter register (electorate profile 2010–2026 and turnout 2014–2022) and the change in the vote by municipality 2010–2026. Answer: partly. Voters aged 16 to 24 have stayed to the left of the country since 2018 (−5, −5, −2); the right-leaning bracket is 25 to 44 (+3 and +2 in 2026), and those 60+ went from +1 to −4. New scripts: coletar_eleitorado_idade.py, coletar_votos_historico.py, coletar_datafolha_2026.py, analisar_geracoes.py, geracoes.py.
  • v49 — Oct 5, 2026 · selector on the municipal map [dashboard] The municipal map gains a selector with four views: who won and where it flipped (the arrows from v48); change 2022 → 2026, with the arrow pointing where the municipality moved (left, red: Lula improved the margin; right, blue: Flávio did better than Bolsonaro) and length proportional to the size of the change, capped at 30 points; winner in 2026 and winner in 2022, municipalities shaded in the winner's color, in four intensities by margin. The change uses the 2026 margin between Lula and Flávio only, comparable to the 2022 runoff: 5,389 municipalities moved right and 181 moved left; median −8.5 points. The tables' "Change" column now uses the same calculation.
  • v48 — Oct 5, 2026 · municipal map and frozen forecast × result [dashboard] Municipal map (new "Municipalities" section): the 5,570 municipalities with one arrow each, a red ← where Lula won the 2026 first round and a blue → where Flávio won; solid if the municipality flipped relative to the 2022 runoff, light if it held; size by the 2026 margin. Zoom by state and a tooltip with the margins from both elections. Lula won 3,123 municipalities in 2022 and 2,663 in 2026; 459 flipped to Flávio (5.96 million voters) and 1 to Lula. Data: TSE (municipal files from the 2026 release, coletar_municipios.py; 2022 Open Data read via HTTP Range, coletar_2022_2t.py) and the IBGE minimal mesh (gerar_geo_mun.py). 11 municipalities still show a partial count in the TSE municipal file. Accuracy: new block "Forecast frozen on the eve × official result", with 80% bands against the result (7 of 8 inside; only "others" outside), the four eve-of-election questions (4 of 4 on the most likely outcome; mean Brier 0.108) and the per-candidate error of the model versus undiscounted polls (0.35 × 1.22 pp; the model came closer on all 7).
  • v47 — Oct 5, 2026 · final TSE count [data] TSE result with 100% of polling stations counted (2:58 a.m. on Oct 5): Flávio 47.03% × Lula 45.16% of valid votes (56,104,503 × 53,879,538; difference of 2,224,965), Cury 2.89, Renan 2.24, Caiado 2.18, Zema 0.27, others 0.22; abstention 21.08%; the sum of the 27 states and Abroad matches the national total. Margin −1.87 (unchanged at the 2nd decimal); common anchor error +4.74 and runoff starting margin −3.85, both unchanged. With the date fixed at Oct 4, the new numbers leave Lula at 18.3% in the runoff (ensemble 17.5%), as before. The Oct 5 run is the first after the first round: the filter applies the planned ±6-point break in the poll level, the counted vote now weighs 87% (previously 52%) and the uncertainty of today's level rises from ±1.4 to ±2.3; the center barely moves (−3.9), and Lula goes to 19.7% (ensemble 18.6%). Conclusions of the analysis sections unchanged.
  • v46 — Oct 4, 2026 (11 p.m.) · count at 99.99% [data] TSE result updated to 99.99% of polling stations (11:12 p.m.): Flávio 47.03% × Lula 45.16% of valid votes (56,102,011 × 53,865,927; difference of 2,236,084), Cury 2.89, Renan 2.24, Caiado 2.18, Zema 0.27, others 0.22; abstention 21.08%. Margin −1.90 → −1.87; common anchor error +4.77 → +4.74; runoff starting margin −3.88 → −3.85; Lula in the runoff 18.1% → 18.3% (ensemble 17.4% → 17.5%). Conclusions of the analysis sections unchanged.
  • v45 — Oct 4, 2026 (night) · official first-round result [method] Result: TSE at 10:42 p.m. on Oct 4, 99.94% of polling stations: Flávio 47.04% × Lula 45.14% of valid votes (56,090,744 × 53,825,990), Cury 2.89, Renan 2.24, Caiado 2.18, Zema 0.27, others 0.22; abstention 21.08%. Saved resultado_1turno.csv (triggers the post-first-round regime) and resultado_1turno_uf.csv (27 states and Abroad). Method: (1) with the result and no new poll, the discount on runoff polls fell from +3.4 to +2.0 and Lula's chance rose from 28% to 37%, in the opposite direction to the error the result revealed. Runoff waves conducted before Oct 4 now receive the anchor's observed first-round error (+4.8); those conducted afterward, the post-first-round rule (runoff finals + 25% of the error, +2.1), blended by the share of the margin that comes from new waves in the filter (F = w_pós ÷ (w_pós + 1/(P + 6²))); (2) the counted vote with vote transfer (−3.9) is now combined with the polls' total error (sampling and systematic), not just sampling: weight from 8% to 52%; corrected polls and counted vote now converge (−3.9 and −3.9); (3) a market read on the night of the count counts in the ensemble if marked as after the result (Polymarket: Lula 15%, US$170M, two mirrors). Tested on a copy with 0, 1 and 3 fictitious post-first-round waves (smooth transition). Dashboard: first-round top section with the result beside the frozen forecast; new sections "Why Flávio flipped it", "Polls × ballot boxes" (error by pollster, method, region and state; house correction against the ballot box; herding test) and "Accuracy" (error, percentile, Brier and log loss, component by component); runoff winning conditions redone from the result; version history now lives in historico_versoes.json and becomes the methodology section "History of changes and adjustments", in the dashboard and on the site. Assessment: forecast margin −0.7 versus −1.90 (error 1.2; undiscounted polls: 4.6); result at the 37th percentile; the historical discount worked and was conservative; the anchored house correction pushed 11 of 14 pollsters away from the result; the state-level reading got the Southeast wrong.
  • v44 — Oct 3, 2026 (night) · first-round forecast frozen [method] Datafolha Oct 2–3 (runoff Lula 47 × Flávio 46; first round 42 × 40), Quaest Oct 2–3 (runoff Flávio 44 × Lula 42; first round Lula 40 × Flávio 38) and Futura Oct 2–3 (runoff Flávio 48 × Lula 45.1; first round in valid votes only, outside the base). AtlasIntel national poll from Sep 27–Oct 2 not found. Polymarket: Lula 36%. Model: level +0.39; Lula 28.4% from polls alone; ensemble 30.3%; Lula 1st in the first round 41.3%; Cury 3rd 49%. This is the run saved in previsoes_1turno_congeladas.json for comparison with the Oct 4 result.
  • v43 — Oct 3, 2026 · second audit (statistics and political science) [method] Post-first-round regime: (1) the discount switch went from +3.5 to +0.95 on date alone, on Oct 4, with no result or new poll; it now depends on the official result; (2) the polls' common first-round error now enters the runoff discount (25% of it), instead of being discarded; (3) the random walk gets a 6 pp break on the day after the first round, so the margin follows the new polls, and state polls from before Oct 4 drop out of the reading; (4) the official result, with vote transfer, enters the runoff level, combined by precision; (5) the market only counts with a recent, post-first-round price; (6) the transfer matrix moved out of the code into transferencia_2turno.csv. First round: (7) Lula and Flávio are no longer two independent series: the model estimates the margin and the sum of the two; (8) historical discount with 2010 in the base and predictive deviation across cycles (± 3.8 instead of ± 3.0). Specification: (9) Student t tails in poll error and drift; (10) pollster quality measured by error relative to the cycle average, not raw error; (11) anchor uncertainty added to the level error; (12) fundamentals at zero weight (a line through three elections); (13) regional shocks removed; (14) herding test redone on residuals; (15) margin sampling error with the exact formula; (16) separate validation for known and new pollsters, and an influence table for each poll. Effect, with the same polls: Lula from polls alone 29.8% → 29.9%; ensemble 34.2% → 31.9% (the fundamentals component, which said 53%, drops out); Lula 1st in the first round 38% → 45%. Dashboard: update time in the header; discount labels fixed (the dashboard called the first-round discount "2006–22 runoffs"); empty links for state polls; texts with hard-coded numbers now come from the data.
  • v42 — Oct 3, 2026 (afternoon) [data] CNT/MDA Sep 30–Oct 2 (runoff Lula 47.3 × Flávio 43.1; first round 43.1 × 38.0), Palver Sep 30–Oct 3 (runoff Flávio 49 × Lula 44; first round Flávio 47 × Lula 43) and DataTrends Sep 28–30 (runoff 45 × 45; first round 41 × 39). Model: level +0.56; Lula 29.8% from polls alone; ensemble 34.2%; Lula 1st in the first round 38%.
  • v41 — Oct 3, 2026 · audit [method] Audit of code, data and text, with method and presentation fixes. Model: (1) the first round's historical error discount is no longer added to the late collapse of the third way, which is already inside that error (the collapse now only redistributes votes, without shifting the mean margin); (2) minor candidates with a common random walk and level estimated from waves in the last 4 weeks, which stabilizes the chance of 3rd place (it used to copy the latest poll) and makes the 3rd-place chances sum to 100% (the "others" aggregate left the contest); (3) regional shocks now split the polls' systematic error instead of adding to it; (4) a cap of 5,000 interviews on a poll's weight; (5) the first-round forecast is never re-saved from Oct 4 onward; (6) post-first-round regime implemented (each pollster's first-round error becomes the prior for its runoff house effect); (7) until Oct 4, the runoff gets the same discount as the first round (+3.5 ± 3.0, previously +0.9 ± 2.0): today's runoff polls have not yet been tested by the first-round result, and the first-round error carries almost entirely into the runoff starting point; in runoff rounds published before the first round in 2014, 2018 and 2022 the error was even larger (+8, +7 and +15); this is the change that moves the dashboard most (with the new state data, Lula's runoff chance from polls goes from 50% to 31%; ensemble from 48% to 35%); (8) fundamentals with a homogeneous series (Datafolha excellent + good during the campaign): the 77.7 attributed to 2006 was Lula's personal approval in the 2008 CNT/Sensus poll. Data: PoderData Aug 30–Sep 2 corrected (Flávio 45 × Lula 44, not 46 × 45); PoderData Sep 13–16 with blank/spoiled at 8 (not 4); Datafolha Sep 8–10 with fieldwork ending Sep 10 in both rounds; Indexa Sep 27–29 with first-round blank and undecided. State polls: 85 new first-round waves and 75 runoff waves (Datafolha in SP, RJ, MG, PE and DF; AtlasIntel in 18 states; Real Time Big Data in 12; Veritá in 26; DataTempo in MG), collected and checked on Oct 3; with them the state-level reading of the national margin goes from +3.1 to +0.8. Map: pollster and round selector. Dashboard: thousands separator, duplicated signs ("++"), "−0.0", scenarios summing to 101%, sensitivity sentence with the wrong scenario, texts on pollsters, markets, the canceled debate and rejection/undecided (now generated from the bases) updated.
  • v40 — Oct 3, 2026 (morning) [data] PoderData/Aya Sep 30–Oct 2 (runoff 46 × 46; first round Lula 42 × Flávio 41) and Gerp Sep 30–Oct 2 (runoff Flávio 49 × Lula 44). Model: level +0.9; Lula 50.1% from polls alone; ensemble 48.1%.
  • v39 — Oct 2, 2026 [method] First-round map by state (Quaest round of Sep 20–28 in 26 states), 14 new state runoff polls in the model and the Vox Brasil wave of Sep 29–Oct 1 (runoff Flávio 48.2 × Lula 45.2; first round Flávio 41.2 × Lula 40.4). Model: level +0.56; ensemble Lula 46%; Lula 1st in the first round at 46%.
  • v38 — Oct 1, 2026 (7 p.m.) [data] New Datafolha wave (fieldwork Sep 28–Oct 1, TSE BR-08039/2026, Folha/Globo, n=2,506, face-to-face, released at 7:05 p.m.): runoff Lula 48% × Flávio 45% (was 47 × 45), blank/spoiled 6%, undecided 1%; first round Lula 42 × Flávio 38 (both +2), Cury 4, Caiado 3, Renan 3, Zema 1. Numbers read in the two g1 stories (the commissioning group); registration, sample and period checked in a second source. g1 gives the fieldwork sometimes as Sep 28–30, sometimes as Sep 29–Oct 1; the base uses Oct 1 as the end. Base: 153 runoff waves. Model recalculated: level +0.6 pp; Lula 48% · Flávio 52% from polls alone; ensemble Lula 47% · Flávio 53%; first round: expected margin +0.2, Lula 1st at 52%.
  • v37 — Oct 1, 2026 (night) [method] New section "Abstention: who stays home and whom it costs", in Part 2. Model: block 4b computes the national margin under turnout scenarios, with two channels: geographic (2022 turnout by state, 2026 electorate and 2026 state margin, aligned with the national margin) and profile (K = 0.25 margin point per point of abstention, range 0–0.4, calibrated on Adriano Laureno's estimate). The scenarios measure the deviation from 2022, because that year's pattern is already in the historical discount. Result: each extra point of abstention costs Lula 0.25 margin point (0.19 if only in the Northeast); 25% abstention would take Lula to 37%; mobilization in the Northeast (−2 points) to 48%. The "Differential abstention" item in the tornado is no longer the fixed −1.5 to 0 range and now comes from these scenarios. New base abstencao_2022_uf.csv (abstention by state in both 2022 rounds, CNN Brasil from TSE, single source; Amapá and Roraima to be checked). Check: the 2022 margin rebuilt by region matches the official result. No new national poll as of 6:40 p.m.; the Datafolha poll of Sep 28–Oct 1 (n=4,006) was announced for today but had not yet been published. Model: level +0.3 pp; Lula 45% · Flávio 55% from polls alone; ensemble Lula 45% · Flávio 55%.
  • v36 — Oct 1, 2026 (afternoon) [data] No new national poll: the ten waves released from Sep 27 to Oct 1 (Quaest, BTG/Nexus, AtlasIntel, Meio/Ideia, Gerp, Palver, Vox Brasil, Indexa, Futura and Real Time Big Data) were already in the base. An automated sweep had also listed a "Datafolha of Sep 28–Oct 1" (n=2,506, 45 × 47); checking the stories did not find that poll, and the most recent Datafolha is still the Sep 22–23 one (47 × 45), which was kept. The same sweep swapped the sides and used valid votes for Real Time Big Data (49 × 51, when the totals are Lula 45 × Flávio 46) and reversed the order for AtlasIntel (Flávio 47.7 × Lula 47.6); none of it entered the base. Market: Polymarket at Lula 38% × Flávio 62% (Oct 1, US$158.9 million, read on the market page), 3 points below Sep 30 — the lowest reading in the series. The 43% reading of Sep 26 in earlier notes came from a search summary and was not corroborated on checking. News checked in two sources: the "Foi o Flávio" jingle suspended by the TSE; Federal Police forensic examination of the Dark Horse material (R$750,000 to the production company Go Up, via Mario Frias's budget amendments); ANJL and IBJR petition to the STF against the betting provisional measure, with no injunction; Lulinha's trip to Europe (R$1.8 million) paid for by a lobbyist; Zema and Caiado reject Flávio's request that they drop out. Two items in circulation did not survive checking: the plea deal of "Mineiro" is not news from this week (approved on Sep 9) and the R$69 million figure does not exist in the sources. Model recalculated: level +0.3 pp; Lula 45% · Flávio 55% from polls alone; ensemble Lula 45% · Flávio 55%. Orchestration: collection with Haiku, verification with Sonnet.
  • v35 — Oct 1, 2026 [data] New Real Time Big Data wave (fieldwork Sep 26–30, TSE BR-09503/2026, n=2,000, telephone, released Oct 1): runoff Flávio 46% × Lula 45%, blank/spoiled 5%, don't know 4%; first round Lula 43 × Flávio 39 (Renan 4, Cury 3, Caiado 3, Zema 1). Confirmed in Transmissão Política, AC24horas and Gazeta do Povo (the latter in valid votes: 46×41; runoff 51×49). News: Lula confirmed he will skip the Globo debate on Oct 1; Flávio will go with or without him. Polymarket with no new reading since Sep 30 (Lula 41%). Model recalculated: level +0.3 pp (2 weeks ago +0.4); Lula 45% · Flávio 55% from polls alone; ensemble Lula 46% · Flávio 54%; first round: expected margin -0.0, Lula 1st at 50%; thermometer: stable.
  • v34 — Sep 30, 2026 (night) [data] New Futura/100% Cidades wave (fieldwork Sep 25–29, TSE BR-01122/2026, n=2,000, telephone, released Sep 30 at 2:42 p.m.): runoff Flávio 49% × Lula 43.5%, blank/spoiled 6.2%, don't know 1.3%; first round Flávio 42.2 × Lula 39.4 (Caiado 4.0, Cury 3.8, Renan 3.2, Zema 1.6); rejection Lula 48.9 × Flávio 45.6. Confirmed in CNN Brasil and Itatiaia. Markets: Polymarket Lula 41% (Sep 30). Palver Sep 24–28 (47×45) was already in the base (fieldwork Sep 24–27). No other national poll released on Sep 30 as of 8 p.m. v33 above was generated with the values in force on Sep 30 (morning): level +0.5; Lula 47% × Flávio 53%. Model recalculated: level +0.2 pp; Lula 45% · Flávio 55% from polls alone; ensemble Lula 46% · Flávio 54%; first round: expected margin -0.1, Lula 1st at 49%.
  • v33 — Sep 30, 2026 · audit and model v3 [method] Full audit of the aggregator against reference practice (Jackman; Linzer; Economist; Silver; Shirani-Mehr et al.). Findings fixed: (1) double counting of the error against the PT: house effects were identified by the average of all pollsters weighted by number of waves, which shifted the "consensus" ~1.4 pp toward Flávio, and on top of that level a bias calibrated on the large pollsters was still being discounted; the anchor is now the pollsters with a verified track record (AtlasIntel, Datafolha, CNT/MDA, Paraná Pesquisas, PoderData, Quaest), the same set used for calibration; (2) bias prior recalculated with the verified 2006–2022 numbers from primary sources (`calibracao_historica.csv`, 39 finals): +0.9 ± 2.0 instead of +2.1 (which weighted the 2022 cycle 2.5× and used wrong values, such as Quaest 2022 53×47 instead of 52×48); (3) single trend model (state space with house effects and per-pollster variance estimated jointly) in place of the stitched-together P-spline + Kalman; σ_rw falls from 0.67 to 0.32 pp/day; (4) drift between rounds calibrated on the movement observed in 2014/18/22 (0.95 pp/day) instead of the 1.4 multiplier; (5) ensemble with calendar-based weights (65/25/10 → 75/25/0 after the first round); (6) first round: its own base of 65 polls since August (two sources each), one state per candidate, discount for the first round's historical error and a frozen forecast; (7) post-first-round regime: the official result recalibrates the runoff weights, house effects and bias; (8) rolling-origin validation published; (9) 7 missing runoff waves (Gerp, Futura, Palver ×2, Vox, Indexa, Alfa). Presentation redesigned to show both candidates' chances side by side in every section. Result: level +0.5 pp (v2: -1.3); Lula 47% · Flávio 53% from polls alone (v2: 24.4%); ensemble Lula 47% · Flávio 53% (v2: 37.7%). First round: Lula 45.1 × Flávio 39.8 in the polls; expected margin +0.6; Lula 1st in 58% of simulations.
  • v32 — Sep 30, 2026 [data] New Meio/Ideia wave (fieldwork Sep 25–28, TSE BR-08706/2026, n=2,000, telephone, Canal Meio): Lula 48.5% × Flávio 48%, blank/spoiled 2.1%, don't know 1.5%; first round Lula 39.4 × Flávio 38.4. Confirmed in CNN Brasil and Gazeta do Povo. No other new national poll as of the morning of Sep 30. Model recalculated: level -1.3 pp (2 weeks ago -0.7); P(Lula) model 24.4%; ensemble 37.7%; thermometer: stable.
  • v31 — Sep 29, 2026 (afternoon) [data] No new national poll besides AtlasIntel as of early afternoon on Sep 29. Added a wave missing from the base: American Analytics/Times Brasil-CNBC (fieldwork Sep 15–20, TSE BR-032587/26, n=2,000, released Sep 23): Lula 43% × Flávio 43%, blank/spoiled 6%, don't know 8%; approval 45/51 (enters the fundamentals average). Confirmed in Poder360, O Povo and CartaCapital. Model recalculated: level -1.2 pp (2 weeks ago -0.4); P(Lula) model 25.3%; ensemble 38.1%; thermometer: stable.
  • v30 — Sep 29, 2026 [data] New AtlasIntel/Bloomberg wave (fieldwork Sep 23–28, TSE BR-04391/2026, n=5,005, online): Flávio 47.7% × Lula 47.6%, blank/spoiled/don't know 4.8%; first round Lula 45.3 × Flávio 42.2 (replaces the Sep 4–9 wave in the first-round table). Confirmed in Exame and Misto Brasil. Markets: Polymarket 42% (Sep 29, Flávio 58.1). "Scandals" section: a "September 28 and 29" block on the escalation of the Aparecida affair (removal of posts ordered by Mendonça at the TSE, suspended by Dino at the STF; meeting called by Fachin; 977,600 posts in 55 hours) and a reading of the three polls with fieldwork after the rumor (Quaest, Nexus, Atlas), with no measurable effect. Model recalculated: level -1.3 pp (2 weeks ago -0.5); P(Lula) model 24.9%; ensemble 37.9%; thermometer: stable.
  • v29 — Sep 28, 2026 (afternoon) [data] New BTG/Nexus wave (fieldwork Sep 25–27, TSE BR-07557/2026, n=2,000, telephone): Lula 46% × Flávio 44%, blank/spoiled 8%, don't know 1%; first round Lula 42 × Flávio 37; rejection Flávio 51 × Lula 48; approval 46/49. Confirmed on the pollster's website and in Poder360. Rejection from Genial/Quaest Sep 24–27 added (Flávio 56 × Lula 55, CNN Brasil). Model recalculated: level -1.0 pp (2 weeks ago -0.2); P(Lula) model 27.1%; ensemble 39.0%; thermometer: stable.
  • v28 — Sep 28, 2026 [method] New trend thermometer in the "The race over time" section: the adjusted margin recalculated day by day over the last two weeks (same P-spline × Kalman × state-level reading combination as the main number), with a 95% band, 7- and 14-day change, a direction label (stable if |Δ7d| < 0.5 pp; "slowing" if the week came in below that but the two weeks add up to 1 pp or more) and a count of polls that moved each way relative to the same pollster's previous wave. Consistency fix: the "2 weeks ago" number now includes the same state-level reading as today's number (it was national only: -0.4 now, versus −0.6 before), so the 14-day change no longer mixes different bases. No probability changed.
  • v27 — Sep 28, 2026 [data] New Genial/Quaest wave (fieldwork Sep 24–27, TSE BR-06520/2026, n=2,004, face-to-face, Globo/O Globo): Lula 42% × Flávio 42%, blank/spoiled 13%, undecided 3% — the first poll with fieldwork entirely after the betting provisional measure and the Aparecida rumor; first round Lula 39 × Flávio 34 (replaces the Sep 10–13 wave in the first-round table). Confirmed in O Tempo, Correio Braziliense and Imirante. No other national poll found for Sep 25–28. Markets: Polymarket 42% (Sep 28, Flávio 57.6). The "Scandals" section gained a "September 26 to 28" block. Model recalculated: level -1.5 pp (2 weeks ago -0.6); P(Lula) model 24.4%; ensemble 37.7%; statistical tie 22.0%.
  • v26 — Sep 26, 2026 [data] New Real Time Big Data wave (fieldwork Sep 19–23, TSE BR-04202/2026, n=2,000, telephone and digital recruitment, released Sep 24): Flávio 45% × Lula 44%, blank/spoiled 8%, undecided 3%; rejection Lula 50 × Flávio 49; approval 45/52 (added to the fundamentals average). Confirmed in CNN Brasil, Poder360 and Terra. No other national poll released on Sep 25–26. Base: 138 waves. Markets: Polymarket 43% (Sep 26, Flávio 56), a third straight rise; the same market prices at 97% that Lula and Flávio reach the runoff, which supports the v25 correction (P(Flávio in the runoff) from 0.88 to 0.99). "Scandals" section expanded with three late-campaign events: (1) the provisional measure banning betting sites (Sep 25), with 73% support in AtlasIntel (Sep 17–22); (2) the Nossa Senhora Aparecida controversy — false information linking Flávio to an archived 2007 bill, corrected by GloboNews, with a risk of resurfacing on Oct 12; (3) the 3rd phase of Operation Carbono Oculto (Sep 24), which reaches the Vorcaro operator whose company transferred US$12.3 million to the fund behind the film Dark Horse. No published poll has fieldwork after these; none changes the model's parameters. Model recalculated: level -1.4 pp (2 weeks ago -0.6); P(Lula) model 25.4%; ensemble 38.4%; statistical tie 22.1%.
  • v25 — Sep 24, 2026 (early hours of the 25th) [method] Probabilistic model revised, with seven changes and the effect of each published in the "Model" section. (1) State space: Kalman filter with the random-walk σ estimated by maximum likelihood (0.67 pp/day), combined with the P-spline. Diagnosis: the P-spline extended the downward slope into September beyond what the polls show (last knot on Aug 1), leaving the margin about 1 point more pro-Flávio than the corrected polls of the last two weeks. (2) State polls: 18 polls from 11 states (76% of the electorate), confirmed in two sources (four discarded), converted into swing over 2022, corrected for house effect and national trend, shrunk state → region → country; national reading +0.5, with an 18% weight in the final level. (3) P(Flávio in the runoff) goes from 0.88 to 0.99: the 0.88 was the "Flávio in 2nd place" market, which excludes the case of him finishing 1st; it now comes from the first-round simulation, with a 1% risk of disqualification. (4) Bias by methodology (applied +2.0). (5) Phase-based drift: 0.61 pp/day, ×1.4 between rounds (assumption). (6) Regional shocks in place of a single noise term. (7) Dynamic fundamentals: margin × approval regression for 2006, 2014 and 2022 (tie at 43.2% approval), applied to the average approval of 44.9% → 60%. Collection check: the "22% excellent/good" for Bolsonaro in Sep 2022 circulating on Portuguese-language Wikipedia is wrong (Datafolha published 30%), and the cited preprint on "44% approval guarantees reelection" is about mayors, not presidents; neither was used. Orchestration: collection with Haiku, verification with Sonnet, modeling with Opus. Result: level -1.2 pp; P(Lula) model 27%; ensemble 39%; statistical tie 22%.
  • v24 — Sep 24, 2026 (night) [dashboard] Storytelling redesign, with no change to data or model. The page now follows an inverted-pyramid logic, in three parts: 1 · The result (a plain-language headline generated from the model, a probability bar with the four outcome bands, a "what changed" box, the day's numbers with lay labels, a four-sentence summary, a "how to read this dashboard" guide and the four scenarios, previously the last section); 2 · What drives the race (the margin's evolution in its own section, decisive variables, rejection, undecided, scandals and opponents); and 3 · Under the hood (model, polls and weights, methodology, bias, a new glossary and the compliance note). Each section opens with a one-sentence summary, the charts got "how to read" boxes, and 18 technical terms have definitions on tap or hover, with the same texts gathered in the glossary. Tooltips now open on tap on mobile, as a sheet fixed to the bottom of the screen. The dashboard's main number is the ensemble; the polls-model number appears as "from polls alone". In this document, the full technical summary moved to the start of Part 3.
  • v23 — Sep 24, 2026 (night) [data] New Datafolha wave (fieldwork Sep 22–23, TSE BR-00304/2026, Folha/Globo, n=2,002, face-to-face, released Sep 24): runoff Lula 47% × Flávio 45%, blank/spoiled 7%, undecided 1% — Lula and Flávio each up 1 pp over the Sep 15–16 wave, the pollster's first poll since the announcement of the 15% Bolsa Família increase. Alternative scenarios: Caiado 46×44, Zema 48×39, Cury 46×43, Renan Santos 48×39. Confirmed in g1 (story of Sep 24, 6:41 p.m.) and SBT News. Rejection: Lula 45% (was 47%) and Flávio 45% (was 47%), tied; government approval 47% × disapproval 50% (was 48/50); first round Lula 40 × Flávio 36 × Cury 5 × Caiado 4 × Renan 3 × Zema 1; 80% of voters fully decided. Base: 137 waves from 19 pollsters. Interface: the dashboard and standalone got responsiveness tweaks (laptop, tablet and phone breakpoints, tables and charts with readable horizontal scrolling, navigation with larger touch targets). Model fully recalculated: nowcast -2.1 pp (2 weeks ago -0.4); P(Lula) model 24.2%; ensemble 37.1%; statistical tie 21.9%.
  • v22 — Sep 24, 2026 [data] New PoderData/Aya wave (fieldwork Sep 20–23, TSE BR-01739/2026, n=3,000, telephone, released Sep 24): runoff Flávio 46% × Lula 45% (1 pp margin; Lula +1 pp over the previous wave, Flávio flat); first round Lula 41 × Flávio 39. Relevant alternative scenario: Cury 45 × Lula 42 — the only test in which Lula loses by more than against Flávio; added to the opponents table (AtlasIntel, in the same period, had Lula 46.2 × Cury 39.4). Runoff blank/spoiled was not released in the sources consulted. Base: 136 waves from 19 pollsters. Markets: Polymarket 42% (Sep 24, Flávio 57), a second straight rise; the ensemble's market component revised to 0.42. Model fully recalculated: nowcast -2.4 pp (2 weeks ago -0.5); P(Lula) model 22.6%; ensemble 36.3%; statistical tie 21.3%.
  • v21 — Sep 23, 2026 [data] New AtlasIntel/Bloomberg wave (fieldwork Sep 17–22, TSE BR-04739/2026, n=5,015, online): runoff Lula 47.7% × Flávio 47.4%, blank/spoiled 4.9% — a statistical tie, reversing the run of waves favorable to Flávio (Gerp, Futura, Palver) and also departing from the near-tied but slightly pro-Flávio reading of Genial/Quaest on Sep 20. No other national pollster (Datafolha, PoderData, Real Time Big Data) released a poll that day. Base: 135 waves from 19 pollsters. Alternative scenarios from the same poll (Lula always ahead): vs. Zema 47.6%×42.2%; vs. Caiado 46.6%×44%; vs. Cury 46.2%×39.4%; vs. Renan Santos 46.5%×31.2% — "Other opponents tested" table adjusted to reflect Caiado's higher range. Markets: Polymarket on Sep 23 at Flávio 59 × Lula 41 (US$154.0M) — the first reading without a drop for Lula since the start of the STF crisis on Sep 8 (59%→49%→45%→43%→40%→41%), although the stabilization still seems to predate the release of the same day's AtlasIntel poll. The ensemble's market component revised to 0.41. Model fully recalculated: nowcast -2.4 pp (2 weeks ago -0.5); P(Lula) model 22.4%; ensemble 36.0%; statistical tie 21.1%.
  • v20 — Sep 21, 2026 (night) [data] Two new waves released on Sep 21: Palver (fieldwork Sep 15–18, TSE BR-00860/2026, self-funded, n=5,000, online): runoff Flávio 47% × Lula 43%, undecided+blank 10% — reinforcing the pollster's own run of readings more favorable to Flávio. And Genial/Quaest (fieldwork Sep 17–20, TSE BR-06004/2026, commissioned by Globo/O Globo, n=2,004, face-to-face): Flávio 42% × Lula 41%, blank/spoiled 14%, undecided 3% — the same numerical margin as the previous wave, but with Lula up 1 pp; both candidates' rejection at the series high (Flávio 56%, Lula 55%, both +1 pp in the month). 79% of those already decided say their vote is final. Base: 134 waves from 19 pollsters. Markets kept falling the same day: Polymarket went from 41% (morning) to 40% (afternoon) — a sixth straight session of decline, more likely a reaction to the day's pile of polls (all with Flávio ahead) than to any STF news. The ensemble's market component revised to 0.40. Model fully recalculated: nowcast -2.3 pp (2 weeks ago -0.2); P(Lula) model 23.6%; ensemble 36.3%; statistical tie 21.4%.
  • v19 — Sep 21, 2026 [data] New BTG/Nexus wave (fieldwork Sep 18–20, TSE BR-00485/2026, commissioned by BTG Pactual, n=2,006, telephone): runoff Lula 46% × Flávio 45%, blank/spoiled 7%, undecided 1% — after two waves in a row (Gerp, Futura) pulling toward Flávio, this pollster returns to a near-tie, with Flávio slightly ahead on rejection (50% × Lula's 49%) and government approval at 46/50. Confirmed that only this poll came out in the period (sweep from Sep 19 to 21); Quaest, Datafolha and AtlasIntel have waves scheduled for the week of Sep 21–24, not yet released. Base: 132 waves from 19 pollsters. Markets: Polymarket fell for the fifth time in a row, to 41% (Sep 21 morning, from 43% on Sep 18) — the STF ruling on Moraes–Vorcaro still has no date to resume. The ensemble's market component revised to 0.41. Model fully recalculated: nowcast −2.0 pp (2 weeks ago −0.2); P(Lula) model 25.1%; ensemble 37.3%; statistical tie 21.9%.
  • v18 — Sep 18, 2026 [data] Two new waves: Gerp (fieldwork Sep 14–16, TSE BR-00535/2026, self-funded, n=2,400, method not disclosed): runoff Flávio 50% × Lula 43% — the most pro-Flávio reading in the base this cycle —, with rejection reversed relative to the month's pattern (Lula 49% × Flávio 40%, Lula +9 pp), read in the dashboard as a reflection of the same pro-Flávio house effect that the "Bias" section had already identified for this pollster, not as a real shift. And Futura/Apex (fieldwork Sep 11–15, TSE BR-00749/2026, commissioned by Apex/100% Cidades, n=2,000, telephone): Flávio 48.1% × Lula 43.7%, with government approval at 44.1% and disapproval at 53.1% (excellent/good 36.5%, the highest of the cycle for that question). A Veritá poll with fieldwork "Sep 4–12" circulating on aggregators (Lula 41.5% × Flávio 43.9%) was not added to the base for lack of independent confirmation — neither the pollster's website nor any other source had the numbers. Base: 131 waves from 19 pollsters. Markets keep falling for Lula even with the STF ruling suspended: Polymarket 43% (Sep 18), from 45% on Sep 16–17 — the ensemble's market component was revised to 0.43. Model fully recalculated: nowcast −1.9 pp (2 weeks ago −0.2); P(Lula) model 26.4%; ensemble 38.5%; statistical tie 21.9%.
  • v17 — Sep 17, 2026 (night) [data] New Datafolha wave (fieldwork Sep 15–16, TSE BR-04029/2026, commissioned by Folha/Globo, n=2,002, face-to-face): runoff Lula 46% × Flávio 44%, blank/spoiled 8%, undecided 1% — the pollster's fourth straight reading stuck at 46×44, but mutual rejection converged (47% for both) and government disapproval stabilized at 50% (approval 48%, up from 47% in the Sep 11 wave). Base: 129 waves from 19 pollsters. Model fully recalculated: nowcast −1.4 pp (2 weeks ago +0.4); P(Lula) model 29.1%; ensemble 40.4%; statistical tie 23.0%.
  • v16 — Sep 17, 2026 (afternoon) [method] New section "Bias, sampling flaws and what the margin of error doesn't cover" (id vies), between "Methodology" and "Undecided". It measures, in the base itself, that a poll's typical error is 4.4 pp versus 2.2 pp predicted by sampling (1.97×, in line with Shirani-Mehr et al./JASA 2018); shows that the stated margin refers to each candidate and that the difference between the two already carries ±4.4 pp; applies a herding test (observed dispersion 1.43× the expected — the opposite of the 2024 US pattern); includes a forest plot of house effects with 95% intervals; a profile of the 19 pollsters (method, sponsor, bias, typical vs. theoretical error, track record); the series of errors in finals from 2010 to 2022 with telling cases (Vox Populi and Paraná in 2018, the ~8.8 pp error against Bolsonaro in the 2022 first round, SP 2020 and 2024); the regulatory framework (PesqEle, fines, PL 2.558/2022) and the 2026 episodes (Áltica, the two lawsuits against AtlasIntel, the suspension of Veritá); and a conclusion ranking the pollsters by reliability. Calibration fix arising from the research: the dashboard's track record recorded only Paraná Pesquisas' 2022 performance, which is why it received the best quality weight — but the pollster missed the 2018 runoff by ~11 pp. Verified finals were added for Paraná (2018), Veritá and Futura (2022), and the quality weight is now shrunk toward the neutral value according to the number of cycles behind it. Effect: nowcast from −1.5 to −1.7 pp, P(Lula) in the model from 28.6% to 27.7% and ensemble from 40.1% to 39.6%.
  • v15 — Sep 17, 2026 [data] Two new waves: PoderData/Aya (Sep 13–16, TSE BR-00360/2026, n=3,000 by IVR, Flávio 46% × Lula 44%, blank/spoiled 4%, don't know 2%; rejection Lula 50% × Flávio 46%) and AtlasIntel/Bloomberg (Sep 11–16, TSE BR-06221/2026, n=5,018 online, total votes Flávio 47.2% × Lula 46.8%, blank/don't know 6%; Lula disapproval 53.6% × approval 45.4%) — Atlas shows the gap shrinking from 6 to ≈2 pp relative to the Sep 4–9 reading. Markets stable (Polymarket Flávio 55 × Lula 45, normalized P(Lula) 45%, same as Sep 16) — the STF session of Sep 15 caused no new swing. No poll has yet published a question dedicated to the STF outcome (Dino's request for review). Model fully recalculated.
  • v14 — Sep 16, 2026 [data] New wave added: DataTrends (a pollster new to the base; fieldwork Sep 12–14 by automated telephone in all 27 states, n=2,000, TSE BR-08691/2026, released Sep 16): runoff Flávio 45% × Lula 44%, blank/spoiled 8%, no answer 3% — it enters with a neutral quality weight (0.8), having no verifiable presidential track record. STF session of Sep 15: ended with no ruling on the merits — a procedural motion by Gilmar Mendes to join the petitions, a provisional 4×3 tally for keeping them separate, a request for review by Flávio Dino (whose vote would tie it at 4×4), with up to 90 days to return it; the validity of the Federal Police report and a possible investigation into Moraes remain undecided and extend past the first round. Sep 16 fallout added to the "Scandals" section: Lula began defending Moraes by name and linking Flávio to Vorcaro; Flávio settled on the slogan "Lula is Moraes and Moraes is Lula". Markets: Polymarket at Flávio 54 × Lula 45 (normalized P(Lula) 45%, from 46% on Sep 15) — no rebound after the session. Model fully recalculated with data as of Sep 16; scenarios, tornado, break-even and uncertainty decomposition regenerated.
  • v13 — Sep 15, 2026 (afternoon) [data] Sweep of the presidential polls released on Sep 15: the only national one is CNT/MDA (170th round, fieldwork Sep 9–13, n=2,002 face-to-face, TSE BR-06902/2026), already in the base since v12 — now completed with runoff blank/spoiled (10.2%) and undecided (2.5%), first round with published blank 6% and undecided 7.5% (previously estimated) and Lula's personal approval 47×48 (excellent/good 35.3%). Also added: blank/undecided from Real Time Big Data Aug 27–31 (9/3) and Quaest approval Sep 10–13 (43 approve × 50 disapprove; excellent/good 32%). A Veritá wave from Sep 4–12 (n=40,500) appears only as a first-round poll on aggregators and has no verifiable national runoff — it was not added. STF session on the Moraes–Vorcaro report under way, with no result at closing. Model recalculated.
  • v12 — Sep 15, 2026 [data] Poll base rebuilt: checked wave by wave against Wikipedia's consolidated table (with a primary source link for each poll), the base went from 54 waves to the full base then available. Added: the new waves of Sep 14–15 — CNT/MDA (Sep 9–13, Lula 47.3 × Flávio 40) and Indexa/Broadcast (Sep 10–13, Lula 43 × Flávio 42) — and earlier missing waves (among them Nexus/BTG from Mar–Aug, Gerp, Futura/Apex, Meio/Ideia and Vox Brasil from Jan–Aug, CNT/MDA from Apr/Jun/Aug, Indexa from May–Aug, Palver from Aug, Alfa Inteligência, Vetor/Arrow and American Analytics). Waves that previously had approximate dates (PoderData, Real Time Big Data) were replaced with the sources' exact dates and numbers; blank/undecided values already tallied were kept. CNT/MDA enters with a quality weight of 1.5 (MDA had the smallest historical error among the large pollsters in 2018/2022). Model — with the full base, the house effects now identify systematically pro-Flávio pollsters (Gerp, Veritá, Futura) and pro-Lula ones (CNT/MDA), and the nowcast is less pro-Flávio than in v11 (−1.8). Markets revised to 46% (Polymarket Sep 15: Flávio 52 × Lula 45). The model's first-round table and Appendix B updated with the waves of Sep 4–13 (including CNT/MDA). Scandals: the STF crisis timeline was corrected and expanded — the core of the crisis is the Federal Police report (218 pp., 187 interactions) on Vorcaro and Justice Alexandre de Moraes, with the Prosecutor General's request for annulment and a plenary ruling on Sep 15 (result pending); the investigation into Flávio was authorized on Jul 22 and made public on Sep 10 (not Sep 9); the film's release suspended on Sep 11; investigation into Lulinha authorized on Jul 30. Comparative reading rewritten: the Flávio investigation does not yet show up in the poll margin after Sep 10. Other sections: undecided (Atlas fell from 10.3% to 7.4% with migration to Flávio; Quaest 18%), rejection (Datafolha Sep 8–10 tied at 46%; Palver 52×51), Sep 14 debate canceled, Marçal disqualified and replaced by Avalanche. Dashboard: methodology footer with an automatically generated wave count per pollster; "nowcast 2 weeks ago" and residual σ now come from the model.
  • v11 — Sep 14, 2026 [data] Update covering three days (data as of moved from Sep 11 to Sep 14). New polls: BTG/Nexus Sep 11–13 (TSE BR-04076/2026, n=2,003, Lula 47 × Flávio 46 — updates the same pollster's Sep 4–7 wave); the first Palver wave in the base (Sep 4–9, n=5,000, Flávio 46 × Lula 44); two waves from Veritá, a pollster new to the base (Aug 16–20, n=3,840, Flávio 47.3 × Lula 42; and Sep 1–4, TSE BR-04006/2026, n=3,804, Flávio 48.5 × Lula 43.1) — a total of 53 waves from 12 pollsters. Nowcast, probabilities and scenarios recalculated (see KPIs at the top of the dashboard). "Scandals" section expanded to "STF, Master, INSS and Dark Horse": a new line on the STF institutional crisis (the Mendonça×Dino×Fachin dispute over control of the Federal Police, Sep 8–9) and the partial lifting of secrecy in the Master case on the night of Sep 9, which made public the Federal Police investigation confirming Flávio Bolsonaro as Vorcaro's intermediary in financing Dark Horse — a jump in personal exposure (from "leaked audio" to "named in an investigation"), still without a dedicated opinion poll. Probabilistic model revised: the ensemble's market component updated from 57% to 48% (Polymarket/Kalshi flipped to Flávio as favorite between Sep 8 and 13, pricing in the STF crisis) — the ensemble already reflects this adjustment, and the "Markets and analysts"/"Model" text was fully revised to reflect the shift, keeping the Sep 8 readings as a historical reference.
  • v10 — Sep 11, 2026 (end of day) [data] New Datafolha wave added (fieldwork Sep 8–10, released Sep 11 at 6:45 p.m., TSE BR-01833/2026, n=2,002): first round Lula 39% × Flávio 35% × Cury 6% × Caiado 4% × Renan Santos 3% × Zema 2%; runoff Lula 46% × Flávio 44% — identical to the previous wave (Sep 1–3), read here as runoff stability even as the first round fluctuates — a total of 49 waves from 10 pollsters. The adjusted nowcast goes from -1.5 to -1.0 pp (Flávio still ahead, but the gap narrows); P(Lula) in the polls model rises from 29.5% to 32.4%; P(Lula) in the ensemble rises from 43.5% to 45%. Consistency review: the "Executive summary", "Decisive variables", "Scenarios" and methodology footer texts were checked against the updated numbers — the analytical passages were already generated by token substitution (nowcast, probabilities, scenarios, poll/pollster counts) and contained no outdated values other than the hard-coded count of Datafolha waves in the footer (7→8), fixed in this version. Also searched for other polls released the same day (Sep 11) besides Datafolha — none found as of this update.
  • v9 — Sep 11, 2026 (night) [data] Two new runoff waves added: the second wave from Futura Inteligência (Sep 4–10, Flávio 45.4 × Lula 45 — updates the Aug 27–Sep 1 reading) and the first wave from Meio/Ideia in the base (Canal Meio, Sep 4–7, Lula 46 × Flávio 46, TSE BR-07935/26 — the same poll already cited in the "Scandals" section for the question on the Vorcaro/Dark Horse case, now with its vote-intention result also included) — a total of 48 waves from 10 pollsters. Adjusted nowcast at -1.5 pp (Flávio still slightly ahead; the model's reference date also moved from Sep 10 to 11, which by itself adds one day of drift to the extrapolation). Also fixed an outdated count in the footer (AtlasIntel/Bloomberg had 9 waves, not 8, since the Sep 10 update).
  • v8 — Sep 11, 2026 (afternoon) [dashboard] Added the section "Qualitative research and exposure to scandals: Master, INSS and Dark Horse", bringing together findings from qualitative research (ongoing focus groups by the Monitor do Debate Público, LEMEP/UERJ) and dedicated polls on exposure/electoral damage for the three cases with the most traction in Sep 2026 — with September 2026 as the chronological baseline (earlier events treated as origin, not as current measurement).
  • v7 — Sep 11, 2026 (morning) [dashboard] Removed the section "What Lula needs to win in the first round" from the dashboard, at the user's request — along with the corresponding navigation link and the calculations exclusive to it (viability grid, table by pollster, third-way reservoir). The occasional mentions of P(first round)/distance to 50%/valid-today that already existed in the Executive summary and in the KPI grid remained, as did Appendix B (raw first-round table by pollster), since they are distinct content.
  • v6 — Sep 10, 2026 (night) [data] Added the AtlasIntel/Bloomberg wave of Sep 4–9 (statistical tie Flávio 46.4 × Lula 46.2) — a total of 46 waves from 9 pollsters. Removed the PDF download/print button from the toolbar, at the user's request.
  • v5 — Sep 10, 2026 (afternoon) [dashboard] Added the section "Transparency and electoral compliance": it makes clear that the dashboard is an aggregator of polls already registered with the Electoral Court (not a poll of its own), that no informal straw poll is part of the base and that the consolidation methodology is auditable and does not use generative AI in calculating the projections. The poll tables (dashboard and Appendix A) now show each poll's sponsor and sampling margin of error (95% confidence) — transparency data associated with TSE Resolutions No. 23.747/2026 and No. 23.755/2026 (see References). Light theme set as default (it no longer follows the visitor's operating-system dark mode).
  • v4 — Sep 10, 2026 [data] New PoderData/Aya wave (Sep 6–9, Flávio 47 × Lula 45, replacing the Sep 2 reading as the pollster's most recent) and the first wave from Futura Inteligência in the base (Aug 27–Sep 1, Lula 45.6 × Flávio 45.2 — published on Sep 3 and added now). Nowcast practically stable (the near-tied Futura Inteligência wave offsets PoderData's pro-Flávio push).
  • v3 — Sep 9, 2026 [data] Added the two most recent runoff waves with fieldwork through Sep 9: BTG/Nexus (Sep 4–7, Flávio 46 × Lula 45) and the first Gerp wave in the base (Sep 3–8, Flávio 47 × Lula 40) — a total of 43 waves from 8 pollsters. The new pollster enters with a neutral quality weight (no verifiable accuracy record) and a heavily shrunk house effect. Removed the section "If Flávio wins: what the government plan says" from the dashboard, at the user's request. The adjusted nowcast fell from +0.2 to -1.4 pp (both new waves favor Flávio); P(Lula) in the model fell from 41.4% to 30.5% and the ensemble from 49.5% to 44.1%.
  • v2 — Sep 8, 2026 (afternoon) [method] Base expanded to 41 waves from 7 pollsters (Dec 2025–Sep 2026); recency × size × quality weights; P-spline with house effects; bias calibration over five cycles; Monte Carlo (400,000) with drift, bias and alternative opponent; ensemble with markets and fundamentals; tornado of decisive variables and DAG; four scenarios derived from the distribution; first-round study (arithmetic, reservoir, viability grid, simulation, history); collection of fundamentals (approval, economy, markets, events). This standalone version adds an executive summary, fundamentals, markets, events, historical drift, calibration by pollster, data and code appendices, and references.
  • v1 — Sep 8, 2026 (morning) [method] Simple aggregation (unweighted average) of the latest polls from 5 pollsters; three qualitative scenarios (narrow Lula win ~50%, Flávio comeback ~30%, statistical tie ~20%); a section on Flávio's government plan; assessment of the pool of undecided/blank/spoiled voters; rejection monitoring (scenarios revised to 45/27/28 after the rejection data).
Bias

A poll's real error is 1.72 times the theoretical one; the published margin of error covers only part of it

In one sentence: the published margin of error covers only part of the real error — this cycle, the typical error of a single poll is 1.72 times what the margin promises, which is why the dashboard never trusts a single poll.

Every poll publishes a number — "margin of error of 2 points" — that readers take as the limit of possible error. It isn't. That margin covers only one source of error, the least important one, and it refers to each candidate separately, not to the gap between the two. This section measures, in this dashboard's own base, how much of the error falls outside the stated margin; shows each pollster's bias in 2026 with a confidence interval; recovers the record of hits and misses from 2010 to 2022; and concludes by assessing which polls this cycle deserve more credit — and why.

1 · What the stated margin really covers

stated margin = 1.96 × √(p(1−p)/n) · with p = 0.5 → ±2.0 pp for n ≈ 2,000

It assumes simple random sampling, a complete list of the population and a response from everyone drawn — three conditions no Brazilian election poll meets. And there is a reading trap: the margin applies to each percentage, not to the gap. When Lula has 46% and Flávio 45% with a "2-point margin", the 1-point gap carries, from sampling error alone, an interval of ±4.4 pp — because the errors of the two percentages add up. Statistical ties are therefore far more common than the usual reading suggests.

2 · What it doesn't cover

Design effect: quotas and clustering inflate the variance; in the US, the real standard error is ≈1.3× that of the simple formula (median; Shirani-Mehr et al., JASA 2018), and quota samples taken at high-traffic street points tend toward a larger factor. Nonresponse: rates above 90% in US election polls — and nonresponse is differential: supporters of the trailing candidate respond less, a bias no demographic weighting corrects. Coverage: telephone excludes people without a line (~4% of adults), online panels exclude offline and less-educated voters, face-to-face stumbles on gated communities. Weighting: calibrating on reported vote in the previous election imports the respondent's memory error. Likely voters and undecided: turnout criteria and the reallocation of the undecided are assumptions, not measurements — and different pollsters use similar assumptions, which makes their errors move together.

Measurement in this dashboard's base
Read the full analysis≈ 25 min read

The real error is double the theoretical one — and the published margin covers a quarter of it

For each of the 221 polls in the base, we can compare the observed deviation from the common trend estimated by the aggregator with the deviation sampling theory predicts for that sample. The typical observed error is 3.9 pp on the valid-vote margin, versus 2.3 pp predicted — a ratio of 1.72×. Translated into a 95% interval: the honest interval for a single poll, this cycle, is about ±7.6 pp on the margin, not the ±2.0 shown in the technical sheet. The match with the international literature is striking: analyzing 4,221 polls from 608 US elections, Shirani-Mehr, Rothschild, Goel and Gelman found an average error of 3.5 pp — also about double what the published intervals imply.

Breakdown of the observed error (pp on the margin)

Total typical error (vs. trend)3.9
After removing the house effect2.4
Predicted by sampling2.3

The systematic share — each pollster's bias — is the largest slice and is correctable: that is exactly what the aggregator does when it estimates house effects. What remains after that is still 1.1× the theoretical: the excess noise no technical sheet declares.

Herding test: no sign of herding

Herding is the artificial convergence of pollsters near the election — publishing what the consensus already says, so as not to be wrong alone. In the 2024 US election, Nate Silver showed that 78% of swing-state polls fell within ±2.5 pp when theory predicted 55%: the probability of that happening by chance, 1 in 9.5 trillion. Here the test is run on residuals: after removing the trend and each pollster's house effect, the 57 waves of the last 21 days deviate by 1.7 pp on average, versus 2.2 pp expected from sampling error alone — a ratio of 0.78×. Close to 1: the dispersion is what chance explains, with no sign of herding (part of the distance from 1 comes from the fit itself absorbing noise). Raw margins, by contrast, vary by 2.0 pp across pollsters: Brazil's divergence comes from real, persistent methodological house effects, not noise. (Until Oct 3 this test compared raw margins with sampling error, which always gave a ratio above 1 and did not measure herding.) The practical consequence is that a simple poll average (used by most press aggregators) is a poor estimator: without correcting house bias, it reflects whoever published most that week.

House effect by pollster in 2026 — systematic deviation from the anchor (◆ = anchor pollster)
How to read. Each dot is a pollster's average deviation from the anchor; the bar is the 95% confidence range. To the right of zero, the pollster measures Lula higher than the anchor; to the left, it measures Flávio higher. Bars that do not cross zero indicate a consistent deviation.
measures Flávio higher than the anchor measures Lula higher than the anchor -6 -4 -2 0 +2 +4 +6 Gerp Gerp: house effect -7.7 pp (95% CI: -8.9 to -6.4) · 20 waves · estimated extra variance σ=0.8 · typical error 7.4 pp vs. 2.2 expected from sampling -7.7* Veritá Veritá: house effect -5.3 pp (95% CI: -7.2 to -3.5) · 9 waves · estimated extra variance σ=1.3 · typical error 6.5 pp vs. 1.6 expected from sampling -5.3* Palver Palver: house effect -4.4 pp (95% CI: -6.2 to -2.6) · 6 waves · estimated extra variance σ=0.8 · typical error 4.2 pp vs. 2.8 expected from sampling -4.4* Futura Inteligência Futura Inteligência: house effect -3.8 pp (95% CI: -5.2 to -2.4) · 19 waves · estimated extra variance σ=0.8 · typical error 6.0 pp vs. 2.3 expected from sampling -3.8* PoderData ◆ PoderData ◆: house effect -2.4 pp (95% CI: -3.6 to -1.3) · 17 waves · estimated extra variance σ=0.8 · typical error 2.0 pp vs. 1.9 expected from sampling -2.4* Paraná Pesquisas ◆ Paraná Pesquisas ◆: house effect -2.0 pp (95% CI: -3.9 to -0.1) · 4 waves · estimated extra variance σ=0.8 · typical error 3.2 pp vs. 2.4 expected from sampling -2.0* DataTrends DataTrends: house effect -1.8 pp (95% CI: -3.9 to 0.2) · 5 waves · estimated extra variance σ=0.8 · typical error 2.2 pp vs. 2.3 expected from sampling -1.8 Real Time Big Data Real Time Big Data: house effect -1.6 pp (95% CI: -3.4 to 0.3) · 9 waves · estimated extra variance σ=0.8 · typical error 1.9 pp vs. 2.4 expected from sampling -1.6 Vox Brasil Vox Brasil: house effect -1.4 pp (95% CI: -3.0 to 0.3) · 11 waves · estimated extra variance σ=1.4 · typical error 3.8 pp vs. 2.3 expected from sampling -1.4 BTG/Nexus BTG/Nexus: house effect -1.1 pp (95% CI: -2.3 to 0.1) · 19 waves · estimated extra variance σ=0.8 · typical error 1.5 pp vs. 2.3 expected from sampling -1.1 Quaest ◆ Quaest ◆: house effect -1.0 pp (95% CI: -2.3 to 0.3) · 19 waves · estimated extra variance σ=0.8 · typical error 1.8 pp vs. 2.4 expected from sampling -1.0 Meio/Ideia Meio/Ideia: house effect -0.9 pp (95% CI: -2.8 to 1.0) · 11 waves · estimated extra variance σ=0.8 · typical error 1.7 pp vs. 2.6 expected from sampling -0.9 Ipespe ◆ Ipespe ◆: house effect -0.9 pp (95% CI: -3.0 to 1.3) · 3 waves · estimated extra variance σ=0.8 · typical error 1.3 pp vs. 2.7 expected from sampling -0.9 AtlasIntel ◆ AtlasIntel ◆: house effect -0.5 pp (95% CI: -1.8 to 0.8) · 17 waves · estimated extra variance σ=0.8 · typical error 1.8 pp vs. 2.1 expected from sampling -0.5 Instituto França Instituto França: house effect -0.5 pp (95% CI: -2.8 to 1.9) · 3 waves · estimated extra variance σ=0.8 · typical error 0.9 pp vs. 2.4 expected from sampling -0.5 Palver (v3 calibration) Palver (v3 calibration): house effect -0.3 pp (95% CI: -2.4 to 1.7) · 3 waves · estimated extra variance σ=0.8 · typical error 0.8 pp vs. 2.4 expected from sampling -0.3 Datafolha ◆ Datafolha ◆: house effect 0.0 pp (95% CI: -1.3 to 1.3) · 18 waves · estimated extra variance σ=0.8 · typical error 2.1 pp vs. 2.2 expected from sampling 0.0 Alfa Inteligência Alfa Inteligência: house effect 0.3 pp (95% CI: -1.7 to 2.2) · 6 waves · estimated extra variance σ=1.0 · typical error 2.1 pp vs. 2.4 expected from sampling 0.3 Indexa Indexa: house effect 1.1 pp (95% CI: -0.7 to 2.8) · 8 waves · estimated extra variance σ=0.8 · typical error 1.6 pp vs. 2.4 expected from sampling 1.1 CNT/MDA ◆ CNT/MDA ◆: house effect 3.9 pp (95% CI: 2.0 to 5.8) · 5 waves · estimated extra variance σ=0.8 · typical error 6.4 pp vs. 2.4 expected from sampling 3.9*

A word of caution on interpretation: a house effect is not a synonym for political bias. It measures distance from the anchor, not distance from the result — and it usually reflects method choices (who polls face-to-face, who polls online, who reallocates the undecided). The consensus can be wrong too: in 2022, the two pollsters closest to the final runoff result were precisely two that departed from the average.

After the first round, house effects count for half. In polls conducted after Oct 4, the observed margin tracks the previously estimated house effect only in part (slope of +0.58, with an interval of ±0.68, where 1 would mean full persistence and 0 none; without Gerp, close to zero), and the spread across pollsters increases when the old effect is applied in full. Several pollsters began weighting their samples by declared first-round vote, which erases part of the differences. The model applies 50% of the historical house effect to polls after Oct 4 (the "House effect" column above shows the value applied). Palver changed its calibration method in the Oct 10–13 wave (v3, with first-round vote by region), and the pollster itself says the wave is not comparable with earlier ones: it enters as a new series, without the house effect of earlier waves, and with the Kish effective n it reports (1,783 instead of 5,000). AtlasIntel's effective n is capped at 2,500, because its declared margin (±1.0) is smaller than that of a simple random sample of the same size (±1.4) and the design effect is not disclosed.

PollsterMethodAverage nHouse effectTypical errorTheoretical errorRatioMAE 2006–22Reading
Gerp · 20 waves · latest Oct 24 phone 2,220 -7.7 * 7.4 2.2 3.31× — Use with caution
Futura Inteligência · 19 waves · latest Oct 17 phone 2,000 -3.8 * 6.0 2.3 2.58× 2.4 Use with caution
Quaest · 19 waves · latest Oct 24 in-person 2,198 -1.0 1.8 2.4 0.75× 4.0 Reliable, with caveats
BTG/Nexus · 19 waves · latest Oct 24 phone 2,007 -1.1 1.5 2.3 0.65× — Most reliable
Datafolha · 18 waves · latest Oct 24 in-person 2,345 0.0 2.1 2.2 0.93× 3.4 Most reliable
AtlasIntel · 17 waves · latest Oct 23 online 5,215 -0.5 1.8 2.1 0.85× 4.5 Reliable, with caveats
PoderData · 17 waves · latest Oct 24 phone 2,964 -2.4 * 2.0 1.9 1.04× 4.5 Reliable, with caveats
Meio/Ideia · 11 waves · latest Oct 19 phone 1,636 -0.9 1.7 2.6 0.64× — Reliable, with caveats
Vox Brasil · 11 waves · latest Oct 22 in-person 2,100 -1.4 3.8 2.3 1.62× — Use with caution
Veritá · 9 waves · latest Oct 23 phone 12,912 -5.3 * 6.5 1.6 3.99× 3.8 Use with caution
Real Time Big Data · 9 waves · latest Oct 22 phone 2,000 -1.6 1.9 2.4 0.78× — Most reliable
Indexa · 8 waves · latest Oct 21 phone 2,000 +1.1 1.6 2.4 0.68× — Reliable, with caveats
Alfa Inteligência · 6 waves · latest Oct 22 in-person 2,200 +0.3 2.1 2.4 0.86× — Use with caution
Palver · 6 waves · latest Oct 3 online 5,000 -4.4 * 4.2 2.8 1.54× — Use with caution
CNT/MDA · 5 waves · latest Oct 2 in-person 2,003 +3.9 * 6.4 2.4 2.69× 3.9 Use with caution
DataTrends · 5 waves · latest Oct 21 phone 2,000 -1.8 2.2 2.3 0.94× — Insufficient series
Paraná Pesquisas · 4 waves · latest Mar 28 in-person 2,069 -2.0 * 3.2 2.4 1.37× 3.8 Reliable, with caveats
Instituto França · 3 waves · latest Oct 23 not reported 2,179 -0.5 0.9 2.4 0.40× — Insufficient series
Ipespe · 3 waves · latest Oct 22 phone 1,500 -0.9 1.3 2.7 0.48× 4.9 Use with caution
Palver (v3 calibration) · 3 waves · latest Oct 24 online 5,000 -0.3 0.8 2.4 0.32× — Use with caution
Vetor/Arrow · 2 waves · latest May 18 not reported 9,000 -0.4 10.4 1.5 7.02× — Insufficient series
American Analytics · 2 waves · latest Sep 20 not reported 2,000 0.0 0.4 2.4 0.16× — Insufficient series
CNT/MDA (weighted by first round) · 2 waves · latest Oct 23 in-person 2,023 -0.3 0.5 2.3 0.23× — Use with caution
Futura (education from PNAD) · 2 waves · latest Oct 24 phone 2,000 -0.4 0.8 2.3 0.33× — Use with caution
Colectta · 1 wave · latest Oct 23 in-person 3,000 +0.2 1.8 1.9 0.94× — Insufficient series

Positive "house effect" = shows Lula better than the anchor; negative = shows Flávio better; * marks statistically significant effects (95%). "Typical error" is the observed deviation from the common trend; "theoretical error" is what sampling predicts for that n. A "ratio" above 2 means the pollster misses by twice what its methodology statement suggests — and a large sample does not fix it: Veritá and Vetor/Arrow declare the smallest margins in the database (samples of 3.8 thousand to 40 thousand) and have the largest ratios, because a large sample reduces sampling error without touching bias.

Method explains much of the divergence

This is not opinion: PollingData measured, across 4,400 polls registered with the TSE, that in 2022 Lula's average lead was 14.1 pp in face-to-face polls versus 9.3 pp in phone polls in the first round (a 4.8 pp gap), and 17.6 versus 13.8 in the runoff. The direction repeats in 2026 for perception variables: Datafolha (face-to-face, street intercept) measures rejection at 46% for Flávio and 45% for Lula; Quaest (face-to-face, in-home), 54% and 52% in the same month — a 7 to 8 pp gap on the variable that sets each candidate's ceiling. On government approval, PoderData (IVR) and Quaest diverged by as much as 10 pp in December 2025. Self-administered methods (online, IVR) tend to measure more right-leaning vote than interviewer-based methods — consistent with lower social desirability bias.

Sponsor bias: what the evidence shows

In the US, polls sponsored by campaigns and parties show an average bias of 4 to 5 pp in favor of the sponsor (FiveThirtyEight). In Brazil, JOTA estimates house effects by pollster and has recorded deviations of around 4 pp in polls from pollsters tied to corporate sponsors. In this dashboard's database, however, the pattern does not line up by sponsor. The largest deviations from the anchor are: Gerp (-7.7, pro-Flávio); Veritá (-5.3, pro-Flávio); Palver (-4.4, pro-Flávio); CNT/MDA (+3.9, pro-Lula). They include both self-funded pollsters and pollsters with a sponsor. In other words: in the 2026 cycle, bias is more associated with method and lack of scrutiny than with who pays the bill.

CycleWhat the final polls projectedResultMargin errorLesson
2010 · runoff Datafolha and Ibope: Dilma 56 × 44 56.05 × 43.95 ≈0.2 The most accurate cycle in the series — when the race is not polarized by identity, polling works well
2014 · runoff Vox Populi 48×41 · Datafolha 47×43 · Ibope 49×43 · CNT/MDA had Aécio ahead Dilma 51.6 × 48.4 0.6 to 3.9 First recent case of a wrong winner from a traditional pollster; the first round underestimated Aécio by ~9 pp
2018 · runoff Datafolha 55×45 · Ibope 54×46 · Vox Populi 50×50 · Paraná Pesquisas 60.6×39.4 Bolsonaro 55.1 × 44.9 0.3 (Datafolha) to 10.9 (Paraná) The same cycle produced the best and the worst performance in the series — the pollster average hides brutal dispersion
2022 · first round Datafolha and Ipec: Lula +14 in valid votes Lula +5.2 ≈8.8 Systematic error against Bolsonaro, with deviations of up to 20 pp in some states — the origin of this dashboard's bias prior
2022 · runoff CNT/MDA 51.1×48.9 · Paraná 50.4×49.6 · Datafolha and Quaest 52×48 · Ipec 54×46 · Veritá, Brasmarket and Futura had Bolsonaro winning Lula 50.9 × 49.1 0.4 to 9.0 The two most accurate were outside the consensus; three pollsters got the winner wrong — two of them are still in the 2026 database
Municipal 2020/2024 SP 2020: Covas 38 (final Ibope) · SP 2024: Boulos 27 × Nunes 24 × Marçal 24 (Datafolha) Covas 32.9 · Nunes 29.5 × Boulos 29.1 × Marçal 28.1 4 to 5.5 In 2024 the order of the top three was reversed — an ordering error, not just one of magnitude

Convention: margin error = (projected left−right margin) − (official margin), in percentage points of valid votes. This series feeds the systematic-error adjustment used in the simulation (+0.8 ± 2.0 pp for the runoff — observed polling error in the first round for waves before Oct 4, and final runoff rounds from 2006–2022 plus 25% of that error for the new waves; +3.4 ± 3.8 for the first round). Note the asymmetry between rounds: large, systematic bias shows up mainly in first rounds (2014 and 2022), while runoffs have smaller and more scattered bias.

Regulation: what the TSE requires — and what it does not guarantee

Under Law 9.504/1997 (art. 33), every poll must be registered in PesqEle at least 5 days before publication, with sponsor, amount paid, sampling plan, fieldwork period and margin of error; afterwards, the final sample composition must be filed. Publishing without registration carries a fine of R$ 53,000 to R$ 106,000, including for those who merely share it. Registration, however, is a transparency formality, not a seal of quality: there is no prior audit of method and no penalty for being wrong. The bill that would have barred for 8 years any pollster missing beyond its margin (PL 2.558/2022) did not advance, and the polling inquiry (CPI) requested in 2022 was never set up.

What has already happened this cycle

Three episodes from 2026 show the size of the gray zone. February: the TSE suspended a poll by Áltica Research published without prior registration. June and July: Flávio Bolsonaro's campaign took AtlasIntel to the TSE twice — the first time, alleging the poll led respondents by playing audio from the Vorcaro case before the image questions, it won an injunction from the TSE president suspending publication; the second time, it challenged the supplementary documentation of a wave showing Lula +7. August: Veritá polls were suspended by the Electoral Court after questions about methodology, sample composition and fieldwork schedule — the same pollster that registered 93 self-funded polls during the year. Taking polls to court is itself a campaign tool: it is worth separating the methodological merits from the political use of the challenge.

Conclusion

Which polls this cycle deserve the most credit

No pollster is infallible, and the right question is not "who is right today" but who misses by less and more predictably. I combined four verifiable criteria: historical accuracy in actual presidential elections (2018 and 2022), size and statistical significance of the 2026 house effect, excess error over the theoretical, and transparency of method, sponsor and registration. The result below is an explicit judgment — not an automatic index — and each name comes with its caveat.

Tier 1 — the most reliable in the database

BTG/Nexus · 19 waves · house effect -1.1 · error 0.65× theoretical

In 2026: 19 waves, house effect of -1.1 pp, indistinguishable from zero, typical error below what the sample implies. The densest series of the cycle, with biweekly waves since March, and one of the smallest typical errors in the database. Phone method and sponsor disclosed in every wave. Caveats: it has no presidential track record of its own to gauge accuracy, and such low dispersion is also consistent with smoothing across waves.

Datafolha · 18 waves · house effect 0.0 · error 0.93× theoretical

In 2026: 18 waves, house effect of 0.0 pp, indistinguishable from zero, typical error close to theoretical. The country's longest series, face-to-face street-intercept method, and the best historical accuracy among the major pollsters (MAE of 3.4 pp on the margin, 2006–22, including a near-exact call of the 2018 runoff).

Real Time Big Data · 9 waves · house effect -1.6 · error 0.78× theoretical

In 2026: 9 waves, house effect of -1.6 pp, indistinguishable from zero, typical error below what the sample implies. Phone method and registration disclosed, typical error below theoretical. Caveats: no verifiable presidential track record and a short series.

Tier 2 — reliable, with a known caveat

Quaest · 19 waves · house effect -1.0 · error 0.75× theoretical

In 2026: 19 waves, house effect of -1.0 pp, indistinguishable from zero, typical error below what the sample implies. Long, face-to-face in-home series, the most expensive method and, in theory, the one with the best coverage, with registration and sponsor disclosed. Against: the worst historical MAE among the major pollsters (4.0 pp). It measures rejection higher than Datafolha, a difference of question wording and method, not of timing.

AtlasIntel · 17 waves · house effect -0.5 · error 0.85× theoretical

In 2026: 17 waves, house effect of -0.5 pp, indistinguishable from zero, typical error below what the sample implies. Samples of 5,000, high frequency and the best performance in the 2022 first round. Against: it is a non-probability online poll (digital recruitment) and was the target of two PL lawsuits in 2026, one of them resulting in a suspension injunction. A large sample shrinks the declared margin without shrinking the real error.

PoderData · 17 waves · house effect -2.4 · error 1.04× theoretical

In 2026: 17 waves, house effect of -2.4 pp (shows Flávio better than the anchor, a statistically significant deviation), typical error close to theoretical. Fixed schedule, method (IVR) and TSE registration published in every wave. Verifiable track record, albeit middling (MAE of 4.5 pp).

Meio/Ideia · 11 waves · house effect -0.9 · error 0.64× theoretical

In 2026: 11 waves, house effect of -0.9 pp, indistinguishable from zero, typical error below what the sample implies. Phone waves with TSE registration and disclosed sponsor, good frequency. Against: smaller sample than the database average and no presidential track record of its own.

Indexa · 8 waves · house effect +1.1 · error 0.68× theoretical

In 2026: 8 waves, house effect of +1.1 pp, indistinguishable from zero, typical error below what the sample implies. Phone poll, media sponsor (Broadcast/Estadão) and registration disclosed. Against: short series and no verifiable presidential track record.

Paraná Pesquisas · 4 waves · house effect -2.0 · error 1.37× theoretical

In 2026: 4 waves, house effect of -2.0 pp (shows Flávio better than the anchor, a statistically significant deviation), typical error 1.4× theoretical. An instructive case: it nailed the 2022 runoff almost exactly when the consensus missed, but had missed 2018 by ~11 pp (combined MAE of 3.8 pp). Its latest wave is from March: it is outside the current reading.

Tier 3 — use with caution or insufficient series

Gerp · 20 waves · house effect -7.7 · error 3.31× theoretical

In 2026: 20 waves, house effect of -7.7 pp (shows Flávio better than the anchor, a statistically significant deviation), typical error 3.3× theoretical. Self-funded and with no verifiable presidential track record. This does not imply bad faith: it may be a methodological choice that better captures right-leaning voters, but there is no way to audit it.

Futura Inteligência · 19 waves · house effect -3.8 · error 2.58× theoretical

In 2026: 19 waves, house effect of -3.8 pp (shows Flávio better than the anchor, a statistically significant deviation), typical error 2.6× theoretical. It projected the wrong winner in the 2022 runoff, in the same direction as its current deviation: the bias is not new.

Vox Brasil · 11 waves · house effect -1.4 · error 1.62× theoretical

In 2026: 11 waves, house effect of -1.4 pp, indistinguishable from zero, typical error 1.6× theoretical. Face-to-face in-home and self-funded in recent waves (TSE registration disclosed); waves through May did not disclose the method. No verifiable presidential track record.

Veritá · 9 waves · house effect -5.3 · error 3.99× theoretical

In 2026: 9 waves, house effect of -5.3 pp (shows Flávio better than the anchor, a statistically significant deviation), typical error 4.0× theoretical. Huge samples (up to 40,000) and a declared margin among the smallest in the database, with observed error several times the theoretical: the clearest illustration of false precision. It had polls suspended by the Electoral Court in August 2026 after questions about methodology and sample composition, and it projected the wrong winner in 2022.

Alfa Inteligência · 6 waves · house effect +0.3 · error 0.86× theoretical

In 2026: 6 waves, house effect of +0.3 pp, indistinguishable from zero, typical error below what the sample implies. Samples smaller than the database norm, method not disclosed and no verifiable track record.

Palver · 6 waves · house effect -4.4 · error 1.54× theoretical

In 2026: 6 waves, house effect of -4.4 pp (shows Flávio better than the anchor, a statistically significant deviation), typical error 1.5× theoretical. Data collection via WhatsApp with ad-based recruitment and raking calibration. On the other hand, it has the most complete methodology statement of the cycle and promised to release its microdata after the runoff.

CNT/MDA · 5 waves · house effect +3.9 · error 2.69× theoretical

In 2026: 5 waves, house effect of +3.9 pp (shows Lula better than the anchor, a statistically significant deviation), typical error 2.7× theoretical. The hardest case in the database. It has good historical accuracy (MAE of 3.9 pp; it was the closest to the result in the 2022 runoff) and, in 2026, it is the anchor pollster that most often shows Lula ahead. Either it captures something the others miss, as in 2022, or it has been an outlier this cycle. Few waves, all face-to-face, sponsor disclosed (CNT).

DataTrends · 5 waves · house effect -1.8 · error 0.94× theoretical

In 2026: 5 waves, house effect of -1.8 pp, indistinguishable from zero, typical error close to theoretical. Debuts in the database with IVR. No track record and a short series for measuring house effects (the rating does not change its weight in the model: each wave is weighted by its sampling error and by the house's extra noise).

Instituto França · 3 waves · house effect -0.5 · error 0.40× theoretical

In 2026: 3 waves, house effect of -0.5 pp, indistinguishable from zero, typical error below what the sample implies. Phone (CATI), self-funded, no verifiable track record. The Oct 16–20 report says it "consolidates surveys" and the overall total does not add up with the breakdowns: no age breakdown has more than 4.1% "don't know" (the total says 8.84%), and the vote by region, weighted by the report's own weights, gives Lula ~41.6 × Flávio ~39.3, the reverse of the total.

Ipespe · 3 waves · house effect -0.9 · error 0.48× theoretical

In 2026: 3 waves, house effect of -0.9 pp, indistinguishable from zero, typical error below what the sample implies. Returns to the database in the 2026 runoff with a verifiable presidential track record (the XP/Ipespe series of 2018 and 2022), but a middling one: it missed the runoff margin by 5.7 pp in 2018 and 4.2 in 2022, in opposite directions. Small sample (1,500) and releases that disagree about the method (digital recruitment × CATI phone).

Palver (v3 calibration) · 3 waves · house effect -0.3 · error 0.32× theoretical

In 2026: 3 waves, house effect of -0.3 pp, indistinguishable from zero, typical error below what the sample implies. Palver's series after its weighting change (v3, from Oct 10–13 onward): same house and same method, but the house effect is estimated only on the new waves, which is why it appears separately. The declared effective n (Kish) is what enters the error.

Vetor/Arrow · 2 waves · house effect -0.4 · error 7.02× theoretical

With 2 waves, there is no series to measure a house effect. Two waves with samples of 9,000 that moved more than 10 pp apart in two months, the largest gap in the database relative to what the sample would allow. No track record and no declared method.

American Analytics · 2 waves · house effect 0.0 · error 0.16× theoretical

With 2 waves, there is no series to measure a house effect. Short series, no verifiable track record in Brazil and no declared method.

CNT/MDA (weighted by first round) · 2 waves · house effect -0.3 · error 0.23× theoretical

With 2 waves, there is no series to measure a house effect. CNT/MDA's series after its weighting change (v2, from Oct 13–15 onward): same house, house effect estimated only on the new waves, kept out of the anchor until it has a series.

Futura (education from PNAD) · 2 waves · house effect -0.4 · error 0.33× theoretical

With 2 waves, there is no series to measure a house effect. Futura's series after switching its education weighting (TSE voter roll → PNAD Contínua, from the Oct 19–20 wave onward): same house, house effect estimated only on the new waves.

Colectta · 1 wave · house effect +0.2 · error 0.94× theoretical

With 1 wave, there is no series to measure a house effect. Debuts in the database on the eve of the runoff: face-to-face, one day of fieldwork (Oct 23), disclosed sponsor (Brazil Economy) and TSE registration BR-05601/2026. No verifiable track record and, with only one wave, no series to measure a house effect.

Rule of thumb

How to read any runoff poll

  • Mentally double the published margin — and remember that, for the gap between two candidates, it is already worth double before any bias correction
  • Be wary of a large sample with a small margin: a high n reduces sampling error, which is the smallest part of the problem, and does not touch bias
  • Compare each pollster with itself: the trend within a series is more informative than the level across different series
  • Ask who paid and what the method was before looking at the number — and treat "method not disclosed" as relevant information about the pollster
  • An outlier poll is not necessarily wrong: in 2022, the two pollsters closest to the result were outside the consensus, and it was the consensus that missed
  • No single poll decides anything: that is why this dashboard aggregates, corrects for house effects and still projects with a wide interval

Correction made in this update. While compiling the track record for this section, I found a flaw in the dashboard's own calibration: Paraná Pesquisas was entering with the best quality weight in the database (lowest historical error) because only its 2022 performance was recorded — but the pollster missed the 2018 runoff by about 11 pp, the largest individual deviation in the series. The track record was completed (Paraná in 2018; Veritá and Futura in 2022), and the quality weight is now shrunk according to the number of cycles supporting it: with a single verified election, the pollster stays close to the neutral weight. The nowcast and probabilities in this version already reflect the correction.

Sources for this section. Total error vs. declared margin: Shirani-Mehr, Rothschild, Goel & Gelman, "Disentangling Bias and Variance in Election Polls", JASA 113(522), 2018. Herding: Nate Silver, "There's more herding in swing state polls", 2024. Face-to-face × phone method: PollingData (database of 4,400 polls registered with the TSE). House effects and sponsor bias: FiveThirtyEight and JOTA (house-effects aggregator). Historical series: Congresso em Foco, O Povo, Poder360, Metrópoles, CartaCapital, O Janelão and Wikipedia (presidential polls 2014/2018/2022). Regulation and 2026 episodes: TSE (PesqEle, Res. 23.747/2026 and decisions of Feb., Jun. and Jul. 2026), Senate (PL 2.558/2022), JOTA, Brasil de Fato and Correio da Manhã. Transparency: AAPOR Transparency Initiative, ABEP and ABRAPEL.

Glossary

The dashboard's terms, in plain language

In one sentence: every technical term used in the dashboard, explained without jargon. The same texts appear when you tap the underlined words.

Abstention
Eligible voters who do not turn out to vote. Voting is compulsory from ages 18 to 69 (an unjustified absence carries a fine) and optional for 16–17-year-olds, those 70 or older and illiterate voters. In 2022, 20.95% stayed away in the first round and 20.59% in the second.
Government approval
Share who approve of how the president governs. It is the factor that weighs most in a reelection: each extra point of approval is worth about 0.8 points of margin for Lula.
Markov chain
A model in which each voter is in a state (voted for X, did not vote, was reached by the campaign, made a voting plan…) and moves to another with probabilities that depend only on the current state, not on the path that led there. Multiplying the population by the transition matrix gives the population in each state at the next step; repeating the step day by day gives the whole campaign.
Regional shocks
Errors that hit an entire region at once (for example, polls underestimating the right-wing vote in the South, as in 2018 and 2022). The national historical error already contains these regional errors; the separate draw by region was removed from the simulation in the Oct 3 audit, because it did not change any national result.
Drift
How much the margin can still move before Election Day. The more campaign there is left, the larger it is; on voting day, it is zero.
Dirichlet distribution
A draw of a set of proportions that add up to 100% (for example, the share of Cury's voters who go to Lula, to Flávio, to blank/spoiled or stay home), around a center and with a concentration κ that says how far they can stray from it: the smaller the κ, the more uncertain the split.
House effect
A pollster's systematic tendency to show a candidate doing better or worse than the average of the others, because of its method (phone, internet, face-to-face interview, question wording). The dashboard measures this deviation and adjusts for it.
Statistical tie
When the gap between the candidates is smaller than the measurement uncertainty: you cannot say who is ahead.
Ensemble
A combination of two sources: the polling model (75%) and betting markets (25%, only while there is a recent price; without one, only the model counts). The track record of presidents running for reelection appears in the dashboard but carries no weight: three elections cannot support a forecast. It is the dashboard's headline number.
Historical first-round error
In 2010, 2014, 2018 and 2022, the final first-round polls overestimated the PT candidate's margin over the main right-wing opponent by 2 to 10 points of valid votes. The model discounts part (60%) of the recent average of these errors in the first-round projection and shows the result with no discount and with the full discount.
Systematic polling error
Error that pushes all polls in the same direction. In the runoffs from 2006 to 2022, the final polls overestimated the PT candidate in three cycles and underestimated him in two; the simulation currently discounts +0.8 points from Lula's margin, with the corresponding uncertainty.
State space (Kalman filter)
A method that treats the true margin as something that moves a little each day and each poll as a noisy measurement of it. From the data, it estimates how much the margin usually moves per day, each pollster's deviation and how much each one misses beyond what it declares.
Fundamentals
Indicators that tend to anticipate elections independently of the polls, such as government approval and the economy. Presidents with approval near 45% tend to be reelected.
80% / 95% interval
The range where the result falls in 80% (or 95%) of the simulations. The wider it is, the greater the uncertainty.
Margin of error
The range around the published number where the true value probably lies (95% confidence). It applies to each candidate separately; for the gap between the two, the range is almost double — and it does not cover methodological errors.
MCMC (Markov chain Monte Carlo)
A simulation method that draws all of the model's parameters together, many times (trend, each pollster's deviation, polling error, how fast the margin changes), according to how strongly the data support them. Instead of fixing each parameter at its most likely value, it carries the uncertainty of all of them through to the forecast.
Prediction markets
Betting platforms (such as Polymarket and Kalshi) where the price of each contract reflects the chance bettors give each outcome. They react quickly to news — and to rumors.
Adjusted margin (nowcast)
An estimate of where the election would stand if it were held today, combining all the polls and correcting each pollster's typical deviation. It is a snapshot, not a forecast for Election Day.
Break-even point
The final-poll margin at which both candidates would have a 50% chance, after discounting historical polling error. Today it stands at +0.8 for Lula: a tie in the polls corresponds to an edge for Flávio.
Percentage point (pp)
The direct difference between two percentages: going from 45% to 47% is 2 percentage points — not 2%.
Rejection
Share of voters who say they would not vote for the candidate under any circumstances. It works as a ceiling: those who reject a candidate rarely change their minds.
Simulation (Monte Carlo)
The computer runs the election 400,000 times, drawing in each run how much the margin can still change and how much the polls can miss. The chance of winning is the share of simulations each candidate wins.
Swing
How much a state's margin has changed from the previous election: today's polling margin minus the 2022 runoff margin. If all states move similarly, the average swing tells how much the whole country has moved.
Smoothed trend
A curve that runs through the cloud of polls without following every up-and-down, to separate real movement from each poll's noise. In the v3 model it comes from the Kalman smoother.
Third way
Candidates other than Lula and Flávio in the first round (Cury, Caiado, Zema, Renan Santos…). Where their voters go in the runoff helps decide the election.
Pollster extra variance
How much a pollster's polls fluctuate beyond what the sample explains. Estimated from the data; more erratic pollsters carry less weight.
Valid votes
Votes cast for candidates, excluding blank and spoiled ballots. This is how the TSE decides the election: whoever gets more than half of the valid votes wins.
Anchor
The set of pollsters with a verified presidential track record (AtlasIntel, Datafolha, Ipespe, CNT/MDA, Paraná Pesquisas, PoderData, Quaest) whose average defines the zero for house effects. It is the same set in which historical error is measured.
Compliance · note to readers

Transparency and electoral compliance

Fundamentals: approval, economy and sentiment

Government approval is the model's central mediator (Lavareda: "approval ≈ runoff vote"; Eurasia: incumbents with ~45% win ~3/4 of the time). The series shows a recovery from Feb 2025 (24% great/good, the worst of his three terms) to Jul 2026 and a deterioration of 3–4 pp since then — coinciding with the shrinking runoff lead.
Datafolha (fieldwork)Great/goodFairBad/terribleApproveDisapprove
Dec 2024352934——
Feb 10–11, 2025243241——
Sep 202533—3848—
Dec 2–4, 202532—374948
May–Jun 20263228384948
Jul 22–23, 20263228384948
Aug 18–20, 20263325414750
Sep 1–2, 20263125424551
Genial/Quaest (month)Jan '25Mar '25May '25Jul '25Sep '25Oct '25Jan '26Apr '26May '26Jun '26Jul '26Aug '26Sep 1 '26Sep 6 '26
Approve47414043464847434647484645—
Disapprove——575351494952494847484850
Other pollstersFieldworkApproveDisapproveGreat-good / fair / bad-terribleNote
AtlasIntel/BloombergDec 10–15 '25 → Jun '26 → Jul 22–27 → Aug 25–3048.8 → 46 → 47.6 → 45.450.7 → 52 → 51.2 → 52.937.0 / 11.9 / 51.1 (Aug)Disapproval: evangelicals 76%, youth 16–24 77%, South 68%. Approval: Northeast 55–67%, 60+ 63%, Catholics 55%
PoderData/AyaMay → Jun → Jul → Sep '2644 → 43 → 42 → 4250 → 52 → 51 → 5134 / 15 / 48 (Sep)Lowest series for Lula, consistent with its -2.4 house effect in the runoff
Ipsos-IpecSep '25 → Dec '25 → Mar '26 → Jun '2644 → 42 → 43 → 4451 → 52 → 51 → 5032 / 28 / 38 (Jun)Trust in Lula 40% / distrust 56% (Mar)
BTG/Nexus · RTBD · CNT/MDAAug '26 · Aug '26 · Nov '2548 · 45 · 48.149 · 51 · 48.835/—/43 · 27/33/39 · 34/29/36—
Economic indicatorLatest dataElectoral reading
IPCA (inflation)Jul '26: +0.07% m/m; 4.44% over 12 months (below the ceiling after 2 months); food at home −1.14%. IPCA-15 Aug: −0.40% (4.24% over 12 months); food −0.57%; electricity −6.25% (Itaipu bonus)Food deflation in Jun–Aug not yet converted into perception (49% say the economy got worse). Food had risen 7.4% cumulatively as of Aug '25
Unemployment (PNAD)May–Jul '26: 5.3% (lowest in the series); 103.3 million employed; 39.4 million in formal private jobs (records); informality 37.5%An asset for the incumbent — but Nunes: 64% say their income rose and only 10% that it outpaced the cost of living
Real incomeR$ 3,762; +3.3% y/y; −0.7% in the quarter; total income R$ 383.5 billion (+4.1%)Modest and slowing real gains
Selic (policy rate)15% → 0.25-point cuts since Mar '26 → 14.00% (Aug 5); Copom Sep 15–16; Focus 13.75% at end-2026Slow easing cycle; effect on household credit still small
GDP2025: +2.3%. Q2 '26: +0.5% q/q, +2.0% y/y; household consumption −0.4% q/q; Focus 2026: 1.92% (falling)A slowdown in consumption in the second half is the main economic risk for the incumbent
Consumer confidence (FGV)Aug '26: 84.7 (−3.6), lowest since Nov 2022; 4th straight drop; expectations 86.1Indebted and delinquent households (FGV) — what is "felt" diverges from what is "measured"
Markets / exchange rateUSD/BRL R$ 5.09 (Sep 8); Ibovespa ≈188,000; foreign outflow of R$ 15.6 billion from B3 in Aug; JPMorgan downgraded Brazil to neutral (Aug 11)"Election trade": stocks rise and the real strengthens when Flávio gains — Eurasia and Morgan Stanley read it as underpricing of risk
FiscalGross debt 81.9% of GDP (Jun '26); primary deficit 1.19%; nominal 9.99% (Morgan Stanley)Morgan Stanley: what matters is post-2026 fiscal credibility, not the winner
SentimentQuaest Aug: worse 49 / same 30 / better 19; next 12 months: improvement 42. Datafolha Jun: worse 45 / better 22; pessimists 26% (35 in Mar). Top problem: violence 33%, economy 15%, health 14%, corruption 14%Security overtakes the economy as a concern — Flávio's turf (Garman: Lula has "low credibility on security and corruption")

Prediction markets and research houses

Markets priced Lula as the favorite (56–59%) until early September, but flipped to Flávio from Sep 8–10 and kept falling for Lula until Sep 15 (45%), the day of the STF session on the Federal Police report on the Moraes–Vorcaro interactions — alongside the fight over control of the Federal Police and the release of the investigation into Flávio in the Dark Horse case. No bank published a probability after the flip; Eurasia is the only consultancy with a pre-crisis number (≈60%) and it had already warned that "the market underestimates Flávio". The dashboard's ensemble uses 20% (markets, Oct 25, 8 a.m.) and 53% (fundamentals), with weights of 25% and 0%.
SourceDateP(Lula)P(Flávio)Note
PolymarketSep 17, 202645%55%Stable since the Sep 15 STF session; volume US$ 150.7 million; normalized P(Lula) ≈ 45%
PolymarketSep 16, 202645%54%After the STF session ended without a ruling on the merits (Dino requested more time to review); volume US$ 150.6 million; normalized P(Lula) ≈ 45%
PolymarketSep 15, 202645%52%Day of the special STF session on the Moraes–Vorcaro report; cumulative volume US$ 150.1 million; normalized P(Lula) ≈ 46%
PolymarketSep 13, 202649%50.2%Flip relative to Sep 8 (Lula 59%); cumulative volume US$ 149.4 million since the market opened (Sep 2025); repricing attributed to the STF/Federal Police leadership crisis and the partial lifting of secrecy in the Master case (Sep 9–10)
KalshiSep 10, 2026≈49%≈51%Market headline: "Flávio Bolsonaro Hits 51% as Supreme Court Clash Rattles Lula" — attributes the flip to the "judiciary standoff" and to emergency fuel cuts. Before Sep 8: Lula 56%; first-round winner (Sep 8 reading): Lula 89%; 2nd place: Flávio 88%; Lula margin 0–5 pts 44%, 5–10 pts 38%
Polymarket/Kalshi (Sep 8, pre-crisis)Sep 8, 202659% · 56%39.9% · 44%Reading prior to the Sep 8–10 STF crisis, kept here for historical comparison; Predict.fun/Prediction Hunt average 57–58%
Eurasia Group (Garman)Sep '25 → Apr '26 → Jul '26 → Aug '2650-50 (leaning Lula) → 55% → 60% → ~60%~40%May '26: 30% chance of another right-wing name in the runoff. Aug '26: "market underestimates Flávio"; clients who see Lula as the favorite went from 47% to 80%
XP ResearchJul '26Lula "slight favorite"—Election decided by 3–4% undecided, 69% of them women; rising abstention hurts Lula's coalition
Morgan Stanley (126 pp.)Aug 25, 2026no P—2027 scenarios: base GDP 0.8% / Selic 12.75 / USD 5.25; pessimistic 0.2% / 15.5% / 6.00 (Ibov −25%); optimistic 1.5% / 9.75% / 4.50; risks tilted to the pessimistic side
BTG Pactual · JPMorganMar–Aug '26no P—BTG: portfolios by scenario (Lula: SUZB3, ITUB4, WEGE3; Flávio: BBAS3, XP, PETR4). JPM downgraded Brazilian stocks to neutral
"Brasil em Mapas" (Monte Carlo nowcast, 15 polls)Sep 7, 202674%26%Central 51.5 × 48.5 valid votes; no historical bias correction — compare with our model's 27% without correction
Panel of 6 AI models (OddsShopper)Aug 6, 202668%23.8%"1 in 3 chance that age, a campaign turnaround or consolidation changes the winner"
This dashboard25/10/2026model 17% · ensemble 18%83% · 82%The only one with an explicit correction for 2006–22 bias and an estimated trend (Kalman smoother); Itaú, Bradesco BBI, Genial, Warren, Goldman, Barclays, the Economist and the FT did not publish a base case with a probability

2026 political events and risks

Discrete shocks that the model treats as drift (0.31 pp/day until Oct 4; 0.95 between rounds) — listed here for a qualitative reading of the scenarios. Who the PL candidate is: Flávio Bolsonaro, senator for Rio de Janeiro since 2019, Jair Bolsonaro's eldest son, former city councilor and former state deputy (RJ); he took over the candidacy after his father was barred from running.

Jair Bolsonaro — legal situation

  • Ineligible until 2030 (TSE, Jun 2023); sentenced by the STF to 27 years and 3 months for the coup plot (Sep 11, 2025); jailed on Nov 22, 2025; humanitarian house arrest since Mar 27, 2026, extended indefinitely on Jul 3 (ankle monitor, no social media, authorized visits).
  • Aug 2026: visits suspended after Flávio released a letter from his father; restriction until Oct 11 (7 days after the first round). Sep 7: Flávio led a rally on Avenida Paulista ("Lula Out, Moraes Out"); Lula at the parade in Brasília with a sovereignty speech.
  • Eduardo Bolsonaro barred from running for 12 years (Jun 16, 2026). Michelle: "I want to be first lady" (18% in the first round, 61% rejection — Quaest Sep '25). Quaest Jan '26: 43% think an opposition candidate without the surname would beat Lula more easily.

Brazil–US relations

  • Jul 2025: 50% tariff and Magnitsky Act sanctions on Moraes; Dec 2025: after three Lula–Trump meetings, Moraes removed from the list and surcharges partly lifted.
  • Jul 22–23, 2026: new tariffs of 25% + 12.5% (forced labor) = 37.5% on ~16.5% of exports; coffee, beef, aircraft, orange juice and pulp exempt. Aug 21: Lula–Trump call (1h20); Aug 31: negotiations with USTR "with no concrete progress". Lula uses the issue as "sovereignty" in the campaign.

Scandals and personal vulnerabilities

  • INSS: fraud estimated at R$ 6.3 billion (2019–24); R$ 3 billion returned; Lupi left (May '25); joint inquiry (CPMI) closed without a report (it sought 200+ indictments, incl. Lulinha). Lulinha case: 3 Federal Police investigations authorized by the STF (influence peddling).
  • Banco Master case: audio of Flávio–Vorcaro asking for funds for a biopic (Intercept, May '26) — Flávio fell 6 pp in Atlas; Flávio went to court and the TSE suspended the poll. Quaest (Sep 1): 40% say Lulinha hurts Lula a lot; 42% that Master hurts Flávio a lot; explanations fail to convince 54% (Lula) and 52% (Flávio).
  • STF crisis (Aug–Sep 2026): on Aug 24, Mendonça orders the Federal Police to deepen its analysis of Vorcaro's phone; the report (218 pp.) points to 187 interactions between Vorcaro and Alexandre de Moraes; the Prosecutor General's Office (PGR) asks for it to be annulled. On Sep 8, Mendonça removes the Federal Police leadership alleging illegal surveillance; Dino reinstates it on Sep 9; Fachin suspends both decisions and any proceedings involving justices, and calls a special session for Sep 15. The session did not rule on the merits: the STF stalled on a procedural question about joining the petitions (4×3 for keeping them separate; Dino's vote would have tied it 4×4) and Dino requested more time to review, with up to 90 days to return it — the report's validity and any investigation into Moraes remain undecided, extending past the first round. On Sep 16, Lula publicly defended Moraes and tied Flávio to Vorcaro ("the R$ 130 million" for the film); Flávio keeps up the slogan "Lula is Moraes and Moraes is Lula". On Sep 10, Mendonça makes public 15 investigations in the Master case, confirming Flávio is under investigation (investigation authorized on Jul 22: money laundering, capital flight, active and passive corruption; US$ 12.3 million paid out of a US$ 24 million contract); release of Dark Horse suspended on Sep 11. Lula calls for full lifting of secrecy; Caiado files a similar request; Flávio speaks of a "tapetão" (a win by legal maneuver). Lulinha: Federal Police investigation for influence peddling authorized on Jul 30.
  • Lula's age/health (80): 50% say it is not a problem, 43% that it is (PoderData Jul '26; a 24 pp improvement in a year). Desenrola 2.0: approval fell from 50% to 43% (Nunes).

Alliances, TV and calendar

  • 13 candidacies. Lula (PT) + Alckmin (PSB), with PCdoB, PV, PSOL, PDT. MDB, PP, União, Republicanos, Podemos, PSDB and Cidadania neutral; fragmented state-level alliances (SP: broad pro-Tarcísio coalition). Kassab (Aug 13): the center prefers Lula, Flávio will "collapse"; Caiado: "under no circumstances alongside Lula"; Tarcísio "measures" his support with an eye on 2030. Centrão (276/513) uncommitted to Flávio.
  • TV time (since Aug 28): Lula 5'31" and 434 spots; Flávio 4'20" and 339; Caiado 2'02"; Cury 35"; Zema and Renan with no time. Marçal (PRTB): TSE barred him from public funds, TV and debates (Aug 20); registration rejected by the TSE on Sep 11; PRTB replaced him with Leonardo Avalanche on Sep 14.
  • Calendar: campaign since Aug 16; free airtime Aug 28–Oct 1; debates Band Aug 23 (Lula and Flávio absent), SBT/CNN pool of Sep 14 canceled on Sep 8 (neither confirmed attendance), Record Sep 27, Globo Oct 1; STF session on Moraes–Vorcaro Sep 15 (suspended by a request for more time to review); Copom Sep 15–16; first round Oct 4; runoff Oct 25.
SegmentLulaFlávioSource
Evangelicals (~30% of the electorate)29%62%Datafolha Aug 18–20 (runoff); Atlas: 76% disapprove of Lula; Quaest first round 43 × 24
Catholics54%36%Datafolha Aug; Quaest first round 47 × 27
Women · Men51% · 43%38% · 48%Datafolha Aug; PoderData Sep: women 48 × 41; male disapproval 50 → 57 (Quaest Jul→Sep)
Northeast · Southeast · South · CW/N61 · 43 · 38 · 4230 · 46 · 50 · 49Datafolha Aug. Quaest state polls (Apr): Flávio +26 RS, +20 PR, +13 GO/RJ, +12 SP; Lula +34 PE, +33 BA, +28 CE, +7 PA, +3 MG (tie)
Income up to 2 MW · 2–5 MW · >5 MWmajority · 37 · 39— · 51 · 54Datafolha Aug; Atlas: approval 55.6% up to R$ 2,000
Independents / no party38.2%26%AtlasIntel Aug (Cury 19.2%); Roman: "only 10% are truly undecided — disengaged, lower-middle class, more women, looking for something new"
Solid base ("firm vote")29–33%24%Nunes/Quaest Sep 7; undecided voters want outsiders (20%) or moderates (17%)

Historical drift: September polls × runoff result

In four of the last five cycles, the anti-PT candidate outperformed the September simulations; the leader's margin shrank by 5 to 16 points (or the leader did not even make the runoff). Part of this is reversion of inflated leads — which hardly applies to a tie — and part is systematic polling error, which the model handles separately (prior +0.8 ± 2.0).
CycleRunoff simulation in SeptemberResultMargin driftEve-of-election polls × result
2006Lula ≈ +20 (Datafolha Sep pointed to a first-round win with 56% of valid votes)Lula 60.8 × 39.2 (+21.7)≈ +2Datafolha 61 × 39: error +0.2
2010Dilma 55 × Serra 38 (Datafolha Sep 21–22; +17)Dilma 56.1 × 43.9 (+12.1)−5Datafolha 56 × 44 (0) · Ibope 55 × 45 (−1)
2014Marina 50 × Dilma 40 (Aug); Dilma 49 × Aécio 41 (Sep 30; +8)Dilma 51.6 × Aécio 48.4 (+3.3)−5 (and the August leader did not even make the runoff)Datafolha 52 × 48 (+0.7) · Ibope 53 × 47 (+2.2)
2018Haddad 45 × Bolsonaro 39 (Datafolha Sep 26–28; +6 Haddad)Bolsonaro 55.1 × 44.9 (+10.3)−16Datafolha 45 × 55 (+0.3) · Ibope 46 × 54 (+2.3) · MDA (−3.3) · Vox 50 × 50 (+10.3)
2022Lula 54 × Bolsonaro 38 (Datafolha Sep 20–22; +16); Sep 26–Oct 1: +5.3 to +18.3Lula 50.9 × 49.1 (+1.8)−14Paraná +0.8 · MDA +2.2 · Datafolha +4 · Quaest/Atlas/PoderData/Ipespe +6 · Ipec +8 → average error +3.1
2026Lula -1.3 (nowcast Oct 25, 2026) · -2.0 two weeks ago?model: drift N(0, 0.0)Lula's break-even on the eve: +0.8

Calibration by pollster (2006 to 2022)

Error on the left − right margin in valid votes (positive = overestimated the PT), in each pollster's final polls before each round, checked against primary sources on Sep 30, 2026 (`calibracao_historica.csv`, 43 final polls). It is the basis for the quality weight (4.0 ÷ MAE, capped at 0.6–1.5, shrunk toward 0.8 according to the number of cycles) and for the systematic-error discount. Pollsters without a verifiable presidential track record get 0.8. Veritá, Futura and Brasmarket are left out of the reference set that defines the discount.

Summary by pollster

PollsterMAE (pp)Average biasActive in 2026Quality weight
Futura Inteligencia2.4-2.4yes0.85
Datafolha3.4+3.4yes1.32
Verita3.8-3.8yes0.76
Paraná Pesquisas3.8-2.1yes0.78
MDA3.9+1.5yes1.32
Quaest4.0+4.0yes1.15
Ibope4.5+4.5no—
PoderData4.5+4.5yes1.12
AtlasIntel4.5+4.5yes0.93
Ipespe4.9+2.0yes0.93
Ipec7.5+7.5no—
Vox Populi7.6+7.6no—
Brasmarket7.8-7.8no—

Reading

Runoff error has no fixed direction: pro-PT in 2014, 2022 and, narrowly, 2006; against the PT in 2010 and 2018 (average by cycle: 2006: +0.3, 2010: -0.1, 2014: +1.1, 2018: -1.2, 2022: +2.9). In the first rounds from 2010 to 2022, by contrast, the error always ran against the PT's main opponent (2010: +5.2, 2014: +10.5, 2018: +2.4, 2022: +5.8). That is why the discount for final runoff rounds is small (+0.9 ± 2.0) and the first-round one is larger (+3.4 ± 3.8); observed polling error in the first round for waves before Oct 4, and final runoff rounds from 2006–2022 plus 25% of that error for the new waves. In 2026, with the complete database (221 waves) and the anchor on the calibrated pollsters, the house effects statistically distinct from zero are: showing Lula better than the anchor, CNT/MDA (+3.9); showing Flávio better, Gerp (-7.7), Veritá (-5.3), Palver (-4.4), Futura (-3.8), PoderData (-2.4), Paraná Pesquisas (-2.0).

PollsterElectionFinal poll (left × right, valid votes)ResultMargin errorSource · verification
AtlasIntel2022 · runoff53.4 × 46.650.9 × 49.1+5.0source · yes
Brasmarket (outside the reference set)2022 · runoff47 × 5350.9 × 49.1-7.8source · yes
Datafolha2022 · runoff52 × 4850.9 × 49.1+2.2source · yes
Futura Inteligencia (outside the reference set)2022 · runoff49.7 × 50.350.9 × 49.1-2.4source · yes
Ipec2022 · runoff54 × 4650.9 × 49.1+6.2source · yes
Ipespe2022 · runoff53 × 4750.9 × 49.1+4.2source · yes
CNT/MDA2022 · runoff51.1 × 48.950.9 × 49.1+0.4source · yes
Paraná Pesquisas2022 · runoff50.4 × 49.650.9 × 49.1-1.0source · yes
PoderData2022 · runoff53 × 4750.9 × 49.1+4.2source · yes
Quaest2022 · runoff52 × 4850.9 × 49.1+2.2source · yes
Verita (outside the reference set)2022 · runoff49 × 5150.9 × 49.1-3.8source · yes
AtlasIntel2022 · first round50.3 × 41.148.4 × 43.2+4.0source · yes
Datafolha2022 · first round50 × 3648.4 × 43.2+8.8source · yes
Ipec2022 · first round51 × 3748.4 × 43.2+8.8source · yes
Ipespe2022 · first round49 × 3548.4 × 43.2+8.8source · yes
CNT/MDA2022 · first round48.3 × 39.748.4 × 43.2+3.4source · yes
Paraná Pesquisas2022 · first round47.1 × 4048.4 × 43.2+1.9source · yes
PoderData2022 · first round48 × 3848.4 × 43.2+4.8source · yes
Quaest2022 · first round49 × 3848.4 × 43.2+5.8source · yes
Datafolha2018 · runoff45 × 5544.9 × 55.1+0.3source · yes
Ibope2018 · runoff46 × 5444.9 × 55.1+2.3source · yes
Ipespe2018 · runoff42 × 5844.9 × 55.1-5.7source · yes
CNT/MDA2018 · runoff43.2 × 56.844.9 × 55.1-3.3source · search summary
Paraná Pesquisas2018 · runoff39.4 × 60.644.9 × 55.1-10.9source · yes
Vox Populi2018 · runoff50 × 5044.9 × 55.1+10.3source · yes
Datafolha2018 · first round25 × 4029.3 × 46.0+1.8source · yes
Ibope2018 · first round25 × 4129.3 × 46.0+0.8source · yes
Ipespe2018 · first round25 × 4129.3 × 46.0+0.8source · yes
CNT/MDA2018 · first round27.8 × 42.629.3 × 46.0+1.9source · search summary
Paraná Pesquisas2018 · first round25.6 × 40.929.3 × 46.0+1.4source · search summary
Vox Populi2018 · first round31 × 4029.3 × 46.0+7.8source · yes
Datafolha2014 · runoff52 × 4851.6 × 48.4+0.7source · yes
Ibope2014 · runoff53 × 4751.6 × 48.4+2.7source · yes
CNT/MDA2014 · runoff49.7 × 50.351.6 × 48.4-3.9source · yes
Vox Populi2014 · runoff54 × 4651.6 × 48.4+4.7source · yes
Datafolha2014 · first round44 × 2641.6 × 33.5+10.0source · yes
Ibope2014 · first round46 × 2741.6 × 33.5+11.0source · yes
CNT/MDA2014 · first round45.6 × 2741.6 × 33.5+10.6source · yes; valid votes computed from totals 40.6/24.0 with 11% blank+undecided
Datafolha2010 · runoff56 × 4456.0 × 44.0-0.1source · wikipedia
Datafolha2010 · first round50 × 3146.9 × 32.6+4.7source · yes; 2nd source: en.wikipedia Opinion polling for the 2010 Brazilian presidential election
Ibope2010 · first round51 × 3146.9 × 32.6+5.7source · yes; 2nd source: en.wikipedia Opinion polling for the 2010 Brazilian presidential election
Datafolha2006 · runoff61 × 3960.8 × 39.2+0.3source · wikipedia
Datafolha (outside the reference set)2006 · first round50 × 3848.6 × 41.6+5.0source · single source (Wikipedia gives 48 x 37 without saying whether these are valid votes); excluded from the calculation

Appendix A — complete poll database (runoff, Lula × Flávio)

221 waves from 22 pollsters, from Dec 2025 to Oct 25, 2026, with margin in valid votes and final weight (recency × precision) as of the data date. "≈" marks a fieldwork date approximated to the month (source without an exact date). Blank/spoiled and undecided only when published for the runoff scenario. "Sponsor" and "Margin of error" (sampling, 95% confidence, p=0.5) are the transparency data the TSE requires for each poll; the official PesqEle registration number is in each row's linked source — see the "Compliance" section for details.
PollsterSponsorFieldwork (end)nMethodMargin of error (95%)LulaFlávioBlank/spoiledUndec.Valid-vote marginWeightSource
Futura Inteligencia v2Apex/100% Cidades24/10/262,000phone±2.2 pp47.948.22.31.6-0.30.96source
PoderDataAya24/10/264,000phone±1.5 pp47.049.03.01.0-2.11.73source
BTG/NexusBTG Pactual24/10/262,000phone±2.2 pp46.047.05.21.8-1.10.93source
QuaestGlobo/O Globo24/10/263,702in-person±1.6 pp45.046.08.01.0-1.11.56source
DatafolhaFolha/Globo24/10/264,050in-person±1.5 pp47.048.03.02.0-1.11.73source
Palver v3self-funded24/10/265,000online±1.4 pp49.050.01.00.0-1.00.89source
Gerpself-funded24/10/262,400phone±2.0 pp46.049.01.03.0-3.21.12source
ColecttaBrazil Economy23/10/263,000in-person±1.8 pp46.546.04.43.1+0.51.28source
Veritaself-funded23/10/2615,554phone±0.8 pp46.549.41.42.7-3.11.40source
AtlasIntelBloomberg23/10/264,990online±1.4 pp48.449.91.7—-1.51.15source
MDA v2CNT23/10/262,032in-person±2.2 pp45.046.35.82.9-1.40.90source
Instituto Francaself-funded23/10/261,537not reported±2.5 pp43.145.67.43.9-2.80.68source
Alfa Inteligenciaself-funded22/10/262,700in-person±1.9 pp46.049.92.02.1-4.11.06source
DatafolhaFolha/Globo22/10/262,520in-person±2.0 pp46.047.05.02.0-1.11.07source
Vox Brasilself-funded22/10/262,100in-person±2.1 pp45.444.36.04.3+1.20.73source
Real Time Big Dataself-funded22/10/262,000phone±2.2 pp45.049.05.01.0-4.30.88source
Veritaself-funded22/10/2615,554phone±0.8 pp47.550.21.40.9-2.81.37source
IpespeAbrapel22/10/261,500phone±2.5 pp47.048.03.02.0-1.10.69source
DataTrendsself-funded21/10/262,000phone±2.2 pp46.050.04.01.0-4.20.87source
IndexaBroadcast/Estadão21/10/262,000phone±2.2 pp44.145.29.11.6-1.20.82source
PoderDataAya21/10/264,000phone±1.5 pp48.049.03.01.0-1.01.58source
Gerpself-funded21/10/262,400phone±2.0 pp45.050.02.02.0-5.31.01source
Futura Inteligencia v2Apex/100% Cidades20/10/262,000phone±2.2 pp46.848.92.02.3-2.20.84source
QuaestGlobo/O Globo20/10/262,154in-person±2.1 pp44.045.09.02.0-1.10.84source
Palver v3self-funded20/10/265,000online±1.4 pp48.050.01.00.0-2.00.77source
Instituto Francaself-funded20/10/263,000phone±1.8 pp39.641.010.68.8-1.61.03source
Alfa Inteligenciaself-funded19/10/262,700in-person±1.9 pp45.948.83.51.8-3.10.95source
Real Time Big Dataself-funded19/10/262,000phone±2.2 pp45.048.05.02.0-3.20.79source
Gerpself-funded19/10/262,400phone±2.0 pp45.050.02.03.0-5.30.95source
Meio/IdeiaCanal Meio19/10/261,500phone±2.5 pp46.048.03.42.6-2.10.62source
Veritaself-funded19/10/2615,554phone±0.8 pp48.249.71.40.7-1.51.24source
AtlasIntelBloomberg18/10/264,998online±1.4 pp47.649.82.6—-2.30.97source
PoderDataAya18/10/263,000phone±1.8 pp46.049.04.02.0-3.21.11source
BTG/NexusBTG Pactual18/10/262,002phone±2.2 pp45.946.54.72.9-0.60.76source
DataTrendsself-funded17/10/262,000phone±2.2 pp46.049.04.01.0-3.20.76source
Futura InteligenciaApex/100% Cidades17/10/262,000phone±2.2 pp47.548.32.41.9-0.80.76source
Vox Brasilself-funded17/10/262,100in-person±2.1 pp46.444.65.03.9+2.00.63source
Veritaself-funded16/10/2615,554phone±0.8 pp47.749.71.51.1-2.11.12source
Gerpself-funded16/10/262,400phone±2.0 pp45.050.02.03.0-5.30.86source
MDA v2CNT15/10/262,014in-person±2.2 pp45.946.84.92.4-1.00.70source
PoderDataAya15/10/263,000phone±1.8 pp47.047.04.02.00.01.00source
Futura InteligenciaApex/100% Cidades15/10/262,000phone±2.2 pp47.549.01.52.1-1.60.72source
IpespeAbrapel15/10/261,500phone±2.5 pp46.048.03.03.0-2.10.54source
IndexaBroadcast/Estadão14/10/262,000phone±2.2 pp42.044.012.02.0-2.30.63source
DatafolhaFolha/Globo14/10/262,520in-person±2.0 pp45.047.06.02.0-2.20.82source
Palver v3self-funded13/10/265,000online±1.4 pp49.050.01.00.0-1.00.62source
QuaestGlobo/O Globo13/10/262,154in-person±2.1 pp44.046.09.01.0-2.20.67source
Gerpself-funded12/10/262,400phone±2.0 pp44.053.01.03.0-9.30.77source
PoderDataAya12/10/263,000phone±1.8 pp46.048.05.02.0-2.10.90source
BTG/NexusBTG Pactual12/10/262,002phone±2.2 pp45.048.05.02.0-3.20.63source
Futura InteligenciaApex/100% Cidades10/10/262,000phone±2.2 pp46.650.01.71.7-3.50.61source
DataTrendsself-funded09/10/262,000phone±2.2 pp44.050.05.01.0-6.40.58source
Ipespeself-funded08/10/261,500phone±2.5 pp44.049.05.03.0-5.40.42source
AtlasIntelBloomberg08/10/265,026online±1.4 pp45.751.13.2—-5.60.70source
PoderDataAya07/10/263,000phone±1.8 pp44.049.05.02.0-5.40.76source
DatafolhaFolha/Globo07/10/262,520in-person±2.0 pp45.049.05.01.0-4.30.66source
Vox Brasilself-funded07/10/262,100in-person±2.1 pp44.242.73.49.7+1.70.43source
Futura InteligenciaApex/100% Cidades03/10/262,000phone±2.2 pp45.148.05.71.2-3.10.47source
QuaestGlobo/O Globo03/10/263,702in-person±1.6 pp42.044.013.01.0-2.30.74source
DatafolhaFolha/Globo03/10/264,006in-person±1.5 pp47.046.06.02.0+1.10.84source
Palverself-funded03/10/265,000online±1.4 pp44.049.06.01.0-5.40.40source
AtlasIntelself-funded02/10/264,945online±1.4 pp47.647.45.0—+0.20.56source
Instituto Francaself-funded02/10/262,000not reported±2.2 pp45.945.03.55.6+1.00.44source
MDACNT02/10/262,007in-person±2.2 pp47.343.17.61.9+4.60.44source
PoderDataAya02/10/264,000phone±1.5 pp46.046.07.01.00.00.81source
Gerpself-funded02/10/262,400phone±2.0 pp44.049.06.01.0-5.40.53source
DatafolhaFolha/Globo01/10/262,506in-person±2.0 pp48.045.06.01.0+3.20.53source
Vox Brasilself-funded01/10/262,100not reported±2.1 pp45.248.22.54.1-3.20.38source
Real Time Big DataCNN30/09/262,000phone±2.2 pp45.046.05.04.0-1.10.41source
DataTrendsself-funded30/09/262,000phone±2.2 pp45.045.07.03.00.00.41source
Futura InteligenciaApex/100% Cidades29/09/262,000phone±2.2 pp43.549.06.21.3-5.90.41source
IndexaBroadcast/Estadão29/09/262,000phone±2.2 pp43.042.07.04.0+1.20.38source
Vox Brasilself-funded28/09/262,100in-person±2.1 pp44.745.26.83.3-0.60.33source
Gerpself-funded28/09/262,400phone±2.0 pp43.050.05.01.0-7.50.47source
Meio/IdeiaCanal Meio28/09/262,000phone±2.2 pp48.548.02.11.5+0.50.41source
AtlasIntelBloomberg28/09/265,005online±1.4 pp47.647.74.8—-0.10.49source
Palverself-funded27/09/265,000online±1.4 pp45.047.07.01.0-2.20.31source
BTG/NexusBTG27/09/262,000phone±2.2 pp46.044.08.01.0+2.20.37source
QuaestGlobo/O Globo27/09/262,004in-person±2.2 pp42.042.013.03.00.00.35source
PoderDataAya23/09/263,000phone±1.8 pp45.046.0——-1.10.47source
Real Time Big Data—23/09/262,000phone±2.2 pp44.045.08.03.0-1.10.32source
DatafolhaFolha/Globo23/09/262,002in-person±2.2 pp47.045.07.01.0+2.20.33source
Futura InteligenciaApex23/09/262,000phone±2.2 pp43.749.45.61.3-6.10.34source
Palverself-funded23/09/265,000online±1.4 pp45.048.06.01.0-3.20.27source
Alfa InteligenciaTMC23/09/262,700in-person±1.9 pp43.043.07.07.00.00.37source
AtlasIntelBloomberg22/09/265,015online±1.4 pp47.747.44.9—+0.30.40source
American AnalyticsTimes Brasil/CNBC20/09/262,000not reported±2.2 pp43.043.06.08.00.00.28source
QuaestGlobo/O Globo20/09/262,004in-person±2.2 pp41.042.014.03.0-1.20.28source
BTG/NexusBTG20/09/262,006phone±2.2 pp46.045.07.01.0+1.10.30source
Palverself-funded18/09/265,000online±1.4 pp43.047.010.0—-4.40.18source
PoderDataAya16/09/263,000phone±1.8 pp44.046.08.02.0-2.20.37source
Gerpself-funded16/09/262,400phone±2.0 pp43.050.06.01.0-7.50.32source
DatafolhaFolha/Globo16/09/262,002in-person±2.2 pp46.044.08.01.0+2.20.26source
AtlasIntelBloomberg16/09/265,018online±1.4 pp46.847.26.0—-0.40.33source
Futura InteligenciaApex15/09/262,000phone±2.2 pp43.748.16.51.7-4.80.26source
DataTrendsself-funded14/09/262,000phone±2.2 pp44.045.08.03.0-1.10.24source
QuaestGlobo/O Globo13/09/262,004in-person±2.2 pp40.042.013.05.0-2.40.22source
MDACNT13/09/262,002in-person±2.2 pp47.340.010.22.5+8.40.23source
IndexaBroadcast/Estadão13/09/262,000phone±2.2 pp43.042.0——+1.20.22source
BTG/NexusBTG13/09/262,003phone±2.2 pp47.046.07.0—+1.10.24source
DatafolhaFolha/Globo10/09/262,002in-person±2.2 pp46.044.0——+2.20.21source
Futura InteligenciaApex10/09/262,000phone±2.2 pp45.045.47.12.5-0.40.21source
PoderDataAya09/09/263,000phone±1.8 pp45.047.0——-2.20.30source
AtlasIntelBloomberg09/09/265,000online±1.4 pp46.246.47.4—-0.20.26source
Gerpself-funded08/09/262,400phone±2.0 pp40.047.011.03.0-8.00.23source
Palverself-funded07/09/265,000online±1.4 pp44.046.0——-2.20.15source
BTG/NexusBTG07/09/262,002phone±2.2 pp45.046.0——-1.10.19source
Meio/IdeiaCanal Meio07/09/261,500phone±2.5 pp46.046.03.54.50.00.15source
QuaestGenial06/09/262,004in-person±2.2 pp41.041.0——0.00.17source
Veritaself-funded04/09/263,804not reported±1.6 pp43.148.5——-5.90.23source
PoderDataAya02/09/263,000phone±1.8 pp44.045.0——-1.10.23source
DatafolhaFolha/Globo02/09/262,002in-person±2.2 pp46.044.09.02.0+2.20.16source
QuaestGenial01/09/262,004in-person±2.2 pp42.041.013.04.0+1.20.15source
Futura InteligenciaApex01/09/262,000phone±2.2 pp45.645.27.22.0+0.40.16source
Real Time Big DataCNN31/08/262,000phone±2.2 pp44.044.09.03.00.00.15source
BTG/NexusBTG30/08/262,005phone±2.2 pp46.045.0——+1.10.15source
AtlasIntelBloomberg30/08/265,014online±1.4 pp47.142.610.3—+5.00.18source
Vox Brasilself-funded27/08/262,100in-person±2.1 pp44.545.1——-0.70.12source
PoderDataAya26/08/262,400phone±2.0 pp45.044.0——+1.10.15source
Gerpself-funded25/08/262,400phone±2.0 pp42.047.0——-5.60.15source
BTG/NexusBTG23/08/262,006phone±2.2 pp46.045.0——+1.10.12source
IndexaBroadcast/Estadão23/08/262,000phone±2.2 pp46.041.0——+5.70.11source
DatafolhaFolha/Globo20/08/262,058in-person±2.2 pp47.043.09.02.0+4.40.11source
Veritaself-funded20/08/263,840phone±1.6 pp42.047.36.14.6-5.90.14source
BTG/NexusBTG16/08/262,003phone±2.2 pp47.044.0——+3.30.09source
QuaestGenial13/08/262,004in-person±2.2 pp43.040.0——+3.60.08source
PoderDataAya12/08/262,400phone±2.0 pp46.045.0——+1.10.10source
Gerpself-funded10/08/262,400phone±2.0 pp43.045.0——-2.30.09source
MDACNT09/08/262,002in-person±2.2 pp48.039.110.62.3+10.20.07source
BTG/NexusBTG09/08/262,001phone±2.2 pp47.044.0——+3.30.07source
Palverself-funded07/08/265,000online±1.4 pp46.046.0——0.00.04source
Futura InteligenciaApex07/08/262,000phone±2.2 pp46.544.0——+2.80.07source
Meio/IdeiaCanal Meio03/08/261,500phone±2.5 pp48.543.0——+6.00.05source
QuaestGenial03/08/262,004in-person±2.2 pp44.039.013.04.0+6.00.06source
BTG/NexusBTG02/08/262,002phone±2.2 pp46.045.0——+1.10.06source
Alfa InteligenciaTMC28/07/262,700not reported±1.9 pp48.041.0——+7.90.06source
PoderDataAya28/07/262,400phone±2.0 pp46.043.0——+3.40.06source
Vox Brasilself-funded28/07/262,100not reported±2.1 pp47.541.1——+7.20.04source
AtlasIntelBloomberg27/07/265,021online±1.4 pp49.242.97.9—+6.80.06source
BTG/NexusBTG26/07/262,004phone±2.2 pp47.043.0——+4.40.05source
DatafolhaFolha/Globo24/07/262,004in-person±2.2 pp48.043.0——+5.50.04source
Real Time Big DataCNN20/07/262,000phone±2.2 pp45.042.0——+3.40.04source
Indexaself-funded19/07/262,000phone±2.2 pp46.039.0——+8.20.04source
Gerpself-funded17/07/262,000not reported±2.2 pp45.046.0——-1.10.04source
PoderDataAya15/07/262,400phone±2.0 pp45.043.0——+2.30.04source
QuaestGenial13/07/262,004in-person±2.2 pp45.037.0——+9.80.03source
BTG/NexusBTG12/07/262,003phone±2.2 pp47.044.0——+3.30.03source
Futura InteligenciaApex11/07/262,000not reported±2.2 pp46.346.1——+0.20.03source
Gerpself-funded07/07/262,000not reported±2.2 pp42.045.0——-3.40.02source
Meio/IdeiaCanal Meio06/07/262,000phone±2.2 pp45.040.0——+5.90.02source
AtlasIntelBloomberg30/06/264,999online±1.4 pp48.842.3——+7.10.02source
BTG/NexusBTG28/06/262,009phone±2.2 pp47.044.0——+3.30.02source
Vox Brasilself-funded25/06/262,100not reported±2.1 pp45.342.8——+2.80.01source
PoderDataAya24/06/262,400phone±2.0 pp46.043.0——+3.40.02source
Indexaself-funded20/06/262,000phone±2.2 pp47.040.09.04.0+8.00.01source
Gerpself-funded20/06/262,000not reported±2.2 pp40.042.0——-2.40.01source
DatafolhaFolha/Globo19/06/262,004in-person±2.2 pp47.043.0——+4.40.01source
American Analyticsself-funded15/06/262,000not reported±2.2 pp45.040.0——+5.90.01source
MDACNT14/06/262,002in-person±2.2 pp49.336.8——+14.50.01source
BTG/NexusBTG14/06/262,017phone±2.2 pp49.043.0——+6.50.01source
Futura InteligenciaApex12/06/262,000not reported±2.2 pp48.142.9——+5.70.01source
Alfa Inteligenciaself-funded10/06/261,400not reported±2.6 pp44.041.0——+3.50.01source
QuaestGenial08/06/262,004in-person±2.2 pp44.038.0——+7.30.01source
Vox Brasilself-funded06/06/262,100not reported±2.1 pp47.841.3——+7.30.01source
Gerpself-funded05/06/262,000not reported±2.2 pp39.144.7——-6.70.01source
Real Time Big DataCNN30/05/262,000phone±2.2 pp45.040.0——+5.90.01source
PoderDataAya28/05/262,400phone±2.0 pp46.042.0——+4.50.01source
Meio/IdeiaCanal Meio27/05/261,500phone±2.5 pp46.541.4——+5.80.01source
Veritaself-funded26/05/263,330not reported±1.7 pp46.853.2——-6.40.01source
Indexaself-funded24/05/262,000phone±2.2 pp46.041.0——+5.70.01source
BTG/NexusBTG24/05/262,045phone±2.2 pp47.043.0——+4.40.01source
Gerpself-funded21/05/262,000not reported±2.2 pp44.047.0——-3.30.01source
DatafolhaFolha/Globo21/05/262,004in-person±2.2 pp47.043.0——+4.40.01source
Futura InteligenciaApex20/05/262,000not reported±2.2 pp47.742.2——+6.10.01source
Vox Brasilself-funded19/05/262,100not reported±2.1 pp46.838.1——+10.20.00source
AtlasIntelBloomberg18/05/265,032online±1.4 pp48.941.8——+7.80.01source
Vetorself-funded18/05/269,000not reported±1.0 pp50.738.3——+13.90.00source
DatafolhaFolha/Globo14/05/262,004in-person±2.2 pp45.045.0——0.00.00source
Vox Brasilself-funded12/05/262,100not reported±2.1 pp40.243.8——-4.30.00source
Gerpself-funded12/05/262,000not reported±2.2 pp43.050.0——-7.50.00source
QuaestGenial11/05/262,004in-person±2.2 pp42.041.0——+1.20.00source
Futura InteligenciaApex08/05/262,000not reported±2.2 pp44.446.9——-2.70.00source
Meio/IdeiaCanal Meio05/05/261,500phone±2.5 pp44.745.3——-0.70.00source
Real Time Big DataCNN04/05/262,000phone±2.2 pp43.044.0——-1.10.00source
AtlasIntelBloomberg27/04/265,008online±1.4 pp47.547.8——-0.30.00source
BTG/NexusBTG26/04/262,028phone±2.2 pp46.045.0——+1.10.00source
QuaestGenial13/04/262,004in-person±2.2 pp40.042.0——-2.40.00source
MDACNT12/04/262,002in-person±2.2 pp44.940.2——+5.50.00source
Futura InteligenciaApex11/04/262,000not reported±2.2 pp42.648.0——-6.00.00source
DatafolhaFolha/Globo09/04/262,004in-person±2.2 pp45.046.0——-1.10.00source
Meio/IdeiaCanal Meio07/04/261,500phone±2.5 pp45.545.8——-0.30.00source
Veritaself-funded04/04/2640,500not reported±0.5 pp38.442.8——-5.40.00source
BTG/NexusBTG29/03/262,000phone±2.2 pp46.046.0——0.00.00source
Paraná Pesquisasself-funded28/03/262,080in-person±2.1 pp44.145.26.24.5-1.20.00source
Gerpself-funded25/03/262,000not reported±2.2 pp45.048.0——-3.20.00source
AtlasIntelBloomberg23/03/265,028online±1.4 pp46.647.6——-1.10.00source
Alfa Inteligenciaself-funded11/03/261,000not reported±3.1 pp41.038.0——+3.80.00source
Meio/IdeiaCanal Meio10/03/261,500phone±2.5 pp47.045.0——+2.20.00source
QuaestGenial09/03/262,004in-person±2.2 pp41.041.0——0.00.00source
Futura InteligenciaApex06/03/262,000not reported±2.2 pp40.548.8——-9.30.00source
DatafolhaFolha/Globo05/03/262,004in-person±2.2 pp46.043.0——+3.40.00source
Real Time Big DataCNN02/03/262,000phone±2.2 pp42.041.0——+1.20.00source
Vetorself-funded02/03/269,000not reported±1.0 pp40.449.0——-9.60.00source
Paraná Pesquisasself-funded25/02/262,080in-person±2.1 pp43.844.4——-0.70.00source
AtlasIntelBloomberg24/02/264,986online±1.4 pp46.246.3——-0.10.00source
QuaestGenial09/02/262,004in-person±2.2 pp43.038.0——+6.20.00source
Futura InteligenciaApex07/02/262,000not reported±2.2 pp42.448.2——-6.40.00source
Meio/IdeiaCanal Meio02/02/261,500phone±2.5 pp45.841.1——+5.40.00source
Gerpself-funded02/02/262,000not reported±2.2 pp45.045.0——0.00.00source
Paraná Pesquisasself-funded28/01/262,080in-person±2.1 pp44.842.2——+3.00.00source
AtlasIntelBloomberg20/01/265,418online±1.3 pp49.045.0——+4.30.00source
Futura InteligenciaApex19/01/262,000not reported±2.2 pp41.948.1——-6.90.00source
Meio/IdeiaCanal Meio12/01/262,000phone±2.2 pp46.236.0——+12.40.00source
QuaestGenial11/01/262,004in-person±2.2 pp45.038.0——+8.40.00source
Paraná Pesquisasself-funded22/12/252,038in-person±2.2 pp44.141.0——+3.60.00source
AtlasIntelBloomberg15/12/258,154online±1.1 pp53.041.0——+12.80.00source
QuaestGenial14/12/252,004in-person±2.2 pp46.036.0——+12.20.00source
Gerpself-funded10/12/252,000not reported±2.2 pp41.042.0——-1.20.00source
Futura InteligenciaApex09/12/252,001not reported±2.2 pp42.141.6——+0.60.00source
Veritaself-funded07/12/252,519not reported±2.0 pp39.839.2——+0.80.00source
DatafolhaFolha/Globo04/12/252,002in-person±2.2 pp51.036.0——+17.20.00source

State polls (runoff, Lula × Flávio; the sources column flags single-source ones)

StatePollsterFieldwork (end)nMethodLulaFlávioRegistrationSources
ACVerita15/09/261,030phone32.761.3BR-02386/2026source · 2nd source
ACQuaest12/10/261,098in-person28.060.0BR-05379/2026source · 2nd source
ALAtlasIntel09/09/261,208online50.640.0AL-00447/2026source (single source)
ALVerita15/09/261,220phone52.840.8BR-04383/2026source · 2nd source
ALQuaest24/09/26804in-person48.034.0BR-04938/2026source · 2nd source
AMAtlasIntel03/09/261,185online41.348.0AM-04939/2026source (single source)
AMVerita15/09/261,220phone42.243.1BR-07214/2026source · 2nd source
AMQuaest23/09/26804in-person45.040.0BR-08101/2026source · 2nd source
AMPoderData11/10/261,200phone49.049.0AM-08245/2026source · 2nd source
AMQuaest12/10/261,098in-person47.043.0AM-09760/2026source · 2nd source
APVerita15/09/261,030phone43.946.2BR-07861/2026source · 2nd source
APAtlasIntel02/10/26800online50.743.5AP-01238/2026source (single source)
BAAtlasIntel02/09/261,804online58.129.0BA-08891/2026source · 2nd source
BAReal Time Big Data08/09/261,600phone61.030.0BR-06566/2026source · 2nd source
BAVerita15/09/262,020phone55.140.5BR-07197/2026source · 2nd source
BAQuaest23/09/26900in-person62.027.0BA-01850/2026source · 2nd source
BAReal Time Big Data26/09/261,600phone64.028.0BR-02319/2026source · 2nd source
BAAtlasIntel28/09/262,000online61.331.4BA-02425/2026source · 2nd source
CEAtlasIntel02/09/261,834online54.630.1BR-07411/2026source · 2nd source
CEVerita15/09/261,525phone54.136.0BR-07551/2026source · 2nd source
CEReal Time Big Data17/09/261,600phone64.027.0BR-03822/2026source · 2nd source
CEQuaest22/09/26900in-person59.027.0CE-08268/2026source · 2nd source
CEReal Time Big Data24/09/261,600phone63.028.0BR-05316/2026source (single source)
DFDatafolha21/08/26910in-person39.051.0—source · 2nd source
DFAtlasIntel01/09/261,193online42.547.4DF-02018/2026source (single source)
DFDatafolha11/09/26910in-person39.051.0—source (single source)
DFVerita15/09/261,220phone49.745.4BR-01988/2026source · 2nd source
DFQuaest22/09/261,104in-person38.049.0DF-02596/2026source · 2nd source
DFDatafolha24/09/26910in-person41.051.0DF-01888/2026source · 2nd source
DFQuaest28/09/261,104in-person33.048.0DF-02515/2026source · 2nd source
DFDatafolha30/09/26910in-person39.051.0DF-00905/2026source · 2nd source
DFAtlasIntel02/10/261,200online39.655.3DF-04297/2026source (single source)
DFDatafolha08/10/26910in-person43.049.0DF-03365/2026source · 2nd source
DFQuaest11/10/261,104not reported40.050.0DF-08737/2026source (single source)
DFQuaest20/10/261,104in-person39.050.0BR-03163/2026source (single source)
ESVerita17/09/261,220phone43.150.9BR-00729/2026source · 2nd source
ESQuaest13/10/261,098not reported38.051.0BR-01973/2026source · 2nd source
GOAtlasIntel01/09/261,214online32.954.6GO-05293/2026source (single source)
GOVerita17/09/261,525phone33.453.4BR-06701/2026source · 2nd source
GOReal Time Big Data21/09/261,600phone33.057.0—source · 2nd source
MAReal Time Big Data09/09/261,600phone65.030.0—source · 2nd source
MAVerita17/09/261,525phone53.738.6BR-07504/2026source · 2nd source
MAQuaest24/09/26900in-person63.026.0—source · 2nd source
MGDatafolha20/08/261,204in-person46.042.0MG-00446/2026source · 2nd source
MGAtlasIntel04/09/261,804online48.742.1—source (single source)
MGDatafolha10/09/261,204in-person46.045.0—source · 2nd source
MGVerita17/09/262,030phone38.445.6BR-03240/2026source · 2nd source
MGQuaest22/09/261,506in-person40.040.0BR-07664/2026source · 2nd source
MGDatafolha24/09/261,204in-person44.046.0BR-06824/2026source · 2nd source
MGQuaest28/09/261,506in-person41.040.0BR-05155/2026source · 2nd source
MGDatafolha01/10/261,204in-person48.044.0—source · 2nd source
MGReal Time Big Data02/10/262,000phone46.046.0BR-01068/2026source · 2nd source
MGAtlasIntel02/10/262,189online47.450.2BR-05974/2026source (single source)
MSAtlasIntel01/09/261,204online36.157.1MS-02047/2026source (single source)
MSReal Time Big Data09/09/261,600phone38.052.0BR-07174/2026source (single source)
MSVerita17/09/261,220phone34.453.5BR-07855/2026source · 2nd source
MTAtlasIntel09/09/261,209online35.259.1MT-02766/2026source (single source)
MTVerita17/09/261,220phone28.154.9BR-02939/2026source · 2nd source
MTQuaest24/09/26804in-person30.056.0MT-08098/2026source · 2nd source
PAVerita17/09/261,525phone39.648.8BR-01223/2026source · 2nd source
PAQuaest25/09/26804in-person44.040.0—source · 2nd source
PBAtlasIntel02/09/261,207online56.333.4PB-01118/2026source · 2nd source
PBVerita18/09/261,220phone51.640.9BR-08734/2026source · 2nd source
PBQuaest21/09/26804in-person60.026.0—source · 2nd source
PBAtlasIntel02/10/261,268online61.433.9BR-07092/2026source (single source)
PEDatafolha20/08/261,204in-person62.029.0—source · 2nd source
PEReal Time Big Data02/09/261,600phone59.035.0BR-05862/2026source · 2nd source
PEAtlasIntel16/09/261,793online57.536.2BR-00357/2026source · 2nd source
PEVerita18/09/262,010phone52.940.1BR-07802/2026source · 2nd source
PEAtlasIntel20/09/261,836online60.532.7PE-02023/2026source · 2nd source
PEQuaest22/09/261,302in-person58.025.0PE-05671/2026source · 2nd source
PEDatafolha24/09/261,204in-person64.029.0PE-08125/2026source · 2nd source
PEReal Time Big Data26/09/261,600phone61.035.0BR-02938/2026source · 2nd source
PEQuaest28/09/261,302in-person61.024.0PE-00324/2026source · 2nd source
PEDatafolha01/10/261,204in-person65.029.0PE-06822/2026source · 2nd source
PIVerita18/09/261,220phone57.628.7BR-07059/2026source · 2nd source
PIAtlasIntel28/09/261,218online70.026.0PI-07497/2026source · 2nd source
PRAtlasIntel09/09/261,799online32.160.7PR-09655/2026source (single source)
PRReal Time Big Data15/09/261,600phone30.061.0BR-08843/2026source · 2nd source
PRAtlasIntel16/09/261,794online38.857.5PR-02671/2026source · 2nd source
PRVerita18/09/262,010phone30.658.9BR-01839/2026source · 2nd source
PRReal Time Big Data28/09/261,600phone30.061.0BR-03875/2026source · 2nd source
RJReal Time Big Data01/09/262,000phone41.045.0—source (single source)
RJDatafolha10/09/261,204in-person41.049.0—source · 2nd source
RJReal Time Big Data12/09/262,000phone41.047.0BR-09111/2026source · 2nd source
RJVerita18/09/262,030phone39.353.4BR-02698/2026source · 2nd source
RJAtlasIntel20/09/261,829online44.744.9RJ-01150/2026source (single source)
RJQuaest22/09/261,302in-person36.044.0RJ-04982/2026source · 2nd source
RJDatafolha24/09/261,204in-person42.051.0RJ-02205/2026source · 2nd source
RJQuaest28/09/261,302in-person34.044.0RJ-04419/2026source · 2nd source
RJReal Time Big Data29/09/262,000phone41.052.0BR-05193/2026source · 2nd source
RJDatafolha01/10/261,204in-person42.051.0RJ-02070/2026source · 2nd source
RNVerita18/09/261,220phone55.437.4BR-02399/2026source · 2nd source
RNQuaest24/09/26804in-person56.030.0RN-01492/2026source · 2nd source
RNQuaest12/10/261,098in-person59.033.0RN-03210/2026source · 2nd source
ROVerita19/09/261,220phone30.563.9BR-07923/2026source · 2nd source
RRAtlasIntel09/09/261,200online25.666.4RR-07280/2026source · 2nd source
RRVerita19/09/261,030phone23.570.8BR-07242/2026source · 2nd source
RRQuaest24/09/26804in-person22.067.0—source · 2nd source
RRAtlasIntel02/10/26800online29.365.3RR-07765/2026source (single source)
RSAtlasIntel01/09/261,783online42.050.4RS-00652/2026source · 2nd source
RSReal Time Big Data09/09/261,600phone42.053.0BR-00631/2026source · 2nd source
RSVerita19/09/262,020phone37.955.7BR-06983/2026source · 2nd source
RSAtlasIntel20/09/261,819online44.447.9BR-01816/2026source · 2nd source
RSReal Time Big Data28/09/261,600phone43.053.0BR-08546/2026source (single source)
SCAtlasIntel09/09/261,211online27.561.5SC-02036/2026source · 2nd source
SCReal Time Big Data16/09/261,600phone30.062.0BR-09582/2026source · 2nd source
SEVerita19/09/261,220phone56.737.6BR-01212/2026source · 2nd source
SEQuaest23/09/26804in-person56.030.0—source · 2nd source
SPDatafolha19/08/261,610in-person42.047.0SP-01806/2026source · 2nd source
SPDatafolha10/09/261,610in-person42.047.0—source · 2nd source
SPReal Time Big Data12/09/262,000phone44.050.0BR-05824/2026source (single source)
SPReal Time Big Data19/09/262,000phone45.051.0BR-09702/2026source · 2nd source
SPVerita19/09/263,025phone40.652.3BR-04470/2026source · 2nd source
SPAtlasIntel20/09/261,800online42.848.8SP-02676/2026source · 2nd source
SPQuaest22/09/261,800in-person36.042.0BR-08886/2026source · 2nd source
SPDatafolha23/09/261,610in-person41.049.0BR-09355/2026source · 2nd source
SPReal Time Big Data28/09/262,000phone43.051.0—source · 2nd source
SPDatafolha30/09/261,610in-person45.048.0BR-02676/2026source · 2nd source
TOVerita19/09/261,220phone33.661.1BR-09946/2026source · 2nd source
TOQuaest13/10/261,098not reported42.048.0TO-03829/2026source · 2nd source
TOParaná Pesquisas17/10/261,500phone41.952.9BR-09051/2026source (single source)

Appendix B — full first-round poll database (since Aug 1)

Main prompted-ballot scenario (without Pablo Marçal, whose candidacy was rejected on Sep 11, when the pollster published both), in total votes as released; each wave confirmed in two sources. Waves in which only Lula and Flávio were published ("—" in the other columns) enter only the two-candidate series. "Others" sums the candidates not listed. Collected and verified on Sep 30, 2026.
PollsterSponsorFieldwork throughnMethodLulaFlávioCuryRenanCaiadoZemaOthersBlank/spoiledUndec.TSE registrationSources
QuaestGlobo/O Globo03/103,702in-person403833300103BR-02197/2026source · 2nd
DatafolhaFolha de S.Paulo/Globo03/104,006in-person42403340043BR-01708/2026source · 2nd
Palverself-funded03/105,000online43471710000BR-00198/2026source · 2nd
PoderDataAya02/104,000phone42413321242BR-03519/2026source · 2nd
CNT/MDACNT02/102,007in-person43.1383.21.530.80.554.7BR-04756/2026source · 2nd
Vox Brasilself-funded01/102,100not reported40.441.21.22.23.31.30.23.27BR-00148/2026source · 2nd
DatafolhaFolha de S.Paulo/Globo01/102,506in-person42384331252BR-08039/2026source · 2nd
Real Time Big DataReal Time Big Data (self-funded)30/092,000phone43393431133BR-09503/2026source · 2nd
DataTrendsself-funded30/092,000phone4139433—144BR-04414/2026source · 2nd
IndexaBroadcast/Estadão29/092,000phone39344341047BR-00698/2026source · 2nd
Futura InteligenciaApex/100% Cidades29/092,000phone39.442.23.83.241.61.22.42.6BR-01122/2026source · 2nd
Vox Brasilself-funded28/092,100in-person41.137.82.22.73.81.80.33.56.8BR-00895/2026source · 2nd
Meio/IdeiaCanal Meio28/092,000phone39.438.46.74.54.43.51.40.71.5BR-08706/2026source · 2nd
Gerpself-funded28/092,400phone40425331123BR-03929/2026source · 2nd
AtlasIntelBloomberg (AtlasIntel self-funded)28/095,005online45.342.225.21.80.90.50.91.2BR-04391/2026source · 2nd
Palverself-funded27/095,000online44441811011BR-02990/2026source · 2nd
BTG/NexusBTG Pactual27/092,000phone42375451132BR-07557/2026source · 2nd
QuaestGlobo/O Globo27/092,004in-person393443410105BR-06520/2026source · 2nd
Real Time Big DataReal Time Big Data (self-funded)23/092,000phone41376621133BR-04202/2026source · 2nd
PoderDataPoderData (self-funded)/Aya23/093,000phone41396321342BR-01739/2026source · 2nd
Palverself-funded23/095,000online43432811111BR-09587/2026source · 2nd
Futura InteligenciaApex/100% Cidades23/092,000phone38.440.45.62.45.70.91.22.13.2BR-05268/2026source · 2nd
DatafolhaFolha de S.Paulo/Globo23/092,002in-person40365341152BR-00304/2026source · 2nd
Alfa InteligenciaTMC23/092,700in-person40336351165BR-02512/2026source · 2nd
AtlasIntelBloomberg (AtlasIntel self-funded)22/095,015online45.843.42.14.51.30.90.80.70.5BR-04739/2026source · 2nd
American AnalyticsTimes Brasil/CNBC20/092,000not reported38345441068BR-032587/26source · 2nd
BTG/NexusBTG Pactual20/092,006phone40376351142BR-00485/2026source · 2nd
QuaestGlobo/O Globo20/092,004in-person37336341178BR-06004/2026source · 2nd
Veritaself-funded19/0940,500phone4043.65.42.91.80.61.30.73.6—source · 2nd
Palverself-funded18/095,000online414221111——1BR-00860/2026source · 2nd
PoderDataPoderData (self-funded)/Aya16/093,000phone37368331642BR-00360/2026source · 2nd
Gerpself-funded16/092,400phone37407231144BR-00535/2026source · 2nd
DatafolhaFolha de S.Paulo/Globo16/092,002in-person39366342263BR-04029/2026source · 2nd
AtlasIntelBloomberg (AtlasIntel self-funded)16/095,018online44.141.73.55.11.71.10.71.60.5BR-06221/2026source · 2nd
Futura InteligenciaApex/100% Cidades15/092,000phone38.337.76.53.44.512.42.73.4BR-00749/2026source · 2nd
DataTrendsself-funded14/092,000phone39356431048BR-08691/2026source · 2nd
QuaestGlobo/O Globo13/092,004in-person363174410710BR-03607/2026source · 2nd
CNT/MDACNT13/092,002in-person40.530.462.7312.967.5BR-06902/2026source · 2nd
BTG/NexusBTG Pactual13/092,003phone42376251142BR-04076/2026source · 2nd
IndexaBroadcast/Estadão13/092,000phone383463410104BR-03482/2026source · 2nd
Futura InteligenciaApex/100% Cidades10/092,000phone39.233.66.44.34.40.733.15.2BR-02322/2026source · 2nd
DatafolhaFolha de S.Paulo/Globo10/092,002in-person39356342464BR-01833/2026source · 2nd
PoderDataPoderData (self-funded)/Aya09/093,000phone383610321531BR-04914/2026source · 2nd
AtlasIntelBloomberg (AtlasIntel self-funded)09/095,000online4337.46.66.51.12.12.30.60.4BR-01452/2026source · 2nd
Gerpself-funded08/092,400phone34376451248BR-00251/2026source · 2nd
Meio/IdeiaCanal Meio07/091,500phone38.437.36.6441.53.12.52.8BR-07935/2026source · 2nd
Palverself-funded07/095,000online404041111201BR-05420/2026source · 2nd
BTG/NexusBTG Pactual07/092,002phone39359441232BR-06790/2026source · 2nd
QuaestGlobo/O Globo06/092,004in-person362983321810BR-01720/2026source · 2nd
Veritaself-funded04/093,804not reported38.438.912.23.71.90.32.61.10.9BR-09426/2026source · 2nd
PoderDataPoderData (self-funded)/Aya02/093,000phone373410321852BR-07561/2026source · 2nd
DatafolhaFolha de S.Paulo/Globo02/092,002in-person38338342363BR-03669/2026source · 2nd
Futura InteligenciaApex/100% Cidades01/092,000phone38.733.69.92.83.51.63.824.1BR-02793/2026source · 2nd
QuaestGlobo/O Globo01/092,004in-person3729103111711BR-07065/2026source · 2nd
Real Time Big DataReal Time Big Data (self-funded)31/082,000phone383011742233BR-03490/2026source · 2nd
AtlasIntelBloomberg (AtlasIntel self-funded)30/085,014online43.433.77.87.63.3130.10.2BR-07972/2026source · 2nd
BTG/NexusBTG Pactual30/082,005phone393311351133BR-08900/2026source · 2nd
Vox Brasilself-funded27/082,100in-person37.134.82.63.352.84.13.56.8BR-05519/2026source · 2nd
PoderDataPoderData (self-funded)/Aya26/082,400phone38354442752BR-04974/2026source · 2nd
GerpAESP25/082,400phone37381331448BR-03547/2026source · 2nd
IndexaBroadcast/Estadão23/082,000phone393414510106BR-06366/2026source · 2nd
BTG/NexusBTG Pactual23/082,006phone41372353153BR-09028/2026source · 2nd
Veritaself-funded20/083,840phone39.339.12.23.83.31.371.82.4BR-04006/2026source · 2nd
DatafolhaFolha de S.Paulo/Globo20/082,058in-person39332453464BR-04496/2026source · 2nd
BTG/NexusBTG Pactual16/082,003phone41361444152BR-03317/2026source · 2nd
QuaestGlobo/O Globo13/082,004in-person383124421810BR-06773/2026source · 2nd
PoderDataPoderData (self-funded)/Aya12/082,400phone41353443342BR-06868/2026source · 2nd
Gerpself-funded10/082,400phone38381542147BR-08045/2026source · 2nd
CNT/MDACNT09/082,002in-person42.428.71.22.843.33.46.87.2BR-06935/2026source · 2nd
BTG/NexusBTG Pactual09/082,001phone40351453353BR-08428/2026source · 2nd
Palverself-funded07/085,000online444001021110BR-06596/2026source · 2nd
Futura InteligenciaApex/100% Cidades07/082,000phone38.834.11.23.65.93.626.24.8BR-08109/2026source · 2nd
QuaestGenial (Banco Genial S.A.)03/082,004in-person393014422810BR-06591/2026source · 2nd
Meio/IdeiaCanal Meio03/081,500phone43351.74.75.72.61.324BR-04579/2026source · 2nd
BTG/NexusBTG Pactual02/082,002phone41371453243BR-02874/2026source · 2nd

After the first round, declared runoff vote by first-round vote (PoderData/Aya Oct 10–12, via TMC), % Flávio / Lula: Cury 45/43 · Caiado 44/30 · Renan Santos 38/33 · other candidate 57/17 (Zema has no row of its own); blank/spoiled: Renan 27%, Caiado 14%, Cury 8%. Datafolha Oct 6–7, only the aggregate of the four: Flávio 46, Lula 32, blank/spoiled 15. Before the first round, declared second choice (Quaest Sep 3–6), % of each candidate's voters who migrate to Flávio / Lula in the runoff: Zema 59/4 · Renan Santos 58/14 · Marçal 47/19 · Cury 46/24 · Caiado 36/25. The model uses the post-first-round matrix (PoderData) and checks the aggregate against Datafolha.

Appendix C — code, data and reproduction

Everything in this document is generated by two Python scripts (numpy, pandas, scipy) from a CSV. To update with new polls: add rows to the CSV, run modelo_eleicao_2026.py (recomputes house effects, simulations and SVGs; after Oct 4, fill in resultado_1turno.csv to switch on the post-first-round regime) and then montar_painel.py / montar_standalone.py. The files below are embedded in this HTML.
⬇ pesquisas_2turno_2026.csv ⬇ pesquisas_1turno_2026.csv ⬇ calibracao_historica.csv ⬇ resultados.json
espaco_estados.py — core: random walk, anchored house effects, per-pollster variance, validation (click to expand)
# -*- coding: utf-8 -*-
"""
Espaço de estados para agregação de pesquisas (núcleo do modelo v3).

    y_i = x_{t(i)} + h_{p(i)} + ε_i          ε_i ~ N(0, se_i² + σ_{p(i)}²)
    x_t = x_{t-1} + η_t                       η_t ~ N(0, σ_rw²)
    h_p ~ N(0, τ²)   com  Σ_{p ∈ âncora} q_p·h_p = 0   (identificação pelos institutos calibrados)

Estimação por ECM: (1) dado h e σ_p, σ_rw por MAP (prior log-normal) e suavizador RTS para x_t;
(2) dado x_t, atualiza h_p (média posterior ridge) e σ_p² (empírico-Bayes, encolhido para a mediana).
Referências: Jackman (2005), Linzer (2013), Heidemanns–Gelman–Morris (2020).
"""
import math
import numpy as np
from scipy.optimize import minimize_scalar


def _kalman(y, day, R, sig_rw, n_days, x0, keep=False, q_extra=None):
    """Filtro para uma série diária. y, day, R ordenados por dia. Retorna loglik e, se keep, trajetórias.
    q_extra: dict dia → variância adicional do passeio nesse dia (quebra estrutural, ex.: o dia seguinte ao 1º turno)."""
    x, P = x0, 100.0
    ll = 0.0
    xf = np.empty(n_days); Pf = np.empty(n_days); xp = np.empty(n_days); Pp = np.empty(n_days)
    obs_by_day = {}
    for i, d in enumerate(day):
        obs_by_day.setdefault(int(d), []).append(i)
    q = sig_rw ** 2
    q_extra = q_extra or {}
    for d in range(n_days):
        if d > 0:
            P += q + q_extra.get(d, 0.0)
        xp[d], Pp[d] = x, P
        for i in obs_by_day.get(d, []):
            F = P + R[i]
            v = y[i] - x
            ll += -0.5 * (math.log(2 * math.pi * F) + v * v / F)
            K = P / F
            x += K * v
            P *= (1 - K)
        xf[d], Pf[d] = x, P
    if not keep:
        return ll
    # suavizador RTS
    xs = xf.copy(); Ps = Pf.copy()
    for d in range(n_days - 2, -1, -1):
        C = Pf[d] / Pp[d + 1] if Pp[d + 1] > 0 else 0.0
        xs[d] = xf[d] + C * (xs[d + 1] - xp[d + 1])
        Ps[d] = Pf[d] + C * C * (Ps[d + 1] - Pp[d + 1])
    return ll, xs, Ps, xf, Pf


def ajustar(y, day, se2, inst, ancora, q_ancora=None, tau_h=1.5, prior_rw=(math.log(0.25), 0.6),
            k_sigma=4.0, sigma_floor=0.8, n_iter=15, tol=0.01, sig_rw_fixo=None, prior_h=None, tau_prior_h=None,
            n_days=None, q_extra=None, a_h=None):
    """
    y, day, se2: arrays (observação, dia inteiro desde a origem, variância amostral).
    inst: array de rótulos. ancora: conjunto de institutos que identificam o nível.
    q_ancora: pesos dos institutos da âncora (default 1). prior_h: dict instituto → média a priori do efeito de casa
    (usado no regime pós-1º turno); tau_prior_h: desvio desse prior (default tau_h). q_extra: ver _kalman.
    a_h: fator por observação com que o efeito de casa entra (y_i = x + a_i·h_p + ε); default 1. Serve à persistência parcial do efeito
    de casa depois do 1º turno (a = λ < 1 nas pesquisas posteriores a 4/out): a observação identifica a_i·h, não h.
    Retorna dict com x (nível suavizado por dia), P (variância), house, house_var (variância posterior de cada efeito de casa),
    var_ancora (variância da média da âncora, que desloca o nível), sigma_inst, sig_rw, sig_rw_ml, loglik, resid.
    """
    y = np.asarray(y, float); day = np.asarray(day, int); se2 = np.asarray(se2, float); inst = np.asarray(inst)
    a_h = np.ones(len(y)) if a_h is None else np.asarray(a_h, float)
    order = np.argsort(day, kind="stable")
    y, day, se2, inst, a_h = y[order], day[order], se2[order], inst[order], a_h[order]
    n_days = max(int(day.max()) + 1, n_days or 0)   # o estado é propagado até n_days−1 (ex.: hoje) sem observações
    insts = list(dict.fromkeys(inst))
    idx = {p: np.where(inst == p)[0] for p in insts}
    q_ancora = q_ancora or {}
    prior_h = prior_h or {}
    tau_prior_h = tau_prior_h or tau_h
    h = {p: float(prior_h.get(p, 0.0)) for p in insts}
    sig_p = {p: 2.0 for p in insts}
    sig_rw = 0.25 if sig_rw_fixo is None else sig_rw_fixo
    x0 = float(y[0])
    hist_ll = []
    for it in range(n_iter):
        h_arr = a_h * np.array([h[p] for p in inst])
        R = se2 + np.array([sig_p[p] ** 2 for p in inst])
        y_adj = y - h_arr
        if sig_rw_fixo is None:
            def neg_post(z):
                ll = _kalman(y_adj, day, R, math.exp(z), n_days, x0, q_extra=q_extra)
                lp = -0.5 * ((z - prior_rw[0]) / prior_rw[1]) ** 2
                return -(ll + lp)
            opt = minimize_scalar(neg_post, bounds=(math.log(0.02), math.log(2.0)), method="bounded",
                                  options={"xatol": 1e-3})
            sig_rw = math.exp(opt.x)
        ll, xs, Ps, xf, Pf = _kalman(y_adj, day, R, sig_rw, n_days, x0, keep=True, q_extra=q_extra)
        hist_ll.append(ll)
        # (2) efeitos de casa: média posterior sob h_p ~ N(prior, τ²) dado x suavizado
        x_at = xs[day]
        h_new = {}
        for p in insts:
            ii = idx[p]
            w = 1.0 / R[ii]
            tau2 = (tau_prior_h if p in prior_h else tau_h) ** 2
            m0 = prior_h.get(p, 0.0)
            h_new[p] = float((np.sum(w * a_h[ii] * (y[ii] - x_at[ii])) + m0 / tau2) / (np.sum(w * a_h[ii] ** 2) + 1.0 / tau2))
        # identificação: média ponderada da âncora = 0 (o deslocamento vai para o nível)
        anc = [p for p in insts if p in ancora]
        if anc:
            wq = np.array([q_ancora.get(p, 1.0) for p in anc])
            c = float(np.sum(wq * np.array([h_new[p] for p in anc])) / wq.sum())
        else:
            c = float(np.mean(list(h_new.values())))
        h_new = {p: v - c for p, v in h_new.items()}
        xs = xs + c; xf = xf + c; x0 = x0 + c
        delta = max(abs(h_new[p] - h[p]) for p in insts)
        h = h_new
        # (3) variância extra por instituto (EB): resíduo² − variância amostral − variância do estado, encolhido para a mediana
        resid = y - xs[day] - a_h * np.array([h[p] for p in inst])
        excess = {}
        for p in insts:
            ii = idx[p]
            e2 = float(np.mean(resid[ii] ** 2 + Ps[day[ii]] - se2[ii]))
            excess[p] = (len(ii), max(e2, 0.0))
        med = float(np.median([v for _, v in excess.values()]))
        sig_p = {p: math.sqrt(max((n * e2 + k_sigma * med) / (n + k_sigma), sigma_floor ** 2)) for p, (n, e2) in excess.items()}
        if delta < tol and it >= 3:
            break
    # verossimilhança sem prior, para transparência sobre σ_rw
    h_arr = a_h * np.array([h[p] for p in inst]); R = se2 + np.array([sig_p[p] ** 2 for p in inst]); y_adj = y - h_arr
    ml = minimize_scalar(lambda z: -_kalman(y_adj, day, R, math.exp(z), n_days, x0, q_extra=q_extra), bounds=(math.log(0.02), math.log(2.0)),
                         method="bounded", options={"xatol": 1e-3})
    ll, xs, Ps, xf, Pf = _kalman(y_adj, day, R, sig_rw, n_days, x0, keep=True, q_extra=q_extra)
    resid = y - xs[day] - h_arr
    # incerteza dos efeitos de casa (tratados como conhecidos no filtro): variância posterior de cada h_p e da média da âncora
    house_var = {p: float(1.0 / (np.sum(a_h[idx[p]] ** 2 / R[idx[p]]) + 1.0 / ((tau_prior_h if p in prior_h else tau_h) ** 2))) for p in insts}
    anc = [p for p in insts if p in ancora]
    if anc:
        wq = np.array([q_ancora.get(p, 1.0) for p in anc])
        var_ancora = float(np.sum(wq ** 2 * np.array([house_var[p] for p in anc])) / wq.sum() ** 2)
    else:
        var_ancora = float(np.mean(list(house_var.values())) / len(insts))
    return {"x": xs, "P": Ps, "x_filtro": xf, "P_filtro": Pf, "house": h, "house_var": house_var, "var_ancora": var_ancora, "sigma_inst": sig_p, "sig_rw": sig_rw,
            "sig_rw_ml": math.exp(ml.x), "loglik": ll, "resid": resid, "order": order, "R": R, "iter": it + 1,
            "n_days": n_days, "insts": insts}


def validacao_rolling(y, day, se2, inst, ancora, q_ancora, dias, horizonte=7, a_h=None, **kw):
    """Origem móvel: para cada dia d em `dias`, ajusta com observações até d e prevê as de (d, d+horizonte].
    `a_h` é a persistência do efeito de casa por observação (λ nas pesquisas pós-1º turno), a mesma do ajuste validado.
    Retorna lista de (dia, y_obs, y_prev, sd_prev, instituto, instituto_ja_visto)."""
    y = np.asarray(y, float); day = np.asarray(day, int); se2 = np.asarray(se2, float); inst = np.asarray(inst)
    a_h = np.ones(len(y)) if a_h is None else np.asarray(a_h, float)
    out = []
    for d in dias:
        m = day <= d
        if m.sum() < 20:
            continue
        fit = ajustar(y[m], day[m], se2[m], inst[m], ancora, q_ancora, n_iter=8, n_days=d + 1, a_h=a_h[m], **kw)   # estado propagado até o dia d
        x_d, P_d = fit["x"][d], fit["P"][d]
        f = (day > d) & (day <= d + horizonte)
        for i in np.where(f)[0]:
            hp = fit["house"].get(inst[i], 0.0)
            sp = fit["sigma_inst"].get(inst[i], float(np.median(list(fit["sigma_inst"].values()))))
            k = day[i] - d
            sd = math.sqrt(P_d + k * fit["sig_rw"] ** 2 + se2[i] + sp ** 2)
            out.append((int(d), float(y[i]), float(x_d + a_h[i] * hp), sd, str(inst[i]), bool(inst[i] in fit["house"])))
    return out
mcmc_2turno.py — parallel runoff model via MCMC: full posterior, sampler and diagnostics (click to expand)
# -*- coding: utf-8 -*-
"""
Modelo do 2º turno estimado por MCMC (Markov chain Monte Carlo): mesmo modelo de espaco_estados.py, com o posterior completo.

    y_i = x_{t(i)} + μ_{r(i)} + β + h_{p(i)} + ε_i        ε_i ~ N(0, se_i² + σ_{p(i)}²)
    x_t = x_{t-1} + η_t        η_t ~ N(0, σ_rw²) até o 1º turno · + SD_QUEBRA² no dia seguinte · N(0, σ_entre²) depois
    h_p ~ N(prior_p, τ²)  com  Σ_{p ∈ âncora} q_p·h_p = 0
    β ~ t₅(0, sd_vies)        erro sistemático comum das pesquisas (μ_r = desconto histórico do regime: antes/depois do 1º turno)
    obs. extras: nível estrutural (voto contado + transferência) = x_{T1+1} + e, sem viés de pesquisa
    log σ_rw ~ N(log 0,25; 0,6) · log σ_entre ~ N(log σ_entre_hist; 0,5)
    log σ_p ~ N(m, 0,5²) truncada em σ_p ≥ 0,8 · m ~ N(log 2; 0,5²)
    previsão: x_{T2} = x_{último dia} + deriva, deriva ~ t₅ com desvio sqrt(Σ σ²_dia) (como em simular())

O modelo atual (espaco_estados.ajustar + simular) fixa σ_rw, σ_p, os efeitos de casa e o nível em estimativas pontuais e sorteia
a deriva e o viés por fora. Aqui tudo é sorteado em conjunto. Amostrador (Gibbs com passos de Metropolis):
 1. σ_rw e σ_entre por Metropolis com (x, β, h) integrados: dado o resto, o modelo é linear-gaussiano e a verossimilhança marginal
    sai da fatoração de Cholesky da precisão conjunta — mistura muito melhor que condicionar na trajetória;
 2. (x, β, h) num bloco só, sorteio exato da normal conjunta (a restrição da âncora entra como observação de variância ~0);
 3. σ_p por Metropolis, dado o resíduo de cada instituto; m por Metropolis; 4. escala da t do viés (ω) por Gama conjugada.
Diagnósticos: R̂ dividido e ESS multicadeia (Vehtari et al. 2021, sem normalização por postos).
"""
import math
import numpy as np
from scipy.linalg import cholesky, solve_triangular
from scipy.stats import norm

DF = 5                          # graus de liberdade da t (deriva e viés), o mesmo DF_ERRO do modelo atual
K_T = (DF - 2) / DF             # escala da t com o mesmo desvio da normal (como _t_unit)
SIG_P_PISO, SIG_P_SD, M_PRIOR = 0.8, 0.5, (math.log(2.0), 0.5)
KAPPA = 1e-6                    # variância da "observação" Σ q_p h_p = 0


def _monta(y, dia, se2, inst, n_dias, mu_obs, ancora, q_ancora, prior_h, tau_h, extras, sd_vies, x0_sd, a_h=None):
    """Pré-calcula a matriz de desenho e as partes fixas da precisão. Vetor z = [x_0 … x_{D−1}, β, h_1 … h_P]."""
    insts = list(dict.fromkeys(inst))
    P, D = len(insts), n_dias
    com_vies = sd_vies > 0
    iB = D if com_vies else None
    i0h = D + (1 if com_vies else 0)
    K = i0h + P
    pidx = np.array([insts.index(p) for p in inst])
    n, ne = len(y), len(extras)
    H = np.zeros((n + ne, K))
    H[np.arange(n), dia] = 1.0
    if com_vies:
        H[:n, iB] = 1.0
    a_h = np.ones(n) if a_h is None else np.asarray(a_h, float)    # persistência do efeito de casa (λ nas pesquisas pós-1º turno)
    H[np.arange(n), i0h + pidx] = a_h
    yy = np.concatenate([np.asarray(y, float) - mu_obs, [e["valor"] - e.get("mu", 0.0) for e in extras]])
    r_extra = np.array([e["sd"] ** 2 for e in extras])
    for j, e in enumerate(extras):
        H[n + j, e["dia"]] = 1.0
        if e.get("com_vies") and com_vies:
            H[n + j, iB] = 1.0
    # prior fixo: efeitos de casa, restrição da âncora, x_0
    Q_fix = np.zeros((K, K)); b_fix = np.zeros(K)
    m_h = np.array([prior_h.get(p, 0.0) for p in insts]); t2 = tau_h ** 2
    Q_fix[i0h + np.arange(P), i0h + np.arange(P)] += 1.0 / t2
    b_fix[i0h:] += m_h / t2
    a = np.zeros(K)
    for j, p in enumerate(insts):
        if p in ancora:
            a[i0h + j] = q_ancora.get(p, 1.0)
    if not a.any():
        a[i0h:] = 1.0
    Q_fix += np.outer(a, a) / KAPPA
    x0_m = float(np.mean(np.sort(np.asarray(y, float)[np.argsort(dia)][:10])))   # média das primeiras ondas, prior difuso
    Q_fix[0, 0] += 1.0 / x0_sd ** 2; b_fix[0] += x0_m / x0_sd ** 2
    return {"H": H, "yy": yy, "n": n, "r_extra": r_extra, "pidx": pidx, "insts": insts, "P": P, "D": D, "K": K, "iB": iB,
            "i0h": i0h, "Q_fix": Q_fix, "b_fix": b_fix, "se2": np.asarray(se2, float), "dia": np.asarray(dia, int), "x0_sd": x0_sd, "a_h": a_h}


def _var_passos(D, dia_t1, sd_quebra, s_rw, s_entre):
    """Variância de η_d para d = 1 … D−1: σ_rw² até o 1º turno, + quebra no dia seguinte, σ_entre² depois."""
    d = np.arange(1, D)
    v = np.where(d <= dia_t1 + 1, s_rw ** 2, s_entre ** 2)
    if sd_quebra > 0 and 1 <= dia_t1 + 1 < D:
        v[dia_t1] += sd_quebra ** 2
    return v


def _posterior(M, v, sig_p, omega, s_b):
    """Precisão e vetor canônico da normal conjunta de z dados os hiperparâmetros; devolve (L, b, log p(y|θ) a menos de constante)."""
    D, n = M["D"], M["n"]
    r = np.concatenate([M["se2"] + sig_p[M["pidx"]] ** 2, M["r_extra"]])
    w = 1.0 / r
    Hw = M["H"] * w[:, None]
    Q = M["Q_fix"] + M["H"].T @ Hw
    b = M["b_fix"] + Hw.T @ M["yy"]
    iv = 1.0 / v
    di = np.arange(D)
    diag = np.zeros(D); diag[1:] += iv; diag[:-1] += iv
    Q[di, di] += diag
    Q[di[1:], di[:-1]] -= iv; Q[di[:-1], di[1:]] -= iv
    if M["iB"] is not None:
        Q[M["iB"], M["iB"]] += omega / s_b ** 2
    L = cholesky(Q, lower=True, check_finite=False)
    u = solve_triangular(L, b, lower=True, check_finite=False)
    # log p(y|θ) = ½log|Q0| − ½log|Q| + ½ bᵀQ⁻¹b − ½ Σ log r − ½ yᵀR⁻¹y (+ constantes); |Q0| só tem a parte de x e de β que varia
    ll = (-0.5 * np.sum(np.log(v)) - np.sum(np.log(np.diag(L))) + 0.5 * u @ u - 0.5 * np.sum(np.log(r)) - 0.5 * np.sum(w * M["yy"] ** 2))
    if M["iB"] is not None:
        ll += 0.5 * math.log(omega)
    return L, u, ll


def _lp_logsig_p(ls, m):
    """Log-prior de log σ_p: normal truncada em σ_p ≥ piso (o termo de truncamento depende de m)."""
    lo = math.log(SIG_P_PISO)
    if np.any(ls < lo):
        return -np.inf
    return float(np.sum(-0.5 * ((ls - m) / SIG_P_SD) ** 2) - len(ls) * norm.logsf((lo - m) / SIG_P_SD))


def _cadeia(M, rng, n_aquec, n_amostras, dia_t1, sd_quebra, prior_rw, prior_entre, sd_vies, n_fut_rw, n_fut_entre, entre_tem_dados, prior_mu_der=(0.0, 0.0)):
    D, P = M["D"], M["P"]
    s_b = sd_vies * math.sqrt(K_T) if sd_vies > 0 else 1.0
    lrw = prior_rw[0] + 0.3 * rng.standard_normal()
    lent = prior_entre[0] + 0.3 * rng.standard_normal()
    ls = np.log(np.maximum(2.0 * np.exp(0.2 * rng.standard_normal(P)), SIG_P_PISO + 0.05))
    m = M_PRIOR[0]; omega = 1.0
    passo = {"rw": 0.25, "entre": 0.3, "m": 0.3}; passo_p = np.full(P, 0.4)
    ac = {"rw": [], "entre": [], "m": []}; ac_p = []
    tot = n_aquec + n_amostras
    out = {k: np.empty(n_amostras) for k in ["x_hoje", "x_ultimo", "x_final", "sig_rw", "sig_entre", "beta", "deriva", "m_sigp", "mu_der"]}
    out["h"] = np.empty((n_amostras, P)); out["sig_p"] = np.empty((n_amostras, P)); out["x"] = np.empty((n_amostras, D))
    lp_rw = lambda z: -0.5 * ((z - prior_rw[0]) / prior_rw[1]) ** 2
    lp_en = lambda z: -0.5 * ((z - prior_entre[0]) / prior_entre[1]) ** 2
    for it in range(tot):
        sp = np.exp(ls)
        L, u, ll = _posterior(M, _var_passos(D, dia_t1, sd_quebra, math.exp(lrw), math.exp(lent)), sp, omega, s_b)
        # 1a. σ_rw (z integrado)
        z_ = lrw + passo["rw"] * rng.standard_normal()
        L2, u2, ll2 = _posterior(M, _var_passos(D, dia_t1, sd_quebra, math.exp(z_), math.exp(lent)), sp, omega, s_b)
        aceita = math.log(rng.random()) < ll2 + lp_rw(z_) - ll - lp_rw(lrw)
        if aceita:
            lrw, L, u, ll = z_, L2, u2, ll2
        ac["rw"].append(aceita)
        # 1b. σ_entre: só tem verossimilhança se houver dias observados depois do 1º turno; senão, sorteio direto do prior
        if entre_tem_dados:
            z_ = lent + passo["entre"] * rng.standard_normal()
            L2, u2, ll2 = _posterior(M, _var_passos(D, dia_t1, sd_quebra, math.exp(lrw), math.exp(z_)), sp, omega, s_b)
            aceita = math.log(rng.random()) < ll2 + lp_en(z_) - ll - lp_en(lent)
            if aceita:
                lent, L, u, ll = z_, L2, u2, ll2
            ac["entre"].append(aceita)
        else:
            lent = prior_entre[0] + prior_entre[1] * rng.standard_normal()
        # 2. (x, β, h) em bloco: z = Q⁻¹b + L⁻ᵀε
        zv = solve_triangular(L.T, u + rng.standard_normal(M["K"]), lower=False, check_finite=False)
        x = zv[:D]; beta = zv[M["iB"]] if M["iB"] is not None else 0.0; h = zv[M["i0h"]:]
        # 3. σ_p por instituto (Metropolis vetorizado, dado o resíduo)
        res = M["yy"][:M["n"]] - x[M["dia"]] - beta - M["a_h"] * h[M["pidx"]]
        def ll_p(lsv):
            r_ = M["se2"] + np.exp(lsv)[M["pidx"]] ** 2
            return np.bincount(M["pidx"], weights=-0.5 * np.log(r_) - 0.5 * res ** 2 / r_, minlength=P)
        lo = math.log(SIG_P_PISO)
        prop = ls + passo_p * rng.standard_normal(P)
        lp_c = -0.5 * ((ls - m) / SIG_P_SD) ** 2; lp_n = np.where(prop >= lo, -0.5 * ((prop - m) / SIG_P_SD) ** 2, -np.inf)
        a_p = np.log(rng.random(P)) < ll_p(prop) + lp_n - ll_p(ls) - lp_c
        ls = np.where(a_p, prop, ls); ac_p.append(a_p)
        # 3b. m (centro hierárquico de log σ_p)
        m_ = m + passo["m"] * rng.standard_normal()
        lm = lambda mm: _lp_logsig_p(ls, mm) - 0.5 * ((mm - M_PRIOR[0]) / M_PRIOR[1]) ** 2
        aceita = math.log(rng.random()) < lm(m_) - lm(m)
        if aceita:
            m = m_
        ac["m"].append(aceita)
        # 4. ω | β (t₅ como mistura de escala de normais)
        if M["iB"] is not None:
            omega = rng.gamma(DF / 2 + 0.5, 1.0 / (DF / 2 + beta ** 2 / (2 * s_b ** 2)))
        # adaptação dos passos no aquecimento (alvo ~0,44 em uma dimensão)
        if it < n_aquec and (it + 1) % 50 == 0:
            for k in passo:
                if ac[k]:
                    passo[k] *= math.exp(np.mean(ac[k][-50:]) - 0.44)
            passo_p *= np.exp(np.mean(ac_p[-50:], axis=0) - 0.44)
        if it >= n_aquec:
            j = it - n_aquec
            # previsão: deriva até 25/out com caudas t₅ e o mesmo desvio da versão normal
            v_fut = n_fut_rw * math.exp(lrw) ** 2 + n_fut_entre * math.exp(lent) ** 2
            om_d = rng.gamma(DF / 2, 2.0 / DF)
            # média da deriva (variante com momentum): sorteada do prior a cada iteração, sem verossimilhança própria (como σ_entre sem dados)
            mu_d = prior_mu_der[0] + prior_mu_der[1] * rng.standard_normal() if prior_mu_der[1] > 0 else prior_mu_der[0]
            der = mu_d + math.sqrt(v_fut * K_T / om_d) * rng.standard_normal()
            out["x_ultimo"][j] = x[-1]; out["x_final"][j] = x[-1] + der; out["deriva"][j] = der; out["mu_der"][j] = mu_d
            out["sig_rw"][j] = math.exp(lrw); out["sig_entre"][j] = math.exp(lent); out["beta"][j] = beta
            out["m_sigp"][j] = math.exp(m); out["h"][j] = h; out["sig_p"][j] = np.exp(ls); out["x"][j] = x
    taxa = {k: round(float(np.mean(v[n_aquec:])), 3) for k, v in ac.items() if len(v) > n_aquec}
    taxa["sig_p_media"] = round(float(np.mean(ac_p[n_aquec:])), 3)
    return out, taxa


def amostrar(y, dia, se2, inst, n_dias, ancora, q_ancora=None, mu_obs=None, sd_vies=0.0, prior_h=None, tau_h=1.5,
             dia_t1=10 ** 9, sd_quebra=0.0, extras=(), prior_rw=(math.log(0.25), 0.6), prior_entre=(math.log(0.25), 0.5),
             n_fut_rw=0, n_fut_entre=0, dia_hoje=None, n_cadeias=4, n_aquec=1500, n_amostras=4000, seed=20261025, x0_sd=10.0, a_h=None,
             prior_mu_der=(0.0, 0.0)):
    """
    y, dia, se2, inst: pesquisas (dia inteiro desde a origem da janela). n_dias: nº de dias do estado (0 … n_dias−1).
    mu_obs: desconto de viés esperado de cada pesquisa (antes/depois do 1º turno). sd_vies: desvio do erro comum β (0 = sem β).
    dia_t1: índice do dia do 1º turno; dia_t1+1 recebe a quebra sd_quebra² e os dias seguintes usam σ_entre.
    extras: observações diretas do estado, dicts {dia, valor, sd, com_vies, mu} (nível estrutural, leitura estadual).
    n_fut_rw / n_fut_entre: dias entre o último dia do estado e 25/out em cada regime de deriva. dia_hoje: dia reportado como "hoje".
    prior_mu_der: (média, desvio) da média da deriva até 25/out; (0, 0) = deriva centrada em zero (modelo principal).
    Retorna dict com as amostras (cadeias × sorteios), taxas de aceitação e diagnósticos.
    """
    q_ancora = q_ancora or {}; prior_h = prior_h or {}
    mu_obs = np.zeros(len(y)) if mu_obs is None else np.asarray(mu_obs, float)
    M = _monta(np.asarray(y, float), np.asarray(dia, int), se2, np.asarray(inst), n_dias, mu_obs, set(ancora), q_ancora, prior_h,
               tau_h, list(extras), sd_vies, x0_sd, a_h)
    entre_tem_dados = n_dias - 1 > dia_t1 + 1
    dia_hoje = n_dias - 1 if dia_hoje is None else dia_hoje
    cad, taxas = [], []
    for c in range(n_cadeias):
        o, t = _cadeia(M, np.random.default_rng([seed, c]), n_aquec, n_amostras, dia_t1, sd_quebra, prior_rw, prior_entre, sd_vies,
                       n_fut_rw, n_fut_entre, entre_tem_dados, prior_mu_der)
        o["x_hoje"] = o["x"][:, dia_hoje]
        cad.append(o); taxas.append(t)
    S = {k: np.stack([o[k] for o in cad]) for k in cad[0]}
    diag = {k: {"rhat": round(rhat(S[k]), 4), "ess": int(ess(S[k]))} for k in ["x_hoje", "x_final", "sig_rw", "sig_entre", "beta", "m_sigp"] + (["mu_der"] if prior_mu_der[1] > 0 else [])}
    for j, p in enumerate(M["insts"]):
        diag[f"h[{p}]"] = {"rhat": round(rhat(S["h"][:, :, j]), 4), "ess": int(ess(S["h"][:, :, j]))}
        diag[f"sig_p[{p}]"] = {"rhat": round(rhat(S["sig_p"][:, :, j]), 4), "ess": int(ess(S["sig_p"][:, :, j]))}
    return {"amostras": S, "insts": M["insts"], "aceitacao": taxas, "diagnostico": diag,
            "rhat_max": max(d["rhat"] for d in diag.values()), "ess_min": min(d["ess"] for d in diag.values()),
            "n_cadeias": n_cadeias, "n_aquec": n_aquec, "n_amostras": n_amostras, "entre_tem_dados": bool(entre_tem_dados)}


# ---------------- diagnósticos ----------------
def _split(x):
    x = np.asarray(x, float)
    h = x.shape[1] // 2
    return np.concatenate([x[:, :h], x[:, h:2 * h]])


def rhat(x):
    """R̂ dividido (Gelman et al., BDA3): cadeias × sorteios."""
    s = _split(x)
    m, n = s.shape
    W = s.var(axis=1, ddof=1).mean()
    B = n * s.mean(axis=1).var(ddof=1)
    if W <= 0:
        return 1.0
    return float(math.sqrt(((n - 1) / n * W + B / n) / W))


def ess(x):
    """Tamanho efetivo de amostra multicadeia (Geyer: soma de pares positivos e monótonos de autocorrelação)."""
    s = _split(x)
    m, n = s.shape
    if s.var() == 0:
        return float(m * n)
    xc = s - s.mean(axis=1, keepdims=True)
    f = np.fft.rfft(xc, n=2 * n, axis=1)
    acov = np.fft.irfft(f * np.conj(f), axis=1)[:, :n] / n
    W = s.var(axis=1, ddof=1).mean()
    var_mais = (n - 1) / n * W + s.mean(axis=1).var(ddof=1)
    rho = 1.0 - (W - acov.mean(axis=0)) / var_mais
    rho[0] = 1.0
    soma, prev = 0.0, np.inf
    for t in range(0, n - 1, 2):
        par = rho[t] + rho[t + 1]
        if par < 0:
            break
        par = min(par, prev); prev = par
        soma += par
    tau = -1.0 + 2.0 * soma
    return float(m * n / max(tau, 1.0 / math.log10(m * n)))


# ---------------- verificação ----------------
if __name__ == "__main__":
    import sys, os, time
    sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
    from espaco_estados import _kalman
    # (a) com hiperparâmetros fixos, sem β e sem efeitos de casa, a normal conjunta coincide com o suavizador RTS do filtro de Kalman
    rng = np.random.default_rng(1)
    D = 80; xs_true = np.cumsum(np.r_[2.0, 0.3 * rng.standard_normal(D - 1)])
    dd = np.sort(rng.integers(0, D, 40)); se2 = np.full(40, 4.0); yv = xs_true[dd] + 2 * rng.standard_normal(40)
    M = _monta(yv, dd, se2, np.array(["A"] * 40), D, np.zeros(40), {"A"}, {}, {}, 1.5, [], 0.0, 10.0)
    M["b_fix"][0] = yv[0] / 100.0      # mesmo x_0 do filtro: N(y_0, 100)
    L, u, _ = _posterior(M, np.full(D - 1, 0.3 ** 2), np.array([1.0]), 1.0, 1.0)
    mean = solve_triangular(L.T, u, lower=False)[:D]
    Linv = solve_triangular(L, np.eye(M["K"]), lower=True); var = (Linv ** 2).sum(axis=0)[:D]
    _, xs_k, Ps_k, _, _ = _kalman(yv, dd, se2 + 1.0, 0.3, D, yv[0], keep=True)
    assert np.allclose(mean, xs_k, atol=1e-4), np.abs(mean - xs_k).max()
    assert np.allclose(var, Ps_k, atol=1e-4), np.abs(var - Ps_k).max()
    print(f"[ok] normal conjunta = suavizador RTS (dif. máx. média {np.abs(mean - xs_k).max():.1e}, variância {np.abs(var - Ps_k).max():.1e})")
    # (b) recuperação de parâmetros em dados simulados: casas, σ_rw, σ_p e o nível final
    rng = np.random.default_rng(7)
    D = 120; srw = 0.2; casas = {"A": 0.0, "B": 0.0, "C": 2.5, "D": -2.0, "E": 1.0}; sigp = {"A": 1.0, "B": 1.0, "C": 3.0, "D": 1.5, "E": 1.0}
    xt = np.cumsum(np.r_[-1.0, srw * rng.standard_normal(D - 1)])
    rows = [(d, p) for d in range(0, D, 2) for p in casas if rng.random() < 0.35]
    dd = np.array([d for d, _ in rows]); ii = np.array([p for _, p in rows]); se2 = np.full(len(rows), 2.2 ** 2)
    yv = xt[dd] + np.array([casas[p] for p in ii]) + np.sqrt(se2 + np.array([sigp[p] ** 2 for p in ii])) * rng.standard_normal(len(rows))
    t0 = time.time()
    R = amostrar(yv, dd, se2, ii, D, {"A", "B"}, n_cadeias=4, n_aquec=800, n_amostras=1500, seed=3)
    S = R["amostras"]
    def cobre(a, v, lo=5, hi=95): q_ = np.percentile(a, [lo, hi]); return q_[0] <= v <= q_[1]
    assert cobre(S["sig_rw"], srw), np.percentile(S["sig_rw"], [5, 95])
    assert cobre(S["x_hoje"], xt[-1]), (np.percentile(S["x_hoje"], [5, 95]), xt[-1])
    for j, p in enumerate(R["insts"]):
        assert cobre(S["h"][:, :, j], casas[p] - (casas["A"] + casas["B"]) / 2), (p, np.percentile(S["h"][:, :, j], [5, 95]))
    assert R["rhat_max"] < 1.05, R["rhat_max"]
    print(f"[ok] recuperação: σ_rw {np.median(S['sig_rw']):.3f} (verdadeiro {srw}), nível final {np.median(S['x_hoje']):+.2f} "
          f"(verdadeiro {xt[-1]:+.2f}), R̂ máx {R['rhat_max']:.3f}, ESS mín {R['ess_min']}, {len(rows)} pesquisas, {time.time() - t0:.1f} s")
migracao_markov.py — migration of non-voters, third-way and blank/spoiled voters: Markov chain + Monte Carlo, runoff campaign and self-checks (click to expand)
# -*- coding: utf-8 -*-
"""
Migração de ausentes, terceira via e brancos/nulos entre o 1º e o 2º turno: cadeia de Markov + Monte Carlo (análise paralela).

Pergunta: para onde vão, até 25/out, os 33,5 mi que não votaram em 4/out, os 9,3 mi que votaram na terceira via e os 6,0 mi de
brancos e nulos, e quanto uma campanha de conversa pessoal do PT com esses grupos (guia "Três nomes, três semanas") poderia mudar.

Estrutura (tudo em votos absolutos, por UF + exterior, a partir do resultado oficial em resultado_1turno_uf.csv):
 1. Estado inicial n_u = (Lula, Flávio, Cury, Renan, Caiado, Zema+outros, brancos+nulos, abstenção), Σ = eleitorado_u.
 2. Matriz de um passo M_u (8 origens × 4 destinos: Lula, Flávio, branco/nulo, não vota), uma por sorteio e por UF: cada linha é
    Dirichlet(κ·m) em torno de um centro m sorteado por fora (2ª opção declarada da Quaest; decisão dos "sem 2ª opção" e dos
    brancos/nulos; fluxos brutos de abstenção: r_out sai, r_in entra, com inclinação de perfil τ). destinos_u = n_u · M_u.
 3. Cadeia de Markov da campanha (5 a 24/out, não homogênea, fases do guia): por bolsão (ausentes, terceira via + brancos/nulos,
    eleitor brando de Flávio), estados S0 não alcançado · S1 alcançado · S2 convertido (plano de voto) · S3 confirmado · S4 recusou.
    O alcance vem do nº de voluntários (V_0 inicial, cada um convida 3 com aceitação p_ac, até 3 gerações, teto ψ·votos de Lula);
    a conversão por contato q vem da literatura de mobilização (Gerber & Green; Nickerson & Rogers; Kalla & Broockman). O estoque
    convertido é repartido pelas UFs ∝ voto de Lula e aplicado como fluxo entre destinos, com teto na massa disponível.
 4. Cenários: escada de alcance (sem campanha dirigida / moderada / forte / máxima) × resposta de Flávio (nenhuma / simétrica, rede
    ρ× a do PT e os mesmos priors de eficácia) + referência "metas do guia cumpridas". Números aleatórios comuns entre células.
 5. Saídas: margem Lula − Flávio em válidos, abstenção e brancos/nulos do 2º turno, saldo por frente, trajetória diária, por região
    e por UF, matriz de transição mediana; P(Lula) "só migração" e "com deriva" (a deriva t₅ do modelo principal, para comparar).
Não é previsão: é análise condicional. A manchete continua sendo o modelo principal. Autoverificações: python migracao_markov.py
"""
import json, math, os, time, html as H
import numpy as np
import pandas as pd

ORIG = ["lula", "flavio", "cury", "renan", "caiado", "zo", "bn", "abs"]
DEST = ["lula", "flavio", "bn", "abs"]
TV = ["cury", "renan", "caiado", "zo"]
ROT_ORIG = {"lula": "Votou em Lula", "flavio": "Votou em Flávio", "cury": "Votou em Cury", "renan": "Votou em Renan", "caiado": "Votou em Caiado",
            "zo": "Votou em Zema ou outros", "bn": "Votou branco ou nulo", "abs": "Não votou"}
ROT_DEST = {"lula": "Lula", "flavio": "Flávio", "bn": "Branco/nulo", "abs": "Não vota"}
REGIAO_NOME = {"N": "Norte", "NE": "Nordeste", "CO": "Centro-Oeste", "SE": "Sudeste", "S": "Sul", "EXT": "Exterior"}
KAPPA = {"lula": 400, "flavio": 400, "cury": 100, "renan": 100, "caiado": 100, "zo": 100, "bn": 150, "abs": 150}
INICIO, FIM, T2 = pd.Timestamp("2026-10-05"), pd.Timestamp("2026-10-24"), pd.Timestamp("2026-10-25")
DIAS = pd.date_range(INICIO, FIM)                 # 20 dias de campanha
# fases do guia: (nome, início, fim, multiplicador do hazard de conversão, hazard de confirmação/dia, hazard de encerramento/dia, geração recrutada)
FASES = [("Largada", "2026-10-05", "2026-10-06", 0.0, 0.0, 0.0, 0),
         ("1ª conversa", "2026-10-07", "2026-10-11", 0.8, 0.0, 0.0, 1),
         ("Pausa (Aparecida)", "2026-10-12", "2026-10-12", 0.0, 0.0, 0.0, None),
         ("2ª conversa", "2026-10-13", "2026-10-18", 1.4, 0.0, 0.0, 2),
         ("Fechamento", "2026-10-19", "2026-10-22", 0.8, 0.15, 0.3, 3),
         ("Confirmação", "2026-10-23", "2026-10-24", 0.0, 0.4, 0.3, None)]
BOLSOES = ["ausentes", "terceira_via", "flavio_brando"]
REALIZA = {"ausentes": (0.85, 0.55), "terceira_via": (0.90, 0.55), "flavio_brando": (0.90, 0.55)}   # P(vota como combinado | S3), (| S2)
CENARIOS = [("sem", "Sem campanha dirigida", 0.0, 0.0), ("moderada", "Moderada", 100_000, 0.5),
            ("forte", "Forte", 500_000, 1.0), ("maxima", "Máxima", 2_000_000, 1.5)]
RESPOSTAS = [("nenhuma", "Nenhuma"), ("simetrica", "Simétrica")]
GUIA_METAS = {"terceira_via": 2.4e6, "eleitores_flavio": 1.2e6, "brancos_nulos": 1.0e6, "ausentes": 0.5e6}   # saldo (L − F) por frente
DF_T = 5
BINS = np.arange(-8.0, 4.01, 0.25)          # histograma da margem só pela migração (caudas somadas nas pontas)
# (nome, símbolo, distribuição, média, desvio, fonte)
PRIORS = [
    ("Troca direta entre Lula e Flávio", "ε_x", "Beta", 0.005, 0.003, "prior explícito: troca direta rara entre os dois"),
    ("Eleitor de Lula ou Flávio que vota branco/nulo", "ε_bn", "Beta", 0.01, 0.005, "prior explícito"),
    ("Votou no 1º turno e falta no 2º", "r_out", "Beta", 0.05, 0.015, "cerca de 6 mi; em 2022 a abstenção quase não mudou entre os turnos, com troca de pessoas"),
    ("Variação líquida nacional da abstenção (p.p.)", "δ_nac", "Normal", 0.5, 1.0, "a abstenção subiu no 2º turno em 2006–2018 e caiu em 2022"),
    ("Variação líquida por UF além do padrão de 2022 (p.p.)", "δ_u", "Normal", 0.0, 0.7, "padrão 1º→2º turno de 2022 por UF encolhido 50% e cortado em ±3 (abstencao_2022_uf.csv)"),
    ("Inclinação de perfil de quem entra ou sai (p.p. de margem pró-Lula)", "τ", "Normal, ≥ 0", 10.0, 5.0, "abstenção por escolaridade 2022 (27/17/13%) × voto por escolaridade (Quaest 3/out) ≈ +6; K = 0,25 da seção Abstenção ≈ 20"),
    ("\"Sem 2ª opção\" da 3ª via que escolhem um dos dois", "d_3", "Beta", 0.50, 0.12, "modelo estrutural: pool decidido a 55%"),
    ("Parcela de Lula entre os que escolhem", "s_3", "Beta", 0.50, 0.08, "faixa 35–65% do tornado"),
    ("Brancos e nulos do 1º turno que escolhem um dos dois", "d_bn", "Beta", 0.35, 0.10, "TSE 2022: brancos + nulos caíram de 7,0 mi no 1º turno para 5,7 mi no 2º"),
    ("Brancos e nulos que faltam no 2º turno", "a_bn", "Beta", 0.08, 0.03, "prior explícito"),
    ("Aceitação do convite para montar a própria lista", "p_ac", "Beta", 0.25, 0.10, "organização relacional: adesão de 1 em 4 convidados"),
    ("Teto de voluntários (fração dos votos de Lula)", "ψ", "Beta", 0.05, 0.02, "2,7 mi no centro, ordem de grandeza dos filiados do PT"),
    ("Conversão por contato: ausentes", "q_aus", "Beta", 0.12, 0.05, "Gerber & Green (porta a porta: 1 voto por ~14 contatos); Nickerson & Rogers (plano de voto: +4 p.p.); organização relacional: +5 a +10 p.p."),
    ("Conversão por contato: 3ª via e brancos/nulos", "q_tv", "Beta", 0.05, 0.03, "Kalla & Broockman 2018: persuasão ≈ 0 em eleição geral, não nula entre indecisos; só quem não tem 2ª opção é permeável"),
    ("Conversão por contato: eleitor brando de Flávio", "q_fb", "Beta", 0.02, 0.015, "idem, com o voto já dado ao adversário"),
    ("Eleitores brandos (fração do voto de cada candidato)", "f_br", "Beta", 0.12, 0.04, "guia: 600 mil \"pouco convictos\" é o piso"),
    ("Reversão diária de convertido para alcançado", "δ_rev", "Beta", 0.03, 0.01, "plano de voto que não se cumpre"),
    ("Parcela de Lula entre ausentes trazidos nominalmente", "ν", "Beta", 0.80, 0.07, "trabalho nominal: só a lista do voluntário"),
    ("Chamado aberto: queda dos ausentes nas UFs de Lula (× intensidade)", "ω", "Beta", 0.015, 0.01, "Ipea 2023: passe livre no 2º turno de 2022 sem efeito mensurável; teto baixo"),
    ("Rede de Flávio ÷ rede do PT (resposta simétrica)", "ρ", "Log-normal", 0.6, 0.4, "mesmos priors de eficácia, menos capilaridade"),
    ("Voluntários iniciais (dispersão em torno do cenário)", "V_0", "Log-normal", None, 0.5, "desconhecido: a escada de cenários vai de 0 a 2 mi"),
]


def priors():
    return [{"nome": n, "simbolo": s, "dist": d, "media": m, "sd": sd, "fonte": f} for n, s, d, m, sd, f in PRIORS]


def _p(simbolo):
    for n, s, d, m, sd, f in PRIORS:
        if s == simbolo: return m, sd
    raise KeyError(simbolo)


def _beta(rng, mu, sd, size=None):
    k = mu*(1 - mu)/sd**2 - 1
    assert k > 0, (mu, sd)
    return rng.beta(mu*k, (1 - mu)*k, size)


def _dirichlet(rng, centro, kappa):
    g = rng.gamma(np.maximum(kappa*centro, 1e-6))
    return g/g.sum(axis=-1, keepdims=True)


def _t_unit(rng, n):
    return rng.standard_t(DF_T, n)*math.sqrt((DF_T - 2)/DF_T)


# ------------------------------------------------------------------ dados
def disponivel(DIR):
    return all(os.path.exists(DIR + a) for a in ("resultado_1turno_uf.csv", "referencia_uf.csv", "abstencao_2022_uf.csv"))


def carregar(DIR):
    r = pd.read_csv(DIR + "resultado_1turno_uf.csv")
    br = r[r.uf == "BR"].iloc[0]
    r = r[r.uf != "BR"].reset_index(drop=True)
    ref = pd.read_csv(DIR + "referencia_uf.csv")[["uf", "nome", "regiao"]]
    ab = pd.read_csv(DIR + "abstencao_2022_uf.csv")[["uf", "abst_1t_2022", "abst_2t_2022"]]
    r = r.merge(ref, on="uf", how="left").merge(ab, on="uf", how="left")
    r["regiao"] = r.regiao.fillna("EXT"); r["nome"] = r.nome.fillna("Exterior")
    n = np.column_stack([r.lula, r.flavio, r.cury, r.renan, r.caiado, r.zema + r.outros, r.brancos + r.nulos, r.abstencao]).astype(float)
    return {"tab": r, "n": n, "br": br, "ufs": list(r.uf), "regiao": list(r.regiao), "nome": list(r.nome),
            "eleitorado": n.sum(1), "eleitorado_tse": r.eleitorado.values.astype(float), "comparecimento": r.comparecimento.values.astype(float),   # Σ das caixas (a tabela do TSE difere do eleitorado em poucas centenas em algumas UFs)
            "pad22": np.clip(0.5*(r.abst_2t_2022 - r.abst_1t_2022).fillna(0.0).values, -3, 3),
            "zema": r.zema.values.astype(float), "outros": r.outros.values.astype(float), "ext": np.array([u == "EXT" for u in r.uf])}


def _transf_uf(D, transf):
    """(pF, pL) por origem; Zema e outros fundidos com peso pelos votos de cada UF. Retorna dict origem → (pF (U,), pL (U,))."""
    U = len(D["ufs"]); out = {}
    for o in ("cury", "renan", "caiado"):
        pf, pl = transf[o]; out[o] = (np.full(U, pf), np.full(U, pl))
    z, x = D["zema"], D["outros"]; w = np.where(z + x > 0, z/np.maximum(z + x, 1), 0.5)
    out["zo"] = (w*transf["zema"][0] + (1 - w)*transf["outros"][0], w*transf["zema"][1] + (1 - w)*transf["outros"][1])
    for o, (pf, pl) in out.items():
        assert np.all(pf + pl <= 1.0 + 1e-9), (o, "2ª opção soma mais de 100%")
    return out


def estrutural_check(D, transf, decis=0.55, split=0.5):
    """Reprodução determinística do modelo estrutural: 2ª opção nos centros, pool (sem 2ª opção + brancos/nulos) decidido a 55% e dividido 50/50."""
    n, tr = D["n"], _transf_uf(D, transf)
    L, F = n[:, 0].sum(), n[:, 1].sum(); pool = n[:, 6].sum()
    for j, o in enumerate(TV):
        pf, pl = tr[o]; v = n[:, 2 + j]
        L += (v*pl).sum(); F += (v*pf).sum(); pool += (v*(1 - pf - pl)).sum()
    L += pool*decis*split; F += pool*decis*(1 - split)
    return 100*(L - F)/(L + F)


# ------------------------------------------------------------------ matriz de um passo
def sortear_base(rng, N, U):
    """Parâmetros nacionais por sorteio (comuns a todas as UFs e a todas as células)."""
    B = {}
    for k, s in [("eps_x", "ε_x"), ("eps_bn", "ε_bn"), ("r_out", "r_out"), ("d3", "d_3"), ("s3", "s_3"), ("d_bn", "d_bn"), ("a_bn", "a_bn"),
                 ("p_ac", "p_ac"), ("psi", "ψ"), ("q_aus", "q_aus"), ("q_tv", "q_tv"), ("q_fb", "q_fb"), ("f_br", "f_br"), ("d_rev", "δ_rev"),
                 ("nu", "ν"), ("omega", "ω")]:
        m, sd = _p(s); B[k] = _beta(rng, m, sd, N)
    for k, s in [("q_aus_F", "q_aus"), ("q_tv_F", "q_tv"), ("q_fb_F", "q_fb")]:      # eficácia da resposta de Flávio: mesmos priors, sorteio próprio
        m, sd = _p(s); B[k] = _beta(rng, m, sd, N)
    m, sd = _p("δ_nac"); B["delta_nac"] = rng.normal(m, sd, N)
    m, sd = _p("δ_u"); B["delta_u"] = rng.normal(m, sd, (N, U))
    m, sd = _p("τ"); B["tau"] = np.clip(rng.normal(m, sd, N), 0.0, None)
    m, sd = _p("ρ"); B["rho"] = np.exp(math.log(m) + sd*rng.standard_normal(N))
    B["z_v0"] = rng.standard_normal(N)
    return B


def matriz(D, B, transf, rng, dirichlet=True):
    """Centros m (N, U, 8, 4) e, se dirichlet, linhas Dirichlet(κ·m) em torno deles. Devolve (M, extras)."""
    n = D["n"]; U, N = n.shape[0], len(B["r_out"])
    L, F, BN, ABS = n[:, 0], n[:, 1], n[:, 6], n[:, 7]
    s_u = L/(L + F)
    tr = _transf_uf(D, transf)
    bc = lambda x: np.broadcast_to(np.asarray(x, float).reshape(N, 1), (N, U))
    r = bc(B["r_out"]); th = bc(np.minimum(B["tau"]/100, 0.9)); ex, eb = bc(B["eps_x"]), bc(B["eps_bn"])
    d3, s3, dbn, abn = bc(B["d3"]), bc(B["s3"]), bc(B["d_bn"]), bc(B["a_bn"])
    rL, rF = r*(1 + th), r*(1 - th)                 # quem sai é mais pró-Lula (mesma τ da entrada): sem variação líquida, efeito ≈ 0
    M = np.zeros((N, U, 8, 4))
    M[:, :, 0] = np.stack([1 - ex - eb - rL, ex, eb, rL], -1)
    M[:, :, 1] = np.stack([ex, 1 - ex - eb - rF, eb, rF], -1)
    perm = {}                                        # massas permeáveis (centros) por origem e destino, para tetos e fluxos da campanha
    for j, o in enumerate(TV):
        pf, pl = tr[o]; res = np.clip(1 - pf - pl, 0, 1)[None, :]
        M[:, :, 2 + j] = np.stack([(pl[None, :] + res*d3*s3)*(1 - r), (pf[None, :] + res*d3*(1 - s3))*(1 - r), res*(1 - d3)*(1 - r), r], -1)
        base = n[None, :, 2 + j]*res*(1 - r)
        perm[o] = {"lula": base*d3*s3, "flavio": base*d3*(1 - s3), "bn": base*(1 - d3)}
    M[:, :, 6] = np.stack([dbn*s3*(1 - abn), dbn*(1 - s3)*(1 - abn), (1 - dbn)*(1 - abn), abn], -1)
    base = n[None, :, 6]*(1 - abn)
    perm["bn"] = {"lula": base*dbn*s3, "flavio": base*dbn*(1 - s3), "bn": base*(1 - dbn)}
    # abstenção: meta líquida por UF (padrão 2022 encolhido + nacional + UF) e taxa bruta de entrada derivada
    dA = D["pad22"][None, :] + bc(B["delta_nac"]) + B["delta_u"]
    r_in = np.clip((r*D["comparecimento"][None, :] - dA*D["eleitorado"][None, :]/100)/ABS[None, :], 0.02, 0.6)
    pi = np.clip(s_u[None, :] + bc(B["tau"])/200, 0.0, 1.0)
    M[:, :, 7] = np.stack([r_in*0.95*pi, r_in*0.95*(1 - pi), r_in*0.05, 1 - r_in], -1)
    assert np.allclose(M.sum(-1), 1.0, atol=1e-9)
    if dirichlet:
        for j, o in enumerate(ORIG):
            M[:, :, j] = _dirichlet(rng, M[:, :, j], KAPPA[o])
    return M, {"s_u": s_u, "pi": pi, "perm": perm, "r_in": r_in, "dA": dA}


# ------------------------------------------------------------------ cadeia de Markov da campanha
def _calendario(inicio):
    """Vetores diários: multiplicador de conversão normalizado (Σ = 1 nos dias ativos), hazards de confirmação e encerramento, dias de cada geração."""
    T = len(DIAS); mult = np.zeros(T); conf = np.zeros(T); enc = np.zeros(T); ger = {}
    for nome, a, b, m, c, e, g in FASES:
        idx = [i for i, d in enumerate(DIAS) if pd.Timestamp(a) <= d <= pd.Timestamp(b) and d >= inicio]
        for i in idx: mult[i], conf[i], enc[i] = m, c, e
        if g is not None: ger[g] = idx
    ativos = [i for i, d in enumerate(DIAS) if d >= inicio]
    for g in range(4):                                   # geração sem dias ativos (início tardio): entra no primeiro dia ativo
        if not ger.get(g): ger[g] = ativos[:1]
    w = mult/mult.sum() if mult.sum() > 0 else mult
    return w, conf, enc, ger


def voluntarios(V0, p_ac, teto, ger):
    """Voluntários acumulados por dia (N, T): V0 na largada, cada geração = 3·p_ac × a anterior, até 3 gerações, teto ψ·votos."""
    N, T = len(V0), len(DIAS); arr = np.zeros((N, T)); g = V0.copy()
    for k in range(4):
        if k > 0: g = g*3*p_ac
        for i in ger[k]: arr[:, i] += g/len(ger[k])
    return np.minimum(np.cumsum(arr, axis=1), teto[:, None])


def cadeia(V_t, P, q, d_rev, w, conf, enc):
    """Fracções da cadeia por dia: (T, N, B, 5) com estados S0 não alcançado · S1 alcançado · S2 convertido · S3 confirmado · S4 recusou.
    V_t (N, T) voluntários acumulados = nomes por bolsão; P (N, B) tamanho dos bolsões; q (N, B) conversão por contato."""
    N, T = V_t.shape; Bn = P.shape[1]
    x = np.zeros((N, Bn, 5)); x[:, :, 0] = 1.0
    traj = np.zeros((T, N, Bn, 5)); D_prev = np.zeros((N, Bn)); Pp = np.maximum(P, 1.0)
    for t in range(T):
        Dt = Pp*(1 - np.exp(-V_t[:, t][:, None]/Pp))         # nomes distintos alcançados até t (sorteio com reposição)
        novo = np.clip((Dt - D_prev)/Pp, 0, None); D_prev = Dt
        f12 = x[:, :, 1]*q*w[t]; f21 = x[:, :, 2]*d_rev[:, None]; f23 = x[:, :, 2]*conf[t]; f14 = x[:, :, 1]*enc[t]
        x[:, :, 0] -= novo; x[:, :, 1] += novo - f12 + f21 - f14; x[:, :, 2] += f12 - f21 - f23; x[:, :, 3] += f23; x[:, :, 4] += f14
        traj[t] = x
    return traj


# ------------------------------------------------------------------ simulação
def _q(a, ps):
    return [round(float(v), 3) for v in np.percentile(a, ps)]


def simular(DIR, transf, hoje, sd_deriva, comparacao=None, regiao_uf=None, transf_fonte="", seed=20261025, n_sorteios=20_000, inicio_cadeia=None):
    t0 = time.time()
    rng = np.random.default_rng(seed)
    D = carregar(DIR); n = D["n"]; U = n.shape[0]; N = n_sorteios
    hoje = pd.Timestamp(hoje); inicio = max(INICIO, pd.Timestamp(inicio_cadeia)) if inicio_cadeia is not None else INICIO
    if regiao_uf:
        D["regiao"] = [regiao_uf.get(u, r) for u, r in zip(D["ufs"], D["regiao"])]
    B = sortear_base(rng, N, U)
    M, X = matriz(D, B, transf, rng)
    T_base = n[None, :, :, None]*M                       # (N, U, 8, 4): massa de cada origem em cada destino
    elei = D["eleitorado"]; ext = D["ext"]; dom = ~ext
    L1, F1 = n[:, 0], n[:, 1]
    w_L = np.where(dom, L1, 0.0); w_L = w_L/w_L.sum(); w_F = np.where(dom, F1, 0.0); w_F = w_F/w_F.sum()
    lula_lidera = (X["s_u"] > 0.5) & dom
    s3, dbn = B["s3"], B["d_bn"]
    # bolsões nacionais (sem o exterior)
    P_aus = float(n[dom, 7].sum())
    P_tv = float(sum(sum(v[:, dom].sum(1) for v in X["perm"][o].values()).mean() for o in TV + ["bn"]))   # resíduo "sem 2ª opção" + brancos/nulos (médio)
    _tru = _transf_uf(D, transf)
    sem_2a = float(sum((n[:, 2 + j]*np.clip(1 - _tru[o][0] - _tru[o][1], 0, 1)).sum() for j, o in enumerate(TV)))   # 3ª via sem 2ª opção declarada
    P_fb = B["f_br"]*float(F1[dom].sum()); P_lb = B["f_br"]*float(L1[dom].sum())
    w, conf, enc, ger = _calendario(inicio)
    deriva = sd_deriva*_t_unit(rng, N)                   # a mesma deriva (t₅) em todas as células, para comparar com o modelo principal
    bins = BINS
    PS = [5, 10, 25, 50, 75, 90, 95]

    def frentes_lula(T, V_t, qs, I_cen, saldo):
        """Aplica a campanha do PT sobre T (in place). Devolve trajetória da cadeia e nomes alcançados por bolsão."""
        P = np.column_stack([np.full(N, P_aus), np.full(N, P_tv), P_fb])
        tr = cadeia(V_t, P, np.column_stack(qs), B["d_rev"], w, conf, enc)
        fim = tr[-1]
        conv = np.column_stack([P[:, b]*(REALIZA[BOLSOES[b]][0]*fim[:, b, 3] + REALIZA[BOLSOES[b]][1]*fim[:, b, 2]) for b in range(3)])   # votos realizados
        # ausentes trazidos nominalmente: ABS→L (ν) e ABS→F (1−ν), ∝ voto de Lula, teto na massa que ficaria em casa
        mov = np.minimum(conv[:, 0][:, None]*w_L[None, :], 0.9*T[:, :, 7, 3])
        T[:, :, 7, 3] -= mov; T[:, :, 7, 0] += B["nu"][:, None]*mov; T[:, :, 7, 1] += (1 - B["nu"])[:, None]*mov
        saldo["ausentes"] += ((2*B["nu"] - 1)[:, None]*mov).sum(1)
        # terceira via + brancos/nulos: sai dos destinos Flávio e branco/nulo das linhas permeáveis, ∝ massa permeável, → Lula
        permF = sum(X["perm"][o]["flavio"] for o in TV + ["bn"]); permB = sum(X["perm"][o]["bn"] for o in TV + ["bn"])
        avail = permF + permB
        mov = np.minimum(conv[:, 1][:, None]*w_L[None, :], 0.9*avail)
        for o in TV + ["bn"]:
            j = ORIG.index(o)
            mF = np.minimum(mov*X["perm"][o]["flavio"]/np.maximum(avail, 1), 0.9*T[:, :, j, 1]); mB = np.minimum(mov*X["perm"][o]["bn"]/np.maximum(avail, 1), 0.9*T[:, :, j, 2])
            T[:, :, j, 1] -= mF; T[:, :, j, 2] -= mB; T[:, :, j, 0] += mF + mB
            saldo["terceira_via" if o != "bn" else "brancos_nulos"] += (2*mF + mB).sum(1)
        # eleitor brando de Flávio: F→L, teto f_br da massa que ficaria com Flávio
        mov = np.minimum(conv[:, 2][:, None]*w_L[None, :], B["f_br"][:, None]*T[:, :, 1, 1])
        T[:, :, 1, 1] -= mov; T[:, :, 1, 0] += mov; saldo["eleitores_flavio"] += (2*mov).sum(1)
        # chamado aberto: nas UFs em que Lula liderou, ω·I_cen dos ausentes votam (composição π_u); nas de Flávio, 0,3× (sai pela culatra)
        inten = np.where(lula_lidera, 1.0, 0.3)[None, :]*np.where(dom, 1.0, 0.0)[None, :]*(B["omega"]*I_cen)[:, None]
        e = inten*T[:, :, 7, 3]
        T[:, :, 7, 3] -= e; T[:, :, 7, 0] += e*X["pi"]; T[:, :, 7, 1] += e*(1 - X["pi"])
        saldo["chamado_aberto"] += (e*(2*X["pi"] - 1)).sum(1)
        nomes = np.column_stack([P[:, b]*(1 - np.exp(-V_t[:, -1]/np.maximum(P[:, b], 1))) for b in range(3)])
        return tr, nomes, conv

    def frentes_flavio(T, V_t, qs, I_cen, saldo):
        """Resposta simétrica de Flávio: mesmos bolsões espelhados, ∝ voto de Flávio, com os mesmos priors de eficácia."""
        P = np.column_stack([np.full(N, P_aus), np.full(N, P_tv), P_lb])
        tr = cadeia(V_t, P, np.column_stack(qs), B["d_rev"], w, conf, enc)
        fim = tr[-1]
        conv = np.column_stack([P[:, b]*(REALIZA[BOLSOES[b]][0]*fim[:, b, 3] + REALIZA[BOLSOES[b]][1]*fim[:, b, 2]) for b in range(3)])
        s = np.zeros(N)
        mov = np.minimum(conv[:, 0][:, None]*w_F[None, :], 0.9*T[:, :, 7, 3])
        T[:, :, 7, 3] -= mov; T[:, :, 7, 1] += B["nu"][:, None]*mov; T[:, :, 7, 0] += (1 - B["nu"])[:, None]*mov
        s -= ((2*B["nu"] - 1)[:, None]*mov).sum(1)
        permL = sum(X["perm"][o]["lula"] for o in TV + ["bn"]); permB = sum(X["perm"][o]["bn"] for o in TV + ["bn"]); avail = permL + permB
        mov = np.minimum(conv[:, 1][:, None]*w_F[None, :], 0.9*avail)
        for o in TV + ["bn"]:
            j = ORIG.index(o)
            mL = np.minimum(mov*X["perm"][o]["lula"]/np.maximum(avail, 1), 0.9*T[:, :, j, 0]); mB = np.minimum(mov*X["perm"][o]["bn"]/np.maximum(avail, 1), 0.9*T[:, :, j, 2])
            T[:, :, j, 0] -= mL; T[:, :, j, 2] -= mB; T[:, :, j, 1] += mL + mB; s -= (2*mL + mB).sum(1)
        mov = np.minimum(conv[:, 2][:, None]*w_F[None, :], B["f_br"][:, None]*T[:, :, 0, 0])
        T[:, :, 0, 0] -= mov; T[:, :, 0, 1] += mov; s -= (2*mov).sum(1)
        inten = np.where(lula_lidera, 0.3, 1.0)[None, :]*np.where(dom, 1.0, 0.0)[None, :]*(B["omega"]*I_cen)[:, None]
        e = inten*T[:, :, 7, 3]
        T[:, :, 7, 3] -= e; T[:, :, 7, 0] += e*X["pi"]; T[:, :, 7, 1] += e*(1 - X["pi"])
        s += (e*(2*X["pi"] - 1)).sum(1)
        saldo["resposta_flavio"] += s
        return tr

    def agrega(T, cid, nome, rid, rnome, saldo, tr=None, nomes=None, conv=None, V_t=None, V_F=None):
        dest = T.sum(axis=2)                              # (N, U, 4)
        Lv, Fv, BNv, ABSv = (dest[:, :, k].sum(1) for k in range(4))
        marg = 100*(Lv - Fv)/(Lv + Fv)
        md = marg + deriva
        c = {"id": cid, "nome": nome, "resposta": rid, "resposta_nome": rnome,
             "margem_q": {str(p): round(float(v), 2) for p, v in zip(PS, np.percentile(marg, PS))},
             "p_lula_so_migracao": round(float((marg > 0).mean()), 3), "p_lula_com_deriva": round(float((md > 0).mean()), 3),
             "margem_com_deriva_q10_50_90": _q(md, [10, 50, 90]),
             "abstencao_q10_50_90": _q(100*ABSv/elei.sum(), [10, 50, 90]), "bn_pct_q10_50_90": _q(100*BNv/(Lv + Fv + BNv), [10, 50, 90]),
             "lula_mi_q50": round(float(np.median(Lv))/1e6, 3), "flavio_mi_q50": round(float(np.median(Fv))/1e6, 3),
             "hist_margem": [round(float(v), 4) for v in np.histogram(np.clip(marg, bins[0] + 1e-6, bins[-1] - 1e-6), bins=bins, density=True)[0]]}
        c["saldo_frentes"] = {k: {"q10": round(float(np.percentile(v, 10))), "q50": round(float(np.median(v))), "q90": round(float(np.percentile(v, 90)))} for k, v in saldo.items()}
        tot = sum(saldo.values())
        c["saldo_frentes"]["total"] = {"q10": round(float(np.percentile(tot, 10))), "q50": round(float(np.median(tot))), "q90": round(float(np.percentile(tot, 90)))}
        if nomes is not None:
            for b, k in enumerate(BOLSOES):
                c["saldo_frentes"][{"ausentes": "ausentes", "terceira_via": "terceira_via", "flavio_brando": "eleitores_flavio"}[k]].update(
                    {"contatos_q50": round(float(np.median(nomes[:, b]))), "convertidos_q50": round(float(np.median(conv[:, b])))})
        if V_t is not None:
            c["voluntarios_q10_50_90"] = [round(float(v)) for v in np.percentile(V_t[:, -1], [10, 50, 90])]
            c["nomes_distintos_q50"] = round(float(np.median(nomes.sum(1)))) if nomes is not None else 0
            c["trajetoria"] = {"dias": [str(d.date()) for d in DIAS],
                               "voluntarios": [[round(float(v)) for v in np.percentile(V_t[:, t], [10, 50, 90])] for t in range(len(DIAS))]}
            if V_F is not None: c["trajetoria"]["voluntarios_flavio"] = [[round(float(v)) for v in np.percentile(V_F[:, t], [10, 50, 90])] for t in range(len(DIAS))]
            if tr is not None:
                P = np.column_stack([np.full(N, P_aus), np.full(N, P_tv), P_fb])
                for b, k in enumerate(BOLSOES):
                    est = (tr[:, :, b, 2] + tr[:, :, b, 3])*P[None, :, b]      # (T, N) estoque convertido (plano de voto ou confirmado)
                    c["trajetoria"][k] = [[round(float(v)) for v in np.percentile(est[t], [10, 50, 90])] for t in range(len(DIAS))]
                c["estados_finais_q50"] = {k: [round(float(np.median(tr[-1, :, b, s])), 4) for s in range(5)] for b, k in enumerate(BOLSOES)}
        # por região e por UF
        dL, dF, dAbs = dest[:, :, 0], dest[:, :, 1], dest[:, :, 3]
        sal = (dL - dF) - (L1 - F1)[None, :]
        novos = T[:, :, 7, :3].sum(-1)
        regs = sorted(set(D["regiao"]), key=lambda r_: ["NE", "SE", "S", "N", "CO", "EXT"].index(r_) if r_ in ["NE", "SE", "S", "N", "CO", "EXT"] else 9)
        c["por_regiao"] = []
        for r_ in regs:
            m_ = np.array([x == r_ for x in D["regiao"]])
            c["por_regiao"].append({"regiao": r_, "nome": REGIAO_NOME.get(r_, r_), "ausentes_1t": int(n[m_, 7].sum()), "eleitorado": int(elei[m_].sum()),
                                    "lula_1t": int(L1[m_].sum()), "flavio_1t": int(F1[m_].sum()),
                                    "novos_eleitores_q50": round(float(np.median(novos[:, m_].sum(1)))),
                                    "saldo_q10": round(float(np.percentile(sal[:, m_].sum(1), 10))), "saldo_q50": round(float(np.median(sal[:, m_].sum(1)))),
                                    "saldo_q90": round(float(np.percentile(sal[:, m_].sum(1), 90))),
                                    "abst_1t": round(100*float(n[m_, 7].sum()/elei[m_].sum()), 2), "abst_2t_q50": round(float(np.median(100*dAbs[:, m_].sum(1)/elei[m_].sum())), 2),
                                    "margem_1t": round(100*float((L1[m_].sum() - F1[m_].sum())/(L1[m_].sum() + F1[m_].sum())), 2),
                                    "margem_2t_q50": round(float(np.median(100*(dL[:, m_].sum(1) - dF[:, m_].sum(1))/(dL[:, m_].sum(1) + dF[:, m_].sum(1)))), 2)})
        c["por_uf"] = [{"uf": u, "nome": D["nome"][i], "regiao": D["regiao"][i], "saldo_q50": round(float(np.median(sal[:, i]))),
                        "margem_1t": round(100*float((L1[i] - F1[i])/(L1[i] + F1[i])), 2),
                        "margem_2t_q50": round(float(np.median(100*(dL[:, i] - dF[:, i])/(dL[:, i] + dF[:, i]))), 2),
                        "abst_1t": round(100*float(n[i, 7]/elei[i]), 2), "abst_2t_q50": round(float(np.median(100*dAbs[:, i]/elei[i])), 2)}
                       for i, u in enumerate(D["ufs"])]
        return c, marg, dest

    def matriz_mediana(T):
        Mn = T.sum(axis=1)/n.sum(0)[None, :, None]           # (N, 8, 4) matriz nacional agregada por sorteio
        return {o: {d: [round(100*float(v), 2) for v in np.percentile(Mn[:, i, j], [10, 50, 90])] for j, d in enumerate(DEST)} for i, o in enumerate(ORIG)}

    cenarios, margens, checks = [], {}, {}
    mats = {}
    V_sem = None
    for rid, rnome in RESPOSTAS:
        for cid, cnome, V0_med, I_cen in CENARIOS:
            T = T_base.copy()
            saldo = {k: np.zeros(N) for k in ["ausentes", "terceira_via", "brancos_nulos", "eleitores_flavio", "chamado_aberto", "resposta_flavio"]}
            V0 = V0_med*np.exp(0.5*B["z_v0"])
            V_t = voluntarios(V0, B["p_ac"], B["psi"]*L1[dom].sum(), ger)
            tr, nomes, conv = frentes_lula(T, V_t, [B["q_aus"], B["q_tv"], B["q_fb"]], I_cen, saldo)
            V_F = None
            if rid == "simetrica" and V0_med > 0:
                V_F = voluntarios(B["rho"]*V0, B["p_ac"], B["psi"]*F1[dom].sum(), ger)
                frentes_flavio(T, V_F, [B["q_aus_F"], B["q_tv_F"], B["q_fb_F"]], I_cen, saldo)
            c, marg, dest = agrega(T, cid, cnome, rid, rnome, saldo, tr, nomes, conv, V_t, V_F)
            assert np.allclose(dest.sum(-1), elei[None, :], rtol=1e-9), "massa não conservada"
            cenarios.append(c); margens[(cid, rid)] = marg
            if rid == "nenhuma" and cid in ("sem", "forte"):
                mats[cid] = matriz_mediana(T)
            if cid == "forte" and rid == "nenhuma":
                nomes_forte = nomes; tr_forte = tr
            if cid == "sem" and rid == "nenhuma":
                dest_sem = dest
            del T
    # referência: metas do guia cumpridas (saldo imposto sobre o cenário sem campanha; o que precisaria acontecer)
    T = T_base.copy(); saldo = {k: np.zeros(N) for k in ["ausentes", "terceira_via", "brancos_nulos", "eleitores_flavio", "chamado_aberto", "resposta_flavio"]}
    def _move(T, j_from, d_from, d_to, total, pesos, cap_frac=0.95):
        mov = np.minimum(total*pesos[None, :], cap_frac*T[:, :, j_from, d_from]); T[:, :, j_from, d_from] -= mov; T[:, :, j_from, d_to] += mov; return mov.sum(1)
    mF = sum(_move(T, ORIG.index(o), 1, 0, GUIA_METAS["terceira_via"]/2*X["perm"][o]["flavio"].mean(0).sum()/sum(X["perm"][o_]["flavio"].mean(0).sum() for o_ in TV), w_L) for o in TV)
    saldo["terceira_via"] += 2*mF
    saldo["brancos_nulos"] += _move(T, 6, 2, 0, GUIA_METAS["brancos_nulos"], w_L)
    saldo["eleitores_flavio"] += 2*_move(T, 1, 1, 0, GUIA_METAS["eleitores_flavio"]/2, w_L)
    a_l = _move(T, 7, 3, 0, 1.0e6, w_L); a_f = _move(T, 7, 3, 1, 0.5e6, w_L); saldo["ausentes"] += a_l - a_f
    c_guia, marg_guia, _ = agrega(T, "guia", "Metas do guia cumpridas (referência)", "nenhuma", "Nenhuma", saldo)
    del T
    med_nomes = np.median(nomes_forte, axis=0)      # alcance mediano do cenário forte, por bolsão
    lit = {"ausentes": _p("q_aus")[0], "terceira_via": _p("q_tv")[0], "brancos_nulos": _p("q_tv")[0], "eleitores_flavio": _p("q_fb")[0]}
    nec = {"ausentes": GUIA_METAS["ausentes"]/(2*_p("ν")[0] - 1)/REALIZA["ausentes"][0]/med_nomes[0],
           "terceira_via": GUIA_METAS["terceira_via"]/2/REALIZA["terceira_via"][0]/med_nomes[1],
           "brancos_nulos": GUIA_METAS["brancos_nulos"]/REALIZA["terceira_via"][0]/med_nomes[1],
           "eleitores_flavio": GUIA_METAS["eleitores_flavio"]/2/REALIZA["flavio_brando"][0]/med_nomes[2]}
    guia = {"metas": {k: int(v) for k, v in GUIA_METAS.items()}, "metas_total": int(sum(GUIA_METAS.values())),
            "alcance_forte_q50": {"ausentes": int(med_nomes[0]), "terceira_via": int(med_nomes[1]), "eleitores_flavio": int(med_nomes[2])},
            "taxa_necessaria": {k: {"literatura": round(lit[k], 3), "necessaria": round(float(nec[k]), 3), "razao": round(float(nec[k]/lit[k]), 1)} for k in nec},
            "margem_se_cumprido": c_guia["margem_q"]["50"], "p_lula_se_cumprido": c_guia["p_lula_so_migracao"],
            "p_lula_se_cumprido_com_deriva": c_guia["p_lula_com_deriva"]}
    # ---- verificações
    br = D["br"]
    checks["br_ok"] = bool(all(abs(float(br[c]) - float(D["tab"][c].sum())) < 1 for c in ["eleitorado", "comparecimento", "abstencao", "votos_validos", "brancos", "nulos", "lula", "flavio", "cury", "renan", "caiado", "zema", "outros"]))
    checks["massa_ok"] = bool(np.allclose(dest_sem.sum(-1), elei[None, :], rtol=1e-9))
    checks["linhas_ok"] = bool(np.allclose(M.sum(-1), 1.0, atol=1e-9))
    fr = tr_forte
    checks["cadeia_ok"] = bool(np.all(fr >= -1e-12) and np.allclose(fr.sum(-1), 1.0, atol=1e-9)
                               and np.all((fr[-1, :, :, 2] + fr[-1, :, :, 3])*1.0 <= (1 - fr[-1, :, :, 0]) + 1e-9))
    esp = (comparacao or {}).get("estrutural_base_margem")
    rec = estrutural_check(D, transf)
    checks["estrutural_ok"] = bool(esp is None or abs(rec - esp) <= 0.3)
    ordem = [margens[(c, "nenhuma")] for c, *_ in CENARIOS]
    med = [float(np.median(m)) for m in ordem]; pl = [float((m > 0).mean()) for m in ordem]
    checks["monotonico_ok"] = bool(all(med[i] <= med[i + 1] + 1e-9 for i in range(3)) and all(pl[i] <= pl[i + 1] + 1e-9 for i in range(3))
                                   and all(float(np.median(margens[(c, "simetrica")])) <= float(np.median(margens[(c, "nenhuma")])) + 1e-9 for c, *_ in CENARIOS))
    seg = round(time.time() - t0, 1)
    checks["tempo_ok"] = bool(seg < 60)
    return {"versao": 1, "usado": True, "seed": seed, "n_sorteios": N, "segundos": seg, "hoje": str(hoje.date()),
            "inicio_cadeia": str(inicio.date()), "dias": [str(d.date()) for d in DIAS],
            "fases": [{"nome": a, "inicio": b, "fim": c_, "mult_conversao": m, "confirmacao_dia": cf, "encerramento_dia": e} for a, b, c_, m, cf, e, g in FASES],
            "transf_fonte": transf_fonte, "transf": {k: [round(v[0], 3), round(v[1], 3)] for k, v in transf.items()},
            "estoques_1t": {"lula": int(n[:, 0].sum()), "flavio": int(n[:, 1].sum()), "terceira_via": int(n[:, 2:6].sum()), "sem_2a_opcao": int(round(sem_2a)),
                            "brancos_nulos": int(n[:, 6].sum()), "abstencao": int(n[:, 7].sum()), "eleitorado": int(elei.sum()), "comparecimento": int(D["comparecimento"].sum()),
                            "margem_1t_votos": int(n[:, 0].sum() - n[:, 1].sum()), "margem_1t": round(100*float((n[:, 0].sum() - n[:, 1].sum())/(n[:, 0].sum() + n[:, 1].sum())), 2),
                            "por_regiao": [{"regiao": r_, "nome": REGIAO_NOME.get(r_, r_), "ausentes": int(n[[x == r_ for x in D["regiao"]], 7].sum()),
                                            "terceira_via": int(n[[x == r_ for x in D["regiao"]], 2:6].sum()), "brancos_nulos": int(n[[x == r_ for x in D["regiao"]], 6].sum())}
                                           for r_ in ["NE", "SE", "S", "N", "CO", "EXT"] if r_ in D["regiao"]]},
            "bolsoes": {"ausentes": int(P_aus), "terceira_via_bn": int(P_tv), "flavio_brando_q50": int(np.median(P_fb))},
            "priors": priors(), "cenarios": cenarios + [c_guia], "guia": guia, "matriz_mediana": mats, "hist_bins": [float(b) for b in BINS],
            "comparacao": {**(comparacao or {}), "sd_deriva": round(float(sd_deriva), 2), "margem_sem_campanha_q50": cenarios[0]["margem_q"]["50"],
                           "p_lula_sem_campanha_com_deriva": cenarios[0]["p_lula_com_deriva"],
                           "check_estrutural": {"recalculado": round(float(rec), 2), "esperado": esp, "dif": (None if esp is None else round(float(rec - esp), 2))}},
            "abstencao_bruta": {"r_out_q10_50_90": _q(B["r_out"], [10, 50, 90]), "r_in_q10_50_90": _q(X["r_in"].mean(1), [10, 50, 90]),
                                "saem_mi_q50": round(float(np.median(B["r_out"]*D["comparecimento"].sum()))/1e6, 2),
                                "entram_mi_q50": round(float(np.median((X["r_in"]*n[None, :, 7]).sum(1)))/1e6, 2),
                                "tau_q10_50_90": _q(B["tau"], [10, 50, 90]), "delta_nac_q10_50_90": _q(B["delta_nac"], [10, 50, 90])},
            "checks": checks}


# ------------------------------------------------------------------ HTML
def f(v, d=1, sinal=False):
    v = round(float(v), d) + 0.0
    s = f"{abs(v) if sinal else v:.{d}f}".replace(".", ",")
    if sinal: return ("+" if v > 0 else "−" if v < 0 else "") + s
    return s.replace("-", "−")
def fs(v, d=1): return f(v, d, True)
def pct(p, d=0): return f(100*p, d) + "%"
def mil(n): return f"{int(round(n)):,}".replace(",", ".")
def mi(v, d=1, sinal=False):
    """Votos em milhões (ou mil, abaixo de 1 mi)."""
    v = float(v)
    if abs(v) >= 950_000: return f(v/1e6, d, sinal) + " mi"
    return f(v/1e3, 0, sinal) + " mil"


def _cel(R, cid, rid):
    for c in R["cenarios"]:
        if c["id"] == cid and c["resposta"] == rid: return c
    raise KeyError((cid, rid))


def svg_trajetoria(R, c):
    W, Hh = 900, 300; x0, x1, y0, y1 = 70, 880, 44, 236
    dias = R["dias"]; T = len(dias)
    series = [("ausentes", "Ausentes trazidos às urnas", "var(--accent)"), ("terceira_via", "3ª via e brancos/nulos", "var(--amber)"), ("flavio_brando", "Eleitores brandos de Flávio", "var(--flavio)")]
    vmax = max(max(v[2] for v in c["trajetoria"][k]) for k, _, _ in series)*1.08 or 1
    X = lambda t: x0 + t/(T - 1)*(x1 - x0)
    Y = lambda v: y1 - v/vmax*(y1 - y0)
    s = [f'<svg viewBox="0 0 {W} {Hh}" role="img" aria-label="Trajetória diária do estoque de eleitores convertidos por bolsão, de 5 a 24 de outubro, no cenário forte">']
    for fase in R["fases"]:
        a = dias.index(fase["inicio"]); b = dias.index(fase["fim"])
        op = 0.0 if fase["mult_conversao"] == 0 and fase["confirmacao_dia"] == 0 and fase["nome"] != "Largada" else min(0.05 + 0.08*fase["mult_conversao"], 0.18)
        if fase["nome"].startswith("Pausa"): op = 0.0
        s.append(f'<rect x="{X(a) - (x1 - x0)/(T - 1)/2:.1f}" y="{y0}" width="{(b - a + 1)*(x1 - x0)/(T - 1):.1f}" height="{y1 - y0}" fill="var(--accent)" opacity="{op:.2f}"/>')
        s.append(f'<text class="axis-label" x="{(X(a) + X(b))/2:.1f}" y="{y0 - 8}" text-anchor="middle">{H.escape(fase["nome"])}</text>')
    passo = 10**math.floor(math.log10(vmax)) if vmax > 0 else 1
    if vmax/passo < 3: passo /= 2
    v = 0
    while v <= vmax:
        s.append(f'<line class="grid-line" x1="{x0}" y1="{Y(v):.1f}" x2="{x1}" y2="{Y(v):.1f}"/>')
        s.append(f'<text class="axis-label" x="{x0 - 8}" y="{Y(v) + 4:.1f}" text-anchor="end">{mi(v, 1) if v else "0"}</text>')
        v += passo
    for k, rot, cor in series:
        tr = c["trajetoria"][k]
        band = " ".join(f"{X(t):.1f},{Y(tr[t][2]):.1f}" for t in range(T)) + " " + " ".join(f"{X(t):.1f},{Y(tr[t][0]):.1f}" for t in range(T - 1, -1, -1))
        s.append(f'<polygon points="{band}" fill="{cor}" opacity="0.14"/>')
        s.append(f'<polyline class="series-line" points="{" ".join(f"{X(t):.1f},{Y(tr[t][1]):.1f}" for t in range(T))}" stroke="{cor}"/>')
        s.append(f'<text class="dot-label" x="{x1 + 4}" y="{Y(tr[-1][1]) + 4:.1f}" fill="{cor}" font-size="10">{mi(tr[-1][1])}</text>')
    if R["hoje"] in dias:
        th = dias.index(R["hoje"])
        s.append(f'<line x1="{X(th):.1f}" y1="{y0}" x2="{X(th):.1f}" y2="{y1}" stroke="var(--ink)" stroke-width="1.2" stroke-dasharray="4,3"/>')
        s.append(f'<text class="axis-label" x="{X(th) + 4:.1f}" y="{y1 - 6}" fill="var(--ink)">hoje</text>')
    for t in range(0, T, 2):
        s.append(f'<text class="axis-label" x="{X(t):.1f}" y="{y1 + 16}" text-anchor="middle">{dias[t][8:10]}/{dias[t][5:7]}</text>')
    s.append(f'<text class="axis-label" x="{(x0 + x1)/2:.0f}" y="{y1 + 34}" text-anchor="middle">eleitores com plano de voto ou confirmados, por bolsão (mediana e faixa de 80%); fundo mais escuro = fase de conversa mais intensa</text>')
    lx = x0
    for k, rot, cor in series:
        s.append(f'<rect x="{lx}" y="{Hh - 12}" width="14" height="4" fill="{cor}"/><text class="dot-label" x="{lx + 18}" y="{Hh - 7}" fill="var(--ink-soft)" font-weight="400">{rot}</text>')
        lx += 230
    s.append("</svg>")
    return "\n".join(s)


def svg_margem(R):
    W, Hh = 900, 330; x0, x1, y0, y1 = 60, 880, 106, 276
    bins = np.array(R["hist_bins"])
    cells = [(c, cor) for c, cor in zip([_cel(R, cid, "nenhuma") for cid, *_ in CENARIOS] + [_cel(R, "guia", "nenhuma")], ["var(--ink-3)", "var(--accent-med)", "var(--accent)", "var(--amber)", "var(--lula)"])]
    hmax = max(max(c["hist_margem"]) for c, _ in cells) or 1
    X = lambda v: x0 + (v - bins[0])/(bins[-1] - bins[0])*(x1 - x0)
    Y = lambda d: y1 - d/hmax*(y1 - y0)
    s = [f'<svg viewBox="0 0 {W} {Hh}" role="img" aria-label="Distribuição da margem Lula − Flávio no 2º turno por cenário de campanha, só pela migração">']
    h0 = cells[0][0]["hist_margem"]
    bw = (x1 - x0)/len(h0)
    for i, d in enumerate(h0):
        s.append(f'<rect x="{X(bins[i]) + 0.5:.1f}" y="{Y(d):.1f}" width="{max(bw - 1, 0.5):.1f}" height="{y1 - Y(d):.1f}" fill="var(--ink-3)" opacity="0.28"><title>margem {f(bins[i], 2, True)} a {f(bins[i + 1], 2, True)} p.p. · sem campanha dirigida: {d*(bins[1] - bins[0])*100:.1f}% dos sorteios</title></rect>')
    for c, cor in cells[1:]:
        pts = []
        for i, d in enumerate(c["hist_margem"]):
            pts += [f"{X(bins[i]):.1f},{Y(d):.1f}", f"{X(bins[i + 1]):.1f},{Y(d):.1f}"]
        s.append(f'<polyline points="{" ".join(pts)}" fill="none" stroke="{cor}" stroke-width="2.2" stroke-linejoin="round"{" stroke-dasharray=\"5,3\"" if c["id"] == "guia" else ""}/>')
    s.append(f'<line x1="{X(0):.1f}" y1="{y0 - 6}" x2="{X(0):.1f}" y2="{y1}" stroke="var(--ink)" stroke-width="1.2"/>')
    mp = R["comparacao"].get("mediana_principal")
    if mp is not None:
        s.append(f'<line x1="{X(mp):.1f}" y1="{y0 - 6}" x2="{X(mp):.1f}" y2="{y1}" stroke="var(--flavio)" stroke-width="1.4" stroke-dasharray="4,3"/>')
        s.append(f'<text class="axis-label" x="{X(mp) + 4:.1f}" y="{y0 + 4}" fill="var(--flavio)">mediana do modelo principal {fs(mp)}</text>')
    for v in range(int(bins[0]), int(bins[-1]) + 1):
        s.append(f'<text class="axis-label" x="{X(v):.1f}" y="{y1 + 18}" text-anchor="middle">{"+" if v > 0 else ""}{v}</text>')
    s.append(f'<text class="axis-label" x="{(x0 + x1)/2:.0f}" y="{y1 + 36}" text-anchor="middle">margem Lula − Flávio em 25/out só pela migração, sem a deriva das pesquisas (p.p. de votos válidos; caudas além do eixo somadas nas pontas)</text>')
    for k, (c, cor) in enumerate(cells):
        xx, yy = x0, 14 + k*15
        if k == 0: s.append(f'<rect x="{xx}" y="{yy - 8}" width="14" height="9" fill="var(--ink-3)" opacity="0.28"/>')
        else: s.append(f'<line x1="{xx}" y1="{yy - 4}" x2="{xx + 14}" y2="{yy - 4}" stroke="{cor}" stroke-width="2.2"{" stroke-dasharray=\"5,3\"" if c["id"] == "guia" else ""}/>')
        s.append(f'<text class="dot-label" x="{xx + 20}" y="{yy}" fill="var(--ink-soft)" font-weight="400">{H.escape(c["nome"])}: mediana {fs(c["margem_q"]["50"], 2)} (80% de {fs(c["margem_q"]["10"])} a {fs(c["margem_q"]["90"])}) · Lula {pct(c["p_lula_so_migracao"], 1)} só migração · {pct(c["p_lula_com_deriva"], 1)} com deriva</text>')
    s.append("</svg>")
    return "\n".join(s)


def _q3(d, k="q50", lo="q10", hi="q90", fn=mi):
    return f'{fn(d[k], 2, True)} <span class="muted">({fn(d[lo], 1, True)} a {fn(d[hi], 1, True)})</span>'


def build(R, DIR=None):
    """Seção #migracao a partir de R["migracao"] (resultados.json). Vazio se a análise não rodou."""
    Mg = R.get("migracao") or {}
    if not Mg.get("usado"): return ""
    E, G, C = Mg["estoques_1t"], Mg["guia"], Mg["comparacao"]
    sem, mod, forte, maxi = (_cel(Mg, k, "nenhuma") for k in ("sem", "moderada", "forte", "maxima"))
    forte_s = _cel(Mg, "forte", "simetrica"); guia = _cel(Mg, "guia", "nenhuma")
    sf = forte["saldo_frentes"]
    p_prin = C.get("p_lula_principal"); p_mc = C.get("p_lula_mcmc")
    css = ("<style>"
           "#migracao .mg-tab th,#migracao .mg-tab td{white-space:nowrap}"
           "#migracao .mg-tab td.txt{white-space:normal;min-width:180px}"
           "#migracao .mg-tab tr.ref td{background:var(--surface-2)}"
           "#migracao .mg-tab tr.ref td.txt{font-weight:600}"
           "#migracao .mg-mat td.lula{color:var(--lula);font-weight:600}#migracao .mg-mat td.flavio{color:var(--flavio);font-weight:600}"
           "#migracao .nota{font-size:12.5px;color:var(--ink-soft);margin:8px 0 0;font-family:var(--sans);line-height:1.55}"
           "#migracao h3.sub{font-size:19px;margin:30px 0 6px}"
           "#migracao .estados{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0 0;font:12.5px var(--sans);color:var(--ink-soft)}"
           "#migracao .estados span{border:1px solid var(--line);border-radius:999px;padding:3px 10px;background:var(--surface)}"
           "@media(max-width:680px){#migracao .mg-tab td.txt{min-width:140px}}"
           "</style>")
    def tile(eb, val, cap):
        return f'<div class="tile"><span class="eyebrow">{eb}</span><div class="val" style="font-size:22px;line-height:1.3">{val}</div><div class="cap">{cap}</div></div>'
    sal_tot = sf["total"]
    tiles = ('<div class="tiles">'
             + tile("Saldo da campanha forte", f'{mi(sal_tot["q50"], 2, True)} <small>votos</small>',
                    f'80% entre {mi(sal_tot["q10"], 1, True)} e {mi(sal_tot["q90"], 1, True)}; 500 mil voluntários iniciais, {mi(forte["voluntarios_q10_50_90"][1])} ao fim. O guia pede +{mi(G["metas_total"], 1)}.')
             + tile("Chance de Lula: sem → forte", f'{pct(sem["p_lula_com_deriva"], 1)} → {pct(forte["p_lula_com_deriva"], 1)}',
                    f'com a deriva das pesquisas, como no modelo principal ({pct(p_prin, 1) if p_prin is not None else "—"}; MCMC {pct(p_mc, 1) if p_mc is not None else "—"}). Só pela migração, sem deriva: {pct(sem["p_lula_so_migracao"], 1)} → {pct(forte["p_lula_so_migracao"], 1)}.')
             + tile("Abstenção esperada no 2º turno", f'{f(sem["abstencao_q10_50_90"][1])}%',
                    f'80% entre {f(sem["abstencao_q10_50_90"][0])}% e {f(sem["abstencao_q10_50_90"][2])}%; foi {f(100*E["abstencao"]/E["eleitorado"])}% no 1º turno. Saem {f(Mg["abstencao_bruta"]["saem_mi_q50"])} mi, entram {f(Mg["abstencao_bruta"]["entram_mi_q50"])} mi.')
             + tile("Conversão necessária × literatura", f'{f(G["taxa_necessaria"]["terceira_via"]["razao"], 0)}× <small>na 3ª via</small>',
                    f'para cumprir a meta do guia com o alcance forte, 1 em {f(1/G["taxa_necessaria"]["terceira_via"]["necessaria"], 1) if G["taxa_necessaria"]["terceira_via"]["necessaria"] < 1 else "menos de 1"} contato teria de virar voto; a literatura dá 1 em {f(1/G["taxa_necessaria"]["terceira_via"]["literatura"], 0)}.')
             + '</div>')
    # tabela 1: cenários
    rows1 = []
    for c in Mg["cenarios"]:
        s_ = c["saldo_frentes"]; ref = c["id"] == "guia"
        vol = ("—" if ref else "0" if c["voluntarios_q10_50_90"][1] == 0 else f'{mi(c["voluntarios_q10_50_90"][1])} <span class="muted">({mi(c["voluntarios_q10_50_90"][0])}–{mi(c["voluntarios_q10_50_90"][2])})</span>')
        nomes = "—" if ref else "0" if c["nomes_distintos_q50"] == 0 else mi(c["nomes_distintos_q50"])
        rows1.append(f'<tr{" class=ref" if ref else ""}><td class="txt">{H.escape(c["nome"])}</td><td>{H.escape(c["resposta_nome"])}</td><td class="num">{vol}</td><td class="num">{nomes}</td>'
                     + "".join(f'<td class="num">{mi(s_[k]["q50"], 2, True) if s_[k]["q50"] else "0"}</td>' for k in ["ausentes", "terceira_via", "brancos_nulos", "eleitores_flavio", "chamado_aberto", "resposta_flavio"])
                     + f'<td class="num"><b>{mi(s_["total"]["q50"], 2, True)}</b> <span class="muted">({mi(s_["total"]["q10"], 1, True)} a {mi(s_["total"]["q90"], 1, True)})</span></td>'
                     + f'<td class="pnum {"lead" if c["margem_q"]["50"] > 0 else "trail"}">{fs(c["margem_q"]["50"])} <span class="muted">({fs(c["margem_q"]["10"])} a {fs(c["margem_q"]["90"])})</span></td>'
                     + f'<td class="pnum">{pct(c["p_lula_so_migracao"], 1)}</td><td class="pnum">{pct(c["p_lula_com_deriva"], 1)}</td></tr>')
    # tabela 2: frente a frente com o guia
    ROT_FRENTE = {"terceira_via": ("Terceira via", "dividir meio a meio os votos de Cury, Caiado e Renan"), "eleitores_flavio": ("Votos de Flávio", "trazer 600 mil eleitores pouco convictos"),
                  "brancos_nulos": ("Brancos e nulos", "convencer 1 milhão a votar em Lula"), "ausentes": ("Quem não foi votar", "levar às urnas 1,5 milhão de ausentes em redutos de Lula")}
    rows2 = []
    for k in ["terceira_via", "eleitores_flavio", "brancos_nulos", "ausentes"]:
        tn = G["taxa_necessaria"][k]; alc = G["alcance_forte_q50"]["terceira_via" if k == "brancos_nulos" else k]
        rows2.append(f'<tr><td class="txt"><b>{ROT_FRENTE[k][0]}</b><br><span class="muted">{ROT_FRENTE[k][1]}</span></td><td class="num">{mi(G["metas"][k], 1, True)}</td>'
                     f'<td class="num">{mi(sf[k]["q50"], 2, True)} <span class="muted">({mi(sf[k]["q10"], 2, True)} a {mi(sf[k]["q90"], 2, True)})</span></td>'
                     f'<td class="num">{mi(alc)}</td><td class="num">{pct(tn["literatura"], 0)}</td><td class="num">{pct(tn["necessaria"], 0) if tn["necessaria"] < 1 else "acima de 100%"}</td><td class="num"><b>{f(tn["razao"], 0)}×</b></td></tr>')
    rows2.append(f'<tr class="ref"><td class="txt">Soma</td><td class="num">{mi(G["metas_total"], 1, True)}</td><td class="num">{mi(sal_tot["q50"], 2, True)} <span class="muted">({mi(sal_tot["q10"], 1, True)} a {mi(sal_tot["q90"], 1, True)})</span></td><td class="num">{mi(forte["nomes_distintos_q50"])}</td><td></td><td></td><td></td></tr>')
    # tabela 3: matriz mediana
    mat_s, mat_f = Mg["matriz_mediana"]["sem"], Mg["matriz_mediana"]["forte"]
    rows3 = []
    for o in ORIG:
        rows3.append(f'<tr><td class="txt">{ROT_ORIG[o]}<br><span class="muted">{mi(E[{"lula": "lula", "flavio": "flavio", "bn": "brancos_nulos", "abs": "abstencao"}[o]] if o in ("lula", "flavio", "bn", "abs") else 0) if o in ("lula", "flavio", "bn", "abs") else ""}</span></td>'
                     + "".join(f'<td class="num {d if d in ("lula", "flavio") else ""}">{f(mat_s[o][d][1])}% <span class="muted">({f(mat_s[o][d][0])}–{f(mat_s[o][d][2])})</span></td>' for d in DEST)
                     + "".join(f'<td class="num">{fs(mat_f[o][d][1] - mat_s[o][d][1], 2)}</td>' for d in DEST) + "</tr>")
    # tabela 4: por região (cenário forte, sem resposta)
    rows4 = []
    for r_, r1 in zip(forte["por_regiao"], sem["por_regiao"]):
        rows4.append(f'<tr><td class="txt">{H.escape(r_["nome"])}</td><td class="num">{mi(r_["ausentes_1t"], 2)}</td><td class="num">{f(r_["abst_1t"])}%</td><td class="num">{f(r1["abst_2t_q50"])}%</td>'
                     f'<td class="num">{mi(r1["novos_eleitores_q50"], 2)}</td><td class="num">{mi(r1["saldo_q50"], 2, True)}</td><td class="num">{mi(r_["novos_eleitores_q50"], 2)}</td>'
                     f'<td class="num">{mi(r_["saldo_q50"], 2, True)} <span class="muted">({mi(r_["saldo_q10"], 2, True)} a {mi(r_["saldo_q90"], 2, True)})</span></td>'
                     f'<td class="pnum {"lead" if r_["margem_1t"] > 0 else "trail"}">{fs(r_["margem_1t"])}</td><td class="pnum {"lead" if r_["margem_2t_q50"] > 0 else "trail"}">{fs(r_["margem_2t_q50"])}</td></tr>')
    priors_rows = "".join(f'<tr><td class="txt">{H.escape(p["nome"])}</td><td class="num">{H.escape(p["simbolo"])}</td><td>{H.escape(p["dist"])}</td><td class="num">{"cenário" if p["media"] is None else (f(p["media"], 3) if p["media"] < 0.1 else f(p["media"], 2))}</td><td class="num">{f(p["sd"], 3) if p["sd"] < 0.1 else f(p["sd"], 2)}</td><td class="txt muted">{H.escape(p["fonte"])}</td></tr>' for p in Mg["priors"])
    ab = Mg["abstencao_bruta"]
    ch = Mg["checks"]
    dif_forte = forte["margem_q"]["50"] - sem["margem_q"]["50"]
    dif_max = maxi["margem_q"]["50"] - sem["margem_q"]["50"]
    tldr = (f'Sem campanha dirigida, a migração leva a margem para {fs(sem["margem_q"]["50"])} ponto{"s" if abs(sem["margem_q"]["50"]) >= 2 else ""} (80% entre {fs(sem["margem_q"]["10"])} e {fs(sem["margem_q"]["90"])}). '
            f'Uma campanha forte de conversa pessoal, com 500 mil voluntários iniciais e a eficácia que a literatura mede, rende {mi(sal_tot["q50"], 1, True)} votos de saldo '
            f'e move a margem em {fs(dif_forte)} ponto{"s" if abs(dif_forte) >= 2 else ""}; a máxima, {fs(dif_max)}. A virada exigiria {mi(G["metas_total"], 1, True)}: '
            f'com o alcance forte, a conversão por contato teria de ser {f(G["taxa_necessaria"]["terceira_via"]["razao"], 0)} vezes a da literatura na terceira via. '
            f'A chance de Lula, com a deriva das pesquisas, vai de {pct(sem["p_lula_com_deriva"], 1)} sem campanha a {pct(forte["p_lula_com_deriva"], 1)} com a forte. A manchete não muda: isto é análise condicional, não previsão.')
    hoje_txt = f'{Mg["hoje"][8:10]}/{Mg["hoje"][5:7]}'
    return f"""
  <section id="migracao">
    {css}
    <div class="kicker">Migração</div>
    <h2>Para onde vão os {f(E["abstencao"]/1e6, 0)} milhões que faltaram, os {f(E["terceira_via"]/1e6, 0)} milhões da terceira via e os {f(E["brancos_nulos"]/1e6, 0)} milhões de brancos e nulos</h2>
    <p class="tldr"><b>Em uma frase:</b> {tldr}</p>
    <div class="howto"><b>Como ler.</b> O resultado oficial por UF é o estado inicial: cada eleitor está numa de oito caixas (votou em Lula, em Flávio, em cada um dos candidatos da 3ª via, branco/nulo ou não votou). Uma [[markov|cadeia de Markov]] leva cada caixa aos quatro destinos do 2º turno (Lula, Flávio, branco/nulo, não vota) com uma matriz de transição sorteada: cada linha é uma [[dirichlet|Dirichlet]] em torno da 2ª opção declarada (Quaest, 3–6/set) e dos fluxos de comparecimento. Por cima, uma segunda cadeia, diária, acompanha a campanha de conversa pessoal do guia "Três nomes, três semanas" (5 a 24/out): quem é alcançado, quem faz um plano de voto, quem confirma, quem desiste. <b>"Sem campanha dirigida"</b> não é "sem campanha": a 2ª opção e os fluxos de 2022 já contêm a campanha normal dos dois lados; o que a escada mede é o acréscimo de uma mobilização organizada junto a esses três grupos. {Mg["n_sorteios"]//1000} mil sorteios por célula, os mesmos números aleatórios em todas (comparação pareada).</div>
    {tiles}

    <h3 class="sub">1. A campanha dia a dia: quantos eleitores a conversa alcança e converte</h3>
    <div class="howto"><b>Como ler.</b> Cenário forte, sem resposta de Flávio: {mi(forte["voluntarios_q10_50_90"][1])} voluntários ao fim (80% entre {mi(forte["voluntarios_q10_50_90"][0])} e {mi(forte["voluntarios_q10_50_90"][2])}), cada um com três nomes (um ausente, um da 3ª via ou branco/nulo, um indeciso). A linha é o estoque de eleitores com plano de voto ou confirmados; a faixa, 80% dos sorteios. As fases seguem o calendário do guia: largada (5–6), 1ª conversa (7–11), pausa de Aparecida (12), 2ª conversa (13–18), fechamento (19–22), confirmação (23–24). A cadeia começa em {Mg["inicio_cadeia"][8:10]}/{Mg["inicio_cadeia"][5:7]}; hoje é {hoje_txt}.</div>
    <div class="trend-card">{svg_trajetoria(Mg, forte)}</div>
    <div class="estados"><span>S0 · não alcançado</span><span>S1 · alcançado</span><span>S2 · plano de voto</span><span>S3 · confirmado</span><span>S4 · recusou ou foi deixado</span></div>
    <p class="nota">Ao fim da campanha forte, nos ausentes: {pct(forte["estados_finais_q50"]["ausentes"][1] + forte["estados_finais_q50"]["ausentes"][2] + forte["estados_finais_q50"]["ausentes"][3] + forte["estados_finais_q50"]["ausentes"][4], 1)} do bolsão de {mi(Mg["bolsoes"]["ausentes"])} alcançados, {pct(forte["estados_finais_q50"]["ausentes"][2] + forte["estados_finais_q50"]["ausentes"][3], 2)} com plano de voto ou confirmados. Os contatos são poucos diante do bolsão: o gargalo é o alcance, não a conversa.</p>

    <h3 class="sub">2. A margem do 2º turno em cada cenário</h3>
    <div class="howto"><b>Como ler.</b> Distribuição da margem Lula − Flávio só pela migração (resultado oficial + matriz de transição + campanha), sem a deriva das pesquisas: é o que o voto contado e a 2ª opção declarada permitem dizer. Barras: sem campanha dirigida. Linhas: moderada (100 mil voluntários iniciais), forte (500 mil) e máxima (2 milhões, teto de {pct(0.05)} dos votos de Lula). A linha tracejada é a mediana do modelo principal, que parte das pesquisas e do voto contado e inclui a deriva até 25/out.</div>
    <div class="trend-card">{svg_margem(Mg)}</div>
    <div class="table-card" style="margin-top:14px"><table class="mg-tab" style="min-width:1100px">
      <thead><tr><th>Cenário</th><th>Resposta de Flávio</th><th>Voluntários ao fim</th><th>Nomes alcançados</th><th>Ausentes</th><th>3ª via</th><th>Brancos/nulos</th><th>Eleitores de Flávio</th><th>Chamado aberto</th><th>Resposta de Flávio</th><th>Saldo total <span class="muted">(80%)</span></th><th>Margem <span class="muted">(80%)</span></th><th>Lula só migração</th><th>Lula com deriva</th></tr></thead>
      <tbody>{"".join(rows1)}</tbody></table></div>
    <p class="nota">Saldo em votos (Lula − Flávio), mediana dos sorteios; a soma das frentes pode diferir do total por arredondamento e pelos tetos de massa disponível por UF. "Com deriva" soma à margem da migração a mesma deriva t<sub>5</sub> do modelo principal (σ = {f(C["sd_deriva"])} p.p. até 25/out): é a chance comparável à manchete ({pct(p_prin) if p_prin is not None else "—"} principal, {pct(p_mc) if p_mc is not None else "—"} MCMC), mas sem as pesquisas. A última linha impõe as metas do guia ({mi(G["metas_total"], 1, True)}) sobre o cenário sem campanha: mostra onde a margem iria se elas fossem cumpridas, não quanto é provável cumpri-las.</p>

    <h3 class="sub">3. Frente a frente com o guia: o que precisaria acontecer</h3>
    <div class="howto"><b>Como ler.</b> O guia soma +{mi(G["metas_total"], 1)} em quatro frentes. Para cada uma, o saldo que o modelo espera no cenário forte, o nº de nomes que a campanha forte alcança nesse bolsão e a conversão por contato que seria necessária para cumprir a meta com esse alcance, comparada com a que a literatura de mobilização mede (Gerber & Green; Nickerson & Rogers; Kalla & Broockman).</div>
    <div class="table-card" style="margin-top:14px"><table class="mg-tab" style="min-width:820px">
      <thead><tr><th>Frente</th><th>Meta do guia</th><th>Modelo, campanha forte <span class="muted">(80%)</span></th><th>Nomes alcançados</th><th>Conversão na literatura</th><th>Conversão necessária</th><th>Razão</th></tr></thead>
      <tbody>{"".join(rows2)}</tbody></table></div>
    <p class="nota">Se as quatro metas fossem cumpridas, a margem mediana iria a {fs(G["margem_se_cumprido"])} ponto e a chance de Lula, só pela migração, a {pct(G["p_lula_se_cumprido"], 1)} ({pct(G["p_lula_se_cumprido_com_deriva"], 1)} com deriva). Cada eleitor da 3ª via ou de Flávio que muda vale dois votos no saldo; cada branco/nulo ou ausente trazido, um (os ausentes trazidos nominalmente votam {pct(0.8)} em Lula, por isso rendem 0,6 cada). Brancos e nulos dividem com a 3ª via o mesmo bolsão de nomes.</p>

    <h3 class="sub">4. A matriz de transição: de onde vem e para onde vai cada voto</h3>
    <div class="howto"><b>Como ler.</b> Parcela de cada origem que termina em cada destino no 2º turno (mediana e faixa de 80% dos sorteios), no cenário sem campanha dirigida; à direita, quanto a campanha forte muda cada célula, em pontos. A 2ª opção declarada é o centro das linhas da 3ª via; quem não declarou 2ª opção decide com probabilidade d<sub>3</sub> e se divide s<sub>3</sub>; os brancos e nulos decidem com d<sub>bn</sub>. Quem votou e falta (r<sub>out</sub>) é compensado por quem faltou e vota (r<sub>in</sub>), com a variação líquida da abstenção sorteada em torno do padrão de 2022 e perfil mais pró-Lula nos dois sentidos (τ).</div>
    <div class="table-card" style="margin-top:14px"><table class="mg-tab mg-mat" style="min-width:900px">
      <thead><tr><th rowspan="2">Origem (1º turno)</th><th colspan="4">Destino no 2º turno, sem campanha dirigida</th><th colspan="4">Campanha forte: diferença (p.p.)</th></tr>
      <tr><th>Lula</th><th>Flávio</th><th>Branco/nulo</th><th>Não vota</th><th>Lula</th><th>Flávio</th><th>Branco/nulo</th><th>Não vota</th></tr></thead>
      <tbody>{"".join(rows3)}</tbody></table></div>
    <p class="nota">Fluxos brutos de comparecimento: r<sub>out</sub> mediano {pct(ab["r_out_q10_50_90"][1])} dos que votaram ({f(ab["saem_mi_q50"])} mi), r<sub>in</sub> mediano {pct(ab["r_in_q10_50_90"][1])} dos ausentes ({f(ab["entram_mi_q50"])} mi); variação líquida nacional da abstenção {fs(ab["delta_nac_q10_50_90"][1])} p.p. (80% entre {fs(ab["delta_nac_q10_50_90"][0])} e {fs(ab["delta_nac_q10_50_90"][2])}); inclinação de perfil τ mediana {f(ab["tau_q10_50_90"][1], 0)} p.p. de margem. Com entrada e saída iguais e a mesma inclinação nos dois sentidos, o efeito líquido do comparecimento é próximo de zero; só o desvio em relação a 2022 importa, como na seção Abstenção.</p>

    <h3 class="sub">5. Por região: onde a campanha rende e onde sai pela culatra</h3>
    <div class="howto"><b>Como ler.</b> Ausentes do 1º turno, abstenção esperada no 2º, novos eleitores (quem não votou em 4/out e vota em 25/out) e saldo de votos (variação de Lula − Flávio em relação ao 1º turno) sem campanha e com a campanha forte. O chamado aberto a votar só entra com força nas UFs em que Lula liderou; nas de Flávio, o modelo supõe que um apelo genérico traz 30% do efeito, com a composição local, que favorece Flávio, como o guia avisa.</div>
    <div class="table-card" style="margin-top:14px"><table class="mg-tab" style="min-width:980px">
      <thead><tr><th rowspan="2">Região</th><th rowspan="2">Ausentes 1º turno</th><th rowspan="2">Abstenção 1º turno</th><th rowspan="2">Abstenção 2º turno esperada</th><th colspan="2">Sem campanha dirigida</th><th colspan="2">Campanha forte</th><th rowspan="2">Margem 1º turno</th><th rowspan="2">Margem 2º turno (forte)</th></tr>
      <tr><th>Novos eleitores</th><th>Saldo</th><th>Novos eleitores</th><th>Saldo <span class="muted">(80%)</span></th></tr></thead>
      <tbody>{"".join(rows4)}</tbody></table></div>
    <p class="nota">Saldo em votos e margem em p.p. de válidos, medianas. O exterior entra na massa e na margem, mas fica fora da campanha e da inclinação de perfil.</p>

    <div class="variables" style="margin-top:18px">
      <div class="variable"><h4>Como a cadeia funciona</h4><p>Duas cadeias encaixadas. A primeira é de um passo: cada eleitor do 1º turno, classificado por UF e pelo que fez em 4/out, vai para um dos quatro destinos do 2º turno segundo uma matriz 8 × 4 sorteada; a massa é conservada (a soma dos destinos é o eleitorado de cada UF). A segunda é diária e não homogênea: em cada bolsão (ausentes, 3ª via e brancos/nulos, eleitores brandos de Flávio), um eleitor passa de não alcançado a alcançado quando entra na lista de um voluntário; de alcançado a convertido com probabilidade proporcional à conversão por contato q, distribuída pelas fases do guia (a 2ª conversa pesa 1,4; o fechamento e a 1ª, 0,8; a pausa, 0); de convertido a confirmado no fechamento e na confirmação; e volta a alcançado com δ<sub>rev</sub> por dia. Quem confirma vota como combinado em {pct(0.85)} (ausentes) ou {pct(0.9)} dos casos; quem só tem plano de voto, em {pct(0.55)}. O estoque convertido é repartido pelas UFs em proporção ao voto de Lula e aplicado sobre os destinos da primeira cadeia, com teto na massa disponível em cada caixa.</p></div>
      <div class="variable"><h4>Resposta de Flávio e chamado aberto</h4><p>Na coluna "simétrica", Flávio monta uma rede ρ vezes a do PT (mediana {f(0.6, 1)}) com os mesmos priors de eficácia e bolsões espelhados: ausentes trazidos nominalmente ({pct(0.8)} para ele), 3ª via e brancos/nulos permeáveis, eleitores brandos de Lula. O chamado aberto a votar (rádio, feira, igreja) tira ω × intensidade dos ausentes das UFs em que cada lado liderou, com a composição de quem falta (perfil mais pró-Lula, τ); na UF do adversário, o apelo genérico rende 30% e tem a composição local. O saldo líquido da resposta aparece em coluna própria. Como as duas redes usam os mesmos priors, a diferença entre as colunas mede só a assimetria de tamanho (ρ) e de base (Lula tem mais ausentes nos seus redutos; Flávio tem mais votos a proteger).</p></div>
      <div class="variable"><h4>De onde vêm os parâmetros</h4><p>Os priors estão na tabela abaixo, com a fonte de cada um. Os que mais pesam são o número de voluntários (desconhecido: por isso a escada de cenários) e a conversão por contato, tomada da literatura de experimentos de campo: cerca de um voto a cada 14 contatos porta a porta para comparecimento, efeito quase nulo de persuasão em eleição geral e efeitos maiores quando o contato vem de alguém próximo. A 2ª opção da Quaest é de setembro; quando sair uma onda de 2º turno com a pergunta refeita, ela substitui os centros e a cadeia da campanha passa a começar no dia seguinte ao campo, para não contar duas vezes a conversa já feita.</p></div>
      <div class="variable"><h4>O que isso muda na manchete</h4><p>Nada. A chance de Lula do painel continua vindo do modelo principal ({pct(p_prin) if p_prin is not None else "—"}) e do MCMC ({pct(p_mc) if p_mc is not None else "—"}), que partem das pesquisas e do voto contado. Esta seção responde a outra pergunta: dado o resultado de 4/out, quanto uma campanha de conversa pessoal com os três grupos pode mover, e o que precisaria acontecer para a virada. A versão "com deriva" da chance existe só para pôr as duas contas na mesma régua: sem campanha dirigida ela dá {pct(sem["p_lula_com_deriva"], 1)}, perto do modelo principal, um pouco abaixo porque não carrega a incerteza das pesquisas e dos efeitos de casa; o nível estrutural dos dois é o mesmo (reprodução do modelo de transferência: {fs(C["check_estrutural"]["recalculado"])} contra {fs(C["check_estrutural"]["esperado"]) if C["check_estrutural"]["esperado"] is not None else "—"}).</p></div>
    </div>
    <div class="table-card" style="margin-top:14px"><table class="mg-tab" style="min-width:900px">
      <thead><tr><th>Parâmetro</th><th>Símbolo</th><th>Distribuição</th><th>Média</th><th>Desvio</th><th>Fonte ou justificativa</th></tr></thead>
      <tbody>{priors_rows}</tbody></table></div>
    <p class="nota">Concentrações das linhas Dirichlet: κ = 400 (Lula e Flávio), 100 (3ª via: subamostras da Quaest de 100 a 150 entrevistas, desvio de 4 a 5 p.p.), 150 (brancos/nulos e ausentes). Verificações do script: massa conservada em todos os sorteios ({"ok" if ch["massa_ok"] else "falhou"}); linhas somam 1 ({"ok" if ch["linhas_ok"] else "falhou"}); cadeia em [0, 1] e convertidos ≤ alcançados ({"ok" if ch["cadeia_ok"] else "falhou"}); reprodução do modelo estrutural ({"ok" if ch["estrutural_ok"] else "falhou"}); escada monotônica e resposta simétrica ≤ sem resposta ({"ok" if ch["monotonico_ok"] else "falhou"}); {f(Mg["segundos"], 0)} s.</p>

    <h3 class="sub">Limites desta análise</h3>
    <ul class="nota" style="padding-left:18px;font-size:13px">
      <li><b>Não é previsão.</b> É uma análise condicional: dado o voto contado e uma campanha com estas características, para onde a margem iria. Não usa as pesquisas de 2º turno, que são a base da manchete, e não substitui as rodadas pós-1º turno.</li>
      <li><b>Literatura americana.</b> As taxas de conversão vêm de experimentos de campo nos EUA, com voto facultativo; no Brasil, com voto obrigatório e 2º turno três semanas depois, o efeito de mobilização pode ser outro, e não há experimento comparável publicado.</li>
      <li><b>Número de voluntários desconhecido.</b> Não existe medida da adesão a uma campanha sem coordenação central; a escada de 0 a 2 milhões cobre as ordens de grandeza, e o teto ψ impede que a corrente de convites cresça sem limite.</li>
      <li><b>Transferência declarada.</b> Os centros das linhas da 3ª via vêm da matriz mais recente da base: a PoderData/Aya de 10–12/out, depois do 1º turno (a 2ª opção da Quaest, de setembro, vale só enquanto não há onda posterior). Zema não tem linha própria na PoderData e herda a do grupo "outro candidato". O Datafolha de 6–7/out publicou só o agregado dos quatro (Flávio 46, Lula 32), que dá a Lula 41% dos decididos, contra 45% na PoderData: reescalar a matriz para o agregado do Datafolha baixaria a chance de Lula no modelo principal de 15,9% para 14,3%.</li>
      <li><b>Sem abstenção por município ou perfil.</b> A inclinação τ é um número nacional, calibrado na abstenção por escolaridade de 2022 e no voto por escolaridade das pesquisas; a composição real de quem entra e sai varia por lugar.</li>
      <li><b>Resposta de Flávio suposta simétrica.</b> A rede dele é um múltiplo da do PT com os mesmos priors; uma campanha de outro tipo (mídia, igrejas, lideranças locais) não está modelada.</li>
      <li><b>Exterior fora da campanha.</b> Os {mi(E["eleitorado"] - sum(r["eleitorado"] for r in sem["por_regiao"] if r["regiao"] != "EXT"))} eleitores no exterior entram na massa, mas não nos bolsões nem na inclinação de perfil.</li>
    </ul>
    <p class="nota">Fontes: TSE, resultado oficial do 1º turno por UF (100% das seções); PoderData/Aya 10–12/out (transferência declarada; antes, Quaest 3–6/set); TSE 2022 via CNN Brasil (abstenção por UF nos dois turnos); TSE, perfil do comparecimento 2022 por escolaridade; Gerber & Green, <i>Get Out the Vote</i>; Nickerson & Rogers (2010), <i>Psychological Science</i>; Kalla & Broockman (2018), <i>American Political Science Review</i>; Pereira et al. (Ipea, 2023) sobre o passe livre de 2022. Código: migracao_markov.py (cálculo, autoverificações e esta seção), chamado por modelo_eleicao_2026.py (bloco 5b).</p>
  </section>
"""


def metodologia(R):
    """Passo 8 da metodologia (div.variable), no formato dos passos 1 a 7 de montar_painel.py."""
    Mg = R.get("migracao") or {}
    if not Mg.get("usado"): return ""
    sem, forte = _cel(Mg, "sem", "nenhuma"), _cel(Mg, "forte", "nenhuma")
    return f"""
      <div class="variable">
        <h4>8 · Migração por cadeia de Markov</h4>
        <div class="formula">destinos<sub>u</sub> = n<sub>u</sub> · M<sub>u</sub> · M<sub>u</sub>[o, ·] ~ Dirichlet(κ<sub>o</sub> · m<sub>o</sub>) · m<sub>3ª via</sub> = 2ª opção + resíduo decidido (d<sub>3</sub>, s<sub>3</sub>) · m<sub>ausente</sub> = (r<sub>in</sub>π<sub>u</sub>, r<sub>in</sub>(1−π<sub>u</sub>), ·, 1−r<sub>in</sub>)<br>campanha: x<sub>t+1</sub> = x<sub>t</sub> · P<sub>p,t</sub> sobre S0…S4 · alcance D<sub>p</sub> = P<sub>p</sub>(1 − e<sup>−N/P<sub>p</sub></sup>) · conversão q<sub>p</sub> por contato, fases do guia · margem = 100(ΣL − ΣF)/(ΣL + ΣF)</div>
        <p>Análise paralela (<code>migracao_markov.py</code>), condicional e sem efeito na manchete: a partir do resultado oficial por UF, uma [[markov|cadeia de Markov]] leva cada eleitor do 1º turno (Lula, Flávio, cada candidato da 3ª via, branco/nulo, ausente) a um dos quatro destinos do 2º turno por uma matriz sorteada linha a linha ([[dirichlet|Dirichlet]] em torno da 2ª opção declarada da Quaest, dos fluxos brutos de comparecimento e de uma inclinação de perfil). Uma segunda cadeia, diária e não homogênea, segue a campanha de conversa pessoal de 5 a 24/out (alcance por voluntários e recrutamento em cadeia; conversão por contato tomada dos experimentos de Gerber &amp; Green, Nickerson &amp; Rogers e Kalla &amp; Broockman; reversão; confirmação) e aplica o estoque convertido como fluxo entre destinos, com teto na massa disponível. {Mg["n_sorteios"]//1000} mil sorteios por célula, em 4 cenários de alcance × 2 respostas de Flávio, mais a referência "metas do guia cumpridas". Sem campanha dirigida a margem mediana é {fs(sem["margem_q"]["50"])}; com a forte, {fs(forte["margem_q"]["50"])}. A chance "com deriva" soma a mesma deriva t<sub>5</sub> do passo 4, só para comparar com o modelo principal.</p>
      </div>"""


# ------------------------------------------------------------------ verificação
if __name__ == "__main__":
    import sys
    DIR = os.path.dirname(os.path.abspath(__file__)) + "/"
    tr = pd.read_csv(DIR + "transferencia_2turno.csv")
    transf = {k: (float(g.pct_flavio.mean())/100, float(g.pct_lula.mean())/100) for k, g in tr.groupby("origem")}
    comp = {}
    if os.path.exists(DIR + "resultados.json"):
        Rj = json.load(open(DIR + "resultados.json", encoding="utf-8"))
        comp = {"estrutural_base_margem": Rj.get("estrutural_base_margem"), "p_lula_principal": Rj.get("p_lula"), "p_lula_mcmc": (Rj.get("mcmc") or {}).get("p_lula"),
                "mediana_principal": (Rj.get("quantis_margem_final") or {}).get("50"), "sd_deriva_principal": Rj.get("sd_deriva")}
    n_s = int(sys.argv[1]) if len(sys.argv) > 1 else 20_000
    t0 = time.time()
    A = simular(DIR, transf, "2026-10-06", comp.get("sd_deriva_principal") or 4.24, comp, seed=7, n_sorteios=n_s)
    A2 = simular(DIR, transf, "2026-10-06", comp.get("sd_deriva_principal") or 4.24, comp, seed=7, n_sorteios=n_s)
    ch = A["checks"]
    print("[checks] " + " · ".join(f"{k}={'ok' if v else 'FALHOU'}" for k, v in ch.items()))
    print(f"[estrutural] recalculado {A['comparacao']['check_estrutural']['recalculado']:+.2f} esperado {A['comparacao']['check_estrutural']['esperado']}")
    assert A["cenarios"][0]["margem_q"] == A2["cenarios"][0]["margem_q"] and A["cenarios"][2]["saldo_frentes"] == A2["cenarios"][2]["saldo_frentes"], "semente fixa deveria reproduzir"
    print("[ok] semente fixa reproduz")
    for c in A["cenarios"]:
        s = c["saldo_frentes"]
        print(f"  {c['nome']:<40} {c['resposta_nome']:<9} vol {c.get('voluntarios_q10_50_90', ['—'] * 3)[1]:>9} | saldo {s['total']['q50']/1e6:+.2f} mi "
              f"(aus {s['ausentes']['q50']/1e6:+.2f}, 3v {s['terceira_via']['q50']/1e6:+.2f}, bn {s['brancos_nulos']['q50']/1e6:+.2f}, fl {s['eleitores_flavio']['q50']/1e6:+.2f}, "
              f"ch {s['chamado_aberto']['q50']/1e6:+.2f}, resp {s['resposta_flavio']['q50']/1e6:+.2f}) | margem {c['margem_q']['50']:+.2f} [{c['margem_q']['10']:+.2f}, {c['margem_q']['90']:+.2f}] "
              f"| P(Lula) {c['p_lula_so_migracao']:.3f} / com deriva {c['p_lula_com_deriva']:.3f} | abst {c['abstencao_q10_50_90'][1]:.1f}%")
    print("[guia]", json.dumps(A["guia"]["taxa_necessaria"], ensure_ascii=False), "margem se cumprido", A["guia"]["margem_se_cumprido"])
    print(f"[tempo] {A['segundos']} s por chamada ({time.time() - t0:.1f} s as duas)")
    assert all(ch.values()), ch
modelo_eleicao_2026.py — calibration, first round and runoff, Monte Carlo, tornado, outputs (click to expand)
# -*- coding: utf-8 -*-
"""
Agregador de pesquisas + modelo probabilístico — Eleição presidencial 2026 (Brasil). Versão 3 (auditoria de 30/set/2026).

Componentes
 1. Dados: pesquisas de 2º turno (Lula × Flávio) e de 1º turno (todos os candidatos), calibração histórica 2006–2022
 2. Calibração: qualidade por instituto (MAE histórico), viés sistemático das pesquisas por ciclo (âncora), movimento entre turnos
 3. Tendência + efeitos de casa: espaço de estados único (espaco_estados.py) — passeio aleatório, efeitos de casa ridge
    identificados pela âncora (institutos com histórico verificado), variância extra por instituto estimada nos dados
 3c. Pesquisas estaduais → leitura nacional independente (swing sobre 2022)
 3d. 1º turno: um estado por candidato (votos válidos), viés histórico do 1º turno, simulação → P(decide no 1º turno), P(Flávio no 2º)
 4. Monte Carlo do 2º turno: nível + deriva em duas fases (até 4/out; entre turnos, calibrada em 2014/18/22) − viés + choques regionais
 4b. Ensemble com pesos por regime (antes/depois do 1º turno) e tabela "o que mudou" (v2 → v3)
 5. Tornado das variáveis decisivas · 6. O que o 1º turno dirá sobre o 2º · 6b. Diagnóstico por instituto · 7. Saídas
 8. Regime pós-1º turno: ativado por resultado_1turno.csv (erro por instituto → priors do 2º turno)
 5b. Migração (migracao_markov.py): ausentes, 3ª via e brancos/nulos por cadeia de Markov + Monte Carlo, análise paralela e condicional

Reprodutível: python3 modelo_eleicao_2026.py  (HOJE pode ser fixado por variável de ambiente ELEICAO_HOJE=YYYY-MM-DD)
"""
import json, math, os, sys
import numpy as np
import pandas as pd
from scipy.stats import norm, beta as beta_dist
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from espaco_estados import ajustar, validacao_rolling

rng = np.random.default_rng(20261025)
HOJE = pd.Timestamp(os.environ.get("ELEICAO_HOJE") or pd.Timestamp.today().normalize())
T1   = pd.Timestamp("2026-10-04")
T2   = pd.Timestamp("2026-10-25")
DATA = os.path.dirname(os.path.abspath(__file__)) + "/"                 # bases (CSV) e previsão congelada: no repositório
OUT  = os.environ.get("ELEICAO_OUT", "/tmp/outputs/modelo/")            # saídas intermediárias (JSON, SVGs) para montar o painel
os.makedirs(OUT, exist_ok=True)
# Variantes do toggle do herói (19/out): com ELEICAO_SO_POS1T a base de 2º turno fica só com as ondas de campo depois de 4/out.
#   ELEICAO_SO_POS1T=1      → sem o voto contado: o nível NÃO é combinado ao resultado do 1º turno + transferência (W_ESTRUT = 0);
#                             grava resultados_pos1t.json.
#   ELEICAO_SO_POS1T=estrut → com o voto contado: mesma combinação do modelo principal; grava resultados_pos1t_estrut.json.
# As duas rodadas terminam logo depois do ensemble (sem MCMC, abstenção, tornado, migração, congelados, CSVs ou SVGs).
SO_POS1T = bool(os.environ.get("ELEICAO_SO_POS1T"))
POS1T_COM_ESTRUT = os.environ.get("ELEICAO_SO_POS1T") == "estrut"
# Variante "com momentum" (21/out): com ELEICAO_MOMENTUM=1 a deriva até 25/out ganha uma média, vinda do agregado das diferenças entre
# ondas consecutivas do mesmo instituto (mudanca_2turno.resumo, em p.p./dia), encolhida por ENCOLHE_MOMENTUM; o desvio dessa média é o
# erro-padrão do agregado (piso SD_MOMENTUM_PISO por dia), encolhido na mesma proporção, para não inflar a variância da deriva. O MCMC recebe a mesma prior. Grava resultados_momentum.json e termina
# depois do MCMC. No modelo principal (flag ausente) a deriva continua com média zero: nenhuma saída muda.
MOMENTUM = bool(os.environ.get("ELEICAO_MOMENTUM"))
ENCOLHE_MOMENTUM, SD_MOMENTUM_PISO = 0.5, 0.1
T0   = pd.Timestamp("2025-12-01")           # origem do índice diário
INICIO_JANELA = pd.Timestamp("2026-06-01")  # janela de campanha usada para o nível de hoje e para σ_rw
def dia(ts): return int((pd.Timestamp(ts) - T0).days)
t_now = dia(HOJE)

# ----------------------------------------------------------------------------
# 1. DADOS — 2º turno
# ----------------------------------------------------------------------------
df = pd.read_csv(DATA + "pesquisas_2turno_2026.csv", parse_dates=["campo_fim"])
df = df[df.campo_fim <= HOJE].copy()
df["margem_total"] = df.lula - df.flavio
df["lula_valid"]   = 100 * df.lula / (df.lula + df.flavio)
df["margem_valid"] = 100 * (df.lula - df.flavio) / (df.lula + df.flavio)
df["idade_dias"]   = (HOJE - df.campo_fim).dt.days
df["t"]            = (df.campo_fim - T0).dt.days.astype(float)
df["dia"]          = df.t.astype(int)

def _se_amostral(l_, f_, n_):
    """Erro padrão da margem em votos válidos m = 100(L−F)/(L+F) sob amostragem aleatória simples (multinomial)."""
    pl, pf = l_/100.0, f_/100.0
    return 100*math.sqrt((pl + pf - (pl - pf)**2/(pl + pf))/n_)/(pl + pf)   # = 100·sqrt(4s(1−s)/(n·V)), s = L/(L+F), V = L+F
N_MAX_EFETIVO = 5000   # teto do tamanho de amostra efetivo: acima disso o erro não amostral domina (e uma onda não deve pesar 20× outra)
# Auditoria de 16/out: (a) a coluna opcional n_efetivo da base traz o n efetivo declarado pelo instituto (Palver: Kish, efeito dos pesos 2,8
# a 4,3 — o n bruto de 5.000 subestimava o erro em até 2×); (b) AtlasIntel (recrutamento digital aleatório, não probabilístico) declara
# ±1,0 p.p. com n≈5.000, mas a amostragem aleatória simples já dá ±1,4 e o efeito de desenho não é reportado: teto de 2.500.
N_EFETIVO_INST = {"AtlasIntel": 2500}
df["n_ef"] = (df["n_efetivo"] if "n_efetivo" in df.columns else df["n"]).fillna(df["n"]).astype(float)
df["n_ef"] = [min(r.n_ef, N_EFETIVO_INST.get(r.instituto, N_MAX_EFETIVO), N_MAX_EFETIVO) for r in df.itertuples()]
df["se_margem_teorico"] = [_se_amostral(r.lula, r.flavio, r.n_ef) for r in df.itertuples()]
df["se2"] = df.se_margem_teorico**2

# ----------------------------------------------------------------------------
# 2. CALIBRAÇÃO HISTÓRICA (calibracao_historica.csv: erro na margem esq−dir em válidos; + = superestimou o PT)
# ----------------------------------------------------------------------------
cal = pd.read_csv(DATA + "calibracao_historica.csv")
hist = cal.rename(columns={"ciclo": "eleicao", "erro_margem": "erro_pp"})[["instituto", "eleicao", "turno", "erro_pp", "referencia"]]
mae_inst = hist.groupby("instituto").erro_pp.apply(lambda s: s.abs().mean())
bias_inst = hist.groupby("instituto").erro_pp.mean()
n_inst = hist.groupby("instituto").size()
bias_el = hist.groupby(["eleicao", "turno"]).erro_pp.mean()

# Qualidade do instituto: erro absoluto médio EM RELAÇÃO À MÉDIA DO CICLO/TURNO (auditoria de 3/out). O MAE bruto mistura o erro
# próprio do instituto com o erro comum do ciclo (em 2014 e 2022 todos erraram o 1º turno em ~9 pontos), que já é tratado pelo
# desconto de viés; quem só tem histórico em ciclos "bons" parecia melhor. Só entram ciclos/turnos com 3 ou mais institutos.
_grp = hist.groupby(["eleicao", "turno"]).erro_pp
hist["erro_rel"] = hist.erro_pp - _grp.transform("mean")
_rel = hist[_grp.transform("size") >= 3]
mae_rel = _rel.groupby("instituto").erro_rel.apply(lambda s: s.abs().mean())
n_rel = _rel.groupby("instituto").size()
MAE_REF, Q_DEFAULT, K_SHRINK = round(float(mae_rel.median()), 2), 0.8, 2.0
def peso_qualidade(inst):
    """Qualidade = MAE relativo mediano / MAE relativo do instituto, encolhida para 0,8 conforme poucas eleições sustentam o MAE."""
    if inst not in mae_rel.index:
        return Q_DEFAULT
    q_ = float(np.clip(MAE_REF / max(mae_rel[inst], 0.5), 0.6, 1.5))
    k_ = float(n_rel[inst])
    return float((k_ * q_ + K_SHRINK * Q_DEFAULT) / (k_ + K_SHRINK))

# Viés sistemático do 2º turno: média por ciclo dos institutos de referência (referencia=1), depois média entre ciclos
# com recência suave (meia-vida de dois ciclos = 8 anos). σ_b = desvio entre ciclos, com piso 2,0 pela mistura de métodos de 2026.
_h2 = hist[(hist.turno == 2) & (hist.referencia == 1)]
bias_2t_ciclos = _h2.groupby("eleicao").erro_pp.mean().to_dict()
MEIA_VIDA_CICLOS = 8.0
pesos_ciclo = {c_: 0.5 ** ((2022 - c_) / MEIA_VIDA_CICLOS) for c_ in bias_2t_ciclos}
BIAS_MEAN = round(sum(bias_2t_ciclos[c_]*pesos_ciclo[c_] for c_ in bias_2t_ciclos) / sum(pesos_ciclo.values()), 2)
# Desvio preditivo para um ciclo novo: desvio entre ciclos × sqrt(1 + 1/n) (a média também é estimada), com piso 2,0.
# As caudas são t de Student (DF_ERRO graus de liberdade, reescalada para o mesmo desvio): com 3 a 5 ciclos, a normal é otimista.
DF_ERRO = 5
def _t_unit(r_, n_): return r_.standard_t(DF_ERRO, n_) * math.sqrt((DF_ERRO - 2) / DF_ERRO)
def _sd_pred(v_): v_ = list(v_); return float(np.std(v_, ddof=1) * math.sqrt(1 + 1/len(v_)))
BIAS_SD = round(max(2.0, _sd_pred(bias_2t_ciclos.values())), 2)
BIAS_MEAN_IGUAL = round(float(np.mean(list(bias_2t_ciclos.values()))), 2)
# 1º turno: os ciclos verificáveis (2010, 2014, 2018, 2022) subestimaram o principal adversário do PT. Prior = 60% da média com
# recência: é uma HIPÓTESE (os institutos ajustam método entre ciclos — em 2026 vários ponderam pelo voto declarado de 2022 — e o
# sinal do erro pode mudar), por isso o desvio é o preditivo entre ciclos (≈ 3,8), não mais 3,0 fixo.
_h1 = hist[(hist.turno == 1) & (hist.referencia == 1)]
bias_1t_ciclos = _h1.groupby("eleicao").erro_pp.mean().to_dict()
_p1 = {c_: 0.5 ** ((2022 - c_) / MEIA_VIDA_CICLOS) for c_ in bias_1t_ciclos}
BIAS_1T_HIST = round(sum(bias_1t_ciclos[c_]*_p1[c_] for c_ in bias_1t_ciclos) / sum(_p1.values()), 2)
ENCOLHIMENTO_1T = 0.6
BIAS_1T_MEAN, BIAS_1T_SD = round(ENCOLHIMENTO_1T * BIAS_1T_HIST, 2), round(max(3.0, _sd_pred(bias_1t_ciclos.values())), 2)
# Viés por método — só diagnóstico (poucas observações de 2º turno para telefônico e online: não se aplica)
METODO_HIST = {"Datafolha": "presencial", "Quaest": "presencial", "Ipec": "presencial", "Ibope": "presencial", "MDA": "presencial",
               "Vox Populi": "presencial", "Parana Pesquisas": "presencial", "AtlasIntel": "online", "PoderData": "telefonico",
               "Ipespe": "telefonico", "Verita": "nao_informado", "Futura Inteligencia": "nao_informado"}
_h2m = hist[hist.turno == 2].assign(metodo=lambda d: d.instituto.map(METODO_HIST))
BIAS_METODO = {m_: round(float(g.erro_pp.mean()), 2) for m_, g in _h2m.groupby("metodo")}
BIAS_N_METODO = _h2m.groupby("metodo").size().to_dict()
# Movimento entre turnos (pesquisas de 2º turno do mesmo instituto: primeira após o 1º turno → última antes do 2º)
mov = pd.read_csv(DATA + "movimento_entre_turnos.csv")
_se_pol = 2.2   # erro amostral típico da margem (n≈2.000) — cada delta observado carrega 2 erros de pesquisa
_var_obs = float((mov.delta_margem**2).mean()); _dias_med = float(mov.dias.mean())
SIG_ENTRE_DIA_BRUTO = math.sqrt(max(_var_obs - 2*_se_pol**2, 1.0) / _dias_med)

# Âncora: institutos com histórico presidencial verificado (referencia=1 no 2º turno) presentes na base de 2026
ANCORA = sorted(set(_h2.instituto) & set(df.instituto))
Q_ANCORA = {p_: peso_qualidade(p_) for p_ in ANCORA}
if SO_POS1T:
    # Variante pós-1º turno: o filtro entra DEPOIS da âncora e dos pesos de qualidade, que ficam os do modelo principal (ajustar ignora
    # âncoras sem observação), de modo que a seção 3d (1º turno) sai idêntica; só a série de 2º turno muda. Sem ondas pré-4/out,
    # _inst_pre1t fica vazio e a_casa = 1 em todas: cada onda identifica o efeito de casa inteiro, encolhido ao prior do erro do 1º turno.
    assert HOJE >= T1, "variante pós-1º turno só faz sentido depois de 4/out"
    df = df[df.campo_fim > T1].copy()
    assert len(df) >= 5, "variante pós-1º turno: menos de 5 pesquisas depois de 4/out"
    assert set(ANCORA) & set(df.instituto), "variante pós-1º turno: nenhuma pesquisa da âncora depois de 4/out"

# ----------------------------------------------------------------------------
# 3. TENDÊNCIA + EFEITOS DE CASA — espaço de estados único (Jackman 2005; Linzer 2013; Economist 2020)
# ----------------------------------------------------------------------------
# Regime pós-1º turno (seção 8): priors de efeito de casa vindos do erro observado no 1º turno
res1t_path = DATA + "resultado_1turno.csv"
RESULTADO_1T = None
if os.path.exists(res1t_path):
    _r1 = pd.read_csv(res1t_path)
    _r1["votos_validos_pct"] = pd.to_numeric(_r1.votos_validos_pct, errors="coerce")
    if len(_r1) and _r1.votos_validos_pct.notna().any():
        RESULTADO_1T = dict(zip(_r1.candidato.str.lower(), _r1.votos_validos_pct))
POS_1T = RESULTADO_1T is not None and HOJE >= T1
if HOJE > T1 and not POS_1T:
    print("[AVISO] já passou o 1º turno e resultado_1turno.csv está vazio: o modelo segue no regime pré-1º turno.")
prior_h_2t, tau_prior_h = {}, None
# Persistência do efeito de casa depois do 1º turno (auditoria de 16/out, estimada nas 14 pesquisas pós-4/out que havia então). A inclinação
# da margem observada sobre o efeito de casa estimado antes do 1º turno era +0,58 ± 0,68 (+0,13 ± 1,0 sem Gerp e Palver; persistência
# total = 1, nula = 0), e a dispersão entre institutos AUMENTAVA depois da correção (DP 0,8 → 2,1 na semana de 10–14/out, sem a Gerp):
# vários institutos passaram a pesar pelo voto declarado no 1º turno (Palver v3, Ipespe). λ = 0,5 entra como compromisso entre a
# persistência total do modelo antigo e o zero que os dados também comportam; a observação pós-1º turno identifica λ·h, não h.
# λ só faz sentido para quem tem efeito de casa estimado ANTES do 1º turno: uma série nova (Palver v3, MDA v2, só com ondas pós-4/out)
# não tem efeito anterior para "persistir", e nela a observação identifica h inteiro (a_casa = 1).
LAMBDA_CASA_POS1T = 0.5
LAMBDA_CASA = LAMBDA_CASA_POS1T if (RESULTADO_1T is not None and HOJE >= T1) else 1.0
_inst_pre1t = set(df.loc[df.campo_fim <= T1, "instituto"])
df["a_casa"] = np.where((df.campo_fim > T1) & df.instituto.isin(_inst_pre1t), LAMBDA_CASA, 1.0)
FLAVIO_TOP2_REAL = None
ERRO_COMUM_1T = None      # erro médio da âncora na margem Lula − Flávio do 1º turno (+ = superestimou Lula)
if POS_1T:
    # Seção 8: o erro de cada instituto na margem Lula − Flávio do 1º turno (última onda até 4/out, em válidos), medido em relação
    # à média da âncora, vira prior do efeito de casa no 2º turno, com metade do peso (o erro de um turno só prevê em parte o do outro).
    _real = RESULTADO_1T.get("lula", np.nan) - RESULTADO_1T.get("flavio", np.nan)
    _ord = sorted(RESULTADO_1T, key=lambda k_: -(RESULTADO_1T[k_] if pd.notna(RESULTADO_1T[k_]) else -1))
    FLAVIO_TOP2_REAL = "flavio" in _ord[:2]
    _p1e = pd.read_csv(DATA + "pesquisas_1turno_2026.csv", parse_dates=["campo_fim"])
    _p1e = _p1e[_p1e.campo_fim <= T1].sort_values("campo_fim").groupby("instituto").tail(1)
    _cand_cols = ["lula", "flavio", "cury", "renan", "caiado", "zema", "outros"]
    _val = _p1e[_cand_cols].sum(axis=1)
    _err = dict(zip(_p1e.instituto, 100*(_p1e.lula - _p1e.flavio)/_val - _real))
    _anc = [v for k_, v in _err.items() if k_ in set(_h2.instituto)]
    _c0 = float(np.mean(_anc)) if _anc else float(np.mean(list(_err.values())))
    if np.isfinite(_real):
        ERRO_COMUM_1T = _c0
        prior_h_2t = {k_: 0.5*(v - _c0) for k_, v in _err.items() if np.isfinite(v)}
        tau_prior_h = 1.5
        # As séries novas do 2º turno ("Palver v3", "MDA v2", "Futura Inteligencia v2") não recebem este prior (a chave é o nome do 1º turno):
        # entram com prior 0 e τ = 1,5, de propósito, porque mudaram a ponderação e o erro do 1º turno foi medido com a régua antiga.

# Quebra estrutural: as simulações de 2º turno feitas antes do 1º turno e as pesquisas feitas depois não são a mesma série (em
# 2014, 2018 e 2022 o salto foi de 9 a 14 pontos). No regime pós-1º turno o passeio recebe variância extra de 6² no dia seguinte
# a 4/out, de modo que o nível passa a seguir as pesquisas novas em vez de levar semanas para se afastar das antigas.
SD_QUEBRA_1T = 6.0
Q_EXTRA = {dia(T1) + 1: SD_QUEBRA_1T**2} if POS_1T else {}
fit_full = ajustar(df.margem_valid.values, df.dia.values, df.se2.values, df.instituto.values, set(ANCORA), Q_ANCORA, n_days=t_now + 1, q_extra=Q_EXTRA,
                   a_h=df.a_casa.values)
jan = df[df.campo_fim >= INICIO_JANELA].copy()
d0_jan = int(jan.dia.min())
Q_EXTRA_WIN = {k_ - d0_jan: v for k_, v in Q_EXTRA.items()}
def _ajusta_janela(sub_):
    return ajustar(sub_.margem_valid.values, (sub_.dia - d0_jan).values, sub_.se2.values, sub_.instituto.values, set(ANCORA), Q_ANCORA,
                   n_days=t_now - d0_jan + 1, prior_h=prior_h_2t, tau_prior_h=tau_prior_h, q_extra=Q_EXTRA_WIN, a_h=sub_.a_casa.values)
fit_win = _ajusta_janela(jan)
def x_win(d_):   # nível suavizado da janela no dia absoluto d_
    k_ = int(np.clip(d_ - d0_jan, 0, fit_win["n_days"] - 1)); return float(fit_win["x"][k_]), float(fit_win["P"][k_])
level_nac, P_nac = x_win(t_now)
se_nac = math.sqrt(P_nac + fit_win["var_ancora"])   # o filtro trata os efeitos de casa como conhecidos; soma a incerteza da média da âncora
house = dict(fit_full["house"]); house.update(fit_win["house"])          # janela prevalece; institutos só antigos ficam com o ajuste completo
sigma_inst = dict(fit_full["sigma_inst"]); sigma_inst.update(fit_win["sigma_inst"])
SIG_RW_KF, SIG_RW_ML = fit_win["sig_rw"], fit_win["sig_rw_ml"]
insts = list(df.instituto.unique())
# casa (instituto sem o sufixo " vN"): "Palver v3" e "MDA v2" são séries novas só para o efeito de casa; nos contadores onda a onda
# e na "última pesquisa por instituto" continuam a Palver e a CNT/MDA. "Futura Inteligencia v2" (21/out): a partir da onda de 19–20/out a Futura
# pondera escolaridade pela PNAD Contínua em vez do cadastro do TSE (mudança declarada no relatório), série nova pelo mesmo critério
df["inst_base"] = df.instituto.str.replace(r" v\d$", "", regex=True)
df["house"] = df.instituto.map(house)
df["house_aplicado"] = df.house * df.a_casa          # o que de fato desconta a margem de cada pesquisa (λ·h depois do 1º turno)
df["sigma_inst"] = df.instituto.map(sigma_inst)
df["w_precisao"] = 1.0 / (df.se2 + df.sigma_inst**2)
df["w_recencia"] = np.exp(-np.log(2) * df.idade_dias / 21.0)      # só para exibição (a recência entra pelo passeio aleatório)
df["w_qualidade"] = df.instituto.map(peso_qualidade)
df["w_tamanho"] = np.clip(np.sqrt(df.n / 2000.0), 0.7, 1.5)
df["w"] = df.w_recencia * df.w_precisao / df.w_precisao.mean()
print(f"[Espaço de estados] janela desde {INICIO_JANELA.date()}: {len(jan)} pesquisas | σ_rw MAP={SIG_RW_KF:.3f} (ML {SIG_RW_ML:.3f}) p.p./dia | "
      f"nível hoje={level_nac:+.2f} ± {se_nac:.2f} | âncora={ANCORA} | iterações {fit_win['iter']}")
level_kf, se_kf = level_nac, se_nac
level_pspline, se_pspline = float(fit_full["x"][t_now]), math.sqrt(float(fit_full["P"][t_now]))   # ajuste completo (dez–set), para comparação

# média ponderada simples (última pesquisa por casa, ajustada pelo efeito de casa da série a que ela pertence) — para comparação
last = df.sort_values("campo_fim").groupby("inst_base").tail(1).copy()
last["margem_adj"] = last.margem_valid - last.house_aplicado
last["w_last"] = last.w
media_simples_adj = float((last.margem_adj * last.w_last).sum() / last.w_last.sum())
media_simples_bruta = float((last.margem_valid * last.w_last).sum() / last.w_last.sum())

# validação de origem móvel (últimos 60 dias, passo 3, horizonte 7 dias) — cobertura e viés das previsões do próprio modelo
# Mesmo modelo do ajuste da janela (prior do efeito de casa vindo do erro do 1º turno e λ nas ondas pós-1º turno): antes a validação
# rodava sem os dois e media um modelo diferente do que o painel usa.
_rv = [] if SO_POS1T else validacao_rolling(jan.margem_valid.values, (jan.dia - d0_jan).values, jan.se2.values, jan.instituto.values, set(ANCORA), Q_ANCORA,
                        dias=range(max(t_now - d0_jan - 60, 25), t_now - d0_jan - 6, 3), q_extra=Q_EXTRA_WIN,
                        prior_h=prior_h_2t, tau_prior_h=tau_prior_h, a_h=jan.a_casa.values)
_z = np.array([(o_ - p_)/s_ for _, o_, p_, s_, _, _ in _rv]) if _rv else np.array([0.0])
# viés separado: institutos que o ajuste já conhecia × institutos novos (entram com efeito de casa 0). Se o viés está só nos novos,
# é composição da base; se está também nos conhecidos, é tendência que o passeio aleatório (sem inclinação) não antecipa.
_ev = [o_ - p_ for _, o_, p_, _, _, v_ in _rv if v_]; _en = [o_ - p_ for _, o_, p_, _, _, v_ in _rv if not v_]
VALIDACAO = {"n_previsoes": len(_rv), "vies_medio": round(float(np.mean([o_ - p_ for _, o_, p_, _, _, _ in _rv])), 2) if _rv else None,
             "vies_inst_vistos": round(float(np.mean(_ev)), 2) if _ev else None, "n_inst_vistos": len(_ev),
             "vies_inst_novos": round(float(np.mean(_en)), 2) if _en else None, "n_inst_novos": len(_en),
             "cobertura_80": round(float(np.mean(np.abs(_z) < 1.2816)), 3), "cobertura_95": round(float(np.mean(np.abs(_z) < 1.96)), 3),
             "erro_abs_medio": round(float(np.mean([abs(o_ - p_) for _, o_, p_, _, _, _ in _rv])), 2) if _rv else None,
             "sd_z": round(float(_z.std()), 2), "horizonte_dias": 7}
print(f"[Validação] {VALIDACAO}")

# curva para o gráfico (ajuste completo dez→hoje)
tt_grid = np.arange(int(df.dia.min()), t_now + 1)
f_grid = fit_full["x"][tt_grid]
se_grid = np.sqrt(fit_full["P"][tt_grid])

# ----------------------------------------------------------------------------
# 3c. PESQUISAS ESTADUAIS → estimativa nacional independente (swing sobre o 2º turno de 2022 por UF)
# ----------------------------------------------------------------------------
MARGEM_2022 = 1.80
SIG_EXTRA_UF, SIG_SIST_UF, RHO_EST_NAC, COBERTURA_MIN, HALF_LIFE = 3.0, 1.5, 0.3, 0.30, 21.0
ref_uf = pd.read_csv(DATA + "referencia_uf.csv") if os.path.exists(DATA + "referencia_uf.csv") else None
est = pd.read_csv(DATA + "pesquisas_estaduais_2026.csv", parse_dates=["campo_fim"]) if os.path.exists(DATA + "pesquisas_estaduais_2026.csv") else None
estadual = {"usado": False, "motivo": "base estadual ausente"}
REGIAO_UF, PESO_UF = {}, {}
if ref_uf is not None:
    ref_uf["peso"] = ref_uf.eleitorado / ref_uf.eleitorado.sum()
    ref_uf["peso_v"] = ref_uf.votos_validos_2022 / ref_uf.votos_validos_2022.sum()   # a margem nacional é média ponderada por votos válidos
    ref_uf["m22"] = ref_uf.lula_2022 - ref_uf.bolsonaro_2022
    REGIAO_UF = dict(zip(ref_uf.uf, ref_uf.regiao)); PESO_UF = dict(zip(ref_uf.uf, ref_uf.peso))
if ref_uf is not None and est is not None:
    e2 = est[(est.turno == 2) & ((HOJE - est.campo_fim).dt.days <= 45) & (est.campo_fim <= HOJE)].copy()
    if POS_1T:
        e2 = e2[e2.campo_fim > T1]      # a quebra de 4/out vale também para as estaduais: só entram as feitas depois do 1º turno
    e2 = e2.merge(ref_uf[["uf", "m22", "peso", "peso_v", "regiao"]], on="uf", how="inner")
    if len(e2):
        e2["margem_bruta"] = 100*(e2.lula - e2.flavio)/(e2.lula + e2.flavio)
        e2["t"] = (e2.campo_fim - T0).dt.days.astype(float)
        e2["ajuste_casa"] = -e2.instituto.map(house).fillna(0.0)
        e2["ajuste_tend"] = [level_nac - x_win(int(tt))[0] for tt in e2.t]
        e2["margem_valid"] = e2.margem_bruta + e2.ajuste_casa + e2.ajuste_tend
        e2["swing"] = e2.margem_valid - e2.m22
        e2["w"] = (np.exp(-np.log(2)*(HOJE - e2.campo_fim).dt.days/HALF_LIFE) * np.clip(np.sqrt(e2.n/1000), 0.6, 1.5)
                   * e2.instituto.map(lambda i_: peso_qualidade(i_)))
        e2["var_i"] = [_se_amostral(r.lula, r.flavio, r.n)**2 + SIG_EXTRA_UF**2 for r in e2.itertuples()]
        por_uf = []
        for uf_, g in e2.groupby("uf"):
            wv = g.w/g.var_i
            s_raw = float((g.swing*wv).sum()/wv.sum())
            v_raw = float(1/(g.w/g.var_i).sum()*g.w.sum()/len(g)) if len(g) > 1 else float(g.var_i.iloc[0])
            por_uf.append({"uf": uf_, "n_pesq": int(len(g)), "swing_bruto": s_raw, "var": v_raw, "peso": float(g.peso.iloc[0]), "peso_v": float(g.peso_v.iloc[0]),
                           "m22": float(g.m22.iloc[0]), "margem_2026": float((g.margem_valid*g.w).sum()/g.w.sum()),
                           "institutos": ", ".join(sorted(g.instituto.unique())), "ultimo_campo": str(g.campo_fim.max().date())})
        pu = pd.DataFrame(por_uf)
        wi = 1/pu["var"]; mu_fe = float((pu.swing_bruto*wi).sum()/wi.sum())
        Qh = float((wi*(pu.swing_bruto - mu_fe)**2).sum()); c_ = float(wi.sum() - (wi**2).sum()/wi.sum())
        tau2 = max(0.0, (Qh - (len(pu) - 1))/c_) if len(pu) > 1 and c_ > 0 else 0.0
        wr = 1/(pu["var"] + tau2); mu_s = float((pu.swing_bruto*wr).sum()/wr.sum()); var_mu = float(1/wr.sum())
        pu["regiao"] = pu.uf.map(REGIAO_UF)
        mu_reg = {}
        for r_, g_ in pu.groupby("regiao"):
            wg = 1/(g_["var"] + tau2); m_r = float((g_.swing_bruto*wg).sum()/wg.sum()); v_r = float(1/wg.sum())
            k_r = (tau2/2)/((tau2/2) + v_r) if tau2 > 0 else 0.0
            mu_reg[r_] = mu_s + k_r*(m_r - mu_s)
        pu["mu_regiao"] = pu.regiao.map(mu_reg)
        pu["swing"] = pu.mu_regiao + (tau2/2)/((tau2/2) + pu["var"])*(pu.swing_bruto - pu.mu_regiao)
        pu["var_post"] = 1/(1/max(tau2/2, 1e-6) + 1/pu["var"])
        cobertura = float(pu.peso.sum()); cobertura_v = float(pu.peso_v.sum())
        _sem = ref_uf[~ref_uf.uf.isin(pu.uf)]
        swing_sem = float(sum(r_.peso_v*mu_reg.get(r_.regiao, mu_s) for r_ in _sem.itertuples()))
        swing_nac = float((pu.swing*pu.peso_v).sum() + swing_sem)
        var_est = float((pu.peso_v**2*pu.var_post).sum() + (1 - cobertura_v)**2*(var_mu + tau2/2) + SIG_SIST_UF**2)
        nivel_est, se_est = MARGEM_2022 + swing_nac, math.sqrt(var_est)
        estadual = {"usado": cobertura >= COBERTURA_MIN, "motivo": "" if cobertura >= COBERTURA_MIN else f"cobertura {cobertura:.0%} < {COBERTURA_MIN:.0%}",
                    "nivel": nivel_est, "se": se_est, "mu_swing": mu_s, "tau": math.sqrt(tau2), "cobertura": cobertura,
                    "n_pesq": int(len(e2)), "n_uf": int(len(pu)), "por_uf": pu.sort_values("peso", ascending=False),
                    "mu_regiao": {k_: round(v, 2) for k_, v in mu_reg.items()},
                    "ajuste_medio_tend": float((e2.ajuste_tend).mean()), "ajuste_medio_casa": float(e2.ajuste_casa.mean())}
        print(f"[Estadual] {len(e2)} pesquisas em {len(pu)} UFs (cobertura {cobertura:.0%}) | swing médio {mu_s:+.2f} τ={math.sqrt(tau2):.2f} | nacional {nivel_est:+.2f} ± {se_est:.2f}")

level_final, se_final = level_nac, se_nac
w_est_comb = 0.0
if estadual["usado"]:
    s1, s2 = se_nac, estadual["se"]
    den = s1**2 + s2**2 - 2*RHO_EST_NAC*s1*s2
    w_est_comb = float(np.clip((s1**2 - RHO_EST_NAC*s1*s2)/den, 0, 1))
    level_final = (1 - w_est_comb)*level_nac + w_est_comb*estadual["nivel"]
    se_final = math.sqrt(s1**2*s2**2*(1 - RHO_EST_NAC**2)/den)
    estadual["peso_na_combinacao"] = w_est_comb

# ----------------------------------------------------------------------------
# 3d. 1º TURNO — um estado por candidato (votos válidos), efeitos de casa por instituto × candidato, viés histórico, simulação
# ----------------------------------------------------------------------------
CANDS = ["lula", "flavio", "cury", "renan", "caiado", "zema", "outros"]
CAND_LABEL = {"lula": "Lula", "flavio": "Flávio Bolsonaro", "cury": "Augusto Cury", "renan": "Renan Santos", "caiado": "Ronaldo Caiado",
              "zema": "Romeu Zema", "outros": "Outros"}
p1 = pd.read_csv(DATA + "pesquisas_1turno_2026.csv", parse_dates=["campo_fim"])
p1 = p1[(p1.campo_fim <= HOJE)].sort_values("campo_fim").reset_index(drop=True)
# Ondas em que só Lula e Flávio foram publicados: os demais candidatos são imputados (última onda completa do mesmo instituto ou,
# na falta, média recente entre institutos) apenas para calcular os válidos; o candidato imputado NÃO entra no seu próprio ajuste.
_terc = ["cury", "renan", "caiado", "zema", "outros"]
_obs = {c_: p1[c_].notna().values.copy() for c_ in CANDS}           # máscara antes de qualquer imputação (inclusive de "outros")
p1["imputada"] = p1[["cury", "renan", "caiado", "zema"]].isna().any(axis=1)
for i_, r_ in p1.iterrows():
    for c_ in _terc:
        if pd.isna(r_[c_]):
            prev = p1[(p1.instituto == r_.instituto) & (p1.index < i_) & p1[c_].notna()]
            ref = p1[(p1.index < i_) & p1[c_].notna()].tail(8)
            p1.loc[i_, c_] = float(prev[c_].iloc[-1]) if len(prev) else (float(ref[c_].mean()) if len(ref) else 0.0)
p1["validos"] = p1[CANDS].sum(axis=1)
p1["n_valid"] = np.minimum(p1.n, N_MAX_EFETIVO) * p1.validos / 100.0   # teto de n: amostras gigantes (Veritá 40.500) não dominam
for c_ in CANDS: p1[c_ + "_v"] = 100 * p1[c_] / p1.validos
p1["dia"] = (p1.campo_fim - T0).dt.days.astype(int)
p1["margem_v"] = p1.lula_v - p1.flavio_v
d0_1t = int(p1.dia.min())
JANELA_MENORES = 28   # dias: o nível dos candidatos menores usa só as ondas recentes (depois da quebra de Cury no início de setembro)
def _fit_cand(c_, sig_rw_fixo=None, desde=None):
    m_ = _obs[c_] & ((p1.campo_fim >= desde).values if desde is not None else True)
    sub_ = p1[m_]
    pv = sub_[c_ + "_v"].values / 100.0
    se2c = np.maximum(pv * (1 - pv) / sub_.n_valid.values, 1e-6) * 1e4
    d0_ = int(sub_.dia.min())
    fit_ = ajustar(sub_[c_ + "_v"].values, (sub_.dia - d0_).values, se2c, sub_.instituto.values, set(ANCORA), Q_ANCORA,
                   n_days=t_now - d0_ + 1, tau_h=1.2, sigma_floor=0.6, sig_rw_fixo=sig_rw_fixo)
    fit_["d0"] = d0_
    return fit_
fit1 = {c_: _fit_cand(c_) for c_ in CANDS}
# Candidatos menores: σ_rw comum (mediana das estimativas individuais). Com σ próprio, uma quebra estrutural (Cury: 1–2% → 10% → 3%)
# leva o passeio de um candidato a ~1 p.p./dia e o nível passa a copiar a última pesquisa, o que fazia o 3º lugar oscilar a cada onda.
MENORES = ["cury", "renan", "caiado", "zema", "outros"]
SIG_RW_MENORES_LIVRE = {c_: round(float(fit1[c_]["sig_rw"]), 3) for c_ in MENORES}
SIG_RW_MENORES = float(np.median(list(SIG_RW_MENORES_LIVRE.values())))
for c_ in MENORES:
    fit1[c_] = _fit_cand(c_, SIG_RW_MENORES, desde=min(HOJE, T1) - pd.Timedelta(days=JANELA_MENORES))   # depois de 4/out não há pesquisa de 1º turno: a janela fica ancorada em T1
share_now = {c_: float(fit1[c_]["x"][t_now - fit1[c_]["d0"]]) for c_ in CANDS}
se_share = {c_: math.sqrt(float(fit1[c_]["P"][t_now - fit1[c_]["d0"]])) for c_ in CANDS}
# Lula e Flávio (auditoria de 3/out): em vez de duas séries independentes, o espaço de estados é ajustado na MARGEM Lula − Flávio
# (como no 2º turno: o efeito de casa e o viés histórico são da margem) e na SOMA dos dois (quanto sobra para os demais).
# Lula = (soma + margem)/2, Flávio = (soma − margem)/2. Duas séries independentes ignoravam que os dois disputam o mesmo voto.
_pl, _pf = p1.lula_v/100.0, p1.flavio_v/100.0
p1["soma_v"] = p1.lula_v + p1.flavio_v
def _fit_par(col_, se2_, tau_, piso_, sub_=None):
    sub_ = p1 if sub_ is None else sub_
    f_ = ajustar(sub_[col_].values, (sub_.dia - d0_1t).values, se2_[sub_.index.values], sub_.instituto.values, set(ANCORA), Q_ANCORA,
                 n_days=t_now - d0_1t + 1, tau_h=tau_, sigma_floor=piso_)
    return f_
_se2_m1 = ((_pl + _pf - (_pl - _pf)**2) / p1.n_valid * 1e4).values
_se2_s1 = (np.maximum((_pl + _pf)*(1 - _pl - _pf), 1e-4) / p1.n_valid * 1e4).values
fit_m1 = _fit_par("margem_v", _se2_m1, 1.5, 0.8)
fit_s1 = _fit_par("soma_v", _se2_s1, 1.2, 0.6)
_m1, _s1 = float(fit_m1["x"][-1]), float(fit_s1["x"][-1])
SE_M1 = math.sqrt(float(fit_m1["P"][-1]) + fit_m1["var_ancora"]); SE_S1 = math.sqrt(float(fit_s1["P"][-1]) + fit_s1["var_ancora"])
share_now["lula"], share_now["flavio"] = (_s1 + _m1)/2, (_s1 - _m1)/2
se_share["lula"] = se_share["flavio"] = math.sqrt(SE_M1**2 + SE_S1**2)/2
_norm = sum(share_now.values())
share_now = {c_: 100 * v / _norm for c_, v in share_now.items()}        # renormaliza (compositional)
house_1t = {c_: fit1[c_]["house"] for c_ in CANDS}
sig_rw_1t = float(np.median([fit1[c_]["sig_rw"] for c_ in CANDS]))
# última pesquisa por instituto (totais e válidos) — para a tabela do painel
last1 = p1.sort_values("campo_fim").groupby("instituto").tail(1).sort_values("campo_fim", ascending=False)
# agregado em totais (para o modelo de transferência): brancos/indecisos = média ponderada por recência das últimas ondas
_w1 = np.exp(-np.log(2) * (HOJE - last1.campo_fim).dt.days / 14.0)
agg1 = {c_: float((last1[c_] * _w1).sum() / _w1[last1[c_].notna()].sum()) for c_ in CANDS + ["branco_nulo", "indecisos"]}   # NaN fora do numerador e do denominador
_tot = sum(agg1[c_] for c_ in CANDS)
_resto = 100 - agg1["branco_nulo"] - agg1["indecisos"]
agg1.update({c_: share_now[c_] * _resto / 100 for c_ in CANDS})     # totais coerentes com o nível de hoje em válidos
agg1["L"], agg1["F"], agg1["branco"], agg1["indec"] = agg1["lula"], agg1["flavio"], agg1["branco_nulo"], agg1["indecisos"]
if POS_1T:
    # Depois do 1º turno o modelo de transferência parte do RESULTADO OFICIAL (votos válidos), não mais do agregado de pesquisas.
    _rv1 = {c_: float(RESULTADO_1T.get(c_, np.nan)) for c_ in CANDS}
    _conh = sum(v for v in _rv1.values() if np.isfinite(v))
    _rv1 = {c_: (v if np.isfinite(v) else (max(100 - _conh, 0.0) if c_ == "outros" else 0.0)) for c_, v in _rv1.items()}
    agg1.update({c_: _rv1[c_] * _resto / 100 for c_ in CANDS})
    agg1["L"], agg1["F"] = agg1["lula"], agg1["flavio"]
for k_, v_ in [("Cury", "cury"), ("Renan", "renan"), ("Caiado", "caiado"), ("Zema", "zema")]:
    agg1[k_] = agg1[v_]
L_valid_now = share_now["lula"]; gap_valid = 50.0 - L_valid_now
# matriz de transferência declarada (2ª opção, Quaest 3–6/set/2026): % dos eleitores de cada candidato que migram p/ (Flávio, Lula)
# Lida de transferencia_2turno.csv (instituto, campo_fim, origem, pct_flavio, pct_lula). Havendo ondas com campo depois do 1º turno,
# só elas entram (média por origem); antes disso vale a 2ª opção declarada.
_tr = pd.read_csv(DATA + "transferencia_2turno.csv", parse_dates=["campo_fim"])
_tr = _tr[_tr.campo_fim <= HOJE]
if (_tr.campo_fim > T1).any(): _tr = _tr[_tr.campo_fim > T1]
TRANSF_FONTE = "; ".join(sorted(set(f"{r_.instituto} {r_.campo_fim:%d/%m}" for r_ in _tr.itertuples())))
transf = {k_: (float(g_.pct_flavio.mean())/100, float(g_.pct_lula.mean())/100) for k_, g_ in _tr.groupby("origem")}
assert set(transf) == {"cury", "renan", "caiado", "zema", "outros"}, "transferencia_2turno.csv precisa cobrir as cinco origens"
share_lula_3via = {k_: transf[k_][1]/(transf[k_][0]+transf[k_][1]) for k_ in transf}
T3 = {k_: share_now[k_] for k_ in transf}; T3_total = sum(T3.values())

# Simulação do 1º turno (200 mil): nível + incerteza + deriva até 4/out + colapso tardio da 3ª via (voto útil) + viés histórico,
# renormalizado a 100 (compositional). O viés entra na margem Lula−Flávio: −b/2 em Lula, +b/2 em Flávio.
M1 = 200_000
r1 = np.random.default_rng(41)
dias_1t = max((T1 - HOJE).days, 0)
sim1 = {}
_m_sim = r1.normal(share_now["lula"] - share_now["flavio"], math.sqrt(SE_M1**2 + dias_1t * fit_m1["sig_rw"]**2), M1)
_s_sim = r1.normal(share_now["lula"] + share_now["flavio"], math.sqrt(SE_S1**2 + dias_1t * fit_s1["sig_rw"]**2), M1)
sim1["lula"], sim1["flavio"] = (_s_sim + _m_sim)/2, (_s_sim - _m_sim)/2
for c_ in CANDS:
    _sig_dia = fit1[c_]["sig_rw"]
    if c_ in ("lula", "flavio"):
        continue
    else:
        # menores: incerteza extra de reta final proporcional ao tamanho (1,5 p.p. para quem tem 5%), sorteada em escala
        # log-normal com a mesma média e o mesmo desvio, para não gerar parcelas negativas nem exigir corte e renormalização
        _sd = math.sqrt(se_share[c_]**2 + dias_1t * _sig_dia**2 + (1.5*math.sqrt(max(share_now[c_], 0.1)/5.0))**2)
        _mu = max(share_now[c_], 0.05); _s = math.sqrt(math.log(1 + (_sd/_mu)**2))
        sim1[c_] = _mu * np.exp(r1.normal(-_s**2/2, _s, M1))
colapso = beta_dist.rvs(2, 8, size=M1, random_state=r1)          # fração da 3ª via que migra na reta final (média 0,20)
mig_l = np.zeros(M1); mig_f = np.zeros(M1)
for k_ in transf:
    m_ = sim1[k_] * colapso
    sim1[k_] = sim1[k_] - m_
    mig_l += m_ * share_lula_3via[k_]; mig_f += m_ * (1 - share_lula_3via[k_])
# O viés histórico do 1º turno compara as pesquisas finais com o resultado e por isso JÁ inclui a migração tardia da 3ª via
# (2014: Marina → Aécio; 2022: Ciro/Tebet → Bolsonaro). Para não contar duas vezes, o colapso redistribui votos com efeito
# médio nulo na margem Lula − Flávio: mantém a dispersão (quanto e para quem migra varia), mas o deslocamento médio fica no viés.
_desloc_colapso = float((mig_l - mig_f).mean())
mig_l -= _desloc_colapso/2; mig_f += _desloc_colapso/2
b1 = BIAS_1T_MEAN + BIAS_1T_SD*_t_unit(r1, M1) if not POS_1T else np.zeros(M1)
sim1["lula"] = sim1["lula"] + mig_l - b1/2
sim1["flavio"] = sim1["flavio"] + mig_f + b1/2
for c_ in ("lula", "flavio"): sim1[c_] = np.clip(sim1[c_], 0.1, None)
_tot1 = sum(sim1[c_] for c_ in CANDS)
for c_ in CANDS: sim1[c_] = 100 * sim1[c_] / _tot1
lula_valid_mc = sim1["lula"]
margem_1t_mc = sim1["lula"] - sim1["flavio"]
_mat = np.column_stack([sim1[c_] for c_ in CANDS])
_rank = np.argsort(-_mat, axis=1)
p_1t = float((lula_valid_mc > 50).mean())
p_flavio_1t = float((sim1["flavio"] > 50).mean())
P_FLAVIO_TOP2_VOTOS = float(((_rank[:, 0] == 1) | (_rank[:, 1] == 1)).mean())
p_lula_lidera_1t = float((margem_1t_mc > 0).mean())
# 3º lugar entre candidatos (o agregado "outros" soma vários nomes e não concorre a posição)
_cn = [c_ for c_ in CANDS if c_ != "outros"]
_rank_c = np.argsort(-np.column_stack([sim1[c_] for c_ in _cn]), axis=1)
p_terceiro = {c_: float((_rank_c[:, 2] == i_).mean()) for i_, c_ in enumerate(_cn) if c_ not in ("lula", "flavio")}
q_1t = np.percentile(lula_valid_mc, [5, 25, 50, 75, 95])
q_margem_1t = np.percentile(margem_1t_mc, [5, 10, 25, 50, 75, 90, 95])
q_share = {c_: [round(float(v), 1) for v in np.percentile(sim1[c_], [10, 50, 90])] for c_ in CANDS}
# sensibilidade do 1º turno ao viés (0 / prior / histórico cheio)
_r_sens = np.random.default_rng(43)
def _p1t_sens(bm):
    # troca o viés sorteado por outro com média alternativa e a mesma incerteza (a linha "prior" reproduz a manchete)
    mm = margem_1t_mc + b1 - (bm + BIAS_1T_SD*_t_unit(_r_sens, M1) if bm else 0.0)
    return {"p_lula_lidera": round(float((mm > 0).mean()), 3), "margem_mediana": round(float(np.median(mm)), 1)}
sens_1t = {"sem viés": _p1t_sens(0.0), f"prior ({BIAS_1T_MEAN:+.1f})": _p1t_sens(BIAS_1T_MEAN), f"histórico cheio ({BIAS_1T_HIST:+.1f})": _p1t_sens(BIAS_1T_HIST)}
print(f"[1º turno] {len(p1)} pesquisas | válidos hoje: " + ", ".join(f"{CAND_LABEL[c_]} {share_now[c_]:.1f}" for c_ in CANDS)
      + f" | viés prior {BIAS_1T_MEAN:+.1f}±{BIAS_1T_SD} | P(Lula>50%)={p_1t:.3f} P(Flávio top-2)={P_FLAVIO_TOP2_VOTOS:.3f} P(Lula lidera)={p_lula_lidera_1t:.3f}")

def estrutural_2t(L=agg1["L"], F=agg1["F"], terceiros=None, indec=agg1["indec"]+agg1["branco"],
                  mult_lula=1.0, mult_flavio=1.0, split_indec=0.5, decis_indec=0.55, abst_dif=0.0):
    terceiros = terceiros or {k_: agg1[k_] for k_ in transf}
    l, f = L, F
    for k_, share in terceiros.items():
        pf, pl = transf[k_]
        l += share * min(pl*mult_lula, 1-pf*mult_flavio)
        f += share * pf*mult_flavio
    pool = indec * decis_indec
    l += pool*split_indec; f += pool*(1-split_indec)
    l -= abst_dif
    return 100*(l-f)/(l+f), l, f
# Nível estrutural pós-1º turno: resultado oficial + transferência. Nos primeiros dias depois de 4/out é a melhor informação que
# existe (voto contado, sem erro de pesquisa); é combinado ao nível das pesquisas por precisão e perde peso sozinho conforme as
# pesquisas pós-1º turno se acumulam. O desvio cobre a incerteza da transferência declarada e da mudança de comparecimento.
# É uma foto de 4/out: como medida do nível de HOJE, à incerteza da transferência soma-se a deriva do eleitorado desde o 1º turno
# (SIG_ENTRE_DIA por dia). Sem essa parcela o peso do voto contado ficava preso em ~50% independentemente de quantas pesquisas
# novas houvesse (auditoria de 18/out), ao contrário do que o parágrafo acima promete e do que o MCMC faz (observação no dia 5/out).
SD_ESTRUT = 2.5
SD_ESTRUT_HOJE = SD_ESTRUT
NIVEL_ESTRUT = float(estrutural_2t()[0]) if POS_1T else None
W_ESTRUT = 0.0
level_pesq, se_pesq = level_final, se_final
# Fração do nível de hoje que vem de pesquisas de 2º turno feitas DEPOIS do 1º turno (revisão de 4/out, com o resultado na mão).
# Depois da quebra de SD_QUEBRA_1T no passeio, o filtro pesa as ondas novas por precisão contra o nível antigo com variância
# P + 6²; essa é a mesma conta, feita fora do filtro, para saber qual desconto de viés vale para o nível.
F_POS1T = 0.0
if POS_1T:
    _wpos = float(df.loc[df.campo_fim > T1, "w_precisao"].sum())
    F_POS1T = _wpos / (_wpos + 1.0/(se_pesq**2 + SD_QUEBRA_1T**2)) if _wpos > 0 else 0.0
N_POS1T = int((df.campo_fim > T1).sum()) if POS_1T else 0

RISCO_CANDIDATURA = 0.0 if HOJE >= T2 else 0.01   # com as urnas abertas não há mais substituição de candidato
P_FLAVIO_2T_MERC = 0.88     # referência histórica (v24): mercado "Flávio em 2º lugar"
P_FLAVIO_2T = ((1.0 if FLAVIO_TOP2_REAL else 0.0) if POS_1T else P_FLAVIO_TOP2_VOTOS) * (1 - RISCO_CANDIDATURA)

# ----------------------------------------------------------------------------
# 4. MONTE CARLO — 2º TURNO
# ----------------------------------------------------------------------------
N = 400_000
dias = max((T2 - HOJE).days, 0)
dias_ate_t1, dias_t1_t2 = max((T1 - HOJE).days, 0), max((T2 - max(T1, HOJE)).days, 0)
# Deriva em duas fases: até 4/out, σ_rw estimado; entre turnos, σ calibrado no movimento observado em 2014/18/22 (descontado o
# erro de pesquisa) e encolhido 50% para σ_rw, porque são só três ciclos.
SIG_ENTRE_DIA = math.sqrt(0.5*SIG_ENTRE_DIA_BRUTO**2 + 0.5*SIG_RW_KF**2)
SD_DIARIO = SIG_RW_KF
MULT_ENTRE_TURNOS = round(SIG_ENTRE_DIA / SIG_RW_KF, 2)
sd_deriva = math.sqrt(dias_ate_t1*SIG_RW_KF**2 + dias_t1_t2*SIG_ENTRE_DIA**2)
sd_deriva_antigo = 0.55*math.sqrt(max(dias, 0))
# Média da deriva (só na variante com momentum): metade do agregado das diferenças intra-instituto, em p.p./dia, vezes os dias que faltam.
MU_DERIVA, SD_MU_DERIVA, MOM = 0.0, 0.0, None
if MOMENTUM and POS_1T:
    import mudanca_2turno
    MOM = mudanca_2turno.resumo(DATA, hoje=HOJE)
    if MOM:
        MU_DERIVA = ENCOLHE_MOMENTUM * MOM["m_dia"] * dias_t1_t2
        SD_MU_DERIVA = ENCOLHE_MOMENTUM * max(MOM["se_dia"], SD_MOMENTUM_PISO) * dias_t1_t2   # o encolhimento vale para o erro também
BIAS_APLICADO = BIAS_MEAN
BIAS_SD_FINAL = BIAS_SD
# Antes do 1º turno, as pesquisas de 2º turno ainda não passaram pelo resultado do 1º turno. O desconto de BIAS_MEAN vem da rodada
# final (véspera do 2º turno) e subestima o erro das rodadas feitas antes do 1º turno: sem ajuste, o 2º turno partia das pesquisas
# de hoje com desconto de ~1 ponto, enquanto o 1º turno, com as mesmas pesquisas, recebia ~3,5, e as duas previsões não
# conversavam (a tabela "1º turno → 2º turno" partia de outra margem). Regra até 4/out: o 2º turno recebe o mesmo desconto do
# 1º turno (BIAS_1T_MEAN ± BIAS_1T_SD), porque um erro na margem do 1º turno se transmite quase um para um ao ponto de partida do
# 2º (inclinação da tabela 1º → 2º turno ≈ 1). O histórico das rodadas de 2º turno publicadas antes do 1º turno
# (vies_2t_pre_1turno.csv: 2014, 2018, 2022) tem erros ainda maiores (+3 a +16); ele fica como referência, não como regra,
# porque 2022 sozinho domina a média e porque em 2026 a distância entre 1º e 2º turno nas pesquisas já é pequena.
BIAS_PRE1T_HIST, BIAS_PRE1T_CICLOS = None, {}
_pre_path = DATA + "vies_2t_pre_1turno.csv"
if os.path.exists(_pre_path):
    _pre = pd.read_csv(_pre_path)
    BIAS_PRE1T_CICLOS = _pre.groupby("ciclo").erro_margem.mean().to_dict()
    _pp = {c_: 0.5 ** ((2022 - c_) / MEIA_VIDA_CICLOS) for c_ in BIAS_PRE1T_CICLOS}
    BIAS_PRE1T_HIST = round(sum(BIAS_PRE1T_CICLOS[c_]*_pp[c_] for c_ in BIAS_PRE1T_CICLOS) / sum(_pp.values()), 2)
BIAS_PRE1T_MEAN, BIAS_PRE1T_SD = BIAS_1T_MEAN, round(max(BIAS_SD, BIAS_1T_SD), 2)
# Auditoria de 3/out: a troca de regime dependia só da data (em 4/out o desconto caía de +3,5 para +0,95 sem resultado nem
# pesquisa nova). Agora depende do resultado: enquanto resultado_1turno.csv estiver vazio vale o desconto pré-1º turno. Com o
# resultado, o desconto das pesquisas de 2º turno é o histórico das rodadas finais mais uma fração do erro COMUM observado no 1º
# turno (nos três ciclos com dados, o erro do 2º turno acompanhou o do 1º com inclinação ≈ 0,25), e só incide sobre a parcela
# do nível que vem de pesquisas (a parcela estrutural vem de voto contado).
# Revisão de 22/out (v56): RHO_1T_2T passou de 0,25 para 0. O erro de 2026 no 1º turno teve a assinatura de voto útil: a média
# final acertou Lula (45,8 contra 45,16) e errou Flávio (43,1 contra 47,03) pelo que os demais perderam (11,0 contra 7,8). Esse
# mecanismo não existe num 2º turno com dois nomes; o erro próprio de 2º turno segue no desconto das rodadas finais (BIAS_MEAN).
# Com 0,25, o 2º turno herdava ~0,9 ponto (1,19 × a parcela de pesquisas) e a chance de Lula era 12,8% (ensemble 15,0%) em 22/out.
RHO_1T_2T, BIAS_POS1T_SD = 0.0, 2.5
# Revisão de 4/out (com o resultado): a regra acima vale para pesquisas feitas DEPOIS do 1º turno. As ondas de 2º turno feitas
# antes dele são as mesmas que, no 1º turno, erraram a margem em ERRO_COMUM_1T; antes do resultado elas recebiam o desconto
# suposto do 1º turno (BIAS_PRE1T_MEAN), e agora recebem o erro observado. Sem isso, na manhã de 5/out, sem nenhuma pesquisa nova,
# o desconto caía de +3,4 para +2,0 e a chance de Lula subia justamente no dia em que o resultado mostrou o erro contra ele.
# O desconto do nível é a média dos dois, pesada pela fração do nível que vem de ondas pós-1º turno (F_POS1T).
# A combinação com o nível estrutural (voto contado) usa o erro TOTAL do nível das pesquisas (amostral + sistemático): antes
# usava só o amostral (± 0,7 contra ± 2,5) e o resultado oficial pesava 8%.
BIAS_PRE1T_OBS = BIAS_POS_REGRA = None
if not POS_1T:
    BIAS_APLICADO, BIAS_SD = BIAS_PRE1T_MEAN, BIAS_PRE1T_SD
else:
    BIAS_PRE1T_OBS = float(ERRO_COMUM_1T if ERRO_COMUM_1T is not None else BIAS_PRE1T_MEAN)
    BIAS_POS_REGRA = BIAS_MEAN + RHO_1T_2T*(ERRO_COMUM_1T or 0.0)
    _b_pesq = F_POS1T*BIAS_POS_REGRA + (1 - F_POS1T)*BIAS_PRE1T_OBS
    _var_pesq = se_pesq**2 + BIAS_POS1T_SD**2
    _dias_desde_t1 = max((HOJE - T1).days, 0)
    SD_ESTRUT_HOJE = math.sqrt(SD_ESTRUT**2 + _dias_desde_t1 * SIG_ENTRE_DIA**2)     # foto de 4/out + deriva até hoje
    W_ESTRUT = float(_var_pesq / (_var_pesq + SD_ESTRUT_HOJE**2))
    level_final = (1 - W_ESTRUT)*level_pesq + W_ESTRUT*NIVEL_ESTRUT
    se_final = math.sqrt((1 - W_ESTRUT)**2*se_pesq**2 + W_ESTRUT**2*SD_ESTRUT_HOJE**2)
    BIAS_APLICADO = round((1 - W_ESTRUT)*_b_pesq, 2)
    BIAS_SD = round((1 - W_ESTRUT)*BIAS_POS1T_SD, 2)
    if SO_POS1T and not POS1T_COM_ESTRUT:
        # Variante: nível = só as pesquisas pós-1º turno, sem parcela de voto contado; todo o desconto é a regra pós-1º turno.
        F_POS1T, W_ESTRUT = 1.0, 0.0
        level_final, se_final = level_pesq, se_pesq
        BIAS_APLICADO, BIAS_SD = round(BIAS_POS_REGRA, 2), round(BIAS_POS1T_SD, 2)
SIG_REGIAO = 2.5
if REGIAO_UF:
    PESO_REGIAO = {r_: sum(PESO_UF[u_] for u_ in PESO_UF if REGIAO_UF[u_] == r_) for r_ in sorted(set(REGIAO_UF.values()))}
else:
    PESO_REGIAO = {"N": 0.085, "NE": 0.265, "CO": 0.075, "SE": 0.43, "S": 0.145}
# Choques regionais: retirados da simulação na auditoria de 3/out (ver simular); as constantes ficam só para o histórico do painel.
SD_RUIDO = math.sqrt(sum((SIG_REGIAO*w_)**2 for w_ in PESO_REGIAO.values()))

def simular(level, bias_mean, n=N, seed=1, se_level=None, sdd=None, pflav=None, bias_sd=None):
    r = np.random.default_rng(seed)
    # Deriva e erro sistemático com caudas t de Student (mesmo desvio da versão normal). O sorteio de choques por macrorregião foi
    # retirado: somava normais sem alterar nenhuma saída nacional (o erro total já era BIAS_SD).
    est    = r.normal(level, se_final if se_level is None else se_level, n)
    deriva = (sd_deriva if sdd is None else sdd) * _t_unit(r, n)
    if SD_MU_DERIVA > 0:      # variante com momentum: a deriva tem média (incerta); fora dela nada é sorteado e o fluxo aleatório não muda
        deriva = deriva + r.normal(MU_DERIVA, SD_MU_DERIVA, n)
    _bsd   = BIAS_SD if bias_sd is None else bias_sd
    vies   = bias_mean + _bsd * _t_unit(r, n)
    adv    = r.random(n) < (P_FLAVIO_2T if pflav is None else pflav)
    est_outro = r.normal(7.0, 4.0, n)
    m = np.where(adv, est, est_outro) + deriva - vies
    return m, adv

margem_final, adv_flavio = simular(level_final, BIAS_APLICADO)
p_lula   = float((margem_final > 0).mean())
p_lula_vs_flavio = float((margem_final[adv_flavio] > 0).mean())
p_empate = float((np.abs(margem_final) < 1.5).mean())
q = np.percentile(margem_final, [5, 10, 25, 50, 75, 90, 95])
var_parts = {"Agregado hoje (amostral+casas)": se_final**2, "Deriva até 25/out": sd_deriva**2,
             "Erro sistemático das pesquisas": BIAS_SD**2}
tot_var = sum(var_parts.values())
var_share = {k_: v/tot_var for k_, v in var_parts.items()}

sens = {}
for lab_l, lv in [("nowcast combinado", level_final), ("média ponderada últimas", media_simples_adj)]:
    for lab_b, bm in [("viés recalibrado", BIAS_APLICADO), ("sem correção de viés", 0.0)]:
        m_, _ = simular(lv, bm, n=200_000, seed=7)
        sens[f"{lab_l} | {lab_b}"] = round(float((m_ > 0).mean()), 3)
sens_bias = {f"{bm:+.1f}": round(float((simular(level_final, bm, n=200_000, seed=7)[0] > 0).mean()), 3) for bm in sorted({0.0, BIAS_MEAN, BIAS_APLICADO, 2.1})}

# "O que mudou no modelo (v3)": parte do resultado do modelo anterior (v2, valores do último resultados.json publicado) e liga um
# fator por vez. O nível "v2" é recomposto com a identificação antiga (média de todos os institutos, peso qualidade × nº de ondas).
_wq_all = {i_: peso_qualidade(i_) * int((jan.instituto == i_).sum()) for i_ in jan.instituto.unique()}
_shift_v2 = sum(fit_win["house"][i_]*_wq_all[i_] for i_ in _wq_all) / sum(_wq_all.values())   # média dos efeitos de casa sob a âncora (v2 forçava = 0)
level_v2_like = level_nac + _shift_v2
def _p_cfg(level, se_l, bias, bsd, sdd, pflav):
    m_, _ = simular(level, bias, n=200_000, seed=11, se_level=se_l, sdd=sdd, pflav=pflav, bias_sd=bsd)
    return round(float((m_ > 0).mean()), 3)
efeitos_fatores = {"Modelo anterior (v2): âncora em todos os institutos, viés +2,1, deriva 4,3": _p_cfg(level_v2_like, se_nac, 2.1, 2.0, 4.3, P_FLAVIO_2T_MERC)}
efeitos_fatores["+ âncora nos institutos calibrados (identificação dos efeitos de casa)"] = _p_cfg(level_nac, se_nac, 2.1, 2.0, 4.3, P_FLAVIO_2T_MERC)
efeitos_fatores[f"+ viés recalibrado ({BIAS_MEAN:+.1f} ± {BIAS_SD_FINAL})"] = _p_cfg(level_nac, se_nac, BIAS_MEAN, BIAS_SD_FINAL, 4.3, P_FLAVIO_2T_MERC)
efeitos_fatores[f"+ deriva em duas fases calibrada ({sd_deriva:.1f})"] = _p_cfg(level_nac, se_nac, BIAS_MEAN, BIAS_SD_FINAL, sd_deriva, P_FLAVIO_2T_MERC)
efeitos_fatores[f"+ P(Flávio no 2º turno) do modelo do 1º turno ({P_FLAVIO_2T:.2f})"] = _p_cfg(level_nac, se_nac, BIAS_MEAN, BIAS_SD_FINAL, sd_deriva, P_FLAVIO_2T)
if estadual["usado"]:
    efeitos_fatores["+ pesquisas estaduais"] = _p_cfg(level_final, se_final, BIAS_MEAN, BIAS_SD_FINAL, sd_deriva, P_FLAVIO_2T)
if BIAS_APLICADO != BIAS_MEAN:
    efeitos_fatores[f"+ viés das pesquisas de 2º turno anteriores ao 1º turno ({BIAS_APLICADO:+.1f} ± {BIAS_SD})"] = _p_cfg(level_final, se_final, BIAS_APLICADO, BIAS_SD, sd_deriva, P_FLAVIO_2T)

# ENSEMBLE — 75% modelo / 25% mercados / 0% fundamentos quando há preço de mercado válido; senão 100% modelo (a versão até 3/out usava
# 65/25/10 antes do 1º turno)
MERC = json.load(open(DATA + "mercados.json", encoding="utf-8"))   # leitura do mercado (valor, data, volume): fonte única para modelo e painel
P_MERC, SD_ALT = float(MERC["p_lula"]), 4.5
# Fundamentos: ótimo+bom do governo no Datafolha durante a campanha, antes do 1º turno (série homogênea; a versão anterior
# misturava a aprovação pessoal de Lula na CNT/Sensus de 2008, 77,7, com outras medidas). Margem = 2º turno do incumbente.
FUND_HIST = [(2006, 47.0, 21.66), (2014, 39.0, 3.28), (2022, 31.0, -1.80)]
FUND_MEDIDA = "Datafolha, durante a campanha"
APROVACAO_ATUAL = {"Datafolha 22–23/set": 34}   # mesma medida da série histórica (ótimo + bom)
aprovacao_media = float(np.mean(list(APROVACAO_ATUAL.values())))
_fx = np.array([a_ for _, a_, _ in FUND_HIST]); _fy = np.array([m_ for _, _, m_ in FUND_HIST])
FUND_B, FUND_A = np.polyfit(_fx, _fy, 1)
FUND_EMPATE = -FUND_A/FUND_B
mu_fund = float(FUND_A + FUND_B*aprovacao_media)
P_FUND = float(norm.cdf(mu_fund/SD_ALT))
mu_merc = SD_ALT * norm.ppf(P_MERC)
# Pesos (auditoria de 3/out). Fundamentos: reta com três eleições e desvio preditivo real acima de 8 pontos — a componente dizia
# ~50% e não informava; fica calculada para exibição, com peso 0. Mercado: só entra se o preço for recente (até 5 dias) e, no
# regime pós-1º turno, posterior ao resultado; um preço velho com 25% do peso contaminaria o resultado sem aviso. Um preço lido
# na própria noite de 4/out, depois da apuração, vale se mercados.json trouxer "pos_resultado": true.
_md = pd.Timestamp(MERC["data_iso"]) if MERC.get("data_iso") else None
MERC_VALIDO = bool(_md is not None and (HOJE - _md).days <= 5 and not (POS_1T and _md < T1)
                   and not (POS_1T and _md == T1 and not MERC.get("pos_resultado")))
W_ENS = {"modelo": 0.75, "mercados": 0.25, "fundamentos": 0.0} if MERC_VALIDO else {"modelo": 1.0, "mercados": 0.0, "fundamentos": 0.0}
r2 = np.random.default_rng(99)
comp = r2.random(N)
ens = np.where(comp < W_ENS["modelo"], margem_final,
               np.where(comp < W_ENS["modelo"] + W_ENS["mercados"], r2.normal(mu_merc, SD_ALT, N), r2.normal(mu_fund, SD_ALT, N)))
p_ens = float((ens > 0).mean())
cen = {"Lula por mais de 4 p.p.": float((ens > 4).mean()), "Lula por 0 a 4 p.p.": float(((ens > 0) & (ens <= 4)).mean()),
       "Flávio por 0 a 4 p.p.": float(((ens <= 0) & (ens > -4)).mean()), "Flávio por mais de 4 p.p.": float((ens <= -4).mean())}
p_ens_empate = float((np.abs(ens) < 1.5).mean())
q_ens = np.percentile(ens, [10, 50, 90])
def _saida_variante(variante):
    """Saída das variantes do herói: só o que o herói e a metodologia precisam."""
    return {
      "data_base": str(HOJE.date()), "atualizado_em": pd.Timestamp.now(tz="America/Sao_Paulo").strftime("%Y-%m-%dT%H:%M"), "variante": variante,
      "regime_pos_1t": POS_1T, "n_pesquisas": int(len(df)), "n_institutos": int(df.inst_base.nunique()), "n_series_casa": len(insts),
      "primeira_pesquisa": str(df.campo_fim.min().date()), "ultima_pesquisa": str(df.campo_fim.max().date()),
      "ancora_presente": sorted(set(ANCORA) & set(df.instituto)), "n_ondas_ancora": int(df.instituto.isin(ANCORA).sum()),
      "nivel_ajustado_hoje_margem_valid": round(level_final, 2), "se_total": round(se_final, 2),
      "nivel_pesquisas": round(level_pesq, 2), "se_pesquisas": round(se_pesq, 2), "var_ancora": round(fit_win["var_ancora"], 3),
      "nivel_estrutural": round(NIVEL_ESTRUT, 2), "sd_estrutural": round(SD_ESTRUT_HOJE, 2), "peso_estrutural": round(W_ESTRUT, 3), "f_pos1t": round(F_POS1T, 3), "lambda_casa_pos1t": None,
      "bias_aplicado": round(BIAS_APLICADO, 2), "bias_sd_aplicado": round(BIAS_SD, 2), "bias_pos_regra": round(BIAS_POS_REGRA, 2),
      "sigma_passeio_estimado": round(SIG_RW_KF, 3), "sigma_passeio_ml": round(SIG_RW_ML, 3), "sd_deriva": round(sd_deriva, 2), "sig_entre_turnos_dia": round(SIG_ENTRE_DIA, 3),
      "house_effects": {i: round(house[i], 2) for i in insts}, "sigma_inst": {i: round(sigma_inst[i], 2) for i in insts},
      "media_ponderada_ultimas_ajustada": round(media_simples_adj, 2),
      "p_lula": round(p_lula, 3), "p_lula_vs_flavio": round(p_lula_vs_flavio, 3), "p_empate_1p5": round(p_empate, 3), "sensibilidade": sens,
      "quantis_margem_final": {str(p): round(float(v), 1) for p, v in zip([5, 10, 25, 50, 75, 90, 95], q)},
      "p_ensemble": round(p_ens, 3), "p_ensemble_empate": round(p_ens_empate, 3), "pesos_ensemble": W_ENS, "mercado_valido": MERC_VALIDO, "p_mercados": P_MERC,
      "cenarios_ensemble": {k_: round(v, 3) for k_, v in cen.items()}, "q_ensemble_10_50_90": [round(float(v), 1) for v in q_ens],
      "mu_deriva": round(MU_DERIVA, 2), "sd_mu_deriva": round(SD_MU_DERIVA, 2),
    }
if SO_POS1T:
    # termina aqui para não tocar nenhum arquivo do modelo principal.
    res_pos = _saida_variante("pos1t_estrut" if POS1T_COM_ESTRUT else "so_pos1t")
    json.dump(res_pos, open(OUT + ("resultados_pos1t_estrut.json" if POS1T_COM_ESTRUT else "resultados_pos1t.json"), "w", encoding="utf-8"), ensure_ascii=False, indent=2)
    print(f"[Pós-1º turno{' + voto contado' if POS1T_COM_ESTRUT else ''}] {len(df)} pesquisas de {res_pos['n_institutos']} institutos desde {df.campo_fim.min():%d/%m} | nível {level_final:+.2f} ± {se_final:.2f} "
          f"(estrutural {NIVEL_ESTRUT:+.2f}, peso {W_ESTRUT:.0%}) | viés {BIAS_APLICADO:+.2f} ± {BIAS_SD} | σ_rw {SIG_RW_KF:.3f} | P(Lula)={p_lula:.3f} ensemble {p_ens:.3f}")
    sys.exit(0)

def p_dado_lead(lead):
    sd = math.sqrt(BIAS_SD**2 + 0.8**2)
    return 1 - norm.cdf(0, loc=lead - BIAS_APLICADO, scale=sd)
leads = np.arange(-2, 9, 1)
tabela_lead = {int(l): round(p_dado_lead(l), 3) for l in leads}
lead_breakeven = BIAS_APLICADO
lead_70 = BIAS_APLICADO + norm.ppf(0.70) * math.sqrt(BIAS_SD**2 + 0.64)
lead_90 = BIAS_APLICADO + norm.ppf(0.90) * math.sqrt(BIAS_SD**2 + 0.64)

# ----------------------------------------------------------------------------
# 4c. MODELO PARALELO POR MCMC (mcmc_2turno.py) — mesmas pesquisas da janela, mesmos priors e as mesmas peças do Monte Carlo acima
#     (efeitos de casa ancorados, prior do erro de cada instituto no 1º turno, quebra de 4/out, nível estrutural, desconto de viés por
#     regime com caudas t, deriva em duas fases), mas com o posterior completo: σ_rw, σ_entre, σ_p, efeitos de casa, viés e trajetória
#     sorteados em conjunto, em vez de fixados em estimativas pontuais. Não altera nenhuma saída do modelo principal (manchete).
# ----------------------------------------------------------------------------
import time as _time
import mcmc_2turno
_t0_mc = _time.time()
_ult = max(t_now, dia(T1) + 1) if POS_1T else t_now          # último dia do estado: inclui o dia seguinte ao 1º turno (voto contado)
_mu_obs = (np.where(jan.campo_fim > T1, BIAS_POS_REGRA, BIAS_PRE1T_OBS) if POS_1T else np.full(len(jan), BIAS_PRE1T_MEAN))
_extras = []
if POS_1T:
    _extras.append({"dia": dia(T1) + 1 - d0_jan, "valor": NIVEL_ESTRUT, "sd": SD_ESTRUT, "com_vies": False})
if estadual["usado"]:
    _extras.append({"dia": t_now - d0_jan, "valor": estadual["nivel"], "sd": estadual["se"], "com_vies": True,
                    "mu": BIAS_POS_REGRA if POS_1T else BIAS_PRE1T_MEAN})
_fut = np.arange(_ult + 1, dia(T2) + 1)
MC = mcmc_2turno.amostrar(jan.margem_valid.values, (jan.dia - d0_jan).values, jan.se2.values, jan.instituto.values, _ult - d0_jan + 1,
                          set(ANCORA), Q_ANCORA, mu_obs=_mu_obs, sd_vies=(BIAS_POS1T_SD if POS_1T else BIAS_PRE1T_SD),
                          prior_h=prior_h_2t, tau_h=tau_prior_h or 1.5, dia_t1=dia(T1) - d0_jan,
                          sd_quebra=(SD_QUEBRA_1T if POS_1T else 0.0), extras=_extras,
                          prior_entre=(math.log(SIG_ENTRE_DIA), 0.5), n_fut_rw=int((_fut <= dia(T1)).sum()),
                          n_fut_entre=int((_fut > dia(T1)).sum()), dia_hoje=t_now - d0_jan, a_h=jan.a_casa.values,
                          prior_mu_der=(MU_DERIVA, SD_MU_DERIVA))
_S = MC["amostras"]
margem_mcmc = _S["x_final"].ravel()
_rm = np.random.default_rng(5)
_adv_m = _rm.random(len(margem_mcmc)) < P_FLAVIO_2T
margem_mcmc = np.where(_adv_m, margem_mcmc, _rm.normal(7.0, 4.0, len(margem_mcmc)))
p_lula_mcmc = float((margem_mcmc > 0).mean())
q_mcmc = np.percentile(margem_mcmc, [5, 10, 25, 50, 75, 90, 95])
_comp_m = _rm.random(len(margem_mcmc))
ens_mcmc = np.where(_comp_m < W_ENS["modelo"], margem_mcmc,
                    np.where(_comp_m < W_ENS["modelo"] + W_ENS["mercados"], _rm.normal(mu_merc, SD_ALT, len(margem_mcmc)),
                             _rm.normal(mu_fund, SD_ALT, len(margem_mcmc))))
def _qs(a_, ps=(10, 50, 90)): return [round(float(v), 2) for v in np.percentile(a_, ps)]
_xh = _S["x_hoje"].ravel()
# variância da margem final do MCMC decomposta como na seção 4 (nível de hoje, deriva até 25/out; o viés já está dentro do nível)
_var_der = float(_S["deriva"].var()); _var_niv = float(_xh.var())
MCMC_CONVERGIU = MC["rhat_max"] < 1.05
mcmc_res = {
    "p_lula": round(p_lula_mcmc, 3), "p_lula_vs_flavio": round(float((margem_mcmc[_adv_m] > 0).mean()), 3),
    "p_empate_1p5": round(float((np.abs(margem_mcmc) < 1.5).mean()), 3),
    "p_ensemble": round(float((ens_mcmc > 0).mean()), 3), "q_ensemble_10_50_90": _qs(ens_mcmc),
    "quantis_margem_final": {str(p): round(float(v), 1) for p, v in zip([5, 10, 25, 50, 75, 90, 95], q_mcmc)},
    "nivel_hoje": round(float(_xh.mean()), 2), "se_nivel_hoje": round(float(_xh.std()), 2),
    "nivel_hoje_q": _qs(_xh), "sd_deriva": round(float(_S["deriva"].std()), 2),
    "var_share": {"Nível de hoje (pesquisas, casas, viés, voto contado)": round(_var_niv/(_var_niv + _var_der), 3),
                  "Deriva até 25/out": round(_var_der/(_var_niv + _var_der), 3)},
    "sig_rw": _qs(_S["sig_rw"]), "sig_entre": _qs(_S["sig_entre"]), "beta": _qs(_S["beta"]), "m_sigma_inst": _qs(_S["m_sigp"]),
    "house_effects": {p_: _qs(_S["h"][:, :, j_]) for j_, p_ in enumerate(MC["insts"])},
    "sigma_inst": {p_: _qs(_S["sig_p"][:, :, j_]) for j_, p_ in enumerate(MC["insts"])},
    "trajetoria": {"inicio": str((T0 + pd.Timedelta(days=d0_jan)).date()),
                   "q10_50_90": [[round(float(v), 2) for v in col] for col in np.percentile(_S["x"].reshape(-1, _S["x"].shape[-1]), [10, 50, 90], axis=0).T]},
    "desconto_obs": {"antes_1t": round(float(_mu_obs[(jan.campo_fim <= T1).values].mean()), 2) if (jan.campo_fim <= T1).any() else None,
                     "depois_1t": (round(float(BIAS_POS_REGRA), 2) if POS_1T else None), "sd_vies": BIAS_POS1T_SD if POS_1T else BIAS_PRE1T_SD},
    "obs_extras": [{k_: (round(v, 2) if isinstance(v, float) else v) for k_, v in e_.items()} for e_ in _extras],
    "rhat_max": round(MC["rhat_max"], 3), "ess_min": MC["ess_min"], "convergiu": MCMC_CONVERGIU,
    "diagnostico": {k_: MC["diagnostico"][k_] for k_ in ["x_hoje", "x_final", "sig_rw", "sig_entre", "beta", "m_sigp"]},
    "aceitacao": MC["aceitacao"], "n_cadeias": MC["n_cadeias"], "n_aquec": MC["n_aquec"], "n_amostras": MC["n_amostras"],
    "sig_entre_tem_dados": MC["entre_tem_dados"], "segundos": round(_time.time() - _t0_mc, 1),
    # comparação com o modelo principal: o nível dele é o das pesquisas (antes do desconto); o "verdadeiro" é nível − viés aplicado
    "comparacao": {"p_lula_principal": round(p_lula, 3), "p_ensemble_principal": round(p_ens, 3),
                   "nivel_verdadeiro_principal": round(level_final - BIAS_APLICADO, 2),
                   "se_nivel_verdadeiro_principal": round(math.sqrt(se_final**2 + BIAS_SD**2), 2),
                   "mediana_principal": round(float(q[3]), 1), "sd_deriva_principal": round(sd_deriva, 2),
                   "sig_rw_principal": round(SIG_RW_KF, 3), "sig_entre_principal": round(SIG_ENTRE_DIA, 3)},
}
print(f"[MCMC] {MC['n_cadeias']}×{MC['n_amostras']} sorteios | hoje {mcmc_res['nivel_hoje']:+.2f} ± {mcmc_res['se_nivel_hoje']:.2f} | "
      f"σ_rw {mcmc_res['sig_rw'][1]:.3f} | P(Lula)={p_lula_mcmc:.3f} (principal {p_lula:.3f}) | ensemble {mcmc_res['p_ensemble']:.3f} "
      f"(principal {p_ens:.3f}) | R̂ máx {MC['rhat_max']:.3f} ESS mín {MC['ess_min']} | {mcmc_res['segundos']} s")
if not MCMC_CONVERGIU:
    print(f"[AVISO] MCMC sem convergência (R̂ máx {MC['rhat_max']:.3f} > 1,05): resultado do modelo paralelo não confiável.")
if MOMENTUM:
    # Variante com momentum: sai depois do MCMC (que recebeu a mesma prior de deriva), sem tocar nenhum arquivo do modelo principal.
    res_mom = _saida_variante("momentum")
    res_mom["momentum"] = ({k_: (round(v_, 4) if isinstance(v_, float) else v_) for k_, v_ in MOM.items() if k_ != "lento"} if MOM else None)
    res_mom["encolhimento_momentum"] = ENCOLHE_MOMENTUM; res_mom["sd_momentum_piso_dia"] = SD_MOMENTUM_PISO
    res_mom["mcmc"] = {k_: mcmc_res[k_] for k_ in ["p_lula", "p_ensemble", "quantis_margem_final", "q_ensemble_10_50_90", "nivel_hoje", "se_nivel_hoje",
                                                   "sd_deriva", "rhat_max", "ess_min", "convergiu", "segundos"]}
    res_mom["mcmc"]["mu_der"] = _qs(_S["mu_der"]) if "mu_der" in _S else None
    json.dump(res_mom, open(OUT + "resultados_momentum.json", "w", encoding="utf-8"), ensure_ascii=False, indent=2)
    print(f"[Momentum] deriva com média {MU_DERIVA:+.2f} ± {SD_MU_DERIVA:.2f} (agregado {MOM['taxa']:+.2f} ± {MOM['se_taxa']:.2f} p.p./semana, {MOM['n_dif']} diferenças de {MOM['n_inst']} séries, encolhido {ENCOLHE_MOMENTUM:.0%})"
          if MOM else "[Momentum] sem diferenças suficientes: deriva com média zero")
    print(f"[Momentum] nível {level_final:+.2f} ± {se_final:.2f} | P(Lula)={p_lula:.3f} ensemble {p_ens:.3f} | MCMC {p_lula_mcmc:.3f} ensemble {mcmc_res['p_ensemble']:.3f}")
    sys.exit(0)

# ----------------------------------------------------------------------------
# 4b. ABSTENÇÃO — quanto a margem nacional muda se o comparecimento de 2026 se afastar do padrão de 2022.
#     Dois canais: (i) geográfico, entre UFs: cada UF pesa no total pelo seu comparecimento, então faltar mais onde um
#     candidato lidera reduz o peso dessa UF; (ii) composicional, dentro de cada UF: quem falta é mais pobre e menos
#     escolarizado (abstenção de 52% entre analfabetos × 12% no superior, 1T 2022), grupos em que Lula lidera; calibrado em
#     K = 0,25 p.p. de margem por p.p. de abstenção (Laureno: +4 p.p. de abstenção ≈ −1 ponto na vantagem de Lula), faixa 0–0,4.
#     O padrão de 2022 já está embutido no desconto histórico (as finais são comparadas com resultados que incluem a abstenção
#     daquele ano), então os cenários medem só o DESVIO em relação a 2022, sem dupla contagem.
# ----------------------------------------------------------------------------
abst = {"usado": False}
_abst_path = DATA + "abstencao_2022_uf.csv"
if ref_uf is not None and os.path.exists(_abst_path):
    K_COMP, K_COMP_LO, K_COMP_HI = 0.25, 0.0, 0.40
    au = ref_uf.merge(pd.read_csv(_abst_path), on="uf", how="inner").copy()
    _sw = {}
    if estadual.get("por_uf") is not None:
        _sw = dict(zip(estadual["por_uf"].uf, estadual["por_uf"].swing))
    _mu_reg = estadual.get("mu_regiao", {}); _mu_s = estadual.get("mu_swing", 0.0)
    au["swing"] = [_sw.get(u_, _mu_reg.get(r_, _mu_s)) for u_, r_ in zip(au.uf, au.regiao)]
    au["a0"] = au.abst_2t_2022/100
    def _nac(a_vec, marg):
        V_ = au.eleitorado*(1 - a_vec)
        return float((V_*marg).sum()/V_.sum())
    _m_raw = au.m22 + au.swing
    _c = level_final - _nac(au.a0, _m_raw)            # alinha o nível por UF ao placar nacional do modelo
    au["m26"] = _m_raw + _c
    base_nac = _nac(au.a0, au.m26)
    def cenario(delta_reg=None, delta_uf=None, k=K_COMP):
        d_ = np.zeros(len(au))
        if delta_reg: d_ = np.array([delta_reg.get(r_, 0.0) for r_ in au.regiao])/100
        if delta_uf is not None: d_ = np.asarray(delta_uf)/100
        geo = _nac(au.a0 + d_, au.m26) - base_nac
        V_ = au.eleitorado*(1 - au.a0)
        comp = -k*100*float((V_*d_).sum()/V_.sum())  # média ponderada do aumento de abstenção × K
        return geo, comp
    _todas = {r_: 1.0 for r_ in au.regiao.unique()}
    cen_defs = [
        ("Abstenção igual à de 2022 (20,6%)", {}, None),
        ("Sobe para cerca de 22% em todo o país (projeção de analistas)", {r_: 1.4 for r_ in _todas}, None),
        ("Sobe para cerca de 25% em todo o país (cenário de desinteresse)", {r_: 4.4 for r_ in _todas}, None),
        ("Nordeste falta mais: +3 p.p. só no NE", {"NE": 3.0}, None),
        ("Sul e Centro-Oeste faltam mais: +3 p.p. só em S e CO", {"S": 3.0, "CO": 3.0}, None),
        ("Mobilização no Nordeste: −2 p.p. só no NE", {"NE": -2.0}, None),
        ("Repete a variação 1º→2º turno de 2022, UF por UF", None, (au.abst_2t_2022 - au.abst_1t_2022).values),
    ]
    _cen_out = []
    for nome_, dreg_, duf_ in cen_defs:
        geo_, comp_ = cenario(dreg_ or None, duf_)
        _, comp_lo = cenario(dreg_ or None, duf_, K_COMP_LO)
        _, comp_hi = cenario(dreg_ or None, duf_, K_COMP_HI)
        dm_ = geo_ + comp_
        m_, _ = simular(level_final + dm_, BIAS_APLICADO, n=200_000, seed=7)
        _cen_out.append({"cenario": nome_, "geo": round(geo_, 2), "comp": round(comp_, 2), "total": round(dm_, 2),
                         "total_faixa": [round(geo_ + comp_lo, 2), round(geo_ + comp_hi, 2)], "p_lula": round(float((m_ > 0).mean()), 3)})
    _p_base = _cen_out[0]["p_lula"]
    for c_ in _cen_out: c_["dp_lula"] = round(c_["p_lula"] - _p_base, 3)
    _geo_ne1, _comp_ne1 = cenario({"NE": 1.0}); _geo_u1, _comp_u1 = cenario({r_: 1.0 for r_ in _todas})
    reg_tab = []
    for r_, g_ in au.groupby("regiao"):
        V_ = g_.eleitorado*(1 - g_.a0)
        reg_tab.append({"regiao": r_, "eleitorado_pct": round(100*float(g_.eleitorado.sum()/au.eleitorado.sum()), 1),
                        "abst_1t_2022": round(float((g_.abst_1t_2022*g_.eleitorado).sum()/g_.eleitorado.sum()), 1),
                        "abst_2t_2022": round(float((g_.abst_2t_2022*g_.eleitorado).sum()/g_.eleitorado.sum()), 1),
                        "m22": round(float((V_*g_.m22).sum()/V_.sum()), 1), "m26": round(float((V_*g_.m26).sum()/V_.sum()), 1)})
    abst = {"usado": True, "k_comp": K_COMP, "k_faixa": [K_COMP_LO, K_COMP_HI], "base_nac": round(base_nac, 2),
            "check_m22": round(_nac(au.a0, au.m22), 2), "abst_base_pond": round(float((au.abst_2t_2022*au.eleitorado).sum()/au.eleitorado.sum()), 2),
            "cenarios": _cen_out, "por_regiao": sorted(reg_tab, key=lambda d_: -d_["eleitorado_pct"]),
            "por_pp_ne": round(_geo_ne1 + _comp_ne1, 2), "por_pp_nacional": round(_geo_u1 + _comp_u1, 2)}
    print(f"[Abstenção] base {base_nac:+.2f} (check 2022 {abst['check_m22']:+.2f}) | +1 p.p. NE: {abst['por_pp_ne']:+.2f} | +1 p.p. nacional: {abst['por_pp_nacional']:+.2f}")

# ----------------------------------------------------------------------------
# 5. MODELO ESTRUTURAL DE TRANSFERÊNCIA (1º → 2º turno) E TORNADO
# ----------------------------------------------------------------------------
base_m, base_l, base_f = estrutural_2t()
tornado = []
def add(nome, lo_kwargs, hi_kwargs, obs):
    lo = estrutural_2t(**lo_kwargs)[0] - base_m
    hi = estrutural_2t(**hi_kwargs)[0] - base_m
    tornado.append({"variavel": nome, "baixo": lo, "alto": hi, "obs": obs})
add("Aprovação do governo (−3 / +3 p.p. → base de Lula)", dict(L=agg1["L"]-2.4), dict(L=agg1["L"]+2.4),
    "elasticidade ≈0,8 voto/p.p. de aprovação (Lavareda: aprovação ≈ voto no 2º turno)")
add("Transferência da 3ª via para Lula (−1/3 / +50% vs. Quaest)", dict(mult_lula=0.67), dict(mult_lula=1.5),
    "hoje ~2:1 para Flávio; se Cury/Caiado dividissem 50/50, +3 p.p. de margem")
add("Transferência da 3ª via para Flávio (−20% / +15%)", dict(mult_flavio=1.15), dict(mult_flavio=0.80),
    "consolidação antipetista vs. dispersão em branco/nulo")
add("Indecisos/brancos que decidem: divisão (35% / 65% Lula)", dict(split_indec=0.35), dict(split_indec=0.65),
    "estoque de 10–17%; Atlas: independentes Lula 38 × Flávio 26")
if abst["usado"]:
    _tots = [c_["total"] for c_ in abst["cenarios"]]
    tornado.append({"variavel": "Abstenção em relação a 2022 (cenários da seção Abstenção)", "baixo": min(_tots), "alto": max(_tots),
                    "obs": "geográfico + perfil de quem falta (K = 0,25 p.p. por p.p.); de 25% de abstenção a mobilização no NE"})
else:
    add("Abstenção diferencial (Lula −1,5 p.p. / 0)", dict(abst_dif=1.5), dict(abst_dif=0.0),
        "abstenção 21%→>22%; eleitor de menor renda/NE falta mais (Ibmec)")
add("Base de Flávio (evangélicos/segurança: +2 / −2 p.p.)", dict(F=agg1["F"]+2), dict(F=agg1["F"]-2),
    "evangélicos ≈30% do eleitorado; Flávio 62×29 (Datafolha)")
tornado.append({"variavel": f"Erro sistemático das pesquisas ({-(BIAS_APLICADO+BIAS_SD):.0f} / {-(BIAS_APLICADO-BIAS_SD):+.0f} p.p.)",
                "baixo": -(BIAS_APLICADO + BIAS_SD), "alto": -(BIAS_APLICADO - BIAS_SD),
                "obs": "medição, não causa: pesquisas finais superestimaram o PT em 3 dos 5 últimos 2º turnos"})
tornado_df = pd.DataFrame(tornado)
tornado_df["amplitude"] = (tornado_df.alto - tornado_df.baixo).abs()
tornado_df = tornado_df.sort_values("amplitude", ascending=False)

# ----------------------------------------------------------------------------
# 6. O QUE O 1º TURNO DIRÁ SOBRE O 2º — margem inicial do 2º turno implicada por cada resultado do 1º (transferência declarada)
# ----------------------------------------------------------------------------
def margem_2t_dado_1t(margem_1t):
    """Desloca Lula e Flávio simetricamente até a margem observada e aplica a transferência da 3ª via + indecisos."""
    d_ = (margem_1t - (share_now["lula"] - share_now["flavio"])) / 2
    L_, F_ = agg1["L"] + d_*_resto/100, agg1["F"] - d_*_resto/100
    return estrutural_2t(L=L_, F=F_)[0]
# A tabela parte do resultado oficial do 1º turno (não de pesquisa), então não leva erro de medição: a incerteza é a deriva entre
# os turnos e a da própria transferência declarada (2ª opção), da ordem da amplitude das variáveis de transferência no tornado.
SD_TRANSF = 2.0
_sd_t12 = math.sqrt(max(dias_t1_t2, 0)*SIG_ENTRE_DIA**2 + SD_TRANSF**2)
tabela_1t_2t = [{"margem_1t": m_, "margem_2t_inicial": round(margem_2t_dado_1t(m_), 1),
                 "p_lula_2t": round(float(1 - norm.cdf(0, loc=margem_2t_dado_1t(m_), scale=_sd_t12)), 3)}
                for m_ in [-6, -4, -2, 0, 2, 4, 6, 8, 10]]
hist_1t = [("2006", "Lula", 56.0, 48.6, "Datafolha 12/set: 50% totais (≈56% válidos)"),
           ("2010", "Dilma", 55.0, 46.9, "Datafolha 21–22/set: 51% totais (≈55% válidos)"),
           ("2014", "Dilma", 46.0, 41.6, "Datafolha 29–30/set: 40% totais (≈46% válidos)"),
           ("2018", "Bolsonaro", 33.0, 46.0, "Datafolha 26–28/set: 28% totais (≈33% válidos) — onda antipetista tardia"),
           ("2022", "Lula", 50.0, 48.4, "Datafolha 1/out: 50% válidos")]

# ----------------------------------------------------------------------------
# 6a. INFLUÊNCIA — quanto cada pesquisa dos últimos 7 dias desloca o nível de hoje (reajuste sem ela). Em séries que terminam hoje, a
#     última pesquisa pesa muito; a tabela mostra de onde vem cada movimento do placar.
# ----------------------------------------------------------------------------
_inf = {}
_corte = HOJE - pd.Timedelta(days=7)
for i_ in jan.index[jan.campo_fim > _corte]:
    f_ = _ajusta_janela(jan.drop(i_))
    _inf[(jan.instituto[i_], str(jan.campo_fim[i_].date()))] = {"d_nivel_2t": round(level_nac - float(f_["x"][-1]), 2)}
if not POS_1T:
    for i_ in p1.index[p1.campo_fim > _corte]:
        f_ = _fit_par("margem_v", _se2_m1, 1.5, 0.8, p1.drop(i_))
        _inf.setdefault((p1.instituto[i_], str(p1.campo_fim[i_].date())), {})["d_margem_1t"] = round(_m1 - float(f_["x"][-1]), 2)
influencia = sorted([{"instituto": k_[0], "campo_fim": k_[1], **v} for k_, v in _inf.items()],
                    key=lambda d_: -max(abs(d_.get("d_nivel_2t", 0)), abs(d_.get("d_margem_1t", 0))))

# ----------------------------------------------------------------------------
# 6b. DIAGNÓSTICO DE VIÉS E DISPERSÃO POR INSTITUTO (resíduos do modelo único)
# ----------------------------------------------------------------------------
def moe95(n_): return 1.96 * math.sqrt(0.25 / n_) * 100
df["x_tend"] = [float(fit_full["x"][d_]) for d_ in df.dia]
df["resid_spline"] = df.margem_valid - df.x_tend                       # desvio da tendência comum (casa + ruído)
df["resid_full"]   = df.resid_spline - df.house_aplicado                # após remover o efeito de casa (o aplicado: λ·h depois do 1º turno)
df["moe_declarada"] = df.n.map(moe95)
df["moe_margem_95"] = 1.96 * df.se_margem_teorico
diag = []
for i_ in insts:
    sub = df[df.instituto == i_]
    n_i = len(sub)
    # variância posterior do efeito de casa vinda do próprio ajuste (conta a_casa = λ nas ondas pós-1º turno, que identificam só λ·h);
    # a soma simples das precisões superestimava a informação dessas ondas
    _hv = (fit_win.get("house_var") or {}).get(i_, (fit_full.get("house_var") or {}).get(i_))
    se_h = math.sqrt(_hv) if _hv else math.sqrt(1.0 / (sub.w_precisao.sum() + 1/1.5**2))
    rms_tend = float(np.sqrt((sub.resid_spline ** 2).mean()))
    rms_full = float(np.sqrt((sub.resid_full ** 2).mean()))
    se_teo = float(sub.se_margem_teorico.mean())
    diag.append({"instituto": i_, "n_ondas": int(n_i), "n_medio": int(sub.n.mean()),
                 "metodo": sub.sort_values("campo_fim").metodo.iloc[-1], "contratante": sub.sort_values("campo_fim").contratante.iloc[-1],
                 "house": round(house[i_], 2), "house_se": round(se_h, 2), "house_z": round(house[i_] / se_h, 1),
                 "sigma_extra": round(sigma_inst[i_], 2), "ancora": i_ in ANCORA,
                 "rms_vs_tendencia": round(rms_tend, 2), "rms_idiossincratico": round(rms_full, 2),
                 "moe_declarada": round(float(sub.moe_declarada.mean()), 2), "moe_margem_95": round(float(sub.moe_margem_95.mean()), 2),
                 "se_margem_teorico": round(se_teo, 2), "razao_erro_teorico": round(rms_tend / se_teo, 2),
                 "mae_hist": round(float(mae_inst[i_]), 2) if i_ in mae_inst.index else None,
                 "n_obs_hist": int(n_inst[i_]) if i_ in n_inst.index else 0, "peso_qualidade": round(peso_qualidade(i_), 2),
                 "ultima_onda": sub.campo_fim.max().strftime("%d/%m"), "diagnostico_confiavel": bool(n_i >= 3)})
diag = sorted(diag, key=lambda d: -d["n_ondas"])
rms_tend_global = float(np.sqrt((df.resid_spline ** 2).mean()))
se_teo_global = float(df.se_margem_teorico.mean())
moe_decl_global = float(df.moe_declarada.mean()); moe_margem_global = float(df.moe_margem_95.mean())
razao_global = rms_tend_global / se_teo_global
sigma2 = float((df.resid_full**2).mean())
recentes = df[df.idade_dias <= 21]
# Teste de manada (auditoria de 3/out): compara a dispersão dos RESÍDUOS (margem − tendência − efeito de casa) com o erro amostral.
# A versão anterior usava as margens brutas, que carregam as diferenças permanentes entre institutos: razão acima de 1 era certa
# e não dizia nada. Razão bem abaixo de 1 nos resíduos indicaria pesquisas mais parecidas entre si do que o acaso permite.
sd_bruto_recente = float(recentes.margem_valid.std(ddof=1))
sd_obs_recente = float(np.sqrt((recentes.resid_full ** 2).mean())); sd_esp_recente = float(np.sqrt((recentes.se_margem_teorico ** 2).mean()))
herding_ratio = sd_obs_recente / sd_esp_recente

# ----------------------------------------------------------------------------
# 5b. MIGRAÇÃO — ausentes, terceira via e brancos/nulos por cadeia de Markov + Monte Carlo (migracao_markov.py). Análise paralela e
#     condicional: parte do resultado oficial por UF e da 2ª opção declarada, sorteia a matriz de transição (Dirichlet) e os fluxos
#     brutos de comparecimento, simula dia a dia a campanha de conversa pessoal do 2º turno (guia "Três nomes, três semanas") e a
#     resposta de Flávio, e compara com as metas do guia. Não altera nenhuma saída do modelo principal (manchete).
# ----------------------------------------------------------------------------
import migracao_markov
migracao_res = {"usado": False, "motivo": "só com o resultado oficial do 1º turno por UF"}
if POS_1T and migracao_markov.disponivel(DATA):
    _ini_cad = str((_tr.campo_fim.max() + pd.Timedelta(days=1)).date()) if (_tr.campo_fim > T1).any() else None   # 2ª opção pós-1º turno: a cadeia começa depois do campo
    migracao_res = migracao_markov.simular(DATA, transf, HOJE, sd_deriva, regiao_uf=REGIAO_UF, transf_fonte=TRANSF_FONTE, inicio_cadeia=_ini_cad,
                                           comparacao={"estrutural_base_margem": round(base_m, 2), "p_lula_principal": round(p_lula, 3), "p_lula_mcmc": round(p_lula_mcmc, 3),
                                                       "mediana_principal": round(float(q[3]), 1), "sd_deriva_principal": round(sd_deriva, 2)})
    _mg = {c_["id"]: c_ for c_ in migracao_res["cenarios"] if c_["resposta"] == "nenhuma"}
    print(f"[Migração] {migracao_res['n_sorteios']} sorteios × {len(migracao_res['cenarios'])} células | sem campanha {_mg['sem']['margem_q']['50']:+.2f} "
          f"(P(Lula) com deriva {_mg['sem']['p_lula_com_deriva']:.3f}; principal {p_lula:.3f}) | forte {_mg['forte']['margem_q']['50']:+.2f} "
          f"(saldo {_mg['forte']['saldo_frentes']['total']['q50']/1e6:+.2f} mi; P {_mg['forte']['p_lula_com_deriva']:.3f}) | metas do guia: margem "
          f"{migracao_res['guia']['margem_se_cumprido']:+.2f}, conversão necessária {migracao_res['guia']['taxa_necessaria']['terceira_via']['razao']:.0f}× a literatura "
          f"| checks {'ok' if all(migracao_res['checks'].values()) else 'FALHOU'} | {migracao_res['segundos']} s")
    if not all(migracao_res["checks"].values()):
        print(f"[AVISO] migração: verificação falhou: {migracao_res['checks']}")

# ----------------------------------------------------------------------------
# 7. SAÍDAS — JSON + CSVs + SVGs
# ----------------------------------------------------------------------------
res = {
  "data_base": str(HOJE.date()), "atualizado_em": pd.Timestamp.now(tz="America/Sao_Paulo").strftime("%Y-%m-%dT%H:%M"), "versao_modelo": 3,
  "influencia": influencia, "df_erro": DF_ERRO, "mae_rel": {i: round(float(mae_rel[i]), 2) for i in mae_rel.index}, "mae_ref": MAE_REF,
  "erro_comum_1t": (None if ERRO_COMUM_1T is None else round(ERRO_COMUM_1T, 2)), "rho_1t_2t": RHO_1T_2T, "sd_quebra_1t": SD_QUEBRA_1T,
  "nivel_pesquisas": round(level_pesq, 2), "se_pesquisas": round(se_pesq, 2), "nivel_estrutural": (None if NIVEL_ESTRUT is None else round(NIVEL_ESTRUT, 2)),
  "sd_estrutural": round(SD_ESTRUT_HOJE, 2), "sd_estrutural_base": SD_ESTRUT, "peso_estrutural": round(W_ESTRUT, 3), "f_pos1t": round(F_POS1T, 3), "n_pos1t": N_POS1T, "bias_pre1t_obs": None if BIAS_PRE1T_OBS is None else round(BIAS_PRE1T_OBS, 2), "bias_pos_regra": None if BIAS_POS_REGRA is None else round(BIAS_POS_REGRA, 2), "mercado_valido": MERC_VALIDO, "transf_fonte": TRANSF_FONTE,
  "transf": {k_: [round(v[0], 2), round(v[1], 2)] for k_, v in transf.items()},
  "t1_margem_nivel": round(share_now["lula"] - share_now["flavio"], 2), "t1_margem_se": round(SE_M1, 2), "t1_sig_rw_margem": round(float(fit_m1["sig_rw"]), 3),
  "t1_house_margem": {i_: round(v, 2) for i_, v in fit_m1["house"].items()}, "var_ancora": round(fit_win["var_ancora"], 3), "regime_pos_1t": POS_1T, "n_pesquisas": int(len(df)), "n_institutos": int(df.inst_base.nunique()), "n_series_casa": len(insts),
  "ancora": ANCORA, "q_ancora": {k_: round(v, 2) for k_, v in Q_ANCORA.items()}, "inicio_janela": str(INICIO_JANELA.date()), "n_pesquisas_janela": int(len(jan)),
  "diagnostico_institutos": diag, "rms_vs_tendencia_global": round(rms_tend_global, 2),
  "moe_declarada_media": round(moe_decl_global, 2), "moe_margem_95_media": round(moe_margem_global, 2),
  "se_margem_teorico_medio": round(se_teo_global, 2), "razao_erro_teorico_global": round(razao_global, 2),
  "herding": {"sd_observado": round(sd_obs_recente, 2), "sd_esperado_amostral": round(sd_esp_recente, 2), "razao": round(herding_ratio, 2), "n_ondas_21d": int(len(recentes)), "sd_bruto": round(sd_bruto_recente, 2)},
  "validacao_rolling": VALIDACAO,
  "nivel_ajustado_hoje_margem_valid": round(level_final, 2), "se_total": round(se_final, 2),
  "nivel_pspline": round(level_pspline, 2), "se_pspline": round(se_pspline, 2), "nivel_kalman": round(level_kf, 2), "se_kalman": round(se_kf, 2),
  "nivel_nacional_combinado": round(level_nac, 2), "se_nacional_combinado": round(se_nac, 2), "nivel_v2_like": round(level_v2_like, 2), "shift_ancora": round(_shift_v2, 2),
  "sigma_passeio_estimado": round(SIG_RW_KF, 3), "sigma_passeio_ml": round(SIG_RW_ML, 3), "sigma_extra_kalman": round(float(np.median(list(sigma_inst.values()))), 2), "sd_diario_usado": round(SD_DIARIO, 3),
  "sig_entre_turnos_dia": round(SIG_ENTRE_DIA, 3), "sig_entre_turnos_bruto": round(SIG_ENTRE_DIA_BRUTO, 3), "mult_entre_turnos": MULT_ENTRE_TURNOS, "sd_deriva_antigo": round(sd_deriva_antigo, 2),
  "movimento_entre_turnos": mov.to_dict(orient="records"),
  "bias_metodo": BIAS_METODO, "bias_n_metodo": {k_: int(v) for k_, v in BIAS_N_METODO.items()}, "bias_aplicado": round(BIAS_APLICADO, 2), "bias_sd_aplicado": round(BIAS_SD, 2), "bias_pre1t_mean": BIAS_PRE1T_MEAN, "bias_pre1t_sd": BIAS_PRE1T_SD, "bias_pre1t_ciclos": {str(k_): round(v, 2) for k_, v in BIAS_PRE1T_CICLOS.items()}, "bias_pre1t_hist": BIAS_PRE1T_HIST, "bias_final_mean": BIAS_MEAN,
  "bias_2t_ciclos": {str(k_): round(v, 2) for k_, v in bias_2t_ciclos.items()}, "pesos_ciclo": {str(k_): round(v, 2) for k_, v in pesos_ciclo.items()},
  "bias_prior_mean_igual": BIAS_MEAN_IGUAL, "sens_bias": sens_bias,
  "bias_1t_ciclos": {str(k_): round(v, 2) for k_, v in bias_1t_ciclos.items()}, "bias_1t_hist": BIAS_1T_HIST, "bias_1t_mean": BIAS_1T_MEAN, "bias_1t_sd": BIAS_1T_SD, "encolhimento_1t": ENCOLHIMENTO_1T,
  "efeitos_fatores": efeitos_fatores, "abstencao": abst, "sd_ruido_regional": round(SD_RUIDO, 2), "sig_regiao": SIG_REGIAO, "peso_regiao": {k_: round(v, 3) for k_, v in PESO_REGIAO.items()},
  "estadual": ({"usado": estadual["usado"], "motivo": estadual.get("motivo", ""), "nivel": round(estadual["nivel"], 2), "se": round(estadual["se"], 2),
                "mu_swing": round(estadual["mu_swing"], 2), "mu_regiao": estadual["mu_regiao"],
                "ajuste_medio_tend": round(estadual["ajuste_medio_tend"], 2), "ajuste_medio_casa": round(estadual["ajuste_medio_casa"], 2), "tau": round(estadual["tau"], 2), "cobertura": round(estadual["cobertura"], 3),
                "n_pesq": estadual["n_pesq"], "n_uf": estadual["n_uf"], "peso_na_combinacao": round(estadual.get("peso_na_combinacao", 0.0), 3),
                "por_uf": estadual["por_uf"][["uf","n_pesq","institutos","ultimo_campo","m22","margem_2026","swing_bruto","swing","peso"]].round(3).to_dict(orient="records")}
               if "nivel" in estadual else {"usado": False, "motivo": estadual["motivo"]}),
  "p_flavio_2t_usado": round(P_FLAVIO_2T, 3), "sig_rw_menores": round(SIG_RW_MENORES, 3), "sig_rw_menores_livre": SIG_RW_MENORES_LIVRE, "janela_menores_dias": JANELA_MENORES, "desloc_colapso_removido": round(_desloc_colapso, 2), "n_max_efetivo": N_MAX_EFETIVO, "n_efetivo_inst": N_EFETIVO_INST, "lambda_casa_pos1t": LAMBDA_CASA, "p_flavio_top2_votos": round(P_FLAVIO_TOP2_VOTOS, 4), "risco_candidatura": RISCO_CANDIDATURA, "p_flavio_2t_mercado": P_FLAVIO_2T_MERC,
  "sigma_resid": round(math.sqrt(sigma2), 2),
  "media_ponderada_ultimas_bruta": round(media_simples_bruta, 2), "media_ponderada_ultimas_ajustada": round(media_simples_adj, 2),
  "house_effects": {i: round(house[i], 2) for i in insts}, "sigma_inst": {i: round(sigma_inst[i], 2) for i in insts},
  "mae_hist": {i: round(float(mae_inst[i]), 2) for i in mae_inst.index}, "bias_hist": {i: round(float(bias_inst[i]), 2) for i in bias_inst.index},
  "bias_prior_mean": BIAS_MEAN, "bias_prior_sd": BIAS_SD_FINAL, "bias_prior_sd_final": BIAS_SD_FINAL, "sd_deriva": round(sd_deriva, 2),
  "p_lula": round(p_lula, 3), "p_lula_vs_flavio": round(p_lula_vs_flavio, 3), "p_empate_1p5": round(p_empate, 3),
  "sensibilidade": sens, "p_ensemble": round(p_ens, 3), "p_ensemble_empate": round(p_ens_empate, 3), "pesos_ensemble": W_ENS,
  "cenarios_ensemble": {k_: round(v, 3) for k_, v in cen.items()}, "q_ensemble_10_50_90": [round(float(v), 1) for v in q_ens],
  "p_mercados": P_MERC, "mercado": MERC, "p_fundamentos": round(P_FUND, 3), "aprovacao_media_corrente": round(aprovacao_media, 1),
  "fund_inclinacao": round(float(FUND_B), 2), "fund_empate": round(float(FUND_EMPATE), 1), "fund_margem_esperada": round(mu_fund, 1),
  "fund_hist": [{"ciclo": c_, "aprovacao": a_, "margem": m_} for c_, a_, m_ in FUND_HIST], "fund_medida": FUND_MEDIDA, "aprovacao_atual": APROVACAO_ATUAL,
  "quantis_margem_final": {str(p): round(float(v), 1) for p, v in zip([5,10,25,50,75,90,95], q)},
  "var_share": {k_: round(v, 3) for k_, v in var_share.items()},
  "p_dado_lead": tabela_lead, "lead_breakeven": round(lead_breakeven, 1), "lead_70": round(lead_70, 1), "lead_90": round(lead_90, 1),
  "estrutural_base_margem": round(base_m, 2), "tornado": tornado_df.round(2).to_dict(orient="records"),
  # 1º turno
  "t1_n_pesquisas": int(len(p1)), "t1_n_institutos": int(p1.instituto.nunique()), "t1_sig_rw": round(sig_rw_1t, 3),
  "t1_share_valid": {c_: round(v, 1) for c_, v in share_now.items()}, "t1_se_share": {c_: round(v, 2) for c_, v in se_share.items()},
  "t1_q_share": q_share, "t1_house": {c_: {i_: round(v, 2) for i_, v in house_1t[c_].items()} for c_ in CANDS},
  "t1_agregado": {k_: round(v, 1) for k_, v in agg1.items() if k_ in ["L","F","Cury","Renan","Caiado","Zema","outros","branco","indec"]},
  "t1_lula_valid_now": round(L_valid_now, 1), "t1_gap": round(gap_valid, 1),
  "t1_por_instituto": [{"instituto": r.instituto, "campo_fim": str(r.campo_fim.date()), "n": int(r.n), **{c_: round(float(getattr(r, c_)), 1) for c_ in CANDS},
                        "branco_nulo": (None if pd.isna(r.branco_nulo) else round(float(r.branco_nulo), 1)), "indecisos": (None if pd.isna(r.indecisos) else round(float(r.indecisos), 1)),
                        "L_valid": round(float(r.lula_v), 1), "F_valid": round(float(r.flavio_v), 1), "margem_v": round(float(r.margem_v), 1), "registro_tse": (r.registro_tse if isinstance(r.registro_tse, str) else "")}
                       for r in last1.itertuples()],
  "p_1turno": round(p_1t, 4), "p_flavio_1turno": round(p_flavio_1t, 4), "p_lula_lidera_1t": round(p_lula_lidera_1t, 3), "q_1turno": [round(float(v), 1) for v in q_1t],
  "q_margem_1t": {str(p): round(float(v), 1) for p, v in zip([5,10,25,50,75,90,95], q_margem_1t)}, "p_terceiro": {c_: round(v, 3) for c_, v in p_terceiro.items()},
  "sens_1t": sens_1t, "tabela_1t_2t": tabela_1t_2t, "hist_1t": hist_1t,
  "share_lula_3via": {k_: round(v, 2) for k_, v in share_lula_3via.items()}, "T3_total": round(T3_total, 1),
  "resultado_1t": RESULTADO_1T,
}

# termômetro de tendência (últimas duas semanas): nível suavizado da janela dia a dia, com a leitura estadual na mesma proporção de hoje.
# É o nível SÓ das pesquisas (sem o voto contado), com o erro do filtro mais o da âncora; o painel o compara com o nível das pesquisas de
# hoje (nivel_pesquisas), não com a manchete mesclada (21/out: antes o erro da série era o do nível mesclado).
_serie = []
for _k in range(14, -1, -1):
    _lv, _Pk = x_win(t_now - _k)
    _lv = (1 - w_est_comb)*_lv + w_est_comb*(estadual["nivel"] if estadual["usado"] else 0.0)
    _serie.append({"data": str((HOJE - pd.Timedelta(days=_k)).date()), "nivel": round(_lv, 2), "se": round(math.sqrt(_Pk + fit_win["var_ancora"]), 2)})
_d7 = _serie[-1]["nivel"] - _serie[-8]["nivel"]; _d14 = _serie[-1]["nivel"] - _serie[0]["nivel"]
_LIM = 0.5
_quem = lambda v: "Lula" if v > 0 else "Flávio"
_dir = (f"a favor de {_quem(_d7)}" if abs(_d7) >= _LIM else (f"a favor de {_quem(_d14)}, desacelerando" if abs(_d14) >= 2*_LIM else "estável"))
_mov = []
_dfs = df.sort_values("campo_fim")
for _r in _dfs[_dfs.campo_fim > HOJE - pd.Timedelta(days=14)].itertuples():
    # onda anterior da mesma série; depois do 1º turno só pares pós-4/out, para não contar a quebra de 4/out como movimento
    _prev = _dfs[(_dfs.instituto == _r.instituto) & (_dfs.campo_fim < _r.campo_fim) & ((_dfs.campo_fim > T1) if POS_1T else True)]
    if len(_prev) == 0: continue
    _p = _prev.iloc[-1]
    _mov.append({"instituto": _r.instituto, "campo_fim": str(_r.campo_fim.date()), "anterior": str(_p.campo_fim.date()),
                 "lula": float(_r.lula), "flavio": float(_r.flavio), "lula_ant": float(_p.lula), "flavio_ant": float(_p.flavio),
                 "delta_margem": round(float(_r.margem_valid - _p.margem_valid), 1)})
res["nivel_ajustado_14d_atras"] = _serie[0]["nivel"]
# mesma linha desde o 1º turno, para o gráfico evolutivo das pesquisas pós-4/out (evolucao_pesquisas.py)
_serie_pos1t = []
for _k in range(max((HOJE - T1).days, 0), -1, -1):
    _lv, _Pk = x_win(t_now - _k)
    _lv = (1 - w_est_comb)*_lv + w_est_comb*(estadual["nivel"] if estadual["usado"] else 0.0)
    _serie_pos1t.append({"data": str((HOJE - pd.Timedelta(days=_k)).date()), "nivel": round(_lv, 2), "se": round(math.sqrt(_Pk + fit_win["var_ancora"]), 2)})
res["serie_pos1t"] = _serie_pos1t
res["termometro"] = {"serie": _serie, "delta_7d": round(_d7, 2), "delta_14d": round(_d14, 2), "limiar": _LIM, "direcao": _dir, "movimentos": _mov,
                     "n_lula": sum(m["delta_margem"] >= 0.5 for m in _mov), "n_flavio": sum(m["delta_margem"] <= -0.5 for m in _mov),
                     "n_estavel": sum(abs(m["delta_margem"]) < 0.5 for m in _mov)}

# previsões do 1º turno congeladas (última rodada antes de 4/out fica gravada no repositório; a partir de 4/out nunca se reescreve,
# nem se o arquivo faltar — recriá-lo depois da eleição produziria uma "previsão" feita com o resultado conhecido)
frozen_path = DATA + "previsoes_1turno_congeladas.json"
if HOJE < T1 and not os.environ.get("ELEICAO_NAO_CONGELAR"):
    json.dump({"data_base": str(HOJE.date()), "share_valid": res["t1_share_valid"], "q_share": q_share, "p_1turno": res["p_1turno"],
               "p_flavio_top2": res["p_flavio_top2_votos"], "p_lula_lidera": res["p_lula_lidera_1t"], "q_margem_1t": res["q_margem_1t"],
               "p_terceiro": res["p_terceiro"], "bias_1t_mean": BIAS_1T_MEAN, "ultimas_por_instituto": res["t1_por_instituto"],
               "nivel_2t": res["nivel_ajustado_hoje_margem_valid"], "p_lula_2t": res["p_lula"], "p_ensemble_2t": res["p_ensemble"]},
              open(frozen_path, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
if os.path.exists(frozen_path):
    res["previsoes_congeladas"] = json.load(open(frozen_path, encoding="utf-8"))
res["mcmc"] = mcmc_res
res["migracao"] = migracao_res

# previsões do 2º turno congeladas, dos dois modelos em paralelo (mesma regra do 1º turno: reescritas a cada rodada até a véspera de
# 25/out e nunca depois), para comparar com o resultado oficial
frozen2_path = DATA + "previsoes_2turno_congeladas.json"
if HOJE < T2 and not os.environ.get("ELEICAO_NAO_CONGELAR"):
    json.dump({"data_base": str(HOJE.date()),
               "principal": {"p_lula": res["p_lula"], "p_ensemble": res["p_ensemble"], "quantis_margem_final": res["quantis_margem_final"],
                             "q_ensemble_10_50_90": res["q_ensemble_10_50_90"], "nivel_pesquisas": res["nivel_pesquisas"], "nivel_ajustado": res["nivel_ajustado_hoje_margem_valid"],
                             "nivel_verdadeiro": mcmc_res["comparacao"]["nivel_verdadeiro_principal"]},
               "mcmc": {k_: mcmc_res[k_] for k_ in ["p_lula", "p_ensemble", "quantis_margem_final", "q_ensemble_10_50_90", "nivel_hoje",
                                                     "se_nivel_hoje", "rhat_max", "ess_min", "convergiu"]}},
              open(frozen2_path, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
if os.path.exists(frozen2_path):
    res["previsoes_2turno_congeladas"] = json.load(open(frozen2_path, encoding="utf-8"))

json.dump(res, open(OUT + "resultados.json", "w", encoding="utf-8"), ensure_ascii=False, indent=2)
def _csv(d_, path, nd=3):
    d_ = d_.copy()
    for c_ in d_.select_dtypes(include="number").columns: d_[c_] = d_[c_].round(nd)
    d_.to_csv(path, index=False)
_csv(df, OUT + "pesquisas_2turno_2026_ponderadas.csv")
last["sigma_inst"] = last.instituto.map(sigma_inst)
_csv(last[["instituto","campo_fim","n","n_ef","lula","flavio","margem_valid","margem_adj","house_aplicado","w_recencia","w_tamanho","w_qualidade","w","sigma_inst","w_precisao"]], OUT + "ultimas_por_instituto.csv")
_csv(p1, OUT + "pesquisas_1turno_2026_processadas.csv", 2)

# ---------------- SVG helpers ----------------
def fmt(v, d=1): return f"{v:.{d}f}".replace(".", ",")
LABEL = {"Parana Pesquisas": "Paraná Pesquisas", "Futura Inteligencia": "Futura Inteligência", "MDA": "CNT/MDA", "Palver v3": "Palver (calibração v3)", "MDA v2": "CNT/MDA (ponderada pelo 1º turno)", "Futura Inteligencia v2": "Futura Inteligência (escolaridade pela PNAD)",
         "Verita": "Veritá", "Alfa Inteligencia": "Alfa Inteligência", "Vetor": "Vetor/Arrow",
         "Instituto Franca": "Instituto França"}

def svg_trend():
    W, H = 900, 300
    x0, x1, y0, y1 = 60, 880, 20, 250
    tmin, tmax = int(df.dia.min()) - 1, (T2 - T0).days
    ymin, ymax = -12, 20
    def X(t): return x0 + (t - tmin)/(tmax - tmin)*(x1-x0)
    def Y(v): return y1 - (v - ymin)/(ymax - ymin)*(y1-y0)
    s = [f'<svg viewBox="0 0 {W} {H}" role="img" aria-label="Margem Lula menos Flávio em votos válidos, pesquisas e tendência ajustada por efeitos de casa">']
    for v in range(-12, 21, 4):
        s.append(f'<line class="grid-line" x1="{x0}" y1="{Y(v):.1f}" x2="{x1}" y2="{Y(v):.1f}"/>')
        s.append(f'<text class="axis-label" x="{x0-8}" y="{Y(v)+4:.1f}" text-anchor="end">{"+" if v>0 else ""}{v}</text>')
    s.append(f'<line x1="{x0}" y1="{Y(0):.1f}" x2="{x1}" y2="{Y(0):.1f}" stroke="var(--ink-soft)" stroke-width="1.2"/>')
    MESES = {1:"jan",2:"fev",3:"mar",4:"abr",5:"mai",6:"jun",7:"jul",8:"ago",9:"set",10:"out",11:"nov",12:"dez"}
    for m in pd.date_range("2025-12-01", "2026-11-01", freq="MS"):
        tt = (m - T0).days
        if tmin-15 <= tt <= tmax:
            s.append(f'<text class="axis-label" x="{X(tt):.1f}" y="{y1+22}" text-anchor="middle">{MESES[m.month]}</text>')
    up = " ".join(f"{X(t):.1f},{Y(f+1.96*e):.1f}" for t, f, e in zip(tt_grid, f_grid, se_grid))
    lo = " ".join(f"{X(t):.1f},{Y(f-1.96*e):.1f}" for t, f, e in zip(tt_grid[::-1], f_grid[::-1], se_grid[::-1]))
    s.append(f'<polygon points="{up} {lo}" fill="var(--accent)" opacity="0.13"/>')
    s.append('<polyline class="series-line" stroke="var(--accent)" stroke-width="2.4" points="' + " ".join(f"{X(t):.1f},{Y(f):.1f}" for t, f in zip(tt_grid, f_grid)) + '"/>')
    for _, r in df.iterrows():
        s.append(f'<circle cx="{X(r.t):.1f}" cy="{Y(r.margem_valid):.1f}" r="4" fill="var(--other)" stroke="var(--surface)" stroke-width="1.2" opacity="0.85" class="iso-dot" tabindex="0"><title>{LABEL.get(r.instituto, r.instituto)} · {r.campo_fim.strftime("%d/%m/%y")} · Lula {fmt(r.lula)} × Flávio {fmt(r.flavio)} → margem válida {fmt(r.margem_valid)} · efeito de casa {fmt(r.house, 1)}</title></circle>')
    marcos = [(t_now, "hoje", "end"), ((T1 - T0).days, "1º turno", "middle"), (tmax, "2º turno", "end")]
    xs = [X(tt) for tt, _, _ in marcos]
    rows = [0]
    for i in range(1, len(xs)):
        rows.append(1 if abs(xs[i] - xs[i-1]) < 58 and rows[i-1] == 0 else 0)
    for (tt, lab, anchor), xr, row in zip(marcos, xs, rows):
        s.append(f'<line x1="{xr:.1f}" y1="{y0}" x2="{xr:.1f}" y2="{y1}" stroke="var(--line)" stroke-dasharray="3,4"/>')
        dx = -4 if anchor == "end" else 0
        ry = (y0 - 6) - (14 if row else 0)
        s.append(f'<text class="axis-label" x="{xr+dx:.1f}" y="{ry}" text-anchor="{anchor}">{lab}</text>')
    tq = tmax; med = q[3]; p10, p90 = q[1], q[5]
    cor_proj = "var(--lula)" if med > 0 else "var(--flavio)"
    s.append(f'<line x1="{X(tq):.1f}" y1="{Y(p10):.1f}" x2="{X(tq):.1f}" y2="{Y(p90):.1f}" stroke="{cor_proj}" stroke-width="6" opacity="0.35"/>')
    s.append(f'<circle cx="{X(tq):.1f}" cy="{Y(med):.1f}" r="5" fill="{cor_proj}"/>')
    s.append(f'<text class="dot-label" x="{x1-4:.1f}" y="{y0+16}" text-anchor="end" fill="{cor_proj}">projeção 25/out · mediana {("+" if med>0 else "")}{fmt(med)}</text>')
    s.append(f'<text class="dot-label" x="{x1-4:.1f}" y="{y0+30}" text-anchor="end" fill="{cor_proj}">intervalo 80%: {fmt(p10)} a {("+" if p90>0 else "")}{fmt(p90)}</text>')
    s.append(f'<circle cx="{X(t_now):.1f}" cy="{Y(level_final):.1f}" r="5.5" fill="var(--accent)" stroke="var(--surface)" stroke-width="1.5"><title>Nível hoje: {fmt(level_final)} ± {fmt(1.96*se_final)}</title></circle>')
    s.append(f'<text class="dot-label" x="{X(t_now)-8:.1f}" y="{Y(level_final)+26:.1f}" text-anchor="end" fill="var(--accent)">hoje {("+" if level_final>0 else "")}{fmt(level_final)} ±{fmt(1.96*se_final)}</text>')
    s.append("</svg>")
    return "\n".join(s)

def svg_hist():
    W, H = 900, 260
    x0, x1, y0, y1 = 60, 880, 44, 220
    bins = np.arange(-14, 18.5, 1.0)
    h, edges = np.histogram(margem_final, bins=bins, density=True)
    hmax = h.max()
    def X(v): return x0 + (v - bins[0])/(bins[-1]-bins[0])*(x1-x0)
    def Y(d): return y1 - d/hmax*(y1-y0)
    s = [f'<svg viewBox="0 0 {W} {H}" role="img" aria-label="Distribuição simulada da margem final Lula menos Flávio em 25 de outubro">']
    for i, d in enumerate(h):
        c = "var(--lula)" if edges[i] >= 0 else "var(--flavio)"
        quem = "Lula" if edges[i] >= 0 else "Flávio"
        s.append(f'<rect x="{X(edges[i])+1:.1f}" y="{Y(d):.1f}" width="{(x1-x0)/len(h)-2:.1f}" height="{y1-Y(d):.1f}" fill="{c}" opacity="{0.85 if abs(edges[i]+0.5)>1.5 else 0.45}" tabindex="0"><title>margem {fmt(edges[i])} a {fmt(edges[i]+1)} p.p. · {quem} vence nessa faixa · densidade {d/hmax*100:.0f}% do pico</title></rect>')
    s.append(f'<line x1="{X(0):.1f}" y1="{y0}" x2="{X(0):.1f}" y2="{y1}" stroke="var(--ink)" stroke-width="1.2"/>')
    for v in range(-12, 17, 4):
        s.append(f'<text class="axis-label" x="{X(v):.1f}" y="{y1+18}" text-anchor="middle">{"+" if v>0 else ""}{v}</text>')
    s.append(f'<text class="axis-label" x="{(x0+x1)/2:.0f}" y="{y1+34}" text-anchor="middle">margem Lula − Flávio em 25/out (p.p. de votos válidos)</text>')
    # rótulos em cor de texto; a cor da série fica no quadrado ao lado (Ardósia: texto de gráfico nunca na cor da série)
    s.append(f'<rect x="{X(6.5):.1f}" y="{y0+5}" width="9" height="9" rx="1" fill="var(--lula)"/><text class="dot-label" x="{X(6.5)+14:.1f}" y="{y0+14}" fill="var(--ink)">Lula vence · {p_lula*100:.0f}%</text>')
    s.append(f'<rect x="{X(-6.5)-9:.1f}" y="{y0+5}" width="9" height="9" rx="1" fill="var(--flavio)"/><text class="dot-label" x="{X(-6.5)-14:.1f}" y="{y0+14}" text-anchor="end" fill="var(--ink)">Flávio vence · {(1-p_lula)*100:.0f}%</text>')
    s.append(f'<text class="dot-label" x="{X(0):.1f}" y="{y0-16}" text-anchor="middle" fill="var(--ink-soft)">faixa clara = |margem| &lt; 1,5 p.p. (resultado contestável): {p_empate*100:.0f}%</text>')
    s.append("</svg>")
    return "\n".join(s)

def svg_mcmc():
    """Os dois modelos lado a lado: barras = modelo principal (Monte Carlo com parâmetros fixos), linha = MCMC (posterior completo)."""
    W, H = 900, 270
    x0, x1, y0, y1 = 60, 880, 56, 220
    bins = np.arange(-20, 20.5, 1.0)
    h_a, edges = np.histogram(np.clip(margem_final, -19.99, 19.99), bins=bins, density=True)
    h_m, _ = np.histogram(np.clip(margem_mcmc, -19.99, 19.99), bins=bins, density=True)
    hmax = max(h_a.max(), h_m.max())
    def X(v): return x0 + (v - bins[0])/(bins[-1]-bins[0])*(x1-x0)
    def Y(d): return y1 - d/hmax*(y1-y0)
    s = [f'<svg viewBox="0 0 {W} {H}" role="img" aria-label="Distribuição da margem final em 25 de outubro: modelo principal em barras, modelo MCMC em linha">']
    bw = (x1-x0)/len(h_a)
    for i, d in enumerate(h_a):
        s.append(f'<rect x="{X(edges[i])+1:.1f}" y="{Y(d):.1f}" width="{bw-2:.1f}" height="{y1-Y(d):.1f}" fill="var(--ink-3)" opacity="0.32" tabindex="0">'
                 f'<title>margem {fmt(edges[i])} a {fmt(edges[i]+1)} p.p. · principal {d*100:.1f}% · MCMC {h_m[i]*100:.1f}% das simulações</title></rect>')
    pts = []
    for i, d in enumerate(h_m):
        pts += [f"{X(edges[i]):.1f},{Y(d):.1f}", f"{X(edges[i+1]):.1f},{Y(d):.1f}"]
    s.append(f'<polyline points="{" ".join(pts)}" fill="none" stroke="var(--accent)" stroke-width="2.4" stroke-linejoin="round"/>')
    s.append(f'<line x1="{X(0):.1f}" y1="{y0-6}" x2="{X(0):.1f}" y2="{y1}" stroke="var(--ink)" stroke-width="1.2"/>')
    for med, cor, dy in [(q[3], "var(--ink-3)", 0), (q_mcmc[3], "var(--accent)", 0)]:
        s.append(f'<line x1="{X(med):.1f}" y1="{y0-6}" x2="{X(med):.1f}" y2="{y1}" stroke="{cor}" stroke-width="1.4" stroke-dasharray="4,3"/>')
    for v in range(-20, 21, 4):
        s.append(f'<text class="axis-label" x="{X(v):.1f}" y="{y1+18}" text-anchor="middle">{"+" if v>0 else ""}{v}</text>')
    s.append(f'<text class="axis-label" x="{(x0+x1)/2:.0f}" y="{y1+36}" text-anchor="middle">margem Lula − Flávio em 25/out (p.p. de votos válidos; caudas além de ±20 somadas nas pontas)</text>')
    s.append(f'<rect x="{x0}" y="{y0-44}" width="14" height="10" fill="var(--ink-3)" opacity="0.32"/>')
    s.append(f'<text class="dot-label" x="{x0+20}" y="{y0-35}" fill="var(--ink-soft)">principal (Monte Carlo): Lula {p_lula*100:.0f}% · mediana {fmt(q[3])} · 80% de {fmt(q[1])} a {fmt(q[5])}</text>')
    s.append(f'<line x1="{x0}" y1="{y0-19}" x2="{x0+14}" y2="{y0-19}" stroke="var(--accent)" stroke-width="2.4"/>')
    s.append(f'<text class="dot-label" x="{x0+20}" y="{y0-15}" fill="var(--accent)">MCMC (posterior completo): Lula {p_lula_mcmc*100:.0f}% · mediana {fmt(q_mcmc[3])} · 80% de {fmt(q_mcmc[1])} a {fmt(q_mcmc[5])}</text>')
    s.append("</svg>")
    return "\n".join(s)

def svg_tornado():
    W = 900; rowh = 34; H = 40 + rowh*len(tornado_df) + 30
    x0, x1 = 400, 880; vmax = max(tornado_df.alto.abs().max(), tornado_df.baixo.abs().max()) + 0.5
    def X(v): return (x0+x1)/2 + v/vmax*(x1-x0)/2
    s = [f'<svg viewBox="0 0 {W} {H}" role="img" aria-label="Tornado: efeito de cada variável sobre a margem de Lula no 2º turno">']
    for v in [-4, -2, 0, 2, 4]:
        if abs(v) <= vmax:
            s.append(f'<line class="grid-line" x1="{X(v):.1f}" y1="30" x2="{X(v):.1f}" y2="{H-30}"/>')
            s.append(f'<text class="axis-label" x="{X(v):.1f}" y="{H-12}" text-anchor="middle">{"+" if v>0 else ""}{v} p.p.</text>')
    s.append(f'<line x1="{X(0):.1f}" y1="30" x2="{X(0):.1f}" y2="{H-30}" stroke="var(--ink)" stroke-width="1.2"/>')
    for i, (_, r) in enumerate(tornado_df.iterrows()):
        y = 40 + i*rowh
        lo, hi = sorted([r.baixo, r.alto])
        s.append(f'<text x="{x0-16}" y="{y+13}" text-anchor="end" font-size="12.5" fill="var(--ink)">{r.variavel}</text>')
        tt_lab = f'<title>{r.variavel}: {fmt(lo)} a +{fmt(hi)} p.p. na margem de Lula · {r.obs}</title>'
        if lo < 0: s.append(f'<rect x="{X(lo):.1f}" y="{y}" width="{X(min(hi,0))-X(lo):.1f}" height="20" fill="var(--flavio)" opacity="0.8" tabindex="0">{tt_lab}</rect>')
        if hi > 0: s.append(f'<rect x="{X(max(lo,0)):.1f}" y="{y}" width="{X(hi)-X(max(lo,0)):.1f}" height="20" fill="var(--lula)" opacity="0.8" tabindex="0">{tt_lab}</rect>')
        s.append(f'<text class="dot-label" x="{X(lo)-6:.1f}" y="{y+14}" text-anchor="end" fill="var(--ink-soft)">{fmt(lo)}</text>')
        s.append(f'<text class="dot-label" x="{X(hi)+6:.1f}" y="{y+14}" fill="var(--ink-soft)">+{fmt(hi)}</text>')
    s.append("</svg>")
    return "\n".join(s)

def svg_house():
    dd = sorted([d for d in diag if d["diagnostico_confiavel"]], key=lambda d: d["house"])
    W = 900; rowh = 26; H = 54 + rowh * len(dd) + 26
    x0, x1 = 240, 870
    vmax = max(abs(d["house"]) + 1.96 * d["house_se"] for d in dd) + 0.4
    def X(v): return (x0 + x1) / 2 + v / vmax * (x1 - x0) / 2
    s_ = [f'<svg viewBox="0 0 {W} {H}" role="img" aria-label="Efeito de casa de cada instituto na margem Lula menos Flávio, com intervalo de 95%">']
    s_.append(f'<text class="axis-label" x="{X(-vmax*0.55):.0f}" y="20" text-anchor="middle">mede mais Flávio que a âncora</text>')
    s_.append(f'<text class="axis-label" x="{X(vmax*0.55):.0f}" y="20" text-anchor="middle">mede mais Lula que a âncora</text>')
    for v in range(-6, 7, 2):
        if abs(v) <= vmax:
            s_.append(f'<line class="grid-line" x1="{X(v):.1f}" y1="30" x2="{X(v):.1f}" y2="{H-26}"/>')
            s_.append(f'<text class="axis-label" x="{X(v):.1f}" y="{H-8}" text-anchor="middle">{"+" if v>0 else ""}{v}</text>')
    s_.append(f'<line x1="{X(0):.1f}" y1="30" x2="{X(0):.1f}" y2="{H-26}" stroke="var(--ink)" stroke-width="1.2"/>')
    for i_, d in enumerate(dd):
        yy = 44 + i_ * rowh
        lo, hi = d["house"] - 1.96 * d["house_se"], d["house"] + 1.96 * d["house_se"]
        cor = "var(--lula)" if d["house"] > 0 else "var(--flavio)"
        signif = abs(d["house_z"]) >= 1.96
        nome = LABEL.get(d["instituto"], d["instituto"]) + (" ◆" if d["ancora"] else "")
        s_.append(f'<text x="{x0-14}" y="{yy+4}" text-anchor="end" font-size="12" font-weight="{"600" if d["ancora"] else "400"}" fill="var(--ink)">{nome}</text>')
        s_.append(f'<line x1="{X(lo):.1f}" y1="{yy}" x2="{X(hi):.1f}" y2="{yy}" stroke="{cor}" stroke-width="2" opacity="0.45"/>')
        s_.append(f'<circle cx="{X(d["house"]):.1f}" cy="{yy}" r="{5 if signif else 3.5}" fill="{cor}" opacity="{1 if signif else 0.55}" tabindex="0">'
                  f'<title>{nome}: efeito de casa {fmt(d["house"])} p.p. (IC 95%: {fmt(lo)} a {fmt(hi)}) · {d["n_ondas"]} ondas · variância extra estimada σ={fmt(d["sigma_extra"])} · '
                  f'erro típico {fmt(d["rms_vs_tendencia"])} p.p. vs. {fmt(d["se_margem_teorico"])} esperado pela amostragem</title></circle>')
        s_.append(f'<text class="dot-label" x="{X(hi)+7:.1f}" y="{yy+4}" fill="var(--ink-3)">{fmt(d["house"], 1)}{"*" if signif else ""}</text>')
    s_.append("</svg>")
    return "\n".join(s_)

def svg_1turno():
    """Barras horizontais: share em válidos por candidato, mediana e intervalo 80% da simulação; marca a linha de 50%."""
    cands = sorted(CANDS, key=lambda c_: -share_now[c_])
    W = 900; rowh = 34; H = 40 + rowh*len(cands) + 34
    x0, x1 = 180, 860; vmax = 60
    def X(v): return x0 + v/vmax*(x1-x0)
    s = [f'<svg viewBox="0 0 {W} {H}" role="img" aria-label="Votos válidos esperados por candidato no 1º turno, com intervalo de 80%">']
    for v in range(0, 61, 10):
        s.append(f'<line class="grid-line" x1="{X(v):.1f}" y1="30" x2="{X(v):.1f}" y2="{H-30}"/>')
        s.append(f'<text class="axis-label" x="{X(v):.1f}" y="{H-12}" text-anchor="middle">{v}%</text>')
    s.append(f'<line x1="{X(50):.1f}" y1="30" x2="{X(50):.1f}" y2="{H-30}" stroke="var(--ink)" stroke-width="1.2" stroke-dasharray="4,3"/>')
    s.append(f'<text class="axis-label" x="{X(50)+4:.1f}" y="26">50% + 1 decide no 1º turno</text>')
    for i, c_ in enumerate(cands):
        y = 40 + i*rowh
        lo, med, hi = q_share[c_]
        cor = "var(--lula)" if c_ == "lula" else ("var(--flavio)" if c_ == "flavio" else "var(--other)")
        s.append(f'<text x="{x0-12}" y="{y+14}" text-anchor="end" font-size="12.5" fill="var(--ink)">{CAND_LABEL[c_]}</text>')
        s.append(f'<rect x="{X(0):.1f}" y="{y+2}" width="{X(med)-X(0):.1f}" height="18" fill="{cor}" opacity="0.75" tabindex="0"><title>{CAND_LABEL[c_]}: mediana {fmt(med)}% dos válidos · 80% entre {fmt(lo)} e {fmt(hi)} · nível nas pesquisas hoje {fmt(share_now[c_])}%</title></rect>')
        s.append(f'<line x1="{X(lo):.1f}" y1="{y+11}" x2="{X(hi):.1f}" y2="{y+11}" stroke="var(--ink)" stroke-width="2"/>')
        s.append(f'<line x1="{X(lo):.1f}" y1="{y+5}" x2="{X(lo):.1f}" y2="{y+17}" stroke="var(--ink)" stroke-width="1.5"/><line x1="{X(hi):.1f}" y1="{y+5}" x2="{X(hi):.1f}" y2="{y+17}" stroke="var(--ink)" stroke-width="1.5"/>')
        s.append(f'<text class="dot-label" x="{X(hi)+8:.1f}" y="{y+15}" fill="var(--ink)">{fmt(med)}% <tspan fill="var(--ink-3)">({fmt(lo)}–{fmt(hi)})</tspan></text>')
    s.append("</svg>")
    return "\n".join(s)

open(OUT + "svg_trend.html", "w", encoding="utf-8").write(svg_trend())
open(OUT + "svg_hist.html", "w", encoding="utf-8").write(svg_hist())
open(OUT + "svg_mcmc.html", "w", encoding="utf-8").write(svg_mcmc())
open(OUT + "svg_house.html", "w", encoding="utf-8").write(svg_house())
open(OUT + "svg_tornado.html", "w", encoding="utf-8").write(svg_tornado())
open(OUT + "svg_1turno.html", "w", encoding="utf-8").write(svg_1turno())

print(json.dumps({k_: res[k_] for k_ in ["nivel_ajustado_hoje_margem_valid","se_total","nivel_v2_like","shift_ancora","house_effects","sigma_inst","bias_prior_mean","bias_prior_sd",
                                          "bias_2t_ciclos","sd_deriva","sig_entre_turnos_dia","p_lula","p_lula_vs_flavio","p_empate_1p5","sensibilidade","sens_bias","p_ensemble",
                                          "cenarios_ensemble","efeitos_fatores","var_share","t1_share_valid","p_1turno","p_flavio_top2_votos","p_lula_lidera_1t","q_margem_1t","p_terceiro","sens_1t","validacao_rolling"]},
                 ensure_ascii=False, indent=1))
print(tornado_df.round(2).to_string())
print("MAE hist:", mae_inst.round(2).to_dict())
print("bias por eleição/turno:", bias_el.round(2).to_dict())

Explicit model assumptions

  • Margin in valid votes: Lula ÷ (Lula + Flávio) — blank, spoiled and undecided left out of the denominator, as in the official result.
  • Single state-space model: y = x_t + h_p + ε, with x_t a random walk (σ_rw = 0.31 pp/day, MAP with a log-normal prior centered on 0.25; ML 0.32), h_p ~ N(0, 1.5²) with a weighted mean of zero across the anchor (AtlasIntel, Datafolha, Ipespe, CNT/MDA, Paraná Pesquisas, PoderData, Quaest; weights = historical quality, measured by each pollster's error relative to the average of each cycle), Var ε = sampling error² + σ_p², σ_p empirical Bayes (shrinkage k=4 toward the median, floor 0.8). ECM estimation. Campaign window since Jun 1 for today's level; full series only for the chart.
  • Systematic error: final runoff polling rounds, per-cycle average of the reference pollsters (2006–2022) with recency weighting → +0.9 ± 2.0. Until the first-round result is in, the first-round discount applies (+3.4 ± 3.8); applied today: +0.8 ± 2.0. With the result: only the final discount (σ = 2.5), on the share of the level that comes from polls (until Oct 22 it added 0.25 × the anchor's common first-round error; removed in v56 because that error was tactical voting); each pollster's error relative to the anchor becomes the prior for its house effect; the random walk gets a 6 pp break on Oct 5; and the official result + transfer (± 5.0) enters the level, combined by precision. Student's t tails (5 d.f.) on the error and the drift.
  • Drift: σ_rw until Oct 4; between rounds 0.95 pp/day (movement in Datafolha and Ibope/Ipec runoff polls in 2014/18/22, net of sampling error and shrunk 50% toward σ_rw) → 0.0 pp until Oct 25. Regional shocks removed on Oct 3 (they did not change national outputs). P(opponent = Flávio) = 100% (first-round model × 0% risk of disqualification); otherwise margin ~ N(+7, 4). State-level reading included when it covers ≥30% of the electorate.
  • Ensemble: 75% model, 25% markets (P=0.20 → N(μ, 4.5), Polymarket Oct 25, 8 a.m.), 0% fundamentals (P=0.53, reference only). The market only enters with a price at most 5 days old and, after the first round, dated after Oct 4.
  • First round: state-space series for the Lula − Flávio margin, the sum of the two and each minor candidate, in valid votes, same anchor; late third-way collapse ~ Beta(2,8) (mean 20%) split by declared second choice; bias ~ t5(+3.4, 3.8) on the Lula − Flávio margin (60% of the recency-weighted average of the 2010/14/18/22 errors: +5.7); ±1.5 pp of extra uncertainty on the other candidates; renormalized to 100%. Forecast frozen on Oct 3.

Known limitations

  • Fieldwork dates for some waves (PoderData, RTBD, Quaest Jan–May, Atlas Jan/Apr) approximated to the month — this affects their position on the chart, and the weights only slightly.
  • The tornado is semi-structural: ranges chosen from the polls, not estimated by regression; it measures sensitivity, not an identified causal effect.
  • The systematic-error discount assumes that the average of the last five cycles is informative for 2026, even though its direction has varied (three cycles pro-PT, two against); it is the assumption that most changes the result, and the sensitivity table shows the result with zero discount and with the old discount. In the first round, the discount (60% of the historical average) is even more uncertain: only three cycles, all with the main right-wing candidate underestimated.
  • Rolling-origin validation shows an average bias of -0.3 pp in 7-day-ahead forecasts during the August–September decline: the random walk does not extrapolate trends, by design.
  • Markets and fundamentals are not independent of the model (they all read the same polls); the ensemble is a judgment-based weighting, not an estimated optimal combination.
  • State polls enter as a second reading of the national race (weight 0%), and abstention through turnout scenarios (Abstention section); neither measures who will actually vote.

References

Sources consulted between September 8 and October 1, 2026 (links live as of that date), with poll updates through Oct 1. The full poll database (Appendix A) gives the source link for each wave. Polls with TSE registration cited in the text.
  1. Datafolha Sep 8–10 (TSE BR-01833/2026, released Sep 11 at 6:45 p.m.): first round (Lula 39 × Flávio 35): Metrópoles, BPMoney; runoff (Lula 46 × Flávio 44): Exame; timing/technical details: NSC Total, CartaCapital
  2. Datafolha Sep 1–3: Gazeta do Povo; CNN Brasil (runoff); rejection: Terra; approval: Jovem Pan
  3. Datafolha Aug 18–20: CNN Brasil; Brasil de Fato; rejection: Tribuna de Jundiaí; segments: RIC; approval: Metrópoles
  4. Datafolha Jul, Jun, Apr, Mar, Dec 2025: Gazeta (Jul); Gazeta (Jun); CNN (Apr); Reuters/Yahoo (Mar); Gazeta (Dec 2025)
  5. Genial/Quaest Sep 3–6: CNN Brasil; vote transfer: O Antagonista; rejection: DGABC; Nunes analysis: Sem Pauta; first round: Transmissão Política
  6. Genial/Quaest Aug 30–Sep 1: Gazeta do Povo; approval: Poder360; Lulinha/Master: InfoMoney
  7. Genial/Quaest Aug, Jul, Jun, Mar, Feb, Jan: Al Jazeera (Aug 13); CNN (Aug 3); Poder360 (rejection, Aug); Gazeta (Jun); CNN (Mar); Painel Político (series); Poder360 (approval, Jul); Correio Braziliense (Aug)
  8. AtlasIntel/Bloomberg: Poder360 (Aug 25–30); Exame; Gazeta (first round, Aug); Metrópoles (Jul); CNN (Jun); CNN (May); JOTA (Mar); Reuters/AOL (Feb); approval: Jornal de Brasília, Poder360 (Jul)
  9. PoderData/Aya: Poder360 (7 waves, Sep 3); Gazeta (first round); approval: Tribuna de Jundiaí; age: Poder360
  10. Real Time Big Data: CNN Brasil (series); Gazeta (first round); rejection/approval: Poder360
  11. Paraná Pesquisas: CNN Brasil (Mar); Brasil de Fato
  12. BTG/Nexus: Poder360 (Aug 24); Nexus/FSB (Aug 31). Gerp: Poder360. Ipsos-Ipec: Ipsos (Jun), CartaCapital. Futura Inteligência (2nd wave): Poder360 (Sep 4–10). Meio/Ideia: Poder360 (runoff, Sep 4–7)
  13. 2022 calibration: Poder360 (runoff eve); CNN Brasil (first round); Congresso em Foco; CNN (Atlas); CNN (Paraná); Congresso em Foco (2022 drift)
  14. 2018 calibration: Brasil de Fato (first round); O Povo (runoff); Seu Dinheiro (Sep 2018); ND Mais. Earlier cycles: ISTOÉ Dinheiro; Gazeta (2010); Agência Brasil (2014)
  15. Economy: Agência Gov (IPCA, Jul); Poder360 (IPCA-15); Agência Gov (PNAD); CNN (Copom); InfoMoney (Focus); Agência Brasil (GDP Q2); Estadão/DGABC (ICC); Bloomberg Línea; CNN (Datafolha, economy)
  16. Markets and analysis: Polymarket; Kalshi; Prediction Hunt; Eurasia 60% (Correio); Eurasia Aug (Guia do Investidor); XP Research; Morgan Stanley (Bloomberg Línea); Brasil em Mapas; OddsShopper
  17. Analysts: Lavareda (CNN); Roman/Atlas (Exame); Sanches/Atlas (CartaCapital); Nicolau/Lavareda (CartaCapital); Americas Quarterly; Gazeta (abstention); Quaest (state-level)
  18. Events: Agência Brasil (house arrest); Agência Brasil (visits); Jornal Grande Bahia (tariffs); InfoMoney (INSS); Gazeta (conventions); InfoMoney (TV); Poder360 (Kassab/Caiado); Poder360 (Marçal); Gazeta (debates); BBC/O Povo (candidates)
  19. Electoral compliance: TSE Res. No. 23,747/2026 (registration and publication of polls, amends Res. No. 23,600/2019); TSE Res. No. 23,755/2026 (use of AI in campaign advertising); TSE (mandatory poll registration); TRE-AL (ban on informal straw polls, Aug 16)
  20. Qualitative research and scandals: MDP/LEMEP-UERJ, Report #121 and Report #125 (ongoing WhatsApp focus groups); perception of Lula: CartaCapital/Quaest. Banco Master: Wikipedia; ND Mais/PoderData (Jun 21–24); CNN Brasil (overview). INSS: Wikipedia; Congresso em Foco; Revista Oeste/Quaest (May 29–Jun 1, 2025). Dark Horse/Azarão: Agência Pública; Agenda do Poder/Meio-Ideia (May 28); Brasil 247/Meio-Ideia (Sep 4–7); Lulinha: DN.pt
  21. Runoff poll database (rebuilt Sep 15): Wikipedia — Opinion polling for the 2026 Brazilian presidential election (primary link for each wave in Appendix A); CNT/MDA Aug: Gazeta do Povo; CNT/MDA Sep: CartaCapital; Indexa Sep: Gazeta do Povo; Datafolha rejection Sep 8–10: Brasil 247
  22. Outcome of the Sep 15 STF session (point of order, vote count and Dino's request for more time): Congresso em Foco. Fallout in the campaign: Revista Oeste (Lula defends Moraes); O Tempo; Band (crisis becomes campaign ammunition). DataTrends poll (Sep 12–14, TSE BR-08691/2026): Revista Oeste
  23. Federal Police report on Moraes–Vorcaro and the Sep 15 STF session: CNN Brasil; InfoMoney; O Tempo; Correio Braziliense. Dark Horse investigation: Rio Times. Lulinha: Poder360. Sep 14 debate canceled: Revista Fórum
  24. STF crisis and lifting of Master's banking secrecy (Sep 8–10, 2026): Times Brasil; Portal 6; Brasil de Fato; N1N; impact on markets: Prediction Hunt, Polymarket
  25. Runoff polls added on Sep 14: BTG/Nexus Sep 11–13 (TSE BR-04076/2026): Poder360; Palver Sep 4–9: JOTA; Veritá Aug 16–20: Itatiaia; Veritá Sep 1–4 (TSE BR-04006/2026): Revista Oeste